diff --git a/.changeset/twenty-hats-deny.md b/.changeset/twenty-hats-deny.md new file mode 100644 index 0000000000..538f3b654f --- /dev/null +++ b/.changeset/twenty-hats-deny.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +fix: Improve Pie Chart implemententation diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 2edeea8f48..90e9e0c504 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1178,6 +1178,47 @@ } } }, + "PieChartConfig": { + "type": "object", + "required": [ + "displayType", + "sourceId", + "select" + ], + "description": "Configuration for a pie chart tile. Each slice represents one group value.", + "properties": { + "displayType": { + "type": "string", + "enum": [ + "pie" + ], + "example": "pie" + }, + "sourceId": { + "type": "string", + "description": "ID of the data source to query.", + "example": "65f5e4a3b9e77c001a111111" + }, + "select": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "description": "Exactly one aggregated value used to size each pie slice.", + "items": { + "$ref": "#/components/schemas/SelectItem" + } + }, + "groupBy": { + "type": "string", + "maxLength": 10000, + "description": "Field expression to group results by (one slice per group value).", + "example": "service" + }, + "numberFormat": { + "$ref": "#/components/schemas/NumberFormat" + } + } + }, "SearchChartConfig": { "type": "object", "required": [ @@ -1255,6 +1296,9 @@ { "$ref": "#/components/schemas/NumberChartConfig" }, + { + "$ref": "#/components/schemas/PieChartConfig" + }, { "$ref": "#/components/schemas/SearchChartConfig" }, @@ -1269,6 +1313,7 @@ "stacked_bar": "#/components/schemas/BarChartConfig", "table": "#/components/schemas/TableChartConfig", "number": "#/components/schemas/NumberChartConfig", + "pie": "#/components/schemas/PieChartConfig", "search": "#/components/schemas/SearchChartConfig", "markdown": "#/components/schemas/MarkdownChartConfig" } diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts index 67d598332c..0cba6ceca5 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.test.ts @@ -1648,6 +1648,26 @@ describe('External API v2 Dashboards - new format', () => { }, }); + const createPieChart = (sourceId: string): ExternalDashboardTileWithId => ({ + name: 'Pie Chart', + x: 6, + y: 3, + w: 3, + h: 3, + id: new ObjectId().toString(), + config: { + displayType: 'pie', + sourceId, + select: [ + { + aggFn: 'count', + where: '', + }, + ], + groupBy: 'service.name', + }, + }); + const server = getServer(); let agent, team, user, traceSource, metricSource; @@ -1912,6 +1932,7 @@ describe('External API v2 Dashboards - new format', () => { createTableChart(traceSource._id.toString()), createNumberChart(traceSource._id.toString()), createMarkdownChart(), + createPieChart(traceSource._id.toString()), ], tags: ['test', 'chart-types'], }; @@ -1922,18 +1943,45 @@ describe('External API v2 Dashboards - new format', () => { const { id } = response.body.data; expect(response.body.data).toHaveProperty('id'); - expect(response.body.data.tiles.length).toBe(4); + expect(response.body.data.tiles.length).toBe(5); // Verify by retrieving the dashboard const retrieveResponse = await authRequest('get', `${BASE_URL}/${id}`); expect(retrieveResponse.status).toBe(200); - expect(retrieveResponse.body.data.tiles.length).toBe(4); + expect(retrieveResponse.body.data.tiles.length).toBe(5); expect(retrieveResponse.body.data.tags).toEqual(['test', 'chart-types']); }); it('can round-trip all supported chart types and all supported fields on each chart type', async () => { // Arrange + const pieChart: ExternalDashboardTile = { + name: 'Pie Chart', + x: 6, + y: 3, + w: 6, + h: 3, + config: { + displayType: 'pie', + sourceId: traceSource._id.toString(), + select: [ + { + aggFn: 'quantile', + level: 0.5, + valueExpression: 'Duration', + alias: 'Median Duration', + where: "env = 'production'", + whereLanguage: 'sql', + }, + ], + groupBy: 'service.name', + numberFormat: { + output: 'number', + mantissa: 2, + }, + }, + }; + const lineChart: ExternalDashboardTile = { name: 'Line Chart', x: 0, @@ -2087,7 +2135,14 @@ describe('External API v2 Dashboards - new format', () => { const response = await authRequest('post', BASE_URL) .send({ name: 'Dashboard with All Chart Types', - tiles: [lineChart, barChart, tableChart, numberChart, markdownChart], + tiles: [ + lineChart, + barChart, + tableChart, + numberChart, + markdownChart, + pieChart, + ], tags: ['round-trip-test'], }) .expect(200); @@ -2098,6 +2153,7 @@ describe('External API v2 Dashboards - new format', () => { expect(omit(response.body.data.tiles[2], ['id'])).toEqual(tableChart); expect(omit(response.body.data.tiles[3], ['id'])).toEqual(numberChart); expect(omit(response.body.data.tiles[4], ['id'])).toEqual(markdownChart); + expect(omit(response.body.data.tiles[5], ['id'])).toEqual(pieChart); }); it('should return 400 when source IDs do not exist', async () => { diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index e36a06c346..ef57896b40 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -585,6 +585,37 @@ async function getMissingSources( * numberFormat: * $ref: '#/components/schemas/NumberFormat' * + * PieChartConfig: + * type: object + * required: + * - displayType + * - sourceId + * - select + * description: Configuration for a pie chart tile. Each slice represents one group value. + * properties: + * displayType: + * type: string + * enum: [pie] + * example: "pie" + * sourceId: + * type: string + * description: ID of the data source to query. + * example: "65f5e4a3b9e77c001a111111" + * select: + * type: array + * minItems: 1 + * maxItems: 1 + * description: Exactly one aggregated value used to size each pie slice. + * items: + * $ref: '#/components/schemas/SelectItem' + * groupBy: + * type: string + * maxLength: 10000 + * description: Field expression to group results by (one slice per group value). + * example: "service" + * numberFormat: + * $ref: '#/components/schemas/NumberFormat' + * * SearchChartConfig: * type: object * required: @@ -641,6 +672,7 @@ async function getMissingSources( * - $ref: '#/components/schemas/BarChartConfig' * - $ref: '#/components/schemas/TableChartConfig' * - $ref: '#/components/schemas/NumberChartConfig' + * - $ref: '#/components/schemas/PieChartConfig' * - $ref: '#/components/schemas/SearchChartConfig' * - $ref: '#/components/schemas/MarkdownChartConfig' * discriminator: @@ -650,6 +682,7 @@ async function getMissingSources( * stacked_bar: '#/components/schemas/BarChartConfig' * table: '#/components/schemas/TableChartConfig' * number: '#/components/schemas/NumberChartConfig' + * pie: '#/components/schemas/PieChartConfig' * search: '#/components/schemas/SearchChartConfig' * markdown: '#/components/schemas/MarkdownChartConfig' * diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index b85ddbca38..b80d620834 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -91,8 +91,8 @@ const convertToExternalTileChartConfig = ( }; switch (config.displayType) { - case 'line': - case 'stacked_bar': + case DisplayType.Line: + case DisplayType.StackedBar: return { displayType: config.displayType, sourceId, @@ -106,12 +106,12 @@ const convertToExternalTileChartConfig = ( select: Array.isArray(config.select) ? config.select.map(convertToExternalSelectItem) : [DEFAULT_SELECT_ITEM], - ...(config.displayType === 'line' + ...(config.displayType === DisplayType.Line ? { compareToPreviousPeriod: config.compareToPreviousPeriod } : {}), numberFormat: config.numberFormat, }; - case 'number': + case DisplayType.Number: return { displayType: config.displayType, sourceId, @@ -120,7 +120,17 @@ const convertToExternalTileChartConfig = ( : [DEFAULT_SELECT_ITEM], numberFormat: config.numberFormat, }; - case 'table': + case DisplayType.Pie: + return { + displayType: config.displayType, + sourceId, + select: Array.isArray(config.select) + ? [convertToExternalSelectItem(config.select[0])] + : [DEFAULT_SELECT_ITEM], + groupBy: stringValueOrDefault(config.groupBy, undefined), + numberFormat: config.numberFormat, + }; + case DisplayType.Table: return { ...pick(config, ['having', 'numberFormat']), displayType: config.displayType, @@ -135,7 +145,7 @@ const convertToExternalTileChartConfig = ( : [DEFAULT_SELECT_ITEM], orderBy: stringValueOrDefault(config.orderBy, undefined), }; - case 'search': + case DisplayType.Search: return { displayType: config.displayType, sourceId, @@ -143,17 +153,20 @@ const convertToExternalTileChartConfig = ( where: config.where, whereLanguage: config.whereLanguage ?? 'lucene', }; - case 'markdown': + case DisplayType.Markdown: return { displayType: config.displayType, markdown: stringValueOrDefault(config.markdown, ''), }; - default: + case DisplayType.Heatmap: + case undefined: logger.error( { config }, - 'Error converting chart config to external chart - unrecognized display type', + 'Error converting chart config to external chart - unsupported display type', ); return undefined; + default: + config.displayType satisfies never; } }; @@ -257,6 +270,16 @@ export function convertToInternalTileConfig( name, }; break; + case 'pie': + internalConfig = { + ...pick(externalConfig, ['groupBy', 'numberFormat']), + displayType: DisplayType.Pie, + select: [convertToInternalSelectItem(externalConfig.select[0])], + source: externalConfig.sourceId, + where: '', + name, + }; + break; case 'search': internalConfig = { ...pick(externalConfig, ['select', 'where']), @@ -276,6 +299,13 @@ export function convertToInternalTileConfig( name, }; break; + default: + // Typecheck to ensure all display types are handled + externalConfig satisfies never; + + // We should never hit this due to the typecheck above. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + internalConfig = {} as SavedChartConfig; } // Omit keys that are null/undefined, so that they're not saved as null in Mongo. diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 35da3dd89c..cf20024739 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -234,6 +234,14 @@ const externalDashboardNumberChartConfigSchema = z.object({ numberFormat: NumberFormatSchema.optional(), }); +const externalDashboardPieChartConfigSchema = z.object({ + displayType: z.literal('pie'), + sourceId: objectIdSchema, + select: z.array(externalDashboardSelectItemSchema).length(1), + groupBy: z.string().max(10000).optional(), + numberFormat: NumberFormatSchema.optional(), +}); + const externalDashboardSearchChartConfigSchema = z.object({ displayType: z.literal('search'), sourceId: objectIdSchema, @@ -253,6 +261,7 @@ export const externalDashboardTileConfigSchema = z externalDashboardBarChartConfigSchema, externalDashboardTableChartConfigSchema, externalDashboardNumberChartConfigSchema, + externalDashboardPieChartConfigSchema, externalDashboardMarkdownChartConfigSchema, externalDashboardSearchChartConfigSchema, ]) diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index 1aceab1d41..c76510ccf3 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -503,6 +503,40 @@ function inferGroupColumns(meta: Array<{ name: string; type: string }>) { ]); } +export function formatResponseForPieChart( + data: ResponseJSON>, + getColor: (index: number, label: string) => string, +): Array<{ label: string; value: number; color: string }> { + if (!data.meta || data.data.length === 0) return []; + + const valueColumns = inferValueColumns(data.meta, new Set()); + const groupByColumns = inferGroupColumns(data.meta); + if (!valueColumns?.length) return []; + const valueColumn = valueColumns[0].name; + + return ( + data.data + .map(row => { + const label = groupByColumns?.length + ? groupByColumns.map(({ name }) => row[name]).join(' - ') + : valueColumn; + const rawValue = row[valueColumn]; + const value = + typeof rawValue === 'number' + ? rawValue + : Number.parseFloat(`${rawValue}`); + return { label, value }; + }) + .filter(entry => !isNaN(entry.value) && isFinite(entry.value)) + // Sort in descending order so the largest slice is always first and gets the first color in the palette + .sort((a, b) => b.value - a.value) + .map((entry, index) => ({ + ...entry, + color: getColor(index, entry.label), + })) + ); +} + export function getPreviousDateRange(currentRange: [Date, Date]): [Date, Date] { const [start, end] = currentRange; const offsetSeconds = differenceInSeconds(end, start); diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 42862837df..6ddfd72740 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -20,12 +20,14 @@ import { import { AxisDomain } from 'recharts/types/util/types'; import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { Popover } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; -import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react'; import type { NumberFormat } from '@/types'; import { COLORS, formatNumber, truncateMiddle } from '@/utils'; +import { + ChartTooltipContainer, + ChartTooltipItem, +} from './components/charts/ChartTooltip'; import { convertGranularityToSeconds, LineData, @@ -48,40 +50,6 @@ type TooltipPayload = { opacity?: number; }; -const percentFormatter = new Intl.NumberFormat('en-US', { - style: 'percent', - maximumFractionDigits: 2, -}); - -const calculatePercentChange = (current: number, previous: number) => { - if (previous === 0) { - return current === 0 ? 0 : undefined; - } - return (current - previous) / previous; -}; - -const PercentChange = ({ - current, - previous, -}: { - current: number; - previous: number; -}) => { - const percentChange = calculatePercentChange(current, previous); - if (percentChange == undefined) { - return null; - } - - const Icon = percentChange > 0 ? IconCaretUpFilled : IconCaretDownFilled; - - return ( - - ( - {percentFormatter.format(Math.abs(percentChange))}) - - ); -}; - export const TooltipItem = memo( ({ p, @@ -93,30 +61,16 @@ export const TooltipItem = memo( numberFormat?: NumberFormat; }) => { return ( -
-
- - - -
-
- - {truncateMiddle(p.name ?? p.dataKey, 50)} - - : {numberFormat ? formatNumber(p.value, numberFormat) : p.value}{' '} - {previous && ( - - )} -
-
+ ); }, ); @@ -145,42 +99,42 @@ const HDXLineChartTooltip = withErrorBoundary( ); if (active && payload && payload.length) { + const header = ( + <> + + {previousPeriodOffsetSeconds != null && ( + <> + {' (vs '} + + {')'} + + )} + + ); return ( -
-
- - {previousPeriodOffsetSeconds != null && ( - <> - {' (vs '} - + {payload + .sort((a: TooltipPayload, b: TooltipPayload) => b.value - a.value) + .map((p: TooltipPayload) => { + const previousKey = lineDataMap[p.dataKey]?.previousPeriodKey; + const isPreviousPeriod = previousKey === p.dataKey; + const previousPayload = + !isPreviousPeriod && previousKey + ? payloadByKey.get(previousKey) + : undefined; + + return ( + - {')'} - - )} -
-
- {payload - .sort((a: TooltipPayload, b: TooltipPayload) => b.value - a.value) - .map((p: TooltipPayload) => { - const previousKey = lineDataMap[p.dataKey]?.previousPeriodKey; - const isPreviousPeriod = previousKey === p.dataKey; - const previousPayload = - !isPreviousPeriod && previousKey - ? payloadByKey.get(previousKey) - : undefined; - - return ( - - ); - })} -
-
+ ); + })} + ); } return null; @@ -195,38 +149,6 @@ const HDXLineChartTooltip = withErrorBoundary( }, ); -function CopyableLegendItem({ entry }: any) { - return ( - { - window.navigator.clipboard.writeText(entry.value); - notifications.show({ color: 'green', message: `Copied to clipboard` }); - }} - title="Click to expand" - > -
-
- - - -
- {entry.value} -
-
- ); -} - function ExpandableLegendItem({ entry, expanded, diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts index c4c0bb6913..4180c0cf00 100644 --- a/packages/app/src/__tests__/ChartUtils.test.ts +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -8,6 +8,7 @@ import { convertToNumberChartConfig, convertToTableChartConfig, convertToTimeChartConfig, + formatResponseForPieChart, formatResponseForTimeChart, } from '@/ChartUtils'; import { COLORS, getChartColorError } from '@/utils'; @@ -738,4 +739,153 @@ describe('ChartUtils', () => { expect(convertedConfig.limit).toEqual({ limit: 200 }); }); }); + + describe('formatResponseForPieChart', () => { + const getColor = (index: number, label: string) => + `color-${index}-${label}`; + + it('returns empty array when data.data is empty', () => { + const result = formatResponseForPieChart( + { + data: [], + meta: [{ name: 'count()', type: 'UInt64' }], + }, + getColor, + ); + expect(result).toEqual([]); + }); + + it('returns empty array when there are no numeric value columns', () => { + const result = formatResponseForPieChart( + { + data: [{ ServiceName: 'checkout' }], + meta: [{ name: 'ServiceName', type: 'LowCardinality(String)' }], + }, + getColor, + ); + expect(result).toEqual([]); + }); + + it('uses the value column name as label when there are no group-by columns', () => { + const result = formatResponseForPieChart( + { + data: [{ 'count()': 10 }], + meta: [{ name: 'count()', type: 'UInt64' }], + }, + getColor, + ); + expect(result).toEqual([ + { label: 'count()', value: 10, color: 'color-0-count()' }, + ]); + }); + + it('joins group-by column values with " - " as the label', () => { + const result = formatResponseForPieChart( + { + data: [ + { 'count()': 10, ServiceName: 'checkout', env: 'prod' }, + { 'count()': 5, ServiceName: 'shipping', env: 'prod' }, + ], + meta: [ + { name: 'count()', type: 'UInt64' }, + { name: 'ServiceName', type: 'LowCardinality(String)' }, + { name: 'env', type: 'LowCardinality(String)' }, + ], + }, + getColor, + ); + expect(result).toEqual([ + { + label: 'checkout - prod', + value: 10, + color: 'color-0-checkout - prod', + }, + { + label: 'shipping - prod', + value: 5, + color: 'color-1-shipping - prod', + }, + ]); + }); + + it('parses string numeric values', () => { + const result = formatResponseForPieChart( + { + data: [{ 'count()': '42' }], + meta: [{ name: 'count()', type: 'UInt64' }], + }, + getColor, + ); + expect(result).toEqual([ + { label: 'count()', value: 42, color: 'color-0-count()' }, + ]); + }); + + it('filters out NaN values', () => { + const result = formatResponseForPieChart( + { + data: [{ 'count()': 'not-a-number' }, { 'count()': 5 }], + meta: [{ name: 'count()', type: 'UInt64' }], + }, + getColor, + ); + expect(result).toEqual([ + { label: 'count()', value: 5, color: 'color-0-count()' }, + ]); + }); + + it('sorts entries in descending order by value', () => { + const result = formatResponseForPieChart( + { + data: [ + { 'count()': 3, ServiceName: 'c' }, + { 'count()': 10, ServiceName: 'a' }, + { 'count()': 1, ServiceName: 'b' }, + ], + meta: [ + { name: 'count()', type: 'UInt64' }, + { name: 'ServiceName', type: 'LowCardinality(String)' }, + ], + }, + getColor, + ); + expect(result.map(e => e.value)).toEqual([10, 3, 1]); + }); + + it('assigns colors by sorted index', () => { + const result = formatResponseForPieChart( + { + data: [ + { 'count()': 1, ServiceName: 'b' }, + { 'count()': 10, ServiceName: 'a' }, + ], + meta: [ + { name: 'count()', type: 'UInt64' }, + { name: 'ServiceName', type: 'LowCardinality(String)' }, + ], + }, + getColor, + ); + // 'a' (value 10) sorts first and gets index 0; 'b' (value 1) gets index 1 + expect(result[0]).toMatchObject({ label: 'a', color: 'color-0-a' }); + expect(result[1]).toMatchObject({ label: 'b', color: 'color-1-b' }); + }); + + it('uses only the first numeric column as the value column', () => { + const result = formatResponseForPieChart( + { + data: [{ count: 5, duration: 999, ServiceName: 'svc' }], + meta: [ + { name: 'count', type: 'UInt64' }, + { name: 'duration', type: 'Float64' }, + { name: 'ServiceName', type: 'LowCardinality(String)' }, + ], + }, + getColor, + ); + expect(result).toEqual([ + { label: 'svc', value: 5, color: 'color-0-svc' }, + ]); + }); + }); }); diff --git a/packages/app/src/components/DBEditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm.tsx index 356d29f34c..c685256815 100644 --- a/packages/app/src/components/DBEditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm.tsx @@ -69,6 +69,7 @@ import { AGG_FNS, buildTableRowSearchUrl, convertToNumberChartConfig, + convertToPieChartConfig, convertToTableChartConfig, convertToTimeChartConfig, getPreviousDateRange, @@ -942,6 +943,8 @@ export default function EditTimeChartForm({ return convertToNumberChartConfig(config); } else if (activeTab === 'table') { return convertToTableChartConfig(config); + } else if (activeTab === 'pie') { + return convertToPieChartConfig(config); } return config; @@ -1212,24 +1215,25 @@ export default function EditTimeChartForm({ - {displayType !== DisplayType.Number && ( - - )} + {displayType !== DisplayType.Number && + displayType !== DisplayType.Pie && ( + + )} {fields.length == 2 && displayType !== DisplayType.Number && ( { + if (!active || !payload?.length) return null; + const entry = payload[0]; + return ( + + + + ); + }, +); + export const DBPieChart = ({ config, title, @@ -53,22 +81,6 @@ export const DBPieChart = ({ }, ); - // Returns an array of aliases, so we can check if something is using an alias - const aliasMap = useMemo(() => { - // If the config.select is a string, we can't infer this. - // One day, we could potentially run this through chSqlToAliasMap but AST parsing - // doesn't work for most DBTableChart queries. - if (typeof config.select === 'string') { - return []; - } - return config.select.reduce((acc, select) => { - if (select.alias) { - acc.push(select.alias); - } - return acc; - }, [] as string[]); - }, [config.select]); - const toolbarItemsMemo = useMemo(() => { const allToolbarItems = []; @@ -110,74 +122,10 @@ export const DBPieChart = ({ queriedConfig, ]); - // Extract group column names from groupBy config - const groupByKeys = useMemo(() => { - if (!queriedConfig.groupBy) return []; - - if (typeof queriedConfig.groupBy === 'string') { - return queriedConfig.groupBy.split(',').map(v => v.trim()); - } - - return queriedConfig.groupBy.map(g => - typeof g === 'string' ? g : g.valueExpression, - ); - }, [queriedConfig.groupBy]); - const pieChartData = useMemo(() => { - if (!data || data.data.length === 0) return []; - - if (groupByKeys.length > 0 && data.data.length > 0) { - const groupColumnSet = new Set(groupByKeys); - - return data.data.map((row, index) => { - const label = - groupByKeys.length === 1 - ? String(row[groupByKeys[0]]) - : groupByKeys.map(key => row[key]).join(' - '); - - let totalValue = 0; - for (const key in row) { - if (!groupColumnSet.has(key)) { - const numValue = parseFloat(row[key]); - if (!isNaN(numValue)) { - totalValue += numValue; - } - } - } - - return { - label, - value: totalValue, - color: - index >= COLORS.length - ? // Source - https://stackoverflow.com/a/5092872 - '#000000'.replace(/0/g, () => { - return (~~(Math.random() * 16)).toString(16); - }) - : COLORS[index], - }; - }); - } - - if (data.data.length === 1) { - const queryData = data.data[0]; - - return Object.keys(queryData).map((key, index) => ({ - // If it's an alias, wrap in quotes to support a variety of formats (ex "Time (ms)", "Req/s", etc) - label: aliasMap.includes(key) ? `${key}` : key, - value: parseFloat(queryData[key]), - color: - index >= COLORS.length - ? // Source - https://stackoverflow.com/a/5092872 - '#000000'.replace(/0/g, () => { - return (~~(Math.random() * 16)).toString(16); - }) - : COLORS[index], - })); - } - - return []; - }, [data, aliasMap, groupByKeys]); + if (!data) return []; + return formatResponseForPieChart(data, getColorProps); + }, [data]); return ( @@ -243,7 +191,9 @@ export const DBPieChart = ({ ))} - + } + /> diff --git a/packages/app/src/components/charts/ChartTooltip.tsx b/packages/app/src/components/charts/ChartTooltip.tsx new file mode 100644 index 0000000000..1918996ae4 --- /dev/null +++ b/packages/app/src/components/charts/ChartTooltip.tsx @@ -0,0 +1,110 @@ +import { memo } from 'react'; +import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react'; + +import type { NumberFormat } from '@/types'; +import { formatNumber, truncateMiddle } from '@/utils'; + +import styles from '../../../styles/HDXLineChart.module.scss'; + +const percentFormatter = new Intl.NumberFormat('en-US', { + style: 'percent', + maximumFractionDigits: 2, +}); + +const calculatePercentChange = (current: number, previous: number) => { + if (previous === 0) { + return current === 0 ? 0 : undefined; + } + return (current - previous) / previous; +}; + +const PercentChange = ({ + current, + previous, +}: { + current: number; + previous: number; +}) => { + const percentChange = calculatePercentChange(current, previous); + if (percentChange == undefined) { + return null; + } + + const Icon = percentChange > 0 ? IconCaretUpFilled : IconCaretDownFilled; + + return ( + + ( + {percentFormatter.format(Math.abs(percentChange))}) + + ); +}; + +export const ChartTooltipItem = memo( + ({ + color, + name, + value, + numberFormat, + indicator = 'line', + strokeDasharray, + opacity, + previous, + }: { + color: string; + name: string; + value: number; + numberFormat?: NumberFormat; + indicator?: 'line' | 'square' | 'none'; + strokeDasharray?: string; + opacity?: number; + previous?: number; + }) => { + return ( +
+
+ {indicator === 'square' ? ( + + + + ) : indicator === 'line' ? ( + + + + ) : null} +
+
+ {truncateMiddle(name, 50)} + {': '} + {numberFormat ? formatNumber(value, numberFormat) : value}{' '} + {previous != null && ( + + )} +
+
+ ); + }, +); + +export const ChartTooltipContainer = ({ + header, + children, +}: { + header?: React.ReactNode; + children: React.ReactNode; +}) => ( +
+ {header != null && ( +
{header}
+ )} +
{children}
+
+);