mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Chart Number Formatting (#162)
This commit is contained in:
parent
f65dd9bc1c
commit
5e37a94513
17 changed files with 572 additions and 42 deletions
6
.changeset/eighty-owls-nail.md
Normal file
6
.changeset/eighty-owls-nail.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@hyperdx/api': patch
|
||||
'@hyperdx/app': patch
|
||||
---
|
||||
|
||||
Allow to customize number formats in dashboard charts
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,19 @@ import mongoose, { Schema } from 'mongoose';
|
|||
import { AggFn } from '../clickhouse';
|
||||
import type { ObjectId } from '.';
|
||||
|
||||
// Based on numbro.js format
|
||||
// https://numbrojs.com/format.html
|
||||
type NumberFormat = {
|
||||
output?: 'currency' | 'percent' | 'byte' | 'time' | 'number';
|
||||
mantissa?: number;
|
||||
thousandSeparated?: boolean;
|
||||
average?: boolean;
|
||||
decimalBytes?: boolean;
|
||||
factor?: number;
|
||||
currencySymbol?: string;
|
||||
unit?: string;
|
||||
};
|
||||
|
||||
type Chart = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -18,6 +31,7 @@ type Chart = {
|
|||
field: string | undefined;
|
||||
where: string;
|
||||
groupBy: string[];
|
||||
numberFormat?: NumberFormat;
|
||||
}
|
||||
| {
|
||||
table: string;
|
||||
|
|
@ -36,6 +50,7 @@ type Chart = {
|
|||
aggFn: AggFn;
|
||||
field: string | undefined;
|
||||
where: string;
|
||||
numberFormat?: NumberFormat;
|
||||
}
|
||||
| {
|
||||
type: 'table';
|
||||
|
|
@ -45,6 +60,7 @@ type Chart = {
|
|||
where: string;
|
||||
groupBy: string[];
|
||||
sortOrder: 'desc' | 'asc';
|
||||
numberFormat?: NumberFormat;
|
||||
}
|
||||
| {
|
||||
type: 'markdown';
|
||||
|
|
|
|||
|
|
@ -36,6 +36,26 @@ const zChart = z.object({
|
|||
groupBy: z.array(z.string()).optional(),
|
||||
sortOrder: z.union([z.literal('desc'), z.literal('asc')]).optional(),
|
||||
content: z.string().optional(),
|
||||
numberFormat: z
|
||||
.object({
|
||||
output: z
|
||||
.union([
|
||||
z.literal('currency'),
|
||||
z.literal('percent'),
|
||||
z.literal('byte'),
|
||||
z.literal('time'),
|
||||
z.literal('number'),
|
||||
])
|
||||
.optional(),
|
||||
mantissa: z.number().optional(),
|
||||
thousandSeparated: z.boolean().optional(),
|
||||
average: z.boolean().optional(),
|
||||
decimalBytes: z.boolean().optional(),
|
||||
factor: z.number().optional(),
|
||||
currencySymbol: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"nextra": "2.0.1",
|
||||
"nextra-theme-docs": "^2.0.2",
|
||||
"normalize-url": "4",
|
||||
"numbro": "^2.4.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-bootstrap": "^2.4.0",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ const mantineTheme: MantineThemeOverride = {
|
|||
label: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
description: {
|
||||
marginBottom: 8,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
Card: {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { useMemo, useRef } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { add } from 'date-fns';
|
||||
import Select from 'react-select';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import { Divider, Group, Paper } from '@mantine/core';
|
||||
|
||||
import { NumberFormatInput } from './components/NumberFormat';
|
||||
import api from './api';
|
||||
import Checkbox from './Checkbox';
|
||||
import MetricTagFilterInput from './MetricTagFilterInput';
|
||||
import SearchInput from './SearchInput';
|
||||
|
||||
export const SORT_ORDER = [
|
||||
{ value: 'asc' as const, label: 'Ascending' },
|
||||
{ value: 'desc' as const, label: 'Descending' },
|
||||
];
|
||||
import { NumberFormat } from './types';
|
||||
export type SortOrder = (typeof SORT_ORDER)[number]['value'];
|
||||
|
||||
export const TABLES = [
|
||||
|
|
@ -394,6 +396,8 @@ export function ChartSeriesForm({
|
|||
sortOrder,
|
||||
table,
|
||||
where,
|
||||
numberFormat,
|
||||
setNumberFormat,
|
||||
}: {
|
||||
aggFn: AggFn;
|
||||
field: string | undefined;
|
||||
|
|
@ -409,6 +413,8 @@ export function ChartSeriesForm({
|
|||
sortOrder?: string;
|
||||
table: string;
|
||||
where: string;
|
||||
numberFormat?: NumberFormat;
|
||||
setNumberFormat?: (format?: NumberFormat) => void;
|
||||
}) {
|
||||
const labelWidth = 350;
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -598,6 +604,27 @@ export function ChartSeriesForm({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
{setNumberFormat && (
|
||||
<div className="ms-2 mt-2 mb-3">
|
||||
<Divider
|
||||
label={
|
||||
<>
|
||||
<i className="bi bi-gear me-1" />
|
||||
Chart Settings
|
||||
</>
|
||||
}
|
||||
c="dark.2"
|
||||
mb={8}
|
||||
/>
|
||||
<Group>
|
||||
<div className="fs-8 text-slate-300">Number Format</div>
|
||||
<NumberFormatInput
|
||||
value={numberFormat}
|
||||
onChange={setNumberFormat}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ const Tile = forwardRef(
|
|||
granularity:
|
||||
granularity ?? convertDateRangeToGranularityString(dateRange, 60),
|
||||
dateRange,
|
||||
numberFormat: chart.series[0].numberFormat,
|
||||
}
|
||||
: type === 'table'
|
||||
? {
|
||||
|
|
@ -147,6 +148,7 @@ const Tile = forwardRef(
|
|||
granularity:
|
||||
granularity ?? convertDateRangeToGranularityString(dateRange, 60),
|
||||
dateRange,
|
||||
numberFormat: chart.series[0].numberFormat,
|
||||
}
|
||||
: type === 'histogram'
|
||||
? {
|
||||
|
|
@ -169,6 +171,7 @@ const Tile = forwardRef(
|
|||
field: chart.series[0].field ?? '', // TODO: Fix in definition
|
||||
where: buildAndWhereClause(query, chart.series[0].where),
|
||||
dateRange,
|
||||
numberFormat: chart.series[0].numberFormat,
|
||||
}
|
||||
: {
|
||||
type,
|
||||
|
|
@ -322,6 +325,7 @@ const EditChartModal = ({
|
|||
onHide={onClose}
|
||||
show={show}
|
||||
size="xl"
|
||||
enforceFocus={false}
|
||||
>
|
||||
<Modal.Body className="bg-hdx-dark rounded">
|
||||
<TabBar
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { useEffect, useMemo, useState } from 'react';
|
|||
import produce from 'immer';
|
||||
import { Button, Form, InputGroup, Modal } from 'react-bootstrap';
|
||||
import Select from 'react-select';
|
||||
import { Divider, Group, Paper } from '@mantine/core';
|
||||
|
||||
import { NumberFormatInput } from './components/NumberFormat';
|
||||
import { intervalToGranularity } from './Alert';
|
||||
import {
|
||||
AGG_FNS,
|
||||
|
|
@ -21,7 +23,7 @@ import HDXMarkdownChart from './HDXMarkdownChart';
|
|||
import HDXNumberChart from './HDXNumberChart';
|
||||
import HDXTableChart from './HDXTableChart';
|
||||
import { LogTableWithSidePanel } from './LogTableWithSidePanel';
|
||||
import type { Alert } from './types';
|
||||
import type { Alert, NumberFormat } from './types';
|
||||
import { hashCode, useDebounce } from './utils';
|
||||
|
||||
export type Chart = {
|
||||
|
|
@ -39,6 +41,7 @@ export type Chart = {
|
|||
field: string | undefined;
|
||||
where: string;
|
||||
groupBy: string[];
|
||||
numberFormat?: NumberFormat;
|
||||
}
|
||||
| {
|
||||
table: string;
|
||||
|
|
@ -57,6 +60,7 @@ export type Chart = {
|
|||
aggFn: AggFn;
|
||||
field: string | undefined;
|
||||
where: string;
|
||||
numberFormat?: NumberFormat;
|
||||
}
|
||||
| {
|
||||
type: 'table';
|
||||
|
|
@ -66,6 +70,7 @@ export type Chart = {
|
|||
where: string;
|
||||
groupBy: string[];
|
||||
sortOrder: 'desc' | 'asc';
|
||||
numberFormat?: NumberFormat;
|
||||
}
|
||||
| {
|
||||
type: 'markdown';
|
||||
|
|
@ -318,6 +323,7 @@ export const EditNumberChartForm = ({
|
|||
field: editedChart.series[0].field ?? '', // TODO: Fix in definition
|
||||
where: editedChart.series[0].where,
|
||||
dateRange,
|
||||
numberFormat: editedChart.series[0].numberFormat,
|
||||
}
|
||||
: null;
|
||||
}, [editedChart, dateRange]);
|
||||
|
|
@ -430,6 +436,34 @@ export const EditNumberChartForm = ({
|
|||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ms-2 mt-2 mb-3">
|
||||
<Divider
|
||||
label={
|
||||
<>
|
||||
<i className="bi bi-gear me-1" />
|
||||
Chart Settings
|
||||
</>
|
||||
}
|
||||
c="dark.2"
|
||||
mb={8}
|
||||
/>
|
||||
<Group>
|
||||
<div className="fs-8 text-slate-300">Number Format</div>
|
||||
<NumberFormatInput
|
||||
value={editedChart.series[0].numberFormat}
|
||||
onChange={numberFormat =>
|
||||
setEditedChart(
|
||||
produce(editedChart, draft => {
|
||||
if (draft.series[0].type === 'number') {
|
||||
draft.series[0].numberFormat = numberFormat;
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-between my-3 ps-2">
|
||||
<Button
|
||||
variant="outline-success"
|
||||
|
|
@ -500,6 +534,7 @@ export const EditTableChartForm = ({
|
|||
sortOrder: editedChart.series[0].sortOrder ?? 'desc',
|
||||
granularity: convertDateRangeToGranularityString(dateRange, 60),
|
||||
dateRange,
|
||||
numberFormat: editedChart.series[0].numberFormat,
|
||||
}
|
||||
: null,
|
||||
[editedChart, dateRange],
|
||||
|
|
@ -623,6 +658,16 @@ export const EditTableChartForm = ({
|
|||
}),
|
||||
)
|
||||
}
|
||||
numberFormat={editedChart.series[0].numberFormat}
|
||||
setNumberFormat={numberFormat =>
|
||||
setEditedChart(
|
||||
produce(editedChart, draft => {
|
||||
if (draft.series[0].type === CHART_TYPE) {
|
||||
draft.series[0].numberFormat = numberFormat;
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="d-flex justify-content-between my-3 ps-2">
|
||||
<Button
|
||||
|
|
@ -854,6 +899,7 @@ export const EditLineChartForm = ({
|
|||
? intervalToGranularity(editedAlert?.interval)
|
||||
: convertDateRangeToGranularityString(dateRange, 60),
|
||||
dateRange,
|
||||
numberFormat: editedChart.series[0].numberFormat,
|
||||
}
|
||||
: null,
|
||||
[editedChart, alertEnabled, editedAlert?.interval, dateRange],
|
||||
|
|
@ -904,6 +950,7 @@ export const EditLineChartForm = ({
|
|||
where={editedChart.series[0].where}
|
||||
groupBy={editedChart.series[0].groupBy[0]}
|
||||
field={editedChart.series[0].field ?? ''}
|
||||
numberFormat={editedChart.series[0].numberFormat}
|
||||
setTable={table =>
|
||||
setEditedChart(
|
||||
produce(editedChart, draft => {
|
||||
|
|
@ -973,10 +1020,19 @@ export const EditLineChartForm = ({
|
|||
}),
|
||||
);
|
||||
}}
|
||||
setNumberFormat={numberFormat =>
|
||||
setEditedChart(
|
||||
produce(editedChart, draft => {
|
||||
if (draft.series[0].type === CHART_TYPE) {
|
||||
draft.series[0].numberFormat = numberFormat;
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{isChartAlertsFeatureEnabled && (
|
||||
<div className="mt-4 border-top border-bottom border-grey p-2 py-3">
|
||||
<Paper bg="dark.7" p="md" py="xs" mt="md" withBorder className="ms-2">
|
||||
{isLocalDashboard ? (
|
||||
<span className="text-gray-600 fs-8">
|
||||
Alerts are not available in unsaved dashboards.
|
||||
|
|
@ -991,15 +1047,17 @@ export const EditLineChartForm = ({
|
|||
/>
|
||||
{alertEnabled && (
|
||||
<div className="mt-2">
|
||||
<Divider mb="sm" />
|
||||
<EditChartFormAlerts
|
||||
alert={editedAlert ?? DEFAULT_ALERT}
|
||||
setAlert={setEditedAlert}
|
||||
numberFormat={editedChart.series[0].numberFormat}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<div className="d-flex justify-content-between my-3 ps-2">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as React from 'react';
|
|||
import produce from 'immer';
|
||||
import { omit } from 'lodash';
|
||||
import { Form } from 'react-bootstrap';
|
||||
import { Tooltip } from '@mantine/core';
|
||||
|
||||
import {
|
||||
ALERT_CHANNEL_OPTIONS,
|
||||
|
|
@ -9,6 +10,8 @@ import {
|
|||
SlackChannelForm,
|
||||
} from './Alert';
|
||||
import type { Alert } from './types';
|
||||
import { NumberFormat } from './types';
|
||||
import { formatNumber } from './utils';
|
||||
|
||||
// Don't allow 1 minute alerts for charts
|
||||
const CHART_ALERT_INTERVAL_OPTIONS = omit(ALERT_INTERVAL_OPTIONS, '1m');
|
||||
|
|
@ -16,16 +19,23 @@ const CHART_ALERT_INTERVAL_OPTIONS = omit(ALERT_INTERVAL_OPTIONS, '1m');
|
|||
type ChartAlertFormProps = {
|
||||
alert: Alert;
|
||||
setAlert: (alert?: Alert) => void;
|
||||
numberFormat?: NumberFormat;
|
||||
};
|
||||
|
||||
export default function EditChartFormAlerts({
|
||||
alert,
|
||||
setAlert,
|
||||
numberFormat,
|
||||
}: ChartAlertFormProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
Alert when the value
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
<span>
|
||||
Alert when the value
|
||||
<Tooltip label="Raw value before applying number format">
|
||||
<i className="bi bi-question-circle ms-1 text-slate-300" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Form.Select
|
||||
id="type"
|
||||
size="sm"
|
||||
|
|
@ -48,22 +58,39 @@ export default function EditChartFormAlerts({
|
|||
falls below
|
||||
</option>
|
||||
</Form.Select>
|
||||
<Form.Control
|
||||
style={{ width: 70 }}
|
||||
type="number"
|
||||
required
|
||||
id="threshold"
|
||||
size="sm"
|
||||
defaultValue={1}
|
||||
value={alert?.threshold}
|
||||
onChange={e => {
|
||||
setAlert(
|
||||
produce(alert, draft => {
|
||||
draft.threshold = parseFloat(e.target.value);
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginBottom: -20 }}>
|
||||
<Form.Control
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
required
|
||||
id="threshold"
|
||||
size="sm"
|
||||
defaultValue={1}
|
||||
value={alert?.threshold}
|
||||
onChange={e => {
|
||||
setAlert(
|
||||
produce(alert, draft => {
|
||||
draft.threshold = parseFloat(e.target.value);
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="text-slate-300 fs-8"
|
||||
style={{
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
{numberFormat && alert?.threshold > 0 && (
|
||||
<>
|
||||
{formatNumber(alert.threshold, numberFormat)}
|
||||
<Tooltip label="Formatted value">
|
||||
<i className="bi bi-question-circle ms-1 text-slate-300" />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
over
|
||||
<Form.Select
|
||||
id="interval"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import cx from 'classnames';
|
||||
import { add, format } from 'date-fns';
|
||||
|
|
@ -20,7 +20,9 @@ import {
|
|||
|
||||
import api from './api';
|
||||
import { AggFn, convertGranularityToSeconds, Granularity } from './ChartUtils';
|
||||
import type { NumberFormat } from './types';
|
||||
import useUserPreferences, { TimeFormat } from './useUserPreferences';
|
||||
import { formatNumber } from './utils';
|
||||
import { semanticKeyedColor, TIME_TOKENS, truncateMiddle } from './utils';
|
||||
|
||||
function ExpandableLegendItem({ value, entry }: any) {
|
||||
|
|
@ -54,6 +56,7 @@ const MemoChart = memo(function MemoChart({
|
|||
alertThreshold,
|
||||
alertThresholdType,
|
||||
displayType = 'line',
|
||||
numberFormat,
|
||||
}: {
|
||||
graphResults: any[];
|
||||
setIsClickActive: (v: any) => void;
|
||||
|
|
@ -63,6 +66,7 @@ const MemoChart = memo(function MemoChart({
|
|||
alertThreshold?: number;
|
||||
alertThresholdType?: 'above' | 'below';
|
||||
displayType?: 'stacked_bar' | 'line';
|
||||
numberFormat?: NumberFormat;
|
||||
}) {
|
||||
const ChartComponent = displayType === 'stacked_bar' ? BarChart : LineChart;
|
||||
|
||||
|
|
@ -93,6 +97,22 @@ const MemoChart = memo(function MemoChart({
|
|||
const tsFormat = TIME_TOKENS[timeFormat];
|
||||
// Gets the preffered time format from User Preferences, then converts it to a formattable token
|
||||
|
||||
const tickFormatter = useCallback(
|
||||
(value: number) =>
|
||||
numberFormat
|
||||
? formatNumber(value, {
|
||||
...numberFormat,
|
||||
average: true,
|
||||
mantissa: 0,
|
||||
unit: undefined,
|
||||
})
|
||||
: new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value),
|
||||
[numberFormat],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
|
|
@ -145,18 +165,15 @@ const MemoChart = memo(function MemoChart({
|
|||
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
|
||||
/>
|
||||
<YAxis
|
||||
width={35}
|
||||
width={40}
|
||||
minTickGap={25}
|
||||
tickFormatter={(value: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value)
|
||||
}
|
||||
tickFormatter={tickFormatter}
|
||||
tick={{ fontSize: 12, fontFamily: 'IBM Plex Mono, monospace' }}
|
||||
/>
|
||||
{lines}
|
||||
<Tooltip content={<HDXLineChartTooltip />} />
|
||||
<Tooltip
|
||||
content={<HDXLineChartTooltip numberFormat={numberFormat} />}
|
||||
/>
|
||||
{alertThreshold != null && alertThresholdType === 'below' && (
|
||||
<ReferenceArea
|
||||
y1={0}
|
||||
|
|
@ -199,7 +216,7 @@ const MemoChart = memo(function MemoChart({
|
|||
const HDXLineChartTooltip = (props: any) => {
|
||||
const timeFormat: TimeFormat = useUserPreferences().timeFormat;
|
||||
const tsFormat = TIME_TOKENS[timeFormat];
|
||||
const { active, payload, label } = props;
|
||||
const { active, payload, label, numberFormat } = props;
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-grey px-3 py-2 rounded fs-8">
|
||||
|
|
@ -208,7 +225,8 @@ const HDXLineChartTooltip = (props: any) => {
|
|||
.sort((a: any, b: any) => b.value - a.value)
|
||||
.map((p: any) => (
|
||||
<div key={p.name} style={{ color: p.color }}>
|
||||
{p.dataKey}: {p.value}
|
||||
{p.dataKey}:{' '}
|
||||
{numberFormat ? formatNumber(p.value, numberFormat) : p.value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -218,7 +236,16 @@ const HDXLineChartTooltip = (props: any) => {
|
|||
};
|
||||
const HDXLineChart = memo(
|
||||
({
|
||||
config: { table, aggFn, field, where, groupBy, granularity, dateRange },
|
||||
config: {
|
||||
table,
|
||||
aggFn,
|
||||
field,
|
||||
where,
|
||||
groupBy,
|
||||
granularity,
|
||||
dateRange,
|
||||
numberFormat,
|
||||
},
|
||||
onSettled,
|
||||
alertThreshold,
|
||||
alertThresholdType,
|
||||
|
|
@ -231,6 +258,7 @@ const HDXLineChart = memo(
|
|||
groupBy: string;
|
||||
granularity: Granularity;
|
||||
dateRange: [Date, Date];
|
||||
numberFormat?: NumberFormat;
|
||||
};
|
||||
onSettled?: () => void;
|
||||
alertThreshold?: number;
|
||||
|
|
@ -488,6 +516,7 @@ const HDXLineChart = memo(
|
|||
alertThreshold={alertThreshold}
|
||||
alertThresholdType={alertThresholdType}
|
||||
displayType={displayType}
|
||||
numberFormat={numberFormat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import { memo } from 'react';
|
|||
|
||||
import api from './api';
|
||||
import { AggFn } from './ChartUtils';
|
||||
import { NumberFormat } from './types';
|
||||
import { formatNumber } from './utils';
|
||||
|
||||
const HDXNumberChart = memo(
|
||||
({
|
||||
config: { table, aggFn, field, where, dateRange },
|
||||
config: { table, aggFn, field, where, dateRange, numberFormat },
|
||||
onSettled,
|
||||
}: {
|
||||
config: {
|
||||
|
|
@ -14,6 +16,7 @@ const HDXNumberChart = memo(
|
|||
field: string;
|
||||
where: string;
|
||||
dateRange: [Date, Date];
|
||||
numberFormat?: NumberFormat;
|
||||
};
|
||||
onSettled?: () => void;
|
||||
}) => {
|
||||
|
|
@ -48,7 +51,7 @@ const HDXNumberChart = memo(
|
|||
},
|
||||
);
|
||||
|
||||
const number = data?.data?.[0]?.data;
|
||||
const number = formatNumber(data?.data?.[0]?.data, numberFormat);
|
||||
|
||||
return isLoading ? (
|
||||
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
|
||||
|
|
|
|||
|
|
@ -7,25 +7,29 @@ import {
|
|||
Row as TableRow,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import api from './api';
|
||||
import { AggFn } from './ChartUtils';
|
||||
import { UNDEFINED_WIDTH } from './tableUtils';
|
||||
|
||||
import type { NumberFormat } from './types';
|
||||
import { formatNumber } from './utils';
|
||||
const Table = ({
|
||||
data,
|
||||
valueColumnName,
|
||||
numberFormat,
|
||||
onRowClick,
|
||||
}: {
|
||||
data: any[];
|
||||
valueColumnName: string;
|
||||
numberFormat?: NumberFormat;
|
||||
onRowClick?: (row: any) => void;
|
||||
}) => {
|
||||
//we need a reference to the scrolling element for logic down below
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const columns = [
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: 'group',
|
||||
header: 'Group',
|
||||
|
|
@ -39,6 +43,13 @@ const Table = ({
|
|||
accessorKey: 'data',
|
||||
header: valueColumnName,
|
||||
size: UNDEFINED_WIDTH,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue() as string;
|
||||
if (numberFormat) {
|
||||
return formatNumber(parseInt(value), numberFormat);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -193,7 +204,16 @@ const Table = ({
|
|||
|
||||
const HDXTableChart = memo(
|
||||
({
|
||||
config: { table, aggFn, field, where, groupBy, dateRange, sortOrder },
|
||||
config: {
|
||||
table,
|
||||
aggFn,
|
||||
field,
|
||||
where,
|
||||
groupBy,
|
||||
dateRange,
|
||||
sortOrder,
|
||||
numberFormat,
|
||||
},
|
||||
onSettled,
|
||||
}: {
|
||||
config: {
|
||||
|
|
@ -204,6 +224,7 @@ const HDXTableChart = memo(
|
|||
groupBy: string;
|
||||
dateRange: [Date, Date];
|
||||
sortOrder: 'asc' | 'desc';
|
||||
numberFormat?: NumberFormat;
|
||||
};
|
||||
onSettled?: () => void;
|
||||
}) => {
|
||||
|
|
@ -275,6 +296,7 @@ const HDXTableChart = memo(
|
|||
data={data?.data ?? []}
|
||||
valueColumnName={valueColumnName}
|
||||
onRowClick={handleRowClick}
|
||||
numberFormat={numberFormat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
257
packages/app/src/components/NumberFormat.tsx
Normal file
257
packages/app/src/components/NumberFormat.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
Button,
|
||||
Checkbox as MCheckbox,
|
||||
Drawer,
|
||||
NativeSelect,
|
||||
Paper,
|
||||
Slider,
|
||||
Stack,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
|
||||
import { NumberFormat } from '../types';
|
||||
import { formatNumber } from '../utils';
|
||||
|
||||
const FORMAT_NAMES: Record<string, string> = {
|
||||
number: 'Number',
|
||||
currency: 'Currency',
|
||||
percent: 'Percentage',
|
||||
byte: 'Bytes',
|
||||
time: 'Time',
|
||||
};
|
||||
|
||||
const FORMAT_ICONS: Record<string, string> = {
|
||||
number: '123',
|
||||
currency: 'currency-dollar',
|
||||
percent: 'percent',
|
||||
byte: 'database',
|
||||
time: 'clock',
|
||||
};
|
||||
|
||||
export const NumberFormatForm: React.VFC<{
|
||||
value?: NumberFormat;
|
||||
onApply: (value: NumberFormat) => void;
|
||||
onClose: () => void;
|
||||
}> = ({ value, onApply, onClose }) => {
|
||||
const { register, handleSubmit, watch, setValue } = useForm<NumberFormat>({
|
||||
values: value,
|
||||
defaultValues: {
|
||||
factor: 1,
|
||||
output: 'number',
|
||||
mantissa: 2,
|
||||
thousandSeparated: true,
|
||||
average: false,
|
||||
decimalBytes: false,
|
||||
},
|
||||
});
|
||||
|
||||
const values = watch();
|
||||
|
||||
const testNumber = 1234;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack style={{ flex: 1 }}>
|
||||
{/* <TextInput
|
||||
label="Coefficient"
|
||||
type="number"
|
||||
description="Multiply number by this value before formatting. You can use it to convert source value to seconds, bytes, base currency, etc."
|
||||
{...register('factor', { valueAsNumber: true })}
|
||||
rightSectionWidth={70}
|
||||
rightSection={
|
||||
<Button
|
||||
variant="default"
|
||||
compact
|
||||
size="sm"
|
||||
onClick={() => setValue('factor', 1)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
}
|
||||
/> */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
alignItems: 'stretch',
|
||||
justifyContent: 'stretch',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<NativeSelect
|
||||
label="Output format"
|
||||
icon={
|
||||
values.output && (
|
||||
<i className={`bi bi-${FORMAT_ICONS[values.output]}`} />
|
||||
)
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
data={[
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'currency', label: 'Currency' },
|
||||
{ value: 'byte', label: 'Bytes' },
|
||||
{ value: 'percent', label: 'Percentage' },
|
||||
{ value: 'time', label: 'Time (seconds)' },
|
||||
]}
|
||||
{...register('output')}
|
||||
/>
|
||||
{values.output === 'currency' && (
|
||||
<TextInput
|
||||
w={80}
|
||||
label="Symbol"
|
||||
placeholder="$"
|
||||
{...register('currencySymbol')}
|
||||
/>
|
||||
)}
|
||||
{/* <TextInput
|
||||
w={100}
|
||||
label="Unit"
|
||||
placeholder=""
|
||||
{...register('unit')}
|
||||
/> */}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: -6 }}>
|
||||
<Paper p="xs" py={4} bg="dark.8">
|
||||
<div
|
||||
className="text-slate-400"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
Example
|
||||
</div>
|
||||
{formatNumber(testNumber || 0, values)}
|
||||
</Paper>
|
||||
</div>
|
||||
|
||||
{values.output !== 'time' && (
|
||||
<div>
|
||||
<div className="text-slate-300 fs-8 mt-2 fw-bold mb-1">
|
||||
Decimals
|
||||
</div>
|
||||
<Slider
|
||||
mb="xl"
|
||||
min={0}
|
||||
max={10}
|
||||
label={value => `Decimals: ${value}`}
|
||||
marks={[
|
||||
{ value: 0, label: '0' },
|
||||
{ value: 10, label: '10' },
|
||||
]}
|
||||
value={values.mantissa}
|
||||
onChange={value => {
|
||||
setValue('mantissa', value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Stack spacing="xs">
|
||||
{values.output === 'byte' ? (
|
||||
<MCheckbox
|
||||
size="xs"
|
||||
label="Decimal base"
|
||||
description="Use 1KB = 1000 bytes"
|
||||
{...register('decimalBytes')}
|
||||
/>
|
||||
) : values.output === 'time' ? null : (
|
||||
<>
|
||||
<MCheckbox
|
||||
size="xs"
|
||||
label="Separate thousands"
|
||||
description="For example: 1,234,567"
|
||||
{...register('thousandSeparated')}
|
||||
/>
|
||||
<MCheckbox
|
||||
size="xs"
|
||||
label="Large number format"
|
||||
description="For example: 1.2m"
|
||||
{...register('average')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack spacing="xs" mt="xs">
|
||||
<Button type="submit" onClick={handleSubmit(onApply)}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="default">
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TEST_NUMBER = 1234;
|
||||
|
||||
export const NumberFormatInput: React.VFC<{
|
||||
value?: NumberFormat;
|
||||
onChange: (value?: NumberFormat) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const example = React.useMemo(
|
||||
() => formatNumber(TEST_NUMBER, value),
|
||||
[value],
|
||||
);
|
||||
|
||||
const handleApply = React.useCallback(
|
||||
(value?: NumberFormat) => {
|
||||
onChange(value);
|
||||
close();
|
||||
},
|
||||
[onChange, close],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title="Number format"
|
||||
position="right"
|
||||
padding="lg"
|
||||
zIndex={100000}
|
||||
>
|
||||
<NumberFormatForm value={value} onApply={handleApply} onClose={close} />
|
||||
</Drawer>
|
||||
<Button.Group>
|
||||
<Button
|
||||
onClick={open}
|
||||
compact
|
||||
size="sm"
|
||||
color="dark"
|
||||
variant="default"
|
||||
leftIcon={
|
||||
value?.output && (
|
||||
<i className={`bi bi-${FORMAT_ICONS[value.output]}`} />
|
||||
)
|
||||
}
|
||||
// rightIcon={
|
||||
// value?.output && (
|
||||
// <div className="text-slate-300 fs-8 fw-bold">{example}</div>
|
||||
// )
|
||||
// }
|
||||
>
|
||||
{value?.output ? FORMAT_NAMES[value.output] : 'Set number format'}
|
||||
</Button>
|
||||
{value?.output && (
|
||||
<Button
|
||||
compact
|
||||
size="sm"
|
||||
color="dark"
|
||||
variant="default"
|
||||
px="xs"
|
||||
onClick={() => handleApply(undefined)}
|
||||
>
|
||||
<i className="bi bi-x-lg" />
|
||||
</Button>
|
||||
)}
|
||||
</Button.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -134,3 +134,14 @@ export type StacktraceBreadcrumb = {
|
|||
data?: { [key: string]: any };
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type NumberFormat = {
|
||||
output?: 'currency' | 'percent' | 'byte' | 'time' | 'number';
|
||||
mantissa?: number;
|
||||
thousandSeparated?: boolean;
|
||||
average?: boolean;
|
||||
decimalBytes?: boolean;
|
||||
factor?: number;
|
||||
currencySymbol?: string;
|
||||
unit?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { format as fnsFormat, formatDistanceToNowStrict } from 'date-fns';
|
||||
import numbro from 'numbro';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import { dateRangeToString } from './timeQuery';
|
||||
import { NumberFormat } from './types';
|
||||
|
||||
export function generateSearchUrl({
|
||||
query,
|
||||
|
|
@ -396,3 +398,34 @@ export const useDrag = (
|
|||
|
||||
return { isDragging };
|
||||
};
|
||||
|
||||
export const formatNumber = (
|
||||
value?: number,
|
||||
options?: NumberFormat,
|
||||
): string => {
|
||||
if (!value && value !== 0) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
const numbroFormat: numbro.Format = {
|
||||
output: options.output || 'number',
|
||||
mantissa: options.mantissa || 0,
|
||||
thousandSeparated: options.thousandSeparated || false,
|
||||
average: options.average || false,
|
||||
...(options.output === 'byte' && {
|
||||
base: options.decimalBytes ? 'decimal' : 'general',
|
||||
spaceSeparated: true,
|
||||
}),
|
||||
...(options.output === 'currency' && {
|
||||
currencySymbol: options.currencySymbol || '$',
|
||||
}),
|
||||
};
|
||||
return (
|
||||
numbro(value * (options.factor ?? 1)).format(numbroFormat) +
|
||||
(options.unit ? ` ${options.unit}` : '')
|
||||
);
|
||||
};
|
||||
|
|
|
|||
14
yarn.lock
14
yarn.lock
|
|
@ -4987,7 +4987,7 @@
|
|||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@>=16", "@types/react@>=16.9.11", "@types/react@^17", "@types/react@^17.0.52":
|
||||
"@types/react@*", "@types/react@17.0.52", "@types/react@>=16", "@types/react@>=16.9.11", "@types/react@^17", "@types/react@^17.0.52":
|
||||
version "17.0.52"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.52.tgz#10d8b907b5c563ac014a541f289ae8eaa9bf2e9b"
|
||||
integrity sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A==
|
||||
|
|
@ -5894,6 +5894,11 @@ big-integer@^1.6.16:
|
|||
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
|
||||
integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==
|
||||
|
||||
"bignumber.js@^8 || ^9":
|
||||
version "9.1.2"
|
||||
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c"
|
||||
integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==
|
||||
|
||||
bignumber.js@^9.0.0:
|
||||
version "9.1.1"
|
||||
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.1.tgz#c4df7dc496bd849d4c9464344c1aa74228b4dac6"
|
||||
|
|
@ -12715,6 +12720,13 @@ npm-run-path@^5.1.0:
|
|||
dependencies:
|
||||
path-key "^4.0.0"
|
||||
|
||||
numbro@^2.4.0:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/numbro/-/numbro-2.4.0.tgz#3cecae307ab2c2d9fd3e1c08249f4abd504bd577"
|
||||
integrity sha512-t6rVkO1CcKvffvOJJu/zMo70VIcQSR6w3AmIhfHGvmk4vHbNe6zHgomB0aWFAPZWM9JBVWBM0efJv9DBiRoSTA==
|
||||
dependencies:
|
||||
bignumber.js "^8 || ^9"
|
||||
|
||||
nwsapi@^2.0.7, nwsapi@^2.2.0, nwsapi@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue