Merge branch 'main' into sr/fullscreen

This commit is contained in:
Shorpo R 2023-12-27 22:26:01 -07:00
commit 63d867501f
28 changed files with 665 additions and 106 deletions

View file

@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---
Loading and error states for metrics dropdown

View file

@ -0,0 +1,5 @@
---
'@hyperdx/api': minor
---
chore: bump clickhouse client to v0.2.7

View file

@ -0,0 +1,6 @@
---
'@hyperdx/api': patch
'@hyperdx/app': patch
---
Allow to customize number formats in dashboard charts

View file

@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---
Add K8s event tags

View file

@ -1,7 +1,7 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true
"source.fixAll": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"

View file

@ -129,7 +129,7 @@ services:
environment:
APP_TYPE: 'scheduled-task'
CLICKHOUSE_HOST: http://ch-server:8123
CLICKHOUSE_LOG_LEVEL: trace
CLICKHOUSE_LOG_LEVEL: ${HYPERDX_LOG_LEVEL}
CLICKHOUSE_PASSWORD: worker
CLICKHOUSE_USER: worker
FRONTEND_URL: 'http://localhost:8080' # need to be localhost (CORS)
@ -166,7 +166,7 @@ services:
AGGREGATOR_API_URL: 'http://aggregator:8001'
APP_TYPE: 'api'
CLICKHOUSE_HOST: http://ch-server:8123
CLICKHOUSE_LOG_LEVEL: trace
CLICKHOUSE_LOG_LEVEL: ${HYPERDX_LOG_LEVEL}
CLICKHOUSE_PASSWORD: api
CLICKHOUSE_USER: api
EXPRESS_SESSION_SECRET: 'hyperdx is cool 👋'

View file

@ -105,7 +105,7 @@ services:
environment:
APP_TYPE: 'scheduled-task'
CLICKHOUSE_HOST: http://ch-server:8123
CLICKHOUSE_LOG_LEVEL: trace
CLICKHOUSE_LOG_LEVEL: ${HYPERDX_LOG_LEVEL}
CLICKHOUSE_PASSWORD: worker
CLICKHOUSE_USER: worker
FRONTEND_URL: ${HYPERDX_APP_URL}:${HYPERDX_APP_PORT} # need to be localhost (CORS)
@ -137,7 +137,7 @@ services:
AGGREGATOR_API_URL: 'http://aggregator:8001'
APP_TYPE: 'api'
CLICKHOUSE_HOST: http://ch-server:8123
CLICKHOUSE_LOG_LEVEL: trace
CLICKHOUSE_LOG_LEVEL: ${HYPERDX_LOG_LEVEL}
CLICKHOUSE_PASSWORD: api
CLICKHOUSE_USER: api
EXPRESS_SESSION_SECRET: 'hyperdx is cool 👋'

View file

@ -7,7 +7,7 @@
"node": ">=18.12.0"
},
"dependencies": {
"@clickhouse/client": "^0.1.1",
"@clickhouse/client": "^0.2.7",
"@hyperdx/lucene": "^3.1.1",
"@hyperdx/node-logger": "^0.2.8",
"@hyperdx/node-opentelemetry": "^0.3.0",

View file

@ -1,19 +1,19 @@
import type { ResponseJSON, ResultSet } from '@clickhouse/client';
import {
BaseResultSet,
createClient,
ErrorLogParams as _CHErrorLogParams,
Logger as _CHLogger,
LogParams as _CHLogParams,
ResponseJSON,
SettingsMap,
} from '@clickhouse/client';
import {
ErrorLogParams as _CHErrorLogParams,
LogParams as _CHLogParams,
} from '@clickhouse/client/dist/logger';
import opentelemetry from '@opentelemetry/api';
import * as fns from 'date-fns';
import _ from 'lodash';
import ms from 'ms';
import { serializeError } from 'serialize-error';
import SqlString from 'sqlstring';
import { Readable } from 'stream';
import * as config from '@/config';
import { sleep } from '@/utils/common';
@ -192,7 +192,16 @@ export const buildMetricStreamAdditionalFilters = (
teamId: string,
) => SettingsMap.from({});
export const healthCheck = () => client.ping();
export const healthCheck = async () => {
const result = await client.ping();
if (!result.success) {
logger.error({
message: 'ClickHouse health check failed',
error: result.error,
});
throw result.error;
}
};
export const connect = async () => {
// FIXME: this is a hack to avoid CI failure
@ -1838,7 +1847,7 @@ export const getRrwebEvents = async ({
],
);
let resultSet: ResultSet;
let resultSet: BaseResultSet<Readable>;
await tracer.startActiveSpan('clickhouse.getRrwebEvents', async span => {
span.setAttribute('query', query);
@ -1897,7 +1906,7 @@ export const getLogStream = async ({
limit,
});
let resultSet: ResultSet;
let resultSet: BaseResultSet<Readable>;
await tracer.startActiveSpan('clickhouse.getLogStream', async span => {
span.setAttribute('query', query);
span.setAttribute('search', q);

View file

@ -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';

View file

@ -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(),
}),
),
});

View file

@ -1,3 +1,4 @@
import type { Row } from '@clickhouse/client';
import opentelemetry, { SpanStatusCode } from '@opentelemetry/api';
import express from 'express';
import { isNumber, omit, parseInt } from 'lodash';
@ -261,7 +262,7 @@ router.get('/stream', async (req, res, next) => {
res.write('event: end\ndata:\n\n');
res.end();
} else {
stream.on('data', (rows: any[]) => {
stream.on('data', (rows: Row[]) => {
resultCount += rows.length;
logger.info(`Sending ${rows.length} rows`);

View file

@ -1,3 +1,4 @@
import type { Row } from '@clickhouse/client';
import opentelemetry, { SpanStatusCode } from '@opentelemetry/api';
import express from 'express';
import { isNumber, parseInt } from 'lodash';
@ -83,7 +84,7 @@ router.get('/:sessionId/rrweb', async (req, res, next) => {
offset: offsetNum,
});
stream.on('data', (rows: any[]) => {
stream.on('data', (rows: Row[]) => {
res.write(`${rows.map(row => `data: ${row.text}`).join('\n')}\n\n`);
res.flush();
});

View file

@ -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",

View file

@ -72,6 +72,10 @@ const mantineTheme: MantineThemeOverride = {
label: {
marginBottom: 4,
},
description: {
marginBottom: 8,
lineHeight: 1.3,
},
},
},
Card: {

View file

@ -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 = [
@ -176,7 +178,7 @@ export function MetricSelect({
setMetricName: (value: string | undefined) => void;
}) {
// TODO: Dedup with metric rate checkbox
const { data: metricTagsData } = api.useMetricsTags();
const { data: metricTagsData, isLoading, isError } = api.useMetricsTags();
const aggFnWithMaybeRate = (aggFn: AggFn, isRate: boolean) => {
if (isRate) {
@ -198,6 +200,8 @@ export function MetricSelect({
<>
<div className="ms-3 flex-grow-1">
<MetricNameSelect
isLoading={isLoading}
isError={isError}
value={metricName}
setValue={name => {
const metricType = metricTagsData?.data?.find(
@ -259,9 +263,13 @@ export function MetricRateSelect({
export function MetricNameSelect({
value,
setValue,
isLoading,
isError,
}: {
value: string | undefined | null;
setValue: (value: string | undefined) => void;
isLoading?: boolean;
isError?: boolean;
}) {
const { data: metricTagsData } = api.useMetricsTags();
@ -276,6 +284,15 @@ export function MetricNameSelect({
return (
<AsyncSelect
isLoading={isLoading}
isDisabled={isError}
placeholder={
isLoading
? 'Loading...'
: isError
? 'Unable to load metrics'
: 'Select a metric...'
}
loadOptions={input => {
return Promise.resolve(
options.filter(v =>
@ -379,6 +396,8 @@ export function ChartSeriesForm({
sortOrder,
table,
where,
numberFormat,
setNumberFormat,
}: {
aggFn: AggFn;
field: string | undefined;
@ -394,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);
@ -583,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>
);
}

View file

@ -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

View file

@ -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">

View file

@ -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"

View file

@ -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>

View file

@ -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">

View file

@ -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>
);

View file

@ -1982,6 +1982,27 @@ function SidePanelHeader({
const playerExpanded = useAtomValue(playerExpandedAtom);
const headerEventTags = useMemo(() => {
return [
['service', logData._service],
['host', logData._host],
['k8s.node.name', parsedProperties['k8s.node.name']],
['k8s.pod.name', parsedProperties['k8s.pod.name']],
['k8s.statefulset.name', parsedProperties['k8s.statefulset.name']],
['k8s.container.name', parsedProperties['k8s.container.name']],
['userEmail', userEmail],
['userName', userName],
['teamName', teamName],
].filter(([, value]) => !!value);
}, [
logData._host,
logData._service,
parsedProperties,
teamName,
userEmail,
userName,
]);
return (
<div>
<div className={styles.panelHeader}>
@ -2069,46 +2090,15 @@ function SidePanelHeader({
</div>
</div>
<div className="d-flex flex-wrap">
{logData._service ? (
{headerEventTags.map(([name, value]) => (
<EventTag
key={name}
onPropertyAddClick={onPropertyAddClick}
generateSearchUrl={generateSearchUrl}
name="service"
value={logData._service}
name={name}
value={value}
/>
) : null}
{logData._host ? (
<EventTag
onPropertyAddClick={onPropertyAddClick}
generateSearchUrl={generateSearchUrl}
name="host"
value={logData._host}
/>
) : null}
{userEmail ? (
<EventTag
onPropertyAddClick={onPropertyAddClick}
generateSearchUrl={generateSearchUrl}
name="userEmail"
value={userEmail}
/>
) : null}
{userName ? (
<EventTag
onPropertyAddClick={onPropertyAddClick}
generateSearchUrl={generateSearchUrl}
name="userName"
value={userName}
/>
) : null}
{teamName ? (
<EventTag
onPropertyAddClick={onPropertyAddClick}
generateSearchUrl={generateSearchUrl}
name="teamName"
value={teamName}
/>
) : null}
))}
</div>
</div>
)}

View 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>
</>
);
};

View file

@ -33,7 +33,6 @@ $horizontalPadding: 12px;
th {
color: $slate-300;
font-family: Inter;
text-transform: uppercase;
font-size: 9px;
font-weight: 500;
@ -101,7 +100,6 @@ $horizontalPadding: 12px;
color: $slate-300;
text-align: center;
font-size: 11px;
font-family: Inter;
&:hover {
background-color: $slate-900;

View file

@ -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;
};

View file

@ -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}` : '')
);
};

View file

@ -1272,12 +1272,17 @@
human-id "^1.0.2"
prettier "^2.7.1"
"@clickhouse/client@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@clickhouse/client/-/client-0.1.1.tgz#1a848438baf5deefadf7dcee40ad441c8666c398"
integrity sha512-oeALCAjNFEXHPxMHJgj0QERiLM2ZknOOavvHB1mxmztZLhTuj86HaQEfh9q8x7LgKnv3jep7lb/fhFgD71WTjA==
"@clickhouse/client-common@0.2.7":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@clickhouse/client-common/-/client-common-0.2.7.tgz#c238ef9f5386f7d7a18e09931bb765a4ad4d7ebb"
integrity sha512-vgZm+8c5Cu1toIx1/xplF5dEHlCPw+7pJDOOEtLv2CIUVZ0Bl6nGVZ43EWxRdHeah9ivTfoRWhN1zI1PxjH0xQ==
"@clickhouse/client@^0.2.7":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@clickhouse/client/-/client-0.2.7.tgz#f13103d7a4ab39d86307e6211504f46b8c71faae"
integrity sha512-ZiyarrGngHc+f5AjZSA7mkQfvnE/71jgXk304B0ps8V+aBpE2CsFB6AQmE/Mk2YkP5j+8r/JfG+m0AZWmE27ig==
dependencies:
uuid "^9.0.0"
"@clickhouse/client-common" "0.2.7"
"@cnakazawa/watch@^1.0.3":
version "1.0.4"
@ -4982,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==
@ -5889,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"
@ -12710,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"
@ -16466,11 +16483,6 @@ uuid@^8.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
uuid@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
uvu@^0.5.0:
version "0.5.6"
resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df"