mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-09 19:50:43 +01:00
Compare commits
23 Commits
feat/scatt
...
feat/top-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af76f1c16c | ||
|
|
46d6541cf4 | ||
|
|
86b9ce27e3 | ||
|
|
be08a42a8d | ||
|
|
a8594d72a6 | ||
|
|
529cff16db | ||
|
|
7dc07f2e3c | ||
|
|
d81c660607 | ||
|
|
ad765ee76c | ||
|
|
c30083e982 | ||
|
|
1233c67b8f | ||
|
|
ca3baf5598 | ||
|
|
b523aa7291 | ||
|
|
bdb8152e8c | ||
|
|
fe2363237d | ||
|
|
901b30ee46 | ||
|
|
3f7cdcf139 | ||
|
|
d9a12c5368 | ||
|
|
43f27f453b | ||
|
|
02b0ef7df1 | ||
|
|
f2cef094a1 | ||
|
|
5a02fb10fd | ||
|
|
51bb0d0617 |
@@ -4178,6 +4178,7 @@ components:
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
signoz/TopListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -4191,6 +4192,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4205,6 +4207,7 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/TopListPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4338,6 +4341,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/TopListPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTopListPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelSpec:
|
||||
properties:
|
||||
display:
|
||||
@@ -4829,6 +4844,29 @@ components:
|
||||
timePreference:
|
||||
$ref: '#/components/schemas/DashboardtypesTimePreference'
|
||||
type: object
|
||||
DashboardtypesTopListAppearance:
|
||||
properties:
|
||||
showRank:
|
||||
description: Numbers each row by its position in the list.
|
||||
type: boolean
|
||||
showShare:
|
||||
description: Shows each row's share of the listed rows' total.
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesTopListPanelSpec:
|
||||
properties:
|
||||
appearance:
|
||||
$ref: '#/components/schemas/DashboardtypesTopListAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesComparisonThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesUpdatableDashboardV2:
|
||||
properties:
|
||||
image:
|
||||
|
||||
@@ -5473,6 +5473,41 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind {
|
||||
'signoz/TopListPanel' = 'signoz/TopListPanel',
|
||||
}
|
||||
export interface DashboardtypesTopListAppearanceDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Numbers each row by its position in the list.
|
||||
*/
|
||||
showRank?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Shows each row's share of the listed rows' total.
|
||||
*/
|
||||
showShare?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesTopListPanelSpecDTO {
|
||||
appearance?: DashboardtypesTopListAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesComparisonThresholdDTO[] | null;
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/TopListPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind;
|
||||
spec: DashboardtypesTopListPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -5484,7 +5519,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6412,6 +6448,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
'signoz/TopListPanel' = 'signoz/TopListPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
|
||||
@@ -54,6 +54,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
case PANEL_TYPES.SCATTER:
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
case PANEL_TYPES.TOP_LIST:
|
||||
return 'scalar';
|
||||
case PANEL_TYPES.TRACE:
|
||||
return 'trace';
|
||||
@@ -281,7 +282,8 @@ export function createAggregation(
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE);
|
||||
panelType === PANEL_TYPES.VALUE ||
|
||||
panelType === PANEL_TYPES.TOP_LIST);
|
||||
|
||||
if (queryData.dataSource === DataSource.METRICS) {
|
||||
return [
|
||||
|
||||
@@ -223,6 +223,14 @@ function QueryAddOns({
|
||||
|
||||
const { handleSetQueryData, currentQuery } = useQueryBuilder();
|
||||
|
||||
// A panel's default limit applies only to a lone query: per-query limits can keep
|
||||
// different groups and break a formula's join.
|
||||
const isLoneQuery =
|
||||
(currentQuery.builder?.queryData?.length ?? 1) +
|
||||
(currentQuery.builder?.queryFormulas?.length ?? 0) +
|
||||
(currentQuery.builder?.queryTraceOperator?.length ?? 0) <=
|
||||
1;
|
||||
|
||||
const supportedAddOns = useMemo((): AddOn[] => {
|
||||
let addOns: AddOn[];
|
||||
|
||||
@@ -519,7 +527,8 @@ function QueryAddOns({
|
||||
onChange={handleChangeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder={
|
||||
resolvedFields.get(QueryBuilderField.Limit)?.placeholder ??
|
||||
(isLoneQuery &&
|
||||
resolvedFields.get(QueryBuilderField.Limit)?.placeholder) ||
|
||||
'Enter limit'
|
||||
}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
@@ -552,6 +561,9 @@ function QueryAddOns({
|
||||
onChange={handleChangeOrderByKeys}
|
||||
isRawQuery={isRawQuery}
|
||||
isNewQueryV2
|
||||
placeholder={
|
||||
resolvedFields.get(QueryBuilderField.OrderBy)?.placeholder
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!isPinned(QueryBuilderField.OrderBy) && (
|
||||
|
||||
@@ -45,7 +45,8 @@ function QueryAggregationOptions({
|
||||
isNonMetricSource &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE)
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.TOP_LIST)
|
||||
) {
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
}
|
||||
@@ -67,7 +68,9 @@ function QueryAggregationOptions({
|
||||
onChange={onChange}
|
||||
queryData={queryData}
|
||||
maxAggregations={
|
||||
panelType === PANEL_TYPES.VALUE || panelType === PANEL_TYPES.PIE
|
||||
panelType === PANEL_TYPES.VALUE ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.TOP_LIST
|
||||
? 1
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -97,7 +97,8 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE),
|
||||
panelType === PANEL_TYPES.VALUE ||
|
||||
panelType === PANEL_TYPES.TOP_LIST),
|
||||
[dataSource, panelType],
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
|
||||
// Mocks: only what is required for this component to render and for us to assert handler calls
|
||||
const mockHandleChangeQueryData = jest.fn();
|
||||
const mockHandleSetQueryData = jest.fn();
|
||||
let mockCurrentQuery: { unit: string | undefined; builder?: unknown } = {
|
||||
unit: undefined,
|
||||
};
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
useQueryOperations: (): {
|
||||
@@ -27,10 +30,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
handleSetQueryData: typeof mockHandleSetQueryData;
|
||||
currentQuery: { unit: string | undefined };
|
||||
currentQuery: typeof mockCurrentQuery;
|
||||
} => ({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
currentQuery: { unit: undefined },
|
||||
currentQuery: mockCurrentQuery,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -46,9 +49,10 @@ jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('container/QueryBuilder/filters/OrderByFilter/OrderByFilter', () => ({
|
||||
OrderByFilter: ({ onChange }: any): JSX.Element => (
|
||||
OrderByFilter: ({ onChange, placeholder }: any): JSX.Element => (
|
||||
<button
|
||||
data-testid="orderby"
|
||||
data-placeholder={placeholder}
|
||||
onClick={(): void => onChange([{ columnName: 'duration', order: 'desc' }])}
|
||||
>
|
||||
OrderByFilter
|
||||
@@ -197,6 +201,84 @@ describe('QueryAddOns', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('a pinned limit with a default', () => {
|
||||
const renderPinnedLimit = (): void => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TOP_LIST}
|
||||
index={0}
|
||||
fieldsConfig={{
|
||||
[QueryBuilderField.Limit]: {
|
||||
state: 'pinned',
|
||||
placeholder: 'Default 10',
|
||||
},
|
||||
}}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mockCurrentQuery = { unit: undefined };
|
||||
});
|
||||
|
||||
it('is open with its default as the placeholder', () => {
|
||||
renderPinnedLimit();
|
||||
|
||||
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Default 10',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the default once a formula joins the query', () => {
|
||||
mockCurrentQuery = {
|
||||
unit: undefined,
|
||||
builder: {
|
||||
queryData: [baseQuery()],
|
||||
queryFormulas: [{ expression: 'A * 2' }],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
renderPinnedLimit();
|
||||
|
||||
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Enter limit',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a pinned Order By its placeholder', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TOP_LIST}
|
||||
index={0}
|
||||
fieldsConfig={{
|
||||
[QueryBuilderField.OrderBy]: {
|
||||
state: 'pinned',
|
||||
placeholder: 'Default: value, desc',
|
||||
},
|
||||
}}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('orderby')).toHaveAttribute(
|
||||
'data-placeholder',
|
||||
'Default: value, desc',
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-opens Order By and Limit when present in query', () => {
|
||||
const query = baseQuery({
|
||||
orderBy: [{ columnName: 'duration', order: 'desc' }],
|
||||
|
||||
@@ -57,6 +57,19 @@ describe('resolveQueryBuilderField', () => {
|
||||
expect(resolved.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("carries a pinned field's placeholder", () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
|
||||
[QueryBuilderField.Limit]: { state: 'pinned', placeholder: 'Default 10' },
|
||||
});
|
||||
|
||||
expect(resolved).toStrictEqual({
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
pinned: true,
|
||||
placeholder: 'Default 10',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a defaulted field as is, carrying its placeholder', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
|
||||
[QueryBuilderField.Limit]: { state: 'defaulted', placeholder: 'Default 10' },
|
||||
|
||||
@@ -24,13 +24,14 @@ export enum QueryBuilderField {
|
||||
|
||||
/**
|
||||
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
|
||||
* `defaulted` leaves the field as is; its placeholder names the value an empty field falls back to.
|
||||
* `defaulted` leaves the field as is; its placeholder names the value an empty field falls back to,
|
||||
* as a `pinned` field's may.
|
||||
*/
|
||||
export type QueryBuilderFieldRule =
|
||||
| { state: 'defaulted'; placeholder: string }
|
||||
| { state: 'hidden' }
|
||||
| { state: 'disabled'; reason: string }
|
||||
| { state: 'pinned' };
|
||||
| { state: 'pinned'; placeholder?: string };
|
||||
|
||||
/**
|
||||
* A caller's narrowing of the builder's surface. The builder works out which fields suit
|
||||
|
||||
@@ -33,7 +33,12 @@ function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
|
||||
pinned: false,
|
||||
};
|
||||
case 'pinned':
|
||||
return { hidden: false, disabled: false, pinned: true };
|
||||
return {
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
pinned: true,
|
||||
...(rule.placeholder !== undefined && { placeholder: rule.placeholder }),
|
||||
};
|
||||
default:
|
||||
return AVAILABLE;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
[PANEL_TYPES.TOP_LIST]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.SCATTER]: null,
|
||||
|
||||
@@ -341,6 +341,7 @@ export enum PANEL_TYPES {
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
TOP_LIST = 'top_list',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export type OrderByFilterProps = {
|
||||
isRawQuery?: boolean;
|
||||
entityVersion?: string;
|
||||
isNewQueryV2?: boolean;
|
||||
/** Shown while no order is set, e.g. to name the order the server falls back to. */
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export type OrderByFilterValue = {
|
||||
|
||||
@@ -15,6 +15,7 @@ export function OrderByFilter({
|
||||
isRawQuery = false,
|
||||
entityVersion,
|
||||
isNewQueryV2 = false,
|
||||
placeholder,
|
||||
}: OrderByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
@@ -87,6 +88,7 @@ export function OrderByFilter({
|
||||
disabled={isMetricsDataSource && isDisabledSelect}
|
||||
showArrow={false}
|
||||
value={selectedValue}
|
||||
placeholder={placeholder}
|
||||
labelInValue
|
||||
filterOption={false}
|
||||
options={optionsData}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
HEATMAP: false,
|
||||
TOP_LIST: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -24,4 +24,5 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
[PANEL_TYPES.TOP_LIST]: null,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export type PartialPanelTypes = {
|
||||
[PANEL_TYPES.SCATTER]: 'scatter';
|
||||
[PANEL_TYPES.HISTOGRAM]: 'histogram';
|
||||
[PANEL_TYPES.HEATMAP]: 'heatmap';
|
||||
[PANEL_TYPES.TOP_LIST]: 'top_list';
|
||||
};
|
||||
|
||||
export type BuilderField = keyof IBuilderQuery;
|
||||
@@ -151,6 +152,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.TOP_LIST]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
|
||||
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
|
||||
};
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
|
||||
import { ScatterTooltipProps } from '../types';
|
||||
import TooltipCard from './components/TooltipCard/TooltipCard';
|
||||
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
|
||||
|
||||
import Styles from './ScatterTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* One point, its channels, then the group values that name it. Purpose-built
|
||||
* rather than composed from the shared `Tooltip`, whose list is one row per
|
||||
@@ -46,69 +43,18 @@ export default function ScatterTooltip({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="scatter-tooltip"
|
||||
>
|
||||
<div className={Styles.header}>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ backgroundColor: point.color, borderColor: point.color }}
|
||||
/>
|
||||
<span
|
||||
className={Styles.title}
|
||||
title={point.label}
|
||||
data-testid="scatter-tooltip-title"
|
||||
>
|
||||
{point.label}
|
||||
</span>
|
||||
{isPinned && (
|
||||
<span className={Styles.status} data-testid="scatter-tooltip-status">
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="scatter-tooltip-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<span className={Styles.divider} />
|
||||
<div className={Styles.rows}>
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label.key}
|
||||
className={cx(Styles.row, Styles.rowMuted)}
|
||||
data-testid="scatter-tooltip-label"
|
||||
>
|
||||
<span className={Styles.rowLabel} title={label.key}>
|
||||
{label.key}
|
||||
</span>
|
||||
<span className={Styles.rowValue} title={label.value}>
|
||||
{label.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
<TooltipCard
|
||||
title={point.label}
|
||||
color={point.color}
|
||||
rows={rows.map((row) => ({ key: row.label, ...row }))}
|
||||
mutedRows={labels.map((label) => ({
|
||||
key: label.key,
|
||||
label: label.key,
|
||||
value: label.value,
|
||||
}))}
|
||||
isPinned={isPinned}
|
||||
footer={renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
testId="scatter-tooltip"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
color: var(--l1-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -61,7 +61,7 @@
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -80,12 +80,12 @@
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-vanilla-100);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// The group values name the point; the channels are what it says.
|
||||
.rowMuted {
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
|
||||
import Styles from './TooltipCard.module.scss';
|
||||
|
||||
export interface TooltipCardRow {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TooltipCardProps {
|
||||
title: string;
|
||||
/** The marker beside the title, matching the item's mark on the chart. */
|
||||
color: string;
|
||||
/** What the item says. */
|
||||
rows: TooltipCardRow[];
|
||||
/** What names the item, below the rows and muted. */
|
||||
mutedRows?: TooltipCardRow[];
|
||||
isPinned?: boolean;
|
||||
footer?: ReactNode;
|
||||
/** Prefixes the test ids of the title, status, rows and muted rows. */
|
||||
testId: string;
|
||||
}
|
||||
|
||||
/** One item's tooltip: a marked title, then label/value rows, on the shared tooltip surface. */
|
||||
export default function TooltipCard({
|
||||
title,
|
||||
color,
|
||||
rows,
|
||||
mutedRows = [],
|
||||
isPinned = false,
|
||||
footer,
|
||||
testId,
|
||||
}: TooltipCardProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={Styles.header}>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ backgroundColor: color, borderColor: color }}
|
||||
/>
|
||||
<span
|
||||
className={Styles.title}
|
||||
title={title}
|
||||
data-testid={`${testId}-title`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{isPinned && (
|
||||
<span className={Styles.status} data-testid={`${testId}-status`}>
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className={Styles.row} data-testid={`${testId}-row`}>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mutedRows.length > 0 && (
|
||||
<>
|
||||
<span className={Styles.divider} />
|
||||
<div className={Styles.rows}>
|
||||
{mutedRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={cx(Styles.row, Styles.rowMuted)}
|
||||
data-testid={`${testId}-label`}
|
||||
>
|
||||
<span className={Styles.rowLabel} title={row.label}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className={Styles.rowValue} title={row.value}>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ interface TooltipFooterProps {
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
canDrilldown?: boolean;
|
||||
/** False for a tooltip that can't be pinned, which drops the pin hint. */
|
||||
canPin?: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
@@ -20,6 +22,7 @@ export default function TooltipFooter({
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
isPinned,
|
||||
canDrilldown = true,
|
||||
canPin = true,
|
||||
dismiss,
|
||||
}: TooltipFooterProps): JSX.Element {
|
||||
const handleUnpinClick = (): void => {
|
||||
@@ -53,11 +56,13 @@ export default function TooltipFooter({
|
||||
<span>Click to drilldown</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>to pin the tooltip</span>
|
||||
</div>
|
||||
{canPin && (
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>to pin the tooltip</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,13 @@ describe('TooltipFooter', () => {
|
||||
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the pin hint when canPin is false', () => {
|
||||
render(<TooltipFooter {...defaultProps} canPin={false} />);
|
||||
|
||||
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
|
||||
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a custom pin key in uppercase', () => {
|
||||
render(<TooltipFooter {...defaultProps} pinKey="x" />);
|
||||
|
||||
|
||||
@@ -26,6 +26,22 @@ export const SWITCH_SKETCHES = {
|
||||
))}
|
||||
</SketchSvg>
|
||||
),
|
||||
rank: (
|
||||
<SketchSvg>
|
||||
{[5, 11, 17].map((y, index) => (
|
||||
<g key={y}>
|
||||
<circle cx={4} cy={y} r={1.2} fill="var(--l3-foreground)" />
|
||||
<path d={`M9 ${y} H${36 - index * 9}`} />
|
||||
</g>
|
||||
))}
|
||||
</SketchSvg>
|
||||
),
|
||||
share: (
|
||||
<SketchSvg>
|
||||
<path d="M3 11 H37" strokeDasharray="3 3" />
|
||||
<rect x={3} y={8} width={22} height={6} fill="var(--l3-foreground)" />
|
||||
</SketchSvg>
|
||||
),
|
||||
combine: (
|
||||
<SketchSvg>
|
||||
<rect x={4} y={10} width={6} height={10} />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
|
||||
import type { SectionEditorContext } from './sectionContext';
|
||||
import AppearanceSection from './sections/AppearanceSection/AppearanceSection';
|
||||
import AxesSection from './sections/AxesSection/AxesSection';
|
||||
import BucketsSection from './sections/BucketsSection/BucketsSection';
|
||||
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
|
||||
@@ -118,6 +119,13 @@ export const SECTION_REGISTRY: {
|
||||
update: (spec, chartAppearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
|
||||
},
|
||||
[SectionKind.Appearance]: {
|
||||
Component: AppearanceSection,
|
||||
get: (spec): SectionSpecMap[SectionKind.Appearance] | undefined =>
|
||||
getPluginSlice<SectionSpecMap[SectionKind.Appearance]>(spec, 'appearance'),
|
||||
update: (spec, appearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'appearance', appearance),
|
||||
},
|
||||
[SectionKind.Visualization]: {
|
||||
Component: VisualizationSection,
|
||||
get: (spec): PanelVisualizationSlice | undefined =>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
|
||||
/**
|
||||
* Edits the `appearance` slice of a Top List panel spec: row numbers and each row's
|
||||
* share of the listed total. Each control is gated by its `controls` flag.
|
||||
*/
|
||||
function AppearanceSection({
|
||||
value,
|
||||
savedValue,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.Appearance>): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.showRank && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-show-rank"
|
||||
title="Show rank"
|
||||
description="Numbers each row by its position."
|
||||
sketch={SWITCH_SKETCHES.rank}
|
||||
changed={reset('showRank').changed}
|
||||
value={value?.showRank ?? false}
|
||||
onChange={(showRank): void => onChange({ ...value, showRank })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.showShare && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-show-share"
|
||||
title="Show share of total"
|
||||
description="Each row's percentage of the listed rows' sum. Suits counts and sums, not averages or percentiles."
|
||||
sketch={SWITCH_SKETCHES.share}
|
||||
changed={reset('showShare').changed}
|
||||
value={value?.showShare ?? false}
|
||||
onChange={(showShare): void => onChange({ ...value, showShare })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppearanceSection;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import AppearanceSection from '../AppearanceSection';
|
||||
|
||||
describe('AppearanceSection', () => {
|
||||
it('renders only the controls whose flag is set', () => {
|
||||
render(
|
||||
<AppearanceSection
|
||||
value={undefined}
|
||||
controls={{ showRank: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-show-rank')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-show-share'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ testId: 'panel-editor-v2-show-rank', field: 'showRank' },
|
||||
{ testId: 'panel-editor-v2-show-share', field: 'showShare' },
|
||||
])('toggles $field through onChange', async ({ testId, field }) => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<AppearanceSection
|
||||
value={{ showRank: false, showShare: false }}
|
||||
controls={{ showRank: true, showShare: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId(testId));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
showRank: false,
|
||||
showShare: false,
|
||||
[field]: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/TopListPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/ListPanel': [QUERY_BUILDER],
|
||||
'signoz/ScatterPlotPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
@@ -48,6 +49,7 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
// A heatmap needs a bucket axis, which only a metric carries.
|
||||
'signoz/HeatmapPanel': [metrics],
|
||||
'signoz/PieChartPanel': [metrics, logs, traces],
|
||||
'signoz/TopListPanel': [metrics, logs, traces],
|
||||
'signoz/TablePanel': [metrics, logs, traces],
|
||||
// List renders raw rows; metrics produce no row data.
|
||||
'signoz/ListPanel': [logs, traces],
|
||||
@@ -114,6 +116,14 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/TopListPanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
defaultRowLimit: 10,
|
||||
},
|
||||
// Only Table and Scatter Plot ask the server to join their scalar results into UI rows.
|
||||
'signoz/TablePanel': {
|
||||
requestType: scalar,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import type { PanelRendererProps } from '../../types/rendererProps';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
import { enrichPieClick } from '../../utils/drilldown/enrichPieClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
import { mapNumberThresholds } from '../NumberPanel/utils';
|
||||
|
||||
import TopList from './components/TopList/TopList';
|
||||
import { prepareTopListData } from './prepareData';
|
||||
import type { TopListRow } from './types';
|
||||
import { ACCENT_COLOR, type RowColors } from './utils';
|
||||
|
||||
function TopListPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/TopListPanel'>): JSX.Element {
|
||||
const spec = panel.spec.plugin.spec;
|
||||
|
||||
const { rows, valueName } = useMemo(() => prepareTopListData(data), [data]);
|
||||
|
||||
const thresholds = useMemo(
|
||||
() => mapNumberThresholds(spec.thresholds),
|
||||
[spec.thresholds],
|
||||
);
|
||||
|
||||
const precision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(
|
||||
row: TopListRow,
|
||||
colors: RowColors,
|
||||
coordinates: { x: number; y: number },
|
||||
): void => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
const payload = enrichPieClick({
|
||||
slice: {
|
||||
label: row.label,
|
||||
value: row.value ?? 0,
|
||||
color: colors.barColor ?? ACCENT_COLOR,
|
||||
queryName: row.queryName,
|
||||
labels: row.labels,
|
||||
},
|
||||
builderQueries,
|
||||
coordinates,
|
||||
timeRange: getPanelTimeRange(data.requestPayload),
|
||||
});
|
||||
if (payload) {
|
||||
onClick(payload);
|
||||
}
|
||||
},
|
||||
[onClick, builderQueries, data.requestPayload],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="top-list-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
) : (
|
||||
<TopList
|
||||
panelId={panelId}
|
||||
rows={rows}
|
||||
valueName={valueName}
|
||||
unit={spec.formatting?.unit}
|
||||
precision={precision}
|
||||
thresholds={thresholds}
|
||||
showRank={spec.appearance?.showRank}
|
||||
showShare={spec.appearance?.showShare}
|
||||
onSelect={enableDrillDown ? handleSelect : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopListPanelRenderer;
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type DashboardtypesTopListPanelSpecDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import type {
|
||||
PanelOfKind,
|
||||
PanelRendererProps,
|
||||
} from '../../../types/rendererProps';
|
||||
import TopListPanelRenderer from '../Renderer';
|
||||
import { mockListLayout } from './mockListLayout';
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesTopListPanelSpecDTO,
|
||||
): PanelOfKind<'signoz/TopListPanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TopListPanel', spec }, queries: [] },
|
||||
} as unknown as PanelOfKind<'signoz/TopListPanel'>;
|
||||
}
|
||||
|
||||
function dataWith(rows: [string, number][]): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: [
|
||||
{ name: 'service.name', queryName: 'A', columnType: 'group' },
|
||||
{
|
||||
name: '__result',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
const SERVICES: [string, number][] = [
|
||||
['checkout', 50],
|
||||
['payment', 100],
|
||||
['auth', 25],
|
||||
];
|
||||
|
||||
function renderPanel(
|
||||
props: Partial<PanelRendererProps<'signoz/TopListPanel'>>,
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TopListPanelRenderer
|
||||
panelId="panel-1"
|
||||
panel={panelWith({})}
|
||||
data={dataWith(SERVICES)}
|
||||
isFetching={false}
|
||||
error={null}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const rowLabels = (): string[] =>
|
||||
screen
|
||||
.getAllByTestId('top-list-row')
|
||||
.map((row) => row.getAttribute('aria-label') ?? '');
|
||||
|
||||
mockListLayout();
|
||||
|
||||
describe('TopListPanelRenderer', () => {
|
||||
it('ranks rows by value with bars relative to the largest value', () => {
|
||||
renderPanel({});
|
||||
|
||||
expect(rowLabels()).toStrictEqual([
|
||||
'payment: 100',
|
||||
'checkout: 50',
|
||||
'auth: 25',
|
||||
]);
|
||||
expect(
|
||||
screen.getAllByTestId('top-list-row-fill').map((fill) => fill.style.width),
|
||||
).toStrictEqual(['100%', '50%', '25%']);
|
||||
});
|
||||
|
||||
it('formats values with the panel unit', () => {
|
||||
renderPanel({ panel: panelWith({ formatting: { unit: 'ms' } }) });
|
||||
|
||||
expect(screen.getAllByTestId('top-list-row-value')[0]).toHaveTextContent(
|
||||
'100 ms',
|
||||
);
|
||||
});
|
||||
|
||||
it('recolours the bar for a background threshold and the value for a text threshold', () => {
|
||||
renderPanel({
|
||||
panel: panelWith({
|
||||
thresholds: [
|
||||
{
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 90,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
{
|
||||
color: '#F5B225',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 40,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const [paymentFill, checkoutFill] =
|
||||
screen.getAllByTestId('top-list-row-fill');
|
||||
const [paymentValue, checkoutValue] =
|
||||
screen.getAllByTestId('top-list-row-value');
|
||||
expect(paymentFill.style.getPropertyValue('--fill-color')).toBe('#F1575F');
|
||||
expect(paymentValue.style.color).toBe('');
|
||||
expect(checkoutValue).toHaveStyle({ color: '#F5B225' });
|
||||
expect(checkoutFill.style.getPropertyValue('--fill-color')).toBe('');
|
||||
});
|
||||
|
||||
it('opens the drilldown with the clicked row', () => {
|
||||
const onClick = jest.fn();
|
||||
renderPanel({ onClick, enableDrillDown: true });
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('top-list-row')[1], {
|
||||
detail: 1,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
});
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(onClick.mock.calls[0][0]).toMatchObject({
|
||||
coordinates: { x: 10, y: 20 },
|
||||
context: { queryName: 'A', label: 'checkout' },
|
||||
});
|
||||
});
|
||||
|
||||
it('anchors the drilldown under the row when opened from the keyboard', () => {
|
||||
const onClick = jest.fn();
|
||||
renderPanel({ onClick, enableDrillDown: true });
|
||||
const row = screen.getAllByTestId('top-list-row')[0];
|
||||
jest
|
||||
.spyOn(row, 'getBoundingClientRect')
|
||||
.mockReturnValue({ left: 30, bottom: 60 } as DOMRect);
|
||||
|
||||
fireEvent.click(row, { detail: 0 });
|
||||
|
||||
expect(onClick.mock.calls[0][0]).toMatchObject({
|
||||
coordinates: { x: 30, y: 60 },
|
||||
context: { label: 'payment' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not drill down when drilldown is disabled', () => {
|
||||
const onClick = jest.fn();
|
||||
renderPanel({ onClick });
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('top-list-row')[0]);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves focus between rows with the arrow keys', () => {
|
||||
renderPanel({});
|
||||
const rows = screen.getAllByTestId('top-list-row');
|
||||
rows[0].focus();
|
||||
|
||||
fireEvent.keyDown(rows[0], { key: 'ArrowDown' });
|
||||
expect(rows[1]).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(rows[1], { key: 'ArrowUp' });
|
||||
expect(rows[0]).toHaveFocus();
|
||||
});
|
||||
|
||||
it('renders No Data when the query returns no rows', () => {
|
||||
renderPanel({ data: dataWith([]) });
|
||||
|
||||
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('top-list')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { act, fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import TopList from '../components/TopList/TopList';
|
||||
import { mockListLayout } from './mockListLayout';
|
||||
import type { TopListRow } from '../types';
|
||||
|
||||
function rowsOf(count: number): TopListRow[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
key: `A-${index}`,
|
||||
label: `service-${index}`,
|
||||
isEmptyLabel: false,
|
||||
value: count - index,
|
||||
rawValue: count - index,
|
||||
ratio: (count - index) / count,
|
||||
share: null,
|
||||
queryName: 'A',
|
||||
labels: { 'service.name': `service-${index}` },
|
||||
}));
|
||||
}
|
||||
|
||||
const renderList = (count: number): ReturnType<typeof render> =>
|
||||
render(
|
||||
<TopList
|
||||
panelId="panel-1"
|
||||
rows={rowsOf(count)}
|
||||
valueName="count()"
|
||||
thresholds={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const renderedRows = (): HTMLElement[] =>
|
||||
screen.queryAllByTestId('top-list-row');
|
||||
|
||||
mockListLayout();
|
||||
|
||||
const focusedRowIndex = (): string | null =>
|
||||
document.activeElement?.getAttribute('data-row-index') ?? null;
|
||||
|
||||
// Moving focus can scroll the window first, and the target row mounts a frame later.
|
||||
async function pressKey(key: string): Promise<void> {
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document.activeElement as Element, { key });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
}
|
||||
|
||||
describe('TopList', () => {
|
||||
it('renders every row of a list that fits', () => {
|
||||
renderList(5);
|
||||
|
||||
expect(renderedRows()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it.each([100, 1000])('renders only a window of a %i-row list', (count) => {
|
||||
renderList(count);
|
||||
|
||||
const rendered = renderedRows().length;
|
||||
expect(rendered).toBeGreaterThan(0);
|
||||
expect(rendered).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it('jumps to the first and last rows with Home and End', async () => {
|
||||
renderList(20);
|
||||
renderedRows()[5].focus();
|
||||
|
||||
await pressKey('End');
|
||||
expect(focusedRowIndex()).toBe('19');
|
||||
|
||||
await pressKey('Home');
|
||||
expect(focusedRowIndex()).toBe('0');
|
||||
});
|
||||
|
||||
it('stays put at the ends of the list', () => {
|
||||
renderList(3);
|
||||
const rows = renderedRows();
|
||||
rows[0].focus();
|
||||
|
||||
fireEvent.keyDown(rows[0], { key: 'ArrowUp' });
|
||||
expect(rows[0]).toHaveFocus();
|
||||
});
|
||||
|
||||
it('numbers rows and shows their share when asked', () => {
|
||||
const rows = rowsOf(2).map((row, index) => ({
|
||||
...row,
|
||||
share: index === 0 ? 2 / 3 : 1 / 3,
|
||||
}));
|
||||
render(
|
||||
<TopList
|
||||
panelId="panel-1"
|
||||
rows={rows}
|
||||
valueName="count()"
|
||||
thresholds={[]}
|
||||
showRank
|
||||
showShare
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getAllByTestId('top-list-row-rank').map((rank) => rank.textContent),
|
||||
).toStrictEqual(['1', '2']);
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId('top-list-row-share')
|
||||
.map((share) => share.textContent),
|
||||
).toStrictEqual(['67%', '33%']);
|
||||
});
|
||||
|
||||
it('follows the pointer with the hovered row in the tooltip', async () => {
|
||||
const rows = rowsOf(2).map((row) => ({ ...row, share: 0.5 }));
|
||||
render(
|
||||
<TopList
|
||||
panelId="panel-1"
|
||||
rows={rows}
|
||||
valueName="count()"
|
||||
thresholds={[]}
|
||||
showShare
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.mouseMove(renderedRows()[1], { clientX: 40, clientY: 30 });
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('top-list-tooltip-title'),
|
||||
).resolves.toHaveTextContent('service-1');
|
||||
expect(
|
||||
screen.getAllByTestId('top-list-tooltip-row').map((row) => row.textContent),
|
||||
).toStrictEqual(['count()1', 'Share of listed total50%']);
|
||||
});
|
||||
|
||||
it('hides the tooltip over the empty space below the rows', async () => {
|
||||
renderList(2);
|
||||
fireEvent.mouseMove(renderedRows()[1]);
|
||||
await screen.findByTestId('top-list-tooltip');
|
||||
|
||||
fireEvent.mouseMove(screen.getByTestId('top-list'));
|
||||
|
||||
expect(screen.queryByTestId('top-list-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the tooltip when the pointer leaves the list', async () => {
|
||||
renderList(2);
|
||||
fireEvent.mouseMove(renderedRows()[0]);
|
||||
await screen.findByTestId('top-list-tooltip');
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId('top-list').parentElement as Element);
|
||||
|
||||
expect(screen.queryByTestId('top-list-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hints at drilldown, not pinning, when rows are clickable', async () => {
|
||||
render(
|
||||
<TopList
|
||||
panelId="panel-1"
|
||||
rows={rowsOf(2)}
|
||||
valueName="count()"
|
||||
thresholds={[]}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.mouseMove(renderedRows()[0]);
|
||||
|
||||
const footer = await screen.findByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toHaveTextContent('Click to drilldown');
|
||||
expect(footer).not.toHaveTextContent('to pin the tooltip');
|
||||
});
|
||||
|
||||
it('leaves the footer out when rows are not clickable', async () => {
|
||||
renderList(2);
|
||||
fireEvent.mouseMove(renderedRows()[0]);
|
||||
await screen.findByTestId('top-list-tooltip');
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-footer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows neither rank nor share by default', () => {
|
||||
renderList(3);
|
||||
|
||||
expect(screen.queryByTestId('top-list-row-rank')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('top-list-row-share')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cuts the middle out of a label wider than its bar', () => {
|
||||
// 100px bar, 17px of label inset, 10px per character: 8 characters fit.
|
||||
jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(100);
|
||||
jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
font: '',
|
||||
measureText: (text: string) => ({ width: text.length * 10 }),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
|
||||
renderList(1);
|
||||
|
||||
expect(screen.getByText('serv…e-0')).toBeInTheDocument();
|
||||
expect(renderedRows()[0]).toHaveAttribute('aria-label', 'service-0: 1');
|
||||
});
|
||||
|
||||
it('focuses a row that was out of view once it renders', async () => {
|
||||
renderList(1000);
|
||||
renderedRows()[0].focus();
|
||||
|
||||
await pressKey('End');
|
||||
|
||||
expect(focusedRowIndex()).toBe('999');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
DashboardtypesTopListPanelSpecDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { getTopListCsvRows } from '../csv';
|
||||
|
||||
const panelWith = (
|
||||
spec: DashboardtypesTopListPanelSpecDTO,
|
||||
): PanelOfKind<'signoz/TopListPanel'> =>
|
||||
({
|
||||
spec: { plugin: { kind: 'signoz/TopListPanel', spec } },
|
||||
}) as unknown as PanelOfKind<'signoz/TopListPanel'>;
|
||||
|
||||
function dataWith(groupColumns: string[], rows: unknown[][]): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: [
|
||||
...groupColumns.map((name) => ({
|
||||
name,
|
||||
queryName: 'A',
|
||||
columnType: 'group',
|
||||
})),
|
||||
{
|
||||
name: '__result',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('getTopListCsvRows', () => {
|
||||
it('exports the rows in ranked order with formatted values', () => {
|
||||
const data = dataWith(
|
||||
['service.name'],
|
||||
[
|
||||
['auth', 25],
|
||||
['payment', 1500],
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
getTopListCsvRows(panelWith({ formatting: { unit: 'ms' } }), data),
|
||||
).toStrictEqual([
|
||||
{ 'service.name': 'payment', A: '1.5 s' },
|
||||
{ 'service.name': 'auth', A: '25 ms' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the label column after every group-by key', () => {
|
||||
const data = dataWith(['http.route', 'region'], [['GET /orders', 'eu', 3]]);
|
||||
|
||||
expect(Object.keys(getTopListCsvRows(panelWith({}), data)[0])).toStrictEqual([
|
||||
'http.route · region',
|
||||
'A',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to a generic label header without a group-by', () => {
|
||||
const data = dataWith([], [[42]]);
|
||||
|
||||
expect(getTopListCsvRows(panelWith({}), data)).toStrictEqual([
|
||||
{ Label: 'A', A: '42' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds rank and share columns when the panel shows them', () => {
|
||||
const data = dataWith(
|
||||
['service.name'],
|
||||
[
|
||||
['auth', 25],
|
||||
['payment', 75],
|
||||
],
|
||||
);
|
||||
const panel = panelWith({
|
||||
appearance: { showRank: true, showShare: true },
|
||||
});
|
||||
|
||||
expect(getTopListCsvRows(panel, data)).toStrictEqual([
|
||||
{ Rank: '1', 'service.name': 'payment', A: '75', Share: '75%' },
|
||||
{ Rank: '2', 'service.name': 'auth', A: '25', Share: '25%' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('exports nothing for an empty result', () => {
|
||||
expect(getTopListCsvRows(panelWith({}), dataWith([], []))).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const CONTAINER_HEIGHT = 280;
|
||||
const CONTAINER_WIDTH = 600;
|
||||
const ROW_HEIGHT = 28;
|
||||
const SCROLL_HEIGHT = ROW_HEIGHT * 1000;
|
||||
|
||||
const heightOf = (element: Element): number =>
|
||||
element.hasAttribute('data-index') ? ROW_HEIGHT : CONTAINER_HEIGHT;
|
||||
|
||||
/**
|
||||
* jsdom has no layout, so the virtualized list would render no rows. Gives the scroll
|
||||
* container and the measured rows (`data-index`) real heights, and makes `scrollTo`
|
||||
* move `scrollTop` and fire `scroll` the way a browser does.
|
||||
*/
|
||||
export function mockListLayout(): void {
|
||||
const originalScrollTo = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'scrollTo',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function rect(this: HTMLElement): DOMRect {
|
||||
const height = heightOf(this);
|
||||
return {
|
||||
width: CONTAINER_WIDTH,
|
||||
height,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: height,
|
||||
right: CONTAINER_WIDTH,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
});
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
||||
.mockImplementation(function height(this: HTMLElement): number {
|
||||
return heightOf(this);
|
||||
});
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
|
||||
.mockReturnValue(CONTAINER_WIDTH);
|
||||
jest
|
||||
.spyOn(Element.prototype, 'clientHeight', 'get')
|
||||
.mockReturnValue(CONTAINER_HEIGHT);
|
||||
jest
|
||||
.spyOn(Element.prototype, 'scrollHeight', 'get')
|
||||
.mockReturnValue(SCROLL_HEIGHT);
|
||||
Element.prototype.scrollTo = function scrollTo(
|
||||
this: Element,
|
||||
options?: ScrollToOptions | number,
|
||||
): void {
|
||||
if (typeof options === 'object' && options.top !== undefined) {
|
||||
this.scrollTop = options.top;
|
||||
this.dispatchEvent(new Event('scroll'));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
if (originalScrollTo) {
|
||||
Object.defineProperty(Element.prototype, 'scrollTo', originalScrollTo);
|
||||
} else {
|
||||
delete (Element.prototype as Partial<Element>).scrollTo;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { prepareTopListData } from '../prepareData';
|
||||
import { getTopListDataWarning } from '../warnings';
|
||||
|
||||
const panelWith = (
|
||||
appearance: { showShare?: boolean } = {},
|
||||
): PanelOfKind<'signoz/TopListPanel'> =>
|
||||
({
|
||||
spec: { plugin: { spec: { appearance } } },
|
||||
}) as unknown as PanelOfKind<'signoz/TopListPanel'>;
|
||||
const panel = panelWith();
|
||||
|
||||
interface Result {
|
||||
queryName: string;
|
||||
rows: [string, number][];
|
||||
}
|
||||
|
||||
interface Envelope {
|
||||
type: 'builder_query' | 'builder_formula';
|
||||
spec: {
|
||||
name: string;
|
||||
limit?: number;
|
||||
expression?: string;
|
||||
disabled?: boolean;
|
||||
order?: { key: { name: string }; direction: 'asc' | 'desc' }[];
|
||||
groupBy?: { name: string }[];
|
||||
aggregations?: Partial<Record<string, string>>[];
|
||||
};
|
||||
}
|
||||
|
||||
function dataWith(results: Result[], queries: Envelope[]): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: results.map(({ queryName, rows }) => ({
|
||||
queryName,
|
||||
columns: [
|
||||
{ name: 'service.name', queryName, columnType: 'group' },
|
||||
{
|
||||
name: '__result',
|
||||
queryName,
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: rows,
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: {
|
||||
compositeQuery: { queries },
|
||||
} as unknown as Querybuildertypesv5QueryRangeRequestDTO,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
const query = (name: string, limit?: number): Envelope => ({
|
||||
type: 'builder_query',
|
||||
spec: { name, limit },
|
||||
});
|
||||
const formula = (name: string, expression: string): Envelope => ({
|
||||
type: 'builder_formula',
|
||||
spec: { name, expression },
|
||||
});
|
||||
|
||||
const A: Result = { queryName: 'A', rows: [['checkout', 10]] };
|
||||
const B: Result = { queryName: 'B', rows: [['checkout', 2]] };
|
||||
const F1: Result = { queryName: 'F1', rows: [['checkout', 5]] };
|
||||
|
||||
describe('top list with several queries', () => {
|
||||
it('ranks the first query in builder order, whatever order the response lists them in', () => {
|
||||
const data = dataWith(
|
||||
[F1, B, A],
|
||||
[query('A'), query('B'), formula('F1', 'A / B')],
|
||||
);
|
||||
|
||||
const { rows, ignoredResults } = prepareTopListData(data);
|
||||
|
||||
expect(rows.map((row) => [row.queryName, row.value])).toStrictEqual([
|
||||
['A', 10],
|
||||
]);
|
||||
expect(ignoredResults).toStrictEqual(['B', 'F1']);
|
||||
});
|
||||
|
||||
it('ranks the formula when its inputs are disabled', () => {
|
||||
// The server leaves disabled queries out of the response.
|
||||
const data = dataWith([F1], [query('A'), query('B'), formula('F1', 'A / B')]);
|
||||
|
||||
expect(prepareTopListData(data).rows[0].queryName).toBe('F1');
|
||||
expect(getTopListDataWarning(panel, data)).toBeNull();
|
||||
});
|
||||
|
||||
it('names the results the ranking leaves out', () => {
|
||||
const data = dataWith([A, B], [query('A'), query('B')]);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'The list ranks "A" only; "B" is not shown. Disable the queries you don\'t want to rank.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns when a formula reads inputs cut to their own limit', () => {
|
||||
const data = dataWith(
|
||||
[F1],
|
||||
[query('A', 10), query('B', 10), query('C', 5), formula('F1', 'A / B')],
|
||||
);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'"A", "B" are limited before the formula combines them, so the formula only sees groups in every input\'s top N. Set the limit on the formula instead.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists rows in the query's ascending order for a bottom N", () => {
|
||||
const data = dataWith(
|
||||
[
|
||||
{
|
||||
queryName: 'A',
|
||||
rows: [
|
||||
['checkout', 10],
|
||||
['auth', 2],
|
||||
['search', 5],
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
order: [{ key: { name: 'count()' }, direction: 'asc' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(prepareTopListData(data).rows.map((row) => row.value)).toStrictEqual([
|
||||
2, 5, 10,
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns when the query orders by a group key instead of the value', () => {
|
||||
const data = dataWith(
|
||||
[
|
||||
{
|
||||
queryName: 'A',
|
||||
rows: [
|
||||
['auth', 2],
|
||||
['checkout', 10],
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
order: [{ key: { name: 'service.name' }, direction: 'asc' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const { rows } = prepareTopListData(data);
|
||||
expect(rows.map((row) => row.value)).toStrictEqual([10, 2]);
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'The query orders by "service.name", so the list shows the first groups by that key rather than the top values. Order by the value instead.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('top list value name', () => {
|
||||
const aggregated = (
|
||||
aggregation: Partial<Record<string, string>>,
|
||||
): Envelope => ({
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', aggregations: [aggregation] },
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenario: 'an alias',
|
||||
aggregation: { alias: 'requests', expression: 'count()' },
|
||||
expected: 'requests',
|
||||
},
|
||||
{
|
||||
scenario: 'an expression',
|
||||
aggregation: { expression: 'p99(duration_nano)' },
|
||||
expected: 'p99(duration_nano)',
|
||||
},
|
||||
{
|
||||
scenario: 'a metric',
|
||||
aggregation: { metricName: 'signoz_calls_total', spaceAggregation: 'sum' },
|
||||
expected: 'sum(signoz_calls_total)',
|
||||
},
|
||||
])('names the value after $scenario', ({ aggregation, expected }) => {
|
||||
expect(
|
||||
prepareTopListData(dataWith([A], [aggregated(aggregation)])).valueName,
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it('names a ranked formula after its expression', () => {
|
||||
const data = dataWith([F1], [query('A'), query('B'), formula('F1', 'A / B')]);
|
||||
|
||||
expect(prepareTopListData(data).valueName).toBe('A / B');
|
||||
});
|
||||
|
||||
it('falls back to the query name with nothing to describe it', () => {
|
||||
expect(prepareTopListData(dataWith([A], [query('A')])).valueName).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('share of total warning', () => {
|
||||
const ranked = (aggregation: Partial<Record<string, string>>): Envelope => ({
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', aggregations: [aggregation] },
|
||||
});
|
||||
const sharePanel = panelWith({ showShare: true });
|
||||
const shareMessage = (data: PanelQueryData): string | undefined =>
|
||||
getTopListDataWarning(sharePanel, data)?.messages?.find((message) =>
|
||||
message.startsWith('Share of total'),
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenario: 'a percentile',
|
||||
aggregation: { expression: 'p99(duration_nano)' },
|
||||
},
|
||||
{ scenario: 'an average', aggregation: { expression: 'avg(duration_nano)' } },
|
||||
{ scenario: 'a maximum', aggregation: { expression: 'max(duration_nano)' } },
|
||||
{
|
||||
scenario: 'a metric averaged across series',
|
||||
aggregation: { metricName: 'cpu', spaceAggregation: 'avg' },
|
||||
},
|
||||
])('warns when ranking $scenario', ({ aggregation }) => {
|
||||
expect(shareMessage(dataWith([A], [ranked(aggregation)]))).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scenario: 'a count', aggregation: { expression: 'count()' } },
|
||||
{ scenario: 'a sum', aggregation: { expression: 'sum(bytes)' } },
|
||||
{
|
||||
scenario: 'a metric summed across series',
|
||||
aggregation: { metricName: 'signoz_calls_total', spaceAggregation: 'sum' },
|
||||
},
|
||||
])('stays quiet when ranking $scenario', ({ aggregation }) => {
|
||||
expect(shareMessage(dataWith([A], [ranked(aggregation)]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays quiet for a formula, whose aggregation it cannot tell', () => {
|
||||
const data = dataWith([F1], [query('A'), query('B'), formula('F1', 'A / B')]);
|
||||
|
||||
expect(shareMessage(data)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays quiet when the panel does not show shares', () => {
|
||||
const data = dataWith([A], [ranked({ expression: 'p99(duration_nano)' })]);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import type {
|
||||
PanelTable,
|
||||
PanelTableColumn,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import { EMPTY_LABEL, prepareTopListRows } from '../prepareData';
|
||||
|
||||
const groupColumn = (name: string): PanelTableColumn => ({
|
||||
name,
|
||||
id: name,
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
});
|
||||
const valueColumn = (name = 'count()'): PanelTableColumn => ({
|
||||
name,
|
||||
id: name,
|
||||
queryName: 'A',
|
||||
isValueColumn: true,
|
||||
});
|
||||
|
||||
function serviceTable(
|
||||
rows: [unknown, unknown][],
|
||||
overrides: Partial<PanelTable> = {},
|
||||
): PanelTable {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [groupColumn('service.name'), valueColumn()],
|
||||
rows: rows.map(([service, value]) => ({
|
||||
data: { 'service.name': service, 'count()': value },
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const summarize = (table: PanelTable): [string, number | null, number][] =>
|
||||
prepareTopListRows([table]).rows.map((row) => [
|
||||
row.label,
|
||||
row.value,
|
||||
row.ratio,
|
||||
]);
|
||||
|
||||
describe('prepareTopListRows', () => {
|
||||
it('ranks rows by value, descending, with bars relative to the largest value', () => {
|
||||
const table = serviceTable([
|
||||
['auth', 30],
|
||||
['payment', 40],
|
||||
['checkout', 10],
|
||||
]);
|
||||
|
||||
expect(summarize(table)).toStrictEqual([
|
||||
['payment', 40, 1],
|
||||
['auth', 30, 0.75],
|
||||
['checkout', 10, 0.25],
|
||||
]);
|
||||
});
|
||||
|
||||
it('breaks ties by label so the order is stable across refreshes', () => {
|
||||
const table = serviceTable([
|
||||
['search', 5],
|
||||
['auth', 5],
|
||||
['checkout', 5],
|
||||
]);
|
||||
|
||||
expect(summarize(table).map(([label]) => label)).toStrictEqual([
|
||||
'auth',
|
||||
'checkout',
|
||||
'search',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps non-numeric values without a bar, sorted last, with the raw cell for display', () => {
|
||||
const table = serviceTable([
|
||||
['nan', 'NaN'],
|
||||
['inf', 'Inf'],
|
||||
['missing', 'n/a'],
|
||||
['null', null],
|
||||
['ok', 7],
|
||||
]);
|
||||
|
||||
const { rows } = prepareTopListRows([table]);
|
||||
|
||||
expect(rows.map((row) => [row.label, row.value, row.ratio])).toStrictEqual([
|
||||
['ok', 7, 1],
|
||||
['inf', null, 0],
|
||||
['missing', null, 0],
|
||||
['nan', null, 0],
|
||||
['null', null, 0],
|
||||
]);
|
||||
expect(rows[1].rawValue).toBe('Inf');
|
||||
});
|
||||
|
||||
it('keeps zero and negative values with an empty bar', () => {
|
||||
const table = serviceTable([
|
||||
['down', -4],
|
||||
['flat', 0],
|
||||
['up', 8],
|
||||
]);
|
||||
|
||||
expect(summarize(table)).toStrictEqual([
|
||||
['up', 8, 1],
|
||||
['flat', 0, 0],
|
||||
['down', -4, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows rows with empty bars when no value is positive', () => {
|
||||
const table = serviceTable([
|
||||
['a', 0],
|
||||
['b', -1],
|
||||
]);
|
||||
|
||||
expect(summarize(table)).toStrictEqual([
|
||||
['a', 0, 0],
|
||||
['b', -1, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives each numeric row its share of the total', () => {
|
||||
const table = serviceTable([
|
||||
['auth', 30],
|
||||
['payment', 60],
|
||||
['missing', 'n/a'],
|
||||
['checkout', 10],
|
||||
]);
|
||||
|
||||
expect(
|
||||
prepareTopListRows([table]).rows.map((row) => [row.label, row.share]),
|
||||
).toStrictEqual([
|
||||
['payment', 0.6],
|
||||
['auth', 0.3],
|
||||
['checkout', 0.1],
|
||||
['missing', null],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scenario: 'any value is negative', values: [8, -4] },
|
||||
{ scenario: 'the total is zero', values: [0, 0] },
|
||||
])('gives no shares when $scenario', ({ values }) => {
|
||||
const table = serviceTable(
|
||||
values.map((value, index) => [`s${index}`, value]),
|
||||
);
|
||||
|
||||
expect(
|
||||
prepareTopListRows([table]).rows.map((row) => row.share),
|
||||
).toStrictEqual([null, null]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ legend: '', expected: 'count()' },
|
||||
{ legend: '{{service.name}}', expected: 'A' },
|
||||
])(
|
||||
'names the value $expected when the legend is "$legend"',
|
||||
({ legend, expected }) => {
|
||||
const table = serviceTable([['auth', 1]], {
|
||||
legend,
|
||||
columns: [groupColumn('service.name'), valueColumn(legend || 'count()')],
|
||||
});
|
||||
|
||||
expect(prepareTopListRows([table]).valueName).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('parses numeric strings', () => {
|
||||
const table = serviceTable([
|
||||
['a', '12'],
|
||||
['b', ' '],
|
||||
]);
|
||||
|
||||
expect(summarize(table)).toStrictEqual([
|
||||
['a', 12, 1],
|
||||
['b', null, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels a missing group value as empty for both null (traces/logs) and "" (metrics)', () => {
|
||||
const table = serviceTable([
|
||||
[null, 2],
|
||||
['', 1],
|
||||
]);
|
||||
|
||||
const { rows } = prepareTopListRows([table]);
|
||||
|
||||
expect(rows.map((row) => [row.label, row.isEmptyLabel])).toStrictEqual([
|
||||
[EMPTY_LABEL, true],
|
||||
[EMPTY_LABEL, true],
|
||||
]);
|
||||
expect(rows.map((row) => row.key)).toStrictEqual(['A-0', 'A-1']);
|
||||
});
|
||||
|
||||
it('joins several group values, marking only the missing ones', () => {
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [groupColumn('http.route'), groupColumn('region'), valueColumn()],
|
||||
rows: [
|
||||
{
|
||||
data: { 'http.route': 'GET /orders', region: 'us-east-1', 'count()': 3 },
|
||||
},
|
||||
{ data: { 'http.route': 'GET /cart', region: null, 'count()': 1 } },
|
||||
],
|
||||
};
|
||||
|
||||
expect(summarize(table).map(([label]) => label)).toStrictEqual([
|
||||
'GET /orders · us-east-1',
|
||||
`GET /cart · ${EMPTY_LABEL}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills a legend template, rendering a key the row lacks as empty', () => {
|
||||
const table = serviceTable([['checkout', 1]], {
|
||||
legend: '{{service.name}} ({{region}})',
|
||||
});
|
||||
|
||||
expect(summarize(table)[0][0]).toBe('checkout ()');
|
||||
});
|
||||
|
||||
it('keeps rows distinct when a legend without variables gives them all one label', () => {
|
||||
const table = serviceTable(
|
||||
[
|
||||
['a', 2],
|
||||
['b', 1],
|
||||
],
|
||||
{ legend: 'requests' },
|
||||
);
|
||||
|
||||
const { rows } = prepareTopListRows([table]);
|
||||
|
||||
expect(rows.map((row) => row.label)).toStrictEqual(['requests', 'requests']);
|
||||
expect(new Set(rows.map((row) => row.key)).size).toBe(2);
|
||||
});
|
||||
|
||||
it('labels a query without a group-by by its name', () => {
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [valueColumn()],
|
||||
rows: [{ data: { 'count()': 42 } }],
|
||||
};
|
||||
|
||||
const data = prepareTopListRows([table]);
|
||||
|
||||
expect(data.labelColumnNames).toStrictEqual([]);
|
||||
expect(data.rows.map((row) => [row.label, row.value])).toStrictEqual([
|
||||
['A', 42],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ranks by the first value column and reports the rest as ignored', () => {
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [groupColumn('host'), valueColumn('p99'), valueColumn('p50')],
|
||||
rows: [
|
||||
{ data: { host: 'a', p99: 1, p50: 9 } },
|
||||
{ data: { host: 'b', p99: 2, p50: 1 } },
|
||||
],
|
||||
};
|
||||
|
||||
const data = prepareTopListRows([table]);
|
||||
|
||||
expect(data.rows.map((row) => [row.label, row.value])).toStrictEqual([
|
||||
['b', 2],
|
||||
['a', 1],
|
||||
]);
|
||||
expect(data.ignoredValueColumns).toStrictEqual(['p50']);
|
||||
});
|
||||
|
||||
it('carries the query and group values for drilldown filters', () => {
|
||||
const table = serviceTable([
|
||||
['checkout', 1],
|
||||
['', 2],
|
||||
[null, 3],
|
||||
]);
|
||||
|
||||
const { rows } = prepareTopListRows([table]);
|
||||
|
||||
expect(rows.map((row) => [row.queryName, row.labels])).toStrictEqual([
|
||||
['A', {}],
|
||||
['A', { 'service.name': '' }],
|
||||
['A', { 'service.name': 'checkout' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no rows when no table has a value column', () => {
|
||||
expect(prepareTopListRows([])).toStrictEqual({
|
||||
rows: [],
|
||||
labelColumnNames: [],
|
||||
valueColumnName: '',
|
||||
valueName: '',
|
||||
ignoredValueColumns: [],
|
||||
ignoredResults: [],
|
||||
orderedByGroupKey: null,
|
||||
});
|
||||
expect(
|
||||
prepareTopListRows([
|
||||
{ queryName: 'A', legend: '', columns: [groupColumn('x')], rows: [] },
|
||||
]).rows,
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { TopListRow } from '../types';
|
||||
import {
|
||||
formatShare,
|
||||
getRowHeight,
|
||||
getTooltipContent,
|
||||
truncateMiddle,
|
||||
} from '../utils';
|
||||
|
||||
// Each case: rows that fit at a density, given a 1px gap and 12px of list padding.
|
||||
describe('getRowHeight', () => {
|
||||
it.each([
|
||||
{ count: 10, height: 10 * 32 + 9 + 12, expected: 32 },
|
||||
{ count: 10, height: 10 * 32 + 9 + 11, expected: 28 },
|
||||
{ count: 10, height: 10 * 28 + 9 + 12, expected: 28 },
|
||||
{ count: 10, height: 10 * 28 + 9 + 11, expected: 24 },
|
||||
{ count: 200, height: 400, expected: 24 },
|
||||
])(
|
||||
'uses $expected px rows for $count rows in $height px',
|
||||
({ count, height, expected }) => {
|
||||
expect(getRowHeight(count, height)).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('falls back to the default height before the panel is measured', () => {
|
||||
expect(getRowHeight(10, 0)).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShare', () => {
|
||||
it.each([
|
||||
{ share: 0.5, expected: '50%' },
|
||||
{ share: 0.12345, expected: '12%' },
|
||||
{ share: 0.0995, expected: '10%' },
|
||||
{ share: 0.0456, expected: '4.6%' },
|
||||
{ share: 0.0004, expected: '<0.1%' },
|
||||
{ share: 0, expected: '0%' },
|
||||
{ share: 1, expected: '100%' },
|
||||
{ share: null, expected: '—' },
|
||||
])('formats $share as $expected', ({ share, expected }) => {
|
||||
expect(formatShare(share)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateMiddle', () => {
|
||||
const byLength = (text: string): number => text.length;
|
||||
|
||||
it('keeps a label that fits', () => {
|
||||
expect(truncateMiddle('checkout', 8, byLength)).toBe('checkout');
|
||||
});
|
||||
|
||||
it('keeps both ends of a label that does not', () => {
|
||||
expect(truncateMiddle('recommendationservice', 9, byLength)).toBe(
|
||||
'reco…vice',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves only the ellipsis when nothing else fits', () => {
|
||||
expect(truncateMiddle('checkout', 1, byLength)).toBe('…');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTooltipContent', () => {
|
||||
const row = (overrides: Partial<TopListRow> = {}): TopListRow => ({
|
||||
key: 'A-0',
|
||||
label: 'checkout',
|
||||
isEmptyLabel: false,
|
||||
value: 1500,
|
||||
rawValue: 1500,
|
||||
ratio: 1,
|
||||
share: 0.25,
|
||||
queryName: 'A',
|
||||
labels: { 'service.name': 'checkout' },
|
||||
...overrides,
|
||||
});
|
||||
const options = {
|
||||
valueName: 'p99(duration_nano)',
|
||||
unit: 'ms',
|
||||
showShare: true,
|
||||
};
|
||||
|
||||
it('names the value and its share', () => {
|
||||
expect(getTooltipContent(row(), options).rows).toStrictEqual([
|
||||
{ key: 'value', label: 'p99(duration_nano)', value: '1.5 s' },
|
||||
{ key: 'share', label: 'Share of listed total', value: '25%' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the share out when the panel hides it or the row has none', () => {
|
||||
expect(
|
||||
getTooltipContent(row(), { ...options, showShare: false }).rows,
|
||||
).toHaveLength(1);
|
||||
expect(getTooltipContent(row({ share: null }), options).rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('lists the group values only when the label is not one already', () => {
|
||||
expect(getTooltipContent(row(), options).mutedRows).toStrictEqual([]);
|
||||
expect(
|
||||
getTooltipContent(
|
||||
row({
|
||||
label: 'checkout · eu',
|
||||
labels: { 'service.name': 'checkout', region: 'eu' },
|
||||
}),
|
||||
options,
|
||||
).mutedRows,
|
||||
).toStrictEqual([
|
||||
{ key: 'service.name', label: 'service.name', value: 'checkout' },
|
||||
{ key: 'region', label: 'region', value: 'eu' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { getTopListDataWarning } from '../warnings';
|
||||
|
||||
const panelWith = (
|
||||
appearance: { showShare?: boolean } = {},
|
||||
): PanelOfKind<'signoz/TopListPanel'> =>
|
||||
({
|
||||
spec: { plugin: { spec: { appearance } } },
|
||||
}) as unknown as PanelOfKind<'signoz/TopListPanel'>;
|
||||
const panel = panelWith();
|
||||
|
||||
interface Column {
|
||||
name: string;
|
||||
columnType: 'group' | 'aggregation';
|
||||
aggregationIndex?: number;
|
||||
}
|
||||
|
||||
const group = (name: string): Column => ({ name, columnType: 'group' });
|
||||
const aggregation = (name: string, aggregationIndex = 0): Column => ({
|
||||
name,
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex,
|
||||
});
|
||||
|
||||
function dataWith(
|
||||
columns: Column[],
|
||||
rows: unknown[][],
|
||||
legend = '',
|
||||
requestPayload?: Querybuildertypesv5QueryRangeRequestDTO,
|
||||
): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: columns.map((column) => ({ ...column, queryName: 'A' })),
|
||||
data: rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload,
|
||||
legendMap: legend ? { A: legend } : {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('getTopListDataWarning', () => {
|
||||
it('stays quiet for a grouped single-value result', () => {
|
||||
const data = dataWith(
|
||||
[group('service.name'), aggregation('__result')],
|
||||
[
|
||||
['checkout', 2],
|
||||
['auth', 1],
|
||||
],
|
||||
);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)).toBeNull();
|
||||
});
|
||||
|
||||
it('stays quiet when there are no rows', () => {
|
||||
expect(getTopListDataWarning(panel, dataWith([], []))).toBeNull();
|
||||
});
|
||||
|
||||
it('warns when the query has no group by', () => {
|
||||
const data = dataWith([aggregation('__result')], [[42]]);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'The query has no group by, so the list shows a single total. Add a group by to rank its groups.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the value columns the ranking ignores', () => {
|
||||
const data = dataWith(
|
||||
[
|
||||
group('host'),
|
||||
aggregation('p99'),
|
||||
aggregation('p50', 1),
|
||||
aggregation('p90', 2),
|
||||
],
|
||||
[['a', 1, 2, 3]],
|
||||
'',
|
||||
// ClickHouse value columns keep their SQL alias as their name.
|
||||
{
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
|
||||
],
|
||||
},
|
||||
} as unknown as Querybuildertypesv5QueryRangeRequestDTO,
|
||||
);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'The list ranks the first value column only; "p50", "p90" are not shown.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns when every row shares one label', () => {
|
||||
const data = dataWith(
|
||||
[group('service.name'), aggregation('__result')],
|
||||
[
|
||||
['checkout', 2],
|
||||
['auth', 1],
|
||||
],
|
||||
'requests',
|
||||
);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'Every row has the same label. Use {{group-by key}} variables in the legend to tell rows apart.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
@use '../../../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
// Rows share these columns through subgrid: the rank, value and share that are shown
|
||||
// (--leading-columns), then the bar with its label. Vertical padding is set inline,
|
||||
// where it also stands in for unrendered rows.
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
repeat(var(--leading-columns, 1), max-content)
|
||||
minmax(0, 1fr);
|
||||
align-content: start;
|
||||
column-gap: 10px;
|
||||
row-gap: 1px;
|
||||
margin: 0;
|
||||
padding-inline: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
// Zero-height row holding the widest value, so the shared columns keep
|
||||
// their size while rows scroll in and out of the rendered window. The negative
|
||||
// margin cancels the row gap after it.
|
||||
.sizer {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
height: 0;
|
||||
margin-bottom: -1px;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sizerValue {
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sizerMuted {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Measured for label truncation, so it carries the label's font.
|
||||
.sizerBar {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// Lets the pointer pass through to the rows underneath; otherwise the tooltip catches
|
||||
// it and stops following.
|
||||
// Sized to its content like the chart tooltips, up to TooltipPlugin's maxWidth/maxHeight;
|
||||
// the floor keeps a short one from collapsing narrower than a scatter tooltip.
|
||||
.tooltip {
|
||||
width: max-content;
|
||||
min-width: 320px;
|
||||
max-width: 450px;
|
||||
max-height: 600px;
|
||||
overflow: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { type CSSProperties, useCallback, useMemo, useRef } from 'react';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
|
||||
|
||||
import type { PanelThreshold } from '../../../../types/threshold';
|
||||
import { LIST_PADDING_Y } from '../../constants';
|
||||
import { useLabelFitter } from '../../hooks/useLabelFitter';
|
||||
import { useTopListTooltip } from '../../hooks/useTopListTooltip';
|
||||
import { useTopListVirtualizer } from '../../hooks/useTopListVirtualizer';
|
||||
import type { TopListRow as TopListRowData } from '../../types';
|
||||
import {
|
||||
getNavigationTarget,
|
||||
getRowHeight,
|
||||
getWidestShare,
|
||||
getWidestValue,
|
||||
type RowColors,
|
||||
} from '../../utils';
|
||||
import TopListRow from '../TopListRow/TopListRow';
|
||||
import TopListTooltip from '../TopListTooltip/TopListTooltip';
|
||||
|
||||
import styles from './TopList.module.scss';
|
||||
|
||||
const RESIZE_DEBOUNCE_MS = 100;
|
||||
|
||||
interface TopListProps {
|
||||
/** Identifies the panel in tooltip analytics. */
|
||||
panelId: string;
|
||||
rows: TopListRowData[];
|
||||
/** What the values measure, named in each row's tooltip. */
|
||||
valueName: string;
|
||||
unit?: string;
|
||||
precision?: PrecisionOption;
|
||||
thresholds: PanelThreshold[];
|
||||
showRank?: boolean;
|
||||
showShare?: boolean;
|
||||
onSelect?: (
|
||||
row: TopListRowData,
|
||||
colors: RowColors,
|
||||
coordinates: { x: number; y: number },
|
||||
) => void;
|
||||
}
|
||||
|
||||
function TopList({
|
||||
panelId,
|
||||
rows,
|
||||
valueName,
|
||||
unit,
|
||||
precision,
|
||||
thresholds,
|
||||
showRank = false,
|
||||
showShare = false,
|
||||
onSelect,
|
||||
}: TopListProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sizerBarRef = useRef<HTMLSpanElement>(null);
|
||||
const fitLabel = useLabelFitter(sizerBarRef);
|
||||
const tooltip = useTopListTooltip();
|
||||
const hoveredRow =
|
||||
tooltip.rowIndex === null ? undefined : rows[tooltip.rowIndex];
|
||||
const { height } = useResizeObserver(containerRef, RESIZE_DEBOUNCE_MS);
|
||||
const rowHeight = getRowHeight(rows.length, height);
|
||||
const { virtualItems, paddingTop, paddingBottom, focusRow } =
|
||||
useTopListVirtualizer({ count: rows.length, rowHeight, containerRef });
|
||||
|
||||
const widestValue = useMemo(
|
||||
() => getWidestValue(rows, unit, precision),
|
||||
[rows, unit, precision],
|
||||
);
|
||||
const widestShare = useMemo(
|
||||
() => (showShare ? getWidestShare(rows) : ''),
|
||||
[rows, showShare],
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(index: number, key: string): boolean => {
|
||||
const target = getNavigationTarget(index, key, rows.length);
|
||||
if (target === null) {
|
||||
return false;
|
||||
}
|
||||
focusRow(target);
|
||||
return true;
|
||||
},
|
||||
[rows.length, focusRow],
|
||||
);
|
||||
|
||||
const listStyle = {
|
||||
'--row-height': `${rowHeight}px`,
|
||||
'--leading-columns': 1 + Number(showRank) + Number(showShare),
|
||||
paddingTop: LIST_PADDING_Y / 2 + paddingTop,
|
||||
paddingBottom: LIST_PADDING_Y / 2 + paddingBottom,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.container}
|
||||
onScroll={tooltip.hide}
|
||||
onMouseLeave={tooltip.hide}
|
||||
onMouseMove={tooltip.hideOffRow}
|
||||
>
|
||||
<ol
|
||||
ref={tooltip.listRef}
|
||||
data-testid="top-list"
|
||||
className={styles.list}
|
||||
style={listStyle}
|
||||
>
|
||||
<li aria-hidden className={styles.sizer}>
|
||||
{showRank && (
|
||||
<span className={styles.sizerMuted}>{String(rows.length)}</span>
|
||||
)}
|
||||
<span className={styles.sizerValue}>{widestValue}</span>
|
||||
{showShare && <span className={styles.sizerMuted}>{widestShare}</span>}
|
||||
<span ref={sizerBarRef} className={styles.sizerBar} />
|
||||
</li>
|
||||
{virtualItems.map(({ index }) => (
|
||||
<TopListRow
|
||||
key={rows[index].key}
|
||||
row={rows[index]}
|
||||
index={index}
|
||||
unit={unit}
|
||||
precision={precision}
|
||||
thresholds={thresholds}
|
||||
showRank={showRank}
|
||||
showShare={showShare}
|
||||
fitLabel={fitLabel}
|
||||
onHover={tooltip.show}
|
||||
onSelect={onSelect}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
{hoveredRow && (
|
||||
<tooltip.TooltipInPortal
|
||||
top={tooltip.top}
|
||||
left={tooltip.left}
|
||||
unstyled
|
||||
applyPositionStyle
|
||||
className={styles.tooltip}
|
||||
>
|
||||
<TopListTooltip
|
||||
row={hoveredRow}
|
||||
valueName={valueName}
|
||||
unit={unit}
|
||||
precision={precision}
|
||||
thresholds={thresholds}
|
||||
showShare={showShare}
|
||||
footer={
|
||||
onSelect && (
|
||||
<TooltipFooter
|
||||
id={panelId}
|
||||
isPinned={false}
|
||||
canPin={false}
|
||||
dismiss={tooltip.hide}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tooltip.TooltipInPortal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopList;
|
||||
@@ -0,0 +1,108 @@
|
||||
.item {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
height: var(--row-height);
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: -1px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: background-color 120ms;
|
||||
|
||||
// The panel surface is --l2-background, so the hovered row sits a level above it.
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.isInteractive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Rank and share: supporting numbers beside the value.
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--l3-foreground);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.isNonNumeric {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// The label sits on the bar, spanning the whole column so it isn't cut at the fill.
|
||||
.bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: calc(var(--row-height) - 2px);
|
||||
}
|
||||
|
||||
.fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 26%, transparent);
|
||||
|
||||
.row:hover & {
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 38%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.hasThresholdColor {
|
||||
background: color-mix(in srgb, var(--fill-color) 30%, transparent);
|
||||
|
||||
.row:hover & {
|
||||
background: color-mix(in srgb, var(--fill-color) 42%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.row:hover & {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.isEmptyLabel {
|
||||
font-style: italic;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
memo,
|
||||
useMemo,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import cx from 'classnames';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
|
||||
import type { PanelThreshold } from '../../../../types/threshold';
|
||||
import type { TopListRow as TopListRowData } from '../../types';
|
||||
import {
|
||||
formatRowValue,
|
||||
formatShare,
|
||||
resolveRowColors,
|
||||
type RowColors,
|
||||
} from '../../utils';
|
||||
|
||||
import styles from './TopListRow.module.scss';
|
||||
|
||||
interface TopListRowProps {
|
||||
row: TopListRowData;
|
||||
/** Position in the ranked list. */
|
||||
index: number;
|
||||
unit?: string;
|
||||
precision?: PrecisionOption;
|
||||
thresholds: PanelThreshold[];
|
||||
showRank: boolean;
|
||||
showShare: boolean;
|
||||
/** Shortens a label to fit its bar. */
|
||||
fitLabel: (label: string) => string;
|
||||
/** Moves the list's tooltip to this row, at the pointer. */
|
||||
onHover: (index: number, event: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||
onSelect?: (
|
||||
row: TopListRowData,
|
||||
colors: RowColors,
|
||||
coordinates: { x: number; y: number },
|
||||
) => void;
|
||||
/** Handles a navigation key; returns whether it moved focus. */
|
||||
onNavigate: (index: number, key: string) => boolean;
|
||||
}
|
||||
|
||||
function TopListRow({
|
||||
row,
|
||||
index,
|
||||
unit,
|
||||
precision,
|
||||
thresholds,
|
||||
showRank,
|
||||
showShare,
|
||||
fitLabel,
|
||||
onHover,
|
||||
onSelect,
|
||||
onNavigate,
|
||||
}: TopListRowProps): JSX.Element {
|
||||
const displayValue = useMemo(
|
||||
() => formatRowValue(row, unit, precision),
|
||||
[row, unit, precision],
|
||||
);
|
||||
const colors = useMemo(
|
||||
() => resolveRowColors(row.value, thresholds, unit),
|
||||
[row.value, thresholds, unit],
|
||||
);
|
||||
const displayShare = showShare ? formatShare(row.share) : null;
|
||||
const knownShare =
|
||||
displayShare !== null && row.share !== null ? displayShare : undefined;
|
||||
const displayLabel = useMemo(() => fitLabel(row.label), [fitLabel, row.label]);
|
||||
|
||||
const handleClick = (event: ReactMouseEvent<HTMLButtonElement>): void => {
|
||||
if (!onSelect) {
|
||||
return;
|
||||
}
|
||||
// Enter/Space clicks carry no pointer position; anchor the menu under the row.
|
||||
if (event.detail === 0) {
|
||||
const { left, bottom } = event.currentTarget.getBoundingClientRect();
|
||||
onSelect(row, colors, { x: left, y: bottom });
|
||||
return;
|
||||
}
|
||||
onSelect(row, colors, { x: event.clientX, y: event.clientY });
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: ReactMouseEvent<HTMLButtonElement>): void =>
|
||||
onHover(index, event);
|
||||
|
||||
const handleKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (onNavigate(index, event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className={styles.item}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup={onSelect ? 'menu' : undefined}
|
||||
aria-label={`${row.label}: ${displayValue}${
|
||||
knownShare ? `, ${knownShare} of total` : ''
|
||||
}`}
|
||||
data-row-index={index}
|
||||
data-testid="top-list-row"
|
||||
className={cx(styles.row, { [styles.isInteractive]: !!onSelect })}
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{showRank && (
|
||||
<span data-testid="top-list-row-rank" className={styles.muted}>
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
data-testid="top-list-row-value"
|
||||
className={cx(styles.value, {
|
||||
[styles.isNonNumeric]: row.value === null,
|
||||
})}
|
||||
style={{ color: colors.valueColor }}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
{displayShare && (
|
||||
<span data-testid="top-list-row-share" className={styles.muted}>
|
||||
{displayShare}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.bar}>
|
||||
<span
|
||||
data-testid="top-list-row-fill"
|
||||
className={cx(styles.fill, {
|
||||
[styles.hasThresholdColor]: !!colors.barColor,
|
||||
})}
|
||||
style={
|
||||
{
|
||||
width: `${row.ratio * 100}%`,
|
||||
'--fill-color': colors.barColor,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={cx(styles.label, { [styles.isEmptyLabel]: row.isEmptyLabel })}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TopListRow);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import TooltipCard from 'lib/uPlotV2/components/Tooltip/components/TooltipCard/TooltipCard';
|
||||
|
||||
import type { PanelThreshold } from '../../../../types/threshold';
|
||||
import type { TopListRow } from '../../types';
|
||||
import { ACCENT_COLOR, getTooltipContent, resolveRowColors } from '../../utils';
|
||||
|
||||
interface TopListTooltipProps {
|
||||
row: TopListRow;
|
||||
/** What the values measure. */
|
||||
valueName: string;
|
||||
unit?: string;
|
||||
precision?: PrecisionOption;
|
||||
thresholds: PanelThreshold[];
|
||||
showShare: boolean;
|
||||
/** Hints for what a click does; none when rows aren't clickable. */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
function TopListTooltip({
|
||||
row,
|
||||
valueName,
|
||||
unit,
|
||||
precision,
|
||||
thresholds,
|
||||
showShare,
|
||||
footer,
|
||||
}: TopListTooltipProps): JSX.Element {
|
||||
const { rows, mutedRows } = useMemo(
|
||||
() => getTooltipContent(row, { valueName, unit, precision, showShare }),
|
||||
[row, valueName, unit, precision, showShare],
|
||||
);
|
||||
const { barColor } = resolveRowColors(row.value, thresholds, unit);
|
||||
|
||||
return (
|
||||
<TooltipCard
|
||||
title={row.label}
|
||||
color={barColor ?? ACCENT_COLOR}
|
||||
rows={rows}
|
||||
mutedRows={mutedRows}
|
||||
footer={footer}
|
||||
testId="top-list-tooltip"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopListTooltip;
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Groups shown when the query sets no limit. */
|
||||
export const DEFAULT_LIMIT = 10;
|
||||
/** Row heights by density, roomiest first: comfortable, default, compact. */
|
||||
export const ROW_HEIGHTS = [32, 28, 24] as const;
|
||||
export const DEFAULT_ROW_HEIGHT = 28;
|
||||
export const ROW_GAP = 1;
|
||||
/** The list's top and bottom padding combined. */
|
||||
export const LIST_PADDING_Y = 12;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../types/rendererProps';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
|
||||
import { prepareTopListData } from './prepareData';
|
||||
import { formatRowValue, formatShare } from './utils';
|
||||
|
||||
const RANK_HEADER = 'Rank';
|
||||
const LABEL_HEADER = 'Label';
|
||||
const VALUE_HEADER = 'Value';
|
||||
const SHARE_HEADER = 'Share';
|
||||
|
||||
/** The list as shown: rows in ranked order with their rank, label, formatted value and share. */
|
||||
export function getTopListCsvRows(
|
||||
panel: PanelOfKind<'signoz/TopListPanel'>,
|
||||
data: PanelQueryData,
|
||||
): Record<string, string>[] {
|
||||
const { formatting, appearance } = panel.spec.plugin.spec;
|
||||
const { rows, labelColumnNames, valueColumnName } = prepareTopListData(data);
|
||||
const labelHeader = labelColumnNames.join(' · ') || LABEL_HEADER;
|
||||
const valueHeader = valueColumnName || VALUE_HEADER;
|
||||
const precision = resolveDecimalPrecision(formatting?.decimalPrecision);
|
||||
|
||||
return rows.map((row, index) => ({
|
||||
...(appearance?.showRank && { [RANK_HEADER]: String(index + 1) }),
|
||||
[labelHeader]: row.label,
|
||||
[valueHeader]: formatRowValue(row, formatting?.unit, precision),
|
||||
...(appearance?.showShare && {
|
||||
[SHARE_HEADER]: formatShare(row.share),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ChartBarDecreasing } from '@signozhq/icons';
|
||||
|
||||
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { DEFAULT_LIMIT } from './constants';
|
||||
import { sections } from './sections';
|
||||
import { getTopListDataWarning } from './warnings';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TopListPanel'> = {
|
||||
kind: 'signoz/TopListPanel',
|
||||
displayName: 'Top List',
|
||||
mode: 'query',
|
||||
icon: ChartBarDecreasing,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
queryBuilderFields: {
|
||||
// A list without a group by is a single row.
|
||||
[QueryBuilderField.GroupBy]: { state: 'pinned' },
|
||||
// Picks top or bottom N on the server; a client-side flip would show the top N reversed.
|
||||
[QueryBuilderField.OrderBy]: {
|
||||
state: 'pinned',
|
||||
placeholder: 'Default highest first',
|
||||
},
|
||||
[QueryBuilderField.Limit]: {
|
||||
state: 'pinned',
|
||||
placeholder: `Default ${DEFAULT_LIMIT} for Top List`,
|
||||
},
|
||||
// Ignored for logs and traces scalars; for metrics they apply before reduceTo.
|
||||
[QueryBuilderField.Functions]: { state: 'hidden' },
|
||||
},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
defaultRowLimit: DEFAULT_LIMIT,
|
||||
},
|
||||
// Formulas join their inputs on group values, so every query groups by the same keys.
|
||||
syncedQueryBuilderFields: [QueryBuilderField.GroupBy],
|
||||
getDataWarning: getTopListDataWarning,
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: true, png: true, svg: true },
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type RefObject, useCallback, useMemo } from 'react';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
|
||||
import { truncateMiddle } from '../utils';
|
||||
|
||||
/** The label's horizontal padding inside the bar, plus a pixel for canvas/DOM rounding. */
|
||||
const LABEL_INSET_X = 17;
|
||||
const RESIZE_DEBOUNCE_MS = 100;
|
||||
|
||||
let canvasContext: CanvasRenderingContext2D | null | undefined;
|
||||
|
||||
function getCanvasContext(): CanvasRenderingContext2D | null {
|
||||
if (canvasContext === undefined) {
|
||||
canvasContext = document.createElement('canvas').getContext('2d');
|
||||
}
|
||||
return canvasContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middle-truncates labels to the bar column, measured on `barRef`, which must carry the
|
||||
* label's font. Labels pass through unchanged until the column has a width.
|
||||
*/
|
||||
export function useLabelFitter(
|
||||
barRef: RefObject<HTMLElement>,
|
||||
): (label: string) => string {
|
||||
const { width } = useResizeObserver(barRef, RESIZE_DEBOUNCE_MS);
|
||||
const maxWidth = width - LABEL_INSET_X;
|
||||
|
||||
const font = useMemo(() => {
|
||||
const node = barRef.current;
|
||||
if (!node || maxWidth <= 0) {
|
||||
return null;
|
||||
}
|
||||
const { fontWeight, fontSize, fontFamily } = getComputedStyle(node);
|
||||
return `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||
}, [barRef, maxWidth]);
|
||||
|
||||
return useCallback(
|
||||
(label: string): string => {
|
||||
const context = font ? getCanvasContext() : null;
|
||||
if (!context || !font) {
|
||||
return label;
|
||||
}
|
||||
context.font = font;
|
||||
return truncateMiddle(
|
||||
label,
|
||||
maxWidth,
|
||||
(value) => context.measureText(value).width,
|
||||
);
|
||||
},
|
||||
[font, maxWidth],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
type FC,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||
import type { TooltipInPortalProps } from '@visx/tooltip/lib/hooks/useTooltipInPortal';
|
||||
|
||||
/** Above the dashboard grid, like the chart tooltips. */
|
||||
const TOOLTIP_Z_INDEX = 1000;
|
||||
|
||||
interface TopListTooltip {
|
||||
/** The hovered row, or null when the tooltip is hidden. */
|
||||
rowIndex: number | null;
|
||||
top?: number;
|
||||
left?: number;
|
||||
show: (index: number, event: ReactMouseEvent<HTMLElement>) => void;
|
||||
hide: () => void;
|
||||
/** Hides the tooltip when the pointer is over the list but not on a row. */
|
||||
hideOffRow: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||
/** The element the tooltip is positioned against. */
|
||||
listRef: (element: HTMLElement | null) => void;
|
||||
TooltipInPortal: FC<TooltipInPortalProps>;
|
||||
}
|
||||
|
||||
/** One tooltip for the whole list, following the pointer the way the chart tooltips do. */
|
||||
export function useTopListTooltip(): TopListTooltip {
|
||||
const {
|
||||
tooltipOpen,
|
||||
tooltipData,
|
||||
tooltipTop,
|
||||
tooltipLeft,
|
||||
showTooltip,
|
||||
hideTooltip,
|
||||
} = useTooltip<number>();
|
||||
const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal({
|
||||
scroll: true,
|
||||
detectBounds: true,
|
||||
zIndex: TOOLTIP_Z_INDEX,
|
||||
});
|
||||
|
||||
// Read at event time so `show` stays stable for the memoised rows.
|
||||
const boundsRef = useRef(containerBounds);
|
||||
boundsRef.current = containerBounds;
|
||||
|
||||
const show = useCallback(
|
||||
(index: number, event: ReactMouseEvent<HTMLElement>): void => {
|
||||
const { left, top } = boundsRef.current;
|
||||
showTooltip({
|
||||
tooltipData: index,
|
||||
tooltipLeft: event.clientX - left,
|
||||
tooltipTop: event.clientY - top,
|
||||
});
|
||||
},
|
||||
[showTooltip],
|
||||
);
|
||||
|
||||
const hideOffRow = useCallback(
|
||||
(event: ReactMouseEvent<HTMLElement>): void => {
|
||||
if (!(event.target as Element).closest('[data-row-index]')) {
|
||||
hideTooltip();
|
||||
}
|
||||
},
|
||||
[hideTooltip],
|
||||
);
|
||||
|
||||
return {
|
||||
rowIndex: tooltipOpen ? (tooltipData ?? null) : null,
|
||||
top: tooltipTop,
|
||||
left: tooltipLeft,
|
||||
show,
|
||||
hide: hideTooltip,
|
||||
hideOffRow,
|
||||
listRef: containerRef,
|
||||
TooltipInPortal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type RefObject, useCallback, useEffect, useRef } from 'react';
|
||||
import { useVirtualizer, type VirtualItem } from '@tanstack/react-virtual';
|
||||
|
||||
import { ROW_GAP } from '../constants';
|
||||
|
||||
const OVERSCAN = 10;
|
||||
/** Size assumed until the container is measured, so the first paint isn't empty. */
|
||||
const INITIAL_RECT = { width: 0, height: 600 };
|
||||
/** Frames to wait for a scrolled-to row to mount before giving up on focusing it. */
|
||||
const FOCUS_RETRY_FRAMES = 10;
|
||||
|
||||
interface UseTopListVirtualizerArgs {
|
||||
count: number;
|
||||
rowHeight: number;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
interface TopListVirtualizer {
|
||||
/** The rows to render: those in view plus an overscan either side. */
|
||||
virtualItems: VirtualItem[];
|
||||
/** Space standing in for the rows above and below the rendered window. */
|
||||
paddingTop: number;
|
||||
paddingBottom: number;
|
||||
/** Scrolls the row into view if needed, then focuses it. */
|
||||
focusRow: (index: number) => void;
|
||||
}
|
||||
|
||||
export function useTopListVirtualizer({
|
||||
count,
|
||||
rowHeight,
|
||||
containerRef,
|
||||
}: UseTopListVirtualizerArgs): TopListVirtualizer {
|
||||
const virtualizer = useVirtualizer({
|
||||
count,
|
||||
getScrollElement: () => containerRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
gap: ROW_GAP,
|
||||
overscan: OVERSCAN,
|
||||
initialRect: INITIAL_RECT,
|
||||
});
|
||||
const focusFrame = useRef<number>();
|
||||
|
||||
// Every row takes the density's height, so a density change re-lays out every row.
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [virtualizer, rowHeight]);
|
||||
|
||||
useEffect(() => (): void => cancelAnimationFrame(focusFrame.current ?? 0), []);
|
||||
|
||||
const focusRow = useCallback(
|
||||
(index: number): void => {
|
||||
cancelAnimationFrame(focusFrame.current ?? 0);
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' });
|
||||
let attempts = 0;
|
||||
const tryFocus = (): void => {
|
||||
const row = containerRef.current?.querySelector<HTMLElement>(
|
||||
`[data-row-index="${index}"]`,
|
||||
);
|
||||
if (row) {
|
||||
// The virtualizer already scrolled; a native focus scroll would fight it.
|
||||
row.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (attempts < FOCUS_RETRY_FRAMES) {
|
||||
attempts += 1;
|
||||
focusFrame.current = requestAnimationFrame(tryFocus);
|
||||
}
|
||||
};
|
||||
tryFocus();
|
||||
},
|
||||
[virtualizer, containerRef],
|
||||
);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const first = virtualItems[0];
|
||||
const last = virtualItems[virtualItems.length - 1];
|
||||
return {
|
||||
virtualItems,
|
||||
paddingTop: first ? first.start : 0,
|
||||
paddingBottom: last ? virtualizer.getTotalSize() - last.end : 0,
|
||||
focusRow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelTable,
|
||||
PanelTableColumn,
|
||||
PanelTableRow,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import type { TopListData, TopListRow } from './types';
|
||||
|
||||
export const EMPTY_LABEL = '(empty)';
|
||||
|
||||
const LABEL_SEPARATOR = ' · ';
|
||||
const LEGEND_VARIABLE = /\{\{\s*([^}]+?)\s*\}\}/g;
|
||||
|
||||
const EMPTY_DATA: TopListData = {
|
||||
rows: [],
|
||||
labelColumnNames: [],
|
||||
valueColumnName: '',
|
||||
valueName: '',
|
||||
ignoredValueColumns: [],
|
||||
ignoredResults: [],
|
||||
orderedByGroupKey: null,
|
||||
};
|
||||
|
||||
/** A missing group value is `null` for traces/logs and `""` for metrics. */
|
||||
function isMissing(value: unknown): boolean {
|
||||
return value == null || value === '';
|
||||
}
|
||||
|
||||
/** Finite numbers only: the server sends NaN/±Inf as "NaN"/"Inf"/"-Inf" and a missing value as "n/a". */
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toGroupValue(value: unknown): string {
|
||||
return typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value as string | number | boolean);
|
||||
}
|
||||
|
||||
function getCell(row: PanelTableRow, column: PanelTableColumn): unknown {
|
||||
return row.data[column.id || column.name];
|
||||
}
|
||||
|
||||
function resolveLabel(
|
||||
labelColumns: PanelTableColumn[],
|
||||
row: PanelTableRow,
|
||||
labels: Record<string, string>,
|
||||
table: PanelTable,
|
||||
): Pick<TopListRow, 'label' | 'isEmptyLabel'> {
|
||||
if (table.legend) {
|
||||
// Unlike `getLabelName`, a key the row doesn't carry renders empty, not "undefined".
|
||||
const label = table.legend.replace(
|
||||
LEGEND_VARIABLE,
|
||||
(_, key: string) => labels[key] ?? '',
|
||||
);
|
||||
return { label, isEmptyLabel: false };
|
||||
}
|
||||
if (labelColumns.length === 0) {
|
||||
return { label: table.queryName, isEmptyLabel: false };
|
||||
}
|
||||
|
||||
const parts = labelColumns.map((column) => getCell(row, column));
|
||||
if (parts.every(isMissing)) {
|
||||
return { label: EMPTY_LABEL, isEmptyLabel: true };
|
||||
}
|
||||
return {
|
||||
label: parts
|
||||
.map((part) => (isMissing(part) ? EMPTY_LABEL : toGroupValue(part)))
|
||||
.join(LABEL_SEPARATOR),
|
||||
isEmptyLabel: false,
|
||||
};
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
/** By value in the query's direction; non-numeric values last; ties by label so the order is stable across refreshes. */
|
||||
function compareRows(direction: SortDirection) {
|
||||
return (a: TopListRow, b: TopListRow): number => {
|
||||
if (a.value === null || b.value === null) {
|
||||
if (a.value === b.value) {
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
return a.value === null ? 1 : -1;
|
||||
}
|
||||
const byValue = direction === 'asc' ? a.value - b.value : b.value - a.value;
|
||||
return byValue || a.label.localeCompare(b.label);
|
||||
};
|
||||
}
|
||||
|
||||
interface NamedQuerySpec {
|
||||
name?: string;
|
||||
/** Formulas. */
|
||||
expression?: string;
|
||||
aggregations?: {
|
||||
alias?: string;
|
||||
expression?: string;
|
||||
metricName?: string;
|
||||
spaceAggregation?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
function findQuerySpec(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
queryName: string,
|
||||
): NamedQuerySpec | undefined {
|
||||
return (requestPayload?.compositeQuery?.queries ?? [])
|
||||
.map((envelope) => envelope.spec as NamedQuerySpec | undefined)
|
||||
.find((candidate) => candidate?.name === queryName);
|
||||
}
|
||||
|
||||
const AGGREGATION_FUNCTION = /^\s*([a-z_]\w*)\s*\(/i;
|
||||
|
||||
/**
|
||||
* The ranked aggregation's function, lowercased: `p99` for `p99(duration_nano)`, the space
|
||||
* aggregation for a metric. Null for formulas and ClickHouse SQL, which have none to read.
|
||||
*/
|
||||
export function getRankedAggregationFunction(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
queryName: string,
|
||||
): string | null {
|
||||
const [aggregation] =
|
||||
findQuerySpec(requestPayload, queryName)?.aggregations ?? [];
|
||||
const name =
|
||||
aggregation?.expression?.match(AGGREGATION_FUNCTION)?.[1] ??
|
||||
aggregation?.spaceAggregation;
|
||||
return name ? name.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/** The ranked aggregation as the query writes it: its alias, expression or formula. */
|
||||
function describeValue(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
queryName: string,
|
||||
): string | null {
|
||||
const spec = findQuerySpec(requestPayload, queryName);
|
||||
const [aggregation] = spec?.aggregations ?? [];
|
||||
if (aggregation?.alias || aggregation?.expression) {
|
||||
return aggregation.alias || aggregation.expression || null;
|
||||
}
|
||||
if (aggregation?.metricName) {
|
||||
return aggregation.spaceAggregation
|
||||
? `${aggregation.spaceAggregation}(${aggregation.metricName})`
|
||||
: aggregation.metricName;
|
||||
}
|
||||
return spec?.expression || null;
|
||||
}
|
||||
|
||||
interface OrderedQuerySpec {
|
||||
name?: string;
|
||||
order?: { key?: { name?: string }; direction?: string }[];
|
||||
groupBy?: { name?: string }[];
|
||||
}
|
||||
|
||||
interface RankedOrder {
|
||||
direction: SortDirection;
|
||||
groupKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the ranked query orders its rows. The server orders by value, descending, when
|
||||
* no order is set; ordering by a group-by key returns the first N groups by that key.
|
||||
*/
|
||||
function getRankedOrder(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
queryName: string | undefined,
|
||||
): RankedOrder {
|
||||
const spec = (requestPayload?.compositeQuery?.queries ?? [])
|
||||
.map((envelope) => envelope.spec as OrderedQuerySpec | undefined)
|
||||
.find((candidate) => candidate?.name === queryName);
|
||||
const [order] = spec?.order ?? [];
|
||||
const keyName = order?.key?.name;
|
||||
if (!keyName) {
|
||||
return { direction: 'desc', groupKey: null };
|
||||
}
|
||||
if (spec?.groupBy?.some((key) => key.name === keyName)) {
|
||||
return { direction: 'desc', groupKey: keyName };
|
||||
}
|
||||
return {
|
||||
direction: order.direction === 'asc' ? 'asc' : 'desc',
|
||||
groupKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The request's queries and formulas, in builder order. */
|
||||
function getQueryOrder(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): string[] {
|
||||
return (requestPayload?.compositeQuery?.queries ?? [])
|
||||
.map((envelope) => envelope.spec?.name)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
/** Tables in builder order; the response lists them in no particular order. */
|
||||
function orderTables(tables: PanelTable[], queryOrder: string[]): PanelTable[] {
|
||||
const position = (table: PanelTable): number => {
|
||||
const index = queryOrder.indexOf(table.queryName);
|
||||
return index === -1 ? queryOrder.length : index;
|
||||
};
|
||||
return [...tables].sort((a, b) => position(a) - position(b));
|
||||
}
|
||||
|
||||
const hasValueColumn = (table: PanelTable): boolean =>
|
||||
table.columns.some((column) => column.isValueColumn);
|
||||
|
||||
/**
|
||||
* Ranks the first value column of the first table that has one. The server already
|
||||
* orders and limits, but its order isn't stable on ties, so the rows are sorted again here.
|
||||
* A query ordered by a group key still lists by value: its rows are the first N groups.
|
||||
*/
|
||||
export function prepareTopListRows(
|
||||
tables: PanelTable[],
|
||||
direction: SortDirection = 'desc',
|
||||
): TopListData {
|
||||
const [table, ...otherTables] = tables.filter(hasValueColumn);
|
||||
if (!table) {
|
||||
return EMPTY_DATA;
|
||||
}
|
||||
|
||||
const [valueColumn, ...ignoredColumns] = table.columns.filter(
|
||||
(column) => column.isValueColumn,
|
||||
);
|
||||
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
|
||||
|
||||
const rows = table.rows.map((row, index): TopListRow => {
|
||||
const labels: Record<string, string> = {};
|
||||
labelColumns.forEach((column) => {
|
||||
const value = getCell(row, column);
|
||||
if (value != null) {
|
||||
labels[column.name] = toGroupValue(value);
|
||||
}
|
||||
});
|
||||
|
||||
const rawValue = getCell(row, valueColumn);
|
||||
return {
|
||||
key: `${table.queryName}-${index}`,
|
||||
...resolveLabel(labelColumns, row, labels, table),
|
||||
value: toFiniteNumber(rawValue),
|
||||
rawValue,
|
||||
ratio: 0,
|
||||
share: null,
|
||||
queryName: valueColumn.queryName,
|
||||
labels,
|
||||
};
|
||||
});
|
||||
|
||||
const values = rows
|
||||
.map((row) => row.value)
|
||||
.filter((value): value is number => value !== null);
|
||||
const max = Math.max(0, ...values);
|
||||
const total = values.some((value) => value < 0)
|
||||
? 0
|
||||
: values.reduce((sum, value) => sum + value, 0);
|
||||
rows.forEach((row) => {
|
||||
row.ratio =
|
||||
max > 0 && row.value !== null && row.value > 0 ? row.value / max : 0;
|
||||
row.share = total > 0 && row.value !== null ? row.value / total : null;
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rows.sort(compareRows(direction)),
|
||||
labelColumnNames: labelColumns.map((column) => column.name),
|
||||
valueColumnName: valueColumn.name,
|
||||
// A lone aggregation's column takes the legend, which labels rows here, not values.
|
||||
valueName:
|
||||
table.legend && valueColumn.name === table.legend
|
||||
? valueColumn.queryName
|
||||
: valueColumn.name,
|
||||
ignoredValueColumns: ignoredColumns.map((column) => column.name),
|
||||
ignoredResults: otherTables.map((other) => other.queryName),
|
||||
orderedByGroupKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ranks the first enabled query or formula, in builder order, in its order direction. */
|
||||
export function prepareTopListData(data: PanelQueryData): TopListData {
|
||||
const tables = prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
});
|
||||
const ordered = orderTables(tables, getQueryOrder(data.requestPayload));
|
||||
const { direction, groupKey } = getRankedOrder(
|
||||
data.requestPayload,
|
||||
ordered.find(hasValueColumn)?.queryName,
|
||||
);
|
||||
const prepared = prepareTopListRows(ordered, direction);
|
||||
const rankedQuery = prepared.rows[0]?.queryName;
|
||||
// A bare query name ("A") says nothing about the value; the query spec can.
|
||||
const valueName =
|
||||
rankedQuery && prepared.valueName === rankedQuery
|
||||
? (describeValue(data.requestPayload, rankedQuery) ?? rankedQuery)
|
||||
: prepared.valueName;
|
||||
return { ...prepared, valueName, orderedByGroupKey: groupKey };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Appearance,
|
||||
controls: { showRank: true, showShare: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.COMPARISON },
|
||||
},
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface TopListRow {
|
||||
/** Unique per row; labels can repeat (e.g. a legend with no `{{…}}`). */
|
||||
key: string;
|
||||
label: string;
|
||||
/** True when every group value behind the label was missing. */
|
||||
isEmptyLabel: boolean;
|
||||
/** The ranked value, or null when the cell isn't a finite number ("NaN", "Inf", "n/a", null). */
|
||||
value: number | null;
|
||||
/** The cell as the server sent it, shown when `value` is null. */
|
||||
rawValue: unknown;
|
||||
/** Bar length: `value` over the largest positive value, 0–1. Zero for values ≤ 0 and null. */
|
||||
ratio: number;
|
||||
/** `value` over the sum of every row's value; null for null values and when any value is negative. */
|
||||
share: number | null;
|
||||
/** Source query, the drilldown target. */
|
||||
queryName: string;
|
||||
/** Group-by key → value, the drilldown filters. */
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TopListData {
|
||||
rows: TopListRow[];
|
||||
/** Group-by columns behind each label; empty when the query has no group-by. */
|
||||
labelColumnNames: string[];
|
||||
/** The ranked value column. */
|
||||
valueColumnName: string;
|
||||
/** What the ranked value measures. Unlike `valueColumnName`, never the legend template. */
|
||||
valueName: string;
|
||||
/** Value columns after the first, which the ranking ignores. */
|
||||
ignoredValueColumns: string[];
|
||||
/** Other enabled queries and formulas, which the ranking ignores. */
|
||||
ignoredResults: string[];
|
||||
/** The group-by key the ranked query orders by, when it orders by one instead of its value. */
|
||||
orderedByGroupKey: string | null;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import type { TooltipCardRow } from 'lib/uPlotV2/components/Tooltip/components/TooltipCard/TooltipCard';
|
||||
|
||||
import type { PanelThreshold } from '../../types/threshold';
|
||||
import { resolveActiveThreshold } from '../../utils/evaluateThresholds';
|
||||
import { formatPanelValue } from '../../utils/formatPanelValue';
|
||||
|
||||
import {
|
||||
DEFAULT_ROW_HEIGHT,
|
||||
LIST_PADDING_Y,
|
||||
ROW_GAP,
|
||||
ROW_HEIGHTS,
|
||||
} from './constants';
|
||||
import type { TopListRow } from './types';
|
||||
|
||||
export const ACCENT_COLOR = 'var(--bg-robin-500)';
|
||||
|
||||
const NO_VALUE = '—';
|
||||
|
||||
export interface RowColors {
|
||||
barColor?: string;
|
||||
valueColor?: string;
|
||||
}
|
||||
|
||||
/** A matching threshold recolours the bar (`background` format) or the value (`text`). */
|
||||
export function resolveRowColors(
|
||||
value: number | null,
|
||||
thresholds: PanelThreshold[],
|
||||
unit?: string,
|
||||
): RowColors {
|
||||
if (value === null || thresholds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(thresholds, value, unit);
|
||||
if (!threshold) {
|
||||
return {};
|
||||
}
|
||||
return threshold.format === 'background'
|
||||
? { barColor: threshold.color }
|
||||
: { valueColor: threshold.color };
|
||||
}
|
||||
|
||||
/** Non-numeric cells ("NaN", "n/a") are shown as the server sent them. */
|
||||
export function formatRowValue(
|
||||
row: TopListRow,
|
||||
unit?: string,
|
||||
precision?: PrecisionOption,
|
||||
): string {
|
||||
if (row.value !== null) {
|
||||
return formatPanelValue(row.value, unit, precision);
|
||||
}
|
||||
if (typeof row.rawValue === 'string' && row.rawValue !== '') {
|
||||
return row.rawValue;
|
||||
}
|
||||
return NO_VALUE;
|
||||
}
|
||||
|
||||
/** Under 10% to one decimal, whole percents above; a nonzero share never reads as 0%. */
|
||||
export function formatShare(share: number | null): string {
|
||||
if (share === null) {
|
||||
return NO_VALUE;
|
||||
}
|
||||
const percent = share * 100;
|
||||
if (percent > 0 && percent < 0.1) {
|
||||
return '<0.1%';
|
||||
}
|
||||
return `${percent < 10 ? Number(percent.toFixed(1)) : Math.round(percent)}%`;
|
||||
}
|
||||
|
||||
interface TooltipContentOptions {
|
||||
valueName: string;
|
||||
unit?: string;
|
||||
precision?: PrecisionOption;
|
||||
showShare: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value and, when shown, the share; then the group values behind the label, unless
|
||||
* the label is the lone group value already.
|
||||
*/
|
||||
export function getTooltipContent(
|
||||
row: TopListRow,
|
||||
{ valueName, unit, precision, showShare }: TooltipContentOptions,
|
||||
): { rows: TooltipCardRow[]; mutedRows: TooltipCardRow[] } {
|
||||
const rows: TooltipCardRow[] = [
|
||||
{
|
||||
key: 'value',
|
||||
label: valueName,
|
||||
value: formatRowValue(row, unit, precision),
|
||||
},
|
||||
];
|
||||
if (showShare && row.share !== null) {
|
||||
rows.push({
|
||||
key: 'share',
|
||||
label: 'Share of listed total',
|
||||
value: formatShare(row.share),
|
||||
});
|
||||
}
|
||||
|
||||
const groups = Object.entries(row.labels);
|
||||
const isLabelTheGroup = groups.length === 1 && groups[0][1] === row.label;
|
||||
return {
|
||||
rows,
|
||||
mutedRows: isLabelTheGroup
|
||||
? []
|
||||
: groups.map(([key, value]) => ({ key, label: key, value })),
|
||||
};
|
||||
}
|
||||
|
||||
const ELLIPSIS = '…';
|
||||
|
||||
/**
|
||||
* Cuts the middle out of `text` so it fits `maxWidth`, keeping both ends: with
|
||||
* multi-key labels and paths, the tail often tells rows apart.
|
||||
*/
|
||||
export function truncateMiddle(
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
measure: (value: string) => number,
|
||||
): string {
|
||||
if (measure(text) <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
const fit = (kept: number): string =>
|
||||
text.slice(0, Math.ceil(kept / 2)) +
|
||||
ELLIPSIS +
|
||||
text.slice(text.length - Math.floor(kept / 2));
|
||||
let low = 0;
|
||||
let high = text.length - 1;
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2);
|
||||
if (measure(fit(mid)) <= maxWidth) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
return fit(low);
|
||||
}
|
||||
|
||||
/** The row a navigation key moves focus to, or null when the key isn't one or the move leaves the list. */
|
||||
export function getNavigationTarget(
|
||||
index: number,
|
||||
key: string,
|
||||
count: number,
|
||||
): number | null {
|
||||
const targets: Record<string, number> = {
|
||||
ArrowDown: index + 1,
|
||||
ArrowUp: index - 1,
|
||||
Home: 0,
|
||||
End: count - 1,
|
||||
};
|
||||
const target = targets[key];
|
||||
return target === undefined || target < 0 || target >= count ? null : target;
|
||||
}
|
||||
|
||||
const longest = (values: string[]): string =>
|
||||
values.reduce(
|
||||
(widest, value) => (value.length > widest.length ? value : widest),
|
||||
'',
|
||||
);
|
||||
|
||||
/**
|
||||
* The widest formatted value across all rows. The list renders only the rows in view,
|
||||
* so a hidden row carrying it keeps the value column from resizing as rows scroll in
|
||||
* and out.
|
||||
*/
|
||||
export function getWidestValue(
|
||||
rows: TopListRow[],
|
||||
unit?: string,
|
||||
precision?: PrecisionOption,
|
||||
): string {
|
||||
return longest(rows.map((row) => formatRowValue(row, unit, precision)));
|
||||
}
|
||||
|
||||
/** The widest share across all rows, for the same reason as `getWidestValue`. */
|
||||
export function getWidestShare(rows: TopListRow[]): string {
|
||||
return longest(rows.map((row) => formatShare(row.share)));
|
||||
}
|
||||
|
||||
/** The roomiest row height at which every row fits; compact when none does. */
|
||||
export function getRowHeight(count: number, availableHeight: number): number {
|
||||
if (availableHeight <= 0) {
|
||||
return DEFAULT_ROW_HEIGHT;
|
||||
}
|
||||
const fits = (rowHeight: number): boolean =>
|
||||
count * rowHeight + Math.max(count - 1, 0) * ROW_GAP + LIST_PADDING_Y <=
|
||||
availableHeight;
|
||||
return ROW_HEIGHTS.find(fits) ?? ROW_HEIGHTS[ROW_HEIGHTS.length - 1];
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelStatusDetail } from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelStatus/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../types/rendererProps';
|
||||
|
||||
import {
|
||||
getRankedAggregationFunction,
|
||||
prepareTopListData,
|
||||
} from './prepareData';
|
||||
import type { TopListData } from './types';
|
||||
|
||||
/** Aggregations whose group values don't add up to a meaningful total. */
|
||||
const NON_ADDITIVE_FUNCTION = /^(avg|min|max|median|p\d+|quantile)/;
|
||||
|
||||
const quoteAll = (names: string[]): string =>
|
||||
names.map((name) => `"${name}"`).join(', ');
|
||||
const verbFor = (names: string[]): string =>
|
||||
names.length === 1 ? 'is' : 'are';
|
||||
|
||||
/**
|
||||
* Builder queries with a limit that an enabled formula reads. Each input is cut to its
|
||||
* own top N before the formula joins them, so the formula only sees groups that made
|
||||
* every input's cut.
|
||||
*/
|
||||
function getLimitedFormulaInputs(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): string[] {
|
||||
const envelopes = requestPayload?.compositeQuery?.queries ?? [];
|
||||
const expressions = envelopes
|
||||
.filter((envelope) => envelope.type === 'builder_formula')
|
||||
.map(
|
||||
(envelope) => envelope.spec as { expression?: string; disabled?: boolean },
|
||||
)
|
||||
.filter((formula) => !formula.disabled && formula.expression)
|
||||
.map((formula) => formula.expression as string);
|
||||
if (expressions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return envelopes
|
||||
.filter((envelope) => envelope.type === 'builder_query')
|
||||
.map((envelope) => envelope.spec as { name?: string; limit?: number })
|
||||
.filter(
|
||||
(query): query is { name: string; limit: number } =>
|
||||
Boolean(query.name) && (query.limit ?? 0) > 0,
|
||||
)
|
||||
.filter((query) =>
|
||||
expressions.some((expression) =>
|
||||
new RegExp(`\\b${query.name}\\b`).test(expression),
|
||||
),
|
||||
)
|
||||
.map((query) => query.name);
|
||||
}
|
||||
|
||||
function collectIssues(
|
||||
{
|
||||
rows,
|
||||
labelColumnNames,
|
||||
ignoredValueColumns,
|
||||
ignoredResults,
|
||||
orderedByGroupKey,
|
||||
}: TopListData,
|
||||
limitedFormulaInputs: string[],
|
||||
): string[] {
|
||||
const issues: string[] = [];
|
||||
if (ignoredResults.length > 0) {
|
||||
issues.push(
|
||||
`The list ranks "${rows[0].queryName}" only; ${quoteAll(ignoredResults)} ${verbFor(ignoredResults)} not shown. Disable the queries you don't want to rank.`,
|
||||
);
|
||||
}
|
||||
if (limitedFormulaInputs.length > 0) {
|
||||
issues.push(
|
||||
`${quoteAll(limitedFormulaInputs)} ${verbFor(limitedFormulaInputs)} limited before the formula combines them, so the formula only sees groups in every input's top N. Set the limit on the formula instead.`,
|
||||
);
|
||||
}
|
||||
if (orderedByGroupKey) {
|
||||
issues.push(
|
||||
`The query orders by "${orderedByGroupKey}", so the list shows the first groups by that key rather than the top values. Order by the value instead.`,
|
||||
);
|
||||
}
|
||||
if (labelColumnNames.length === 0) {
|
||||
issues.push(
|
||||
'The query has no group by, so the list shows a single total. Add a group by to rank its groups.',
|
||||
);
|
||||
}
|
||||
if (ignoredValueColumns.length > 0) {
|
||||
issues.push(
|
||||
`The list ranks the first value column only; ${quoteAll(ignoredValueColumns)} ${verbFor(ignoredValueColumns)} not shown.`,
|
||||
);
|
||||
}
|
||||
if (rows.length > 1 && rows.every((row) => row.label === rows[0].label)) {
|
||||
issues.push(
|
||||
'Every row has the same label. Use {{group-by key}} variables in the legend to tell rows apart.',
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Share of total sums the rows, which says nothing for averages, percentiles, min or max. */
|
||||
function getShareIssue(
|
||||
{ rows, valueName }: TopListData,
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): string | null {
|
||||
const aggregation = getRankedAggregationFunction(
|
||||
requestPayload,
|
||||
rows[0].queryName,
|
||||
);
|
||||
if (!aggregation || !NON_ADDITIVE_FUNCTION.test(aggregation)) {
|
||||
return null;
|
||||
}
|
||||
return `Share of total adds up "${valueName}" across rows, which isn't meaningful for averages, percentiles, minimums or maximums. Turn it off in Appearance, or rank a count or sum.`;
|
||||
}
|
||||
|
||||
export function getTopListDataWarning(
|
||||
panel: PanelOfKind<'signoz/TopListPanel'>,
|
||||
data: PanelQueryData,
|
||||
): PanelStatusDetail | null {
|
||||
const topList = prepareTopListData(data);
|
||||
if (topList.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shareIssue = panel.spec.plugin.spec.appearance?.showShare
|
||||
? getShareIssue(topList, data.requestPayload)
|
||||
: null;
|
||||
const issues = [
|
||||
...collectIssues(topList, getLimitedFormulaInputs(data.requestPayload)),
|
||||
...(shareIssue ? [shareIssue] : []),
|
||||
];
|
||||
if (issues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
message: 'This list may not rank what you expect.',
|
||||
messages: issues,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { definition as NumberValue } from './kinds/NumberPanel/definition';
|
||||
import { definition as PieChart } from './kinds/PieChartPanel/definition';
|
||||
import { definition as ScatterPlot } from './kinds/ScatterPlotPanel/definition';
|
||||
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
|
||||
import { definition as TopList } from './kinds/TopListPanel/definition';
|
||||
import { definition as Table } from './kinds/TablePanel/definition';
|
||||
import { definition as List } from './kinds/ListPanel/definition';
|
||||
import { definition as Text } from './kinds/TextPanel/definition';
|
||||
@@ -27,6 +28,7 @@ export const PANELS: PanelRegistry = {
|
||||
[AreaChart.kind]: AreaChart,
|
||||
[PieChart.kind]: PieChart,
|
||||
[ScatterPlot.kind]: ScatterPlot,
|
||||
[TopList.kind]: TopList,
|
||||
[Histogram.kind]: Histogram,
|
||||
[Heatmap.kind]: Heatmap,
|
||||
[List.kind]: List,
|
||||
|
||||
@@ -37,6 +37,7 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/HeatmapPanel': { onDragSelect?: DragSelect };
|
||||
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/TopListPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/ScatterPlotPanel': {
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
|
||||
@@ -25,6 +25,7 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
|
||||
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
|
||||
'signoz/ListPanel': PANEL_TYPES.LIST,
|
||||
'signoz/HeatmapPanel': PANEL_TYPES.HEATMAP,
|
||||
'signoz/TopListPanel': PANEL_TYPES.TOP_LIST,
|
||||
'signoz/TextPanel': PANEL_TYPES.TEXT,
|
||||
'signoz/ScatterPlotPanel': PANEL_TYPES.SCATTER,
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
DashboardtypesTextPresentationDTO,
|
||||
DashboardtypesThresholdWithLabelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
DashboardtypesTopListAppearanceDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { LegendSeriesResolver } from '../utils/legendSeries';
|
||||
@@ -71,6 +72,8 @@ export enum SectionKind {
|
||||
Dimensions = 'dimensions',
|
||||
/** Scatter Plot's per-axis `axes` shape; `Axes` is the single y-axis shape. */
|
||||
ScatterAxes = 'scatterAxes',
|
||||
/** Top List's row display, for a kind with no chart to style. */
|
||||
Appearance = 'appearance',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +149,7 @@ export interface SectionSpecMap {
|
||||
[SectionKind.PanelHeader]: DashboardtypesHeaderOptionsDTO; // spec.plugin.spec.headerOptions (Text)
|
||||
[SectionKind.Dimensions]: DashboardtypesScatterPlotDimensionsDTO; // spec.plugin.spec.dimensions (Scatter Plot)
|
||||
[SectionKind.ScatterAxes]: DashboardtypesScatterPlotAxesDTO; // spec.plugin.spec.axes (Scatter Plot)
|
||||
[SectionKind.Appearance]: DashboardtypesTopListAppearanceDTO; // spec.plugin.spec.appearance (Top List)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,6 +208,10 @@ export interface SectionControls {
|
||||
};
|
||||
// Editor discriminator (not a spec field): which threshold variant a kind edits.
|
||||
[SectionKind.Thresholds]: { variant?: ThresholdVariant };
|
||||
[SectionKind.Appearance]: {
|
||||
showRank?: boolean;
|
||||
showShare?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ControlledSectionKind = keyof SectionControls;
|
||||
@@ -252,6 +260,7 @@ export const SECTION_METADATA = {
|
||||
[SectionKind.PanelHeader]: { title: 'Panel header', icon: PanelTop },
|
||||
[SectionKind.Dimensions]: { title: 'Dimensions', icon: Crosshair },
|
||||
[SectionKind.ScatterAxes]: { title: 'Axes', icon: Scale3D },
|
||||
[SectionKind.Appearance]: { title: 'Appearance', icon: Palette },
|
||||
} as const satisfies Record<SectionKind, SectionMetadata>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -325,6 +325,30 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('appearance seed', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Appearance,
|
||||
controls: { showRank: true, showShare: true },
|
||||
},
|
||||
];
|
||||
|
||||
it('carries the toggles across a switch between kinds that declare them', () => {
|
||||
const oldSpec = oldSpecWith({
|
||||
appearance: { showRank: true, showShare: false },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).appearance).toStrictEqual({
|
||||
showRank: true,
|
||||
showShare: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds nothing for a new panel, where unset reads as off', () => {
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartAppearance seed', () => {
|
||||
it('seeds only the declared defaulting controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface SeededPluginSpec {
|
||||
axes?: SectionSpecMap[SectionKind.Axes];
|
||||
legend?: SectionSpecMap[SectionKind.Legend];
|
||||
chartAppearance?: SectionSpecMap[SectionKind.ChartAppearance];
|
||||
appearance?: SectionSpecMap[SectionKind.Appearance];
|
||||
formatting?: Pick<
|
||||
PanelFormattingSlice,
|
||||
'unit' | 'decimalPrecision' | 'columnUnits'
|
||||
@@ -162,6 +163,22 @@ function translateStackingForKind(
|
||||
}
|
||||
|
||||
const SECTION_SEEDS: SectionSeeds = {
|
||||
[SectionKind.Appearance]: {
|
||||
specKey: 'appearance',
|
||||
// Carries the toggles the target kind declares; unset reads as off.
|
||||
seed: (
|
||||
controls,
|
||||
{ oldPluginSpec },
|
||||
): SectionSpecMap[SectionKind.Appearance] => {
|
||||
const old = oldPluginSpec?.appearance;
|
||||
return {
|
||||
...(controls.showRank &&
|
||||
old?.showRank !== undefined && { showRank: old.showRank }),
|
||||
...(controls.showShare &&
|
||||
old?.showShare !== undefined && { showShare: old.showShare }),
|
||||
};
|
||||
},
|
||||
},
|
||||
[SectionKind.TextLayout]: {
|
||||
specKey: 'presentation',
|
||||
// Explicit alignment defaults (not the API's implicit ones) so the controls
|
||||
|
||||
@@ -45,6 +45,11 @@ const PANEL_TYPE_META: Record<PanelKind, PanelTypeMeta> = {
|
||||
description: 'Two values per group, plotted against each other',
|
||||
isNew: true,
|
||||
},
|
||||
'signoz/TopListPanel': {
|
||||
group: 'compare',
|
||||
description: 'Groups ranked by a single value',
|
||||
isNew: true,
|
||||
},
|
||||
'signoz/HistogramPanel': {
|
||||
group: 'distributions',
|
||||
description: 'Distribution of values into buckets',
|
||||
|
||||
@@ -10,6 +10,8 @@ const AREA_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.22 };
|
||||
const RING_TRACK = { stroke: 'var(--l3-background)' };
|
||||
const RING_PRIMARY = { stroke: 'var(--bg-robin-500)' };
|
||||
const RING_SECONDARY = { stroke: 'var(--bg-robin-400)', strokeOpacity: 0.55 };
|
||||
const RING_TRACK_FILL = { fill: 'var(--l3-background)' };
|
||||
const BAR_FILL = { fill: 'var(--bg-robin-500)' };
|
||||
const DOT_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.55 };
|
||||
|
||||
/** `[cx, cy, r]`: a loose up-and-right cloud with one large outlier. */
|
||||
@@ -55,6 +57,8 @@ const HEATMAP_CELLS = [
|
||||
[0.4, 0.85, 0.7, 0.2],
|
||||
[0.3, 0.65, 0.9, 0.45],
|
||||
];
|
||||
// Bar lengths (% of the track) for the top-list sketch, longest first.
|
||||
const TOP_LIST_BARS = [100, 76, 54, 34];
|
||||
const TEXT_ROWS = [45, 100, 92, 64].map((width, i) => [
|
||||
{ ...(i === 0 ? HEAD : CELL), width },
|
||||
]);
|
||||
@@ -88,6 +92,30 @@ export const PANEL_TYPE_PREVIEWS: Record<PanelKind, JSX.Element> = {
|
||||
'signoz/BarChartPanel': (
|
||||
<PreviewBars heights={[80, 58, 44, 30, 18]} fade className={styles.bars} />
|
||||
),
|
||||
'signoz/TopListPanel': (
|
||||
<svg viewBox="0 0 120 44" preserveAspectRatio="none" className={styles.svg}>
|
||||
{TOP_LIST_BARS.map((width, row) => (
|
||||
<g key={width}>
|
||||
<rect
|
||||
x={0}
|
||||
y={row * 11 + 2}
|
||||
width={120}
|
||||
height={7}
|
||||
rx={1.5}
|
||||
style={RING_TRACK_FILL}
|
||||
/>
|
||||
<rect
|
||||
x={0}
|
||||
y={row * 11 + 2}
|
||||
width={(width / 100) * 120}
|
||||
height={7}
|
||||
rx={1.5}
|
||||
style={BAR_FILL}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
),
|
||||
'signoz/HistogramPanel': (
|
||||
<PreviewBars
|
||||
heights={[14, 26, 52, 88, 100, 70, 40, 20, 10]}
|
||||
|
||||
@@ -3,6 +3,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { getTableCsvRows } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TablePanel/tableCsv';
|
||||
import { getTopListCsvRows } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TopListPanel/csv';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { downloadCsv } from 'pages/DashboardPage/DashboardContainer/Panels/utils/downloadCsv';
|
||||
@@ -13,10 +14,9 @@ interface UseDownloadPanelCsvArgs {
|
||||
data: PanelQueryData;
|
||||
/**
|
||||
* Whether the kind's definition declares CSV as a downloadable format
|
||||
* (`actions.download.csv`). Only kinds drawn from a joined scalar table
|
||||
* (Table, Scatter Plot) carry tabular data, so this is the same gate the menu
|
||||
* uses — kept here so the callback stays a no-op when invoked for a kind that
|
||||
* can't produce CSV.
|
||||
* (`actions.download.csv`). Only Table, Scatter Plot and Top List carry tabular
|
||||
* data, so this is the same gate the menu uses — kept here so the callback stays a
|
||||
* no-op when invoked for a kind that can't produce CSV.
|
||||
*/
|
||||
canDownloadCsv: boolean;
|
||||
}
|
||||
@@ -36,10 +36,13 @@ export function useDownloadPanelCsv({
|
||||
if (!canDownloadCsv) {
|
||||
return;
|
||||
}
|
||||
const rows = getTableCsvRows(
|
||||
panel as PanelOfKind<'signoz/TablePanel' | 'signoz/ScatterPlotPanel'>,
|
||||
data,
|
||||
);
|
||||
const rows =
|
||||
panel.spec.plugin.kind === 'signoz/TopListPanel'
|
||||
? getTopListCsvRows(panel as PanelOfKind<'signoz/TopListPanel'>, data)
|
||||
: getTableCsvRows(
|
||||
panel as PanelOfKind<'signoz/TablePanel' | 'signoz/ScatterPlotPanel'>,
|
||||
data,
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export function panelTypeToRequestType(
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
case PANEL_TYPES.SCATTER:
|
||||
case PANEL_TYPES.TOP_LIST:
|
||||
return Querybuildertypesv5RequestTypeDTO.scalar;
|
||||
case PANEL_TYPES.LIST:
|
||||
return Querybuildertypesv5RequestTypeDTO.raw;
|
||||
|
||||
@@ -16,6 +16,7 @@ export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
|
||||
[PANEL_TYPES.PIE]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.HISTOGRAM]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.HEATMAP]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.TOP_LIST]: ExplorerViews.TABLE,
|
||||
// Dashboard-only visualisation; explorers never offer it.
|
||||
[PANEL_TYPES.TEXT]: ExplorerViews.LIST,
|
||||
[PANEL_TYPES.SCATTER]: ExplorerViews.TABLE,
|
||||
|
||||
@@ -2147,6 +2147,60 @@ func TestNumberPanelDefaults(t *testing.T) {
|
||||
assert.Contains(t, outputStr, `"operator":"above"`, "expected stored/response JSON to contain operator:above")
|
||||
}
|
||||
|
||||
func TestTopListPanelSpec(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/TopListPanel",
|
||||
"spec": {
|
||||
"formatting": {"unit": "ms", "decimalPrecision": 2},
|
||||
"appearance": {"showRank": true, "showShare": true},
|
||||
"thresholds": [{"value": 500, "operator": "above", "format": "background", "color": "Red"}]
|
||||
}
|
||||
},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &TopListPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*TopListPanelSpec)
|
||||
assert.Equal(t, "ms", spec.Formatting.Unit)
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
assert.Equal(t, "background", spec.Thresholds[0].Format.ValueOrDefault())
|
||||
assert.Equal(t, TopListAppearance{ShowRank: true, ShowShare: true}, spec.Appearance)
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
roundTripped, err := unmarshalDashboard(output)
|
||||
require.NoError(t, err, "round trip failed")
|
||||
require.IsType(t, &TopListPanelSpec{}, roundTripped.Panels["p1"].Spec.Plugin.Spec)
|
||||
roundTrippedSpec := roundTripped.Panels["p1"].Spec.Plugin.Spec.(*TopListPanelSpec)
|
||||
assert.Equal(t, spec.Formatting, roundTrippedSpec.Formatting)
|
||||
assert.Equal(t, spec.Thresholds, roundTrippedSpec.Thresholds)
|
||||
assert.Equal(t, spec.Appearance, roundTrippedSpec.Appearance)
|
||||
|
||||
t.Run("UnknownField_Rejected", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(strings.Replace(string(data), `"formatting"`, `"legend": {}, "formatting"`, 1)))
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("UnknownAppearanceField_Rejected", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(strings.Replace(string(data), `"showRank"`, `"showPoints": true, "showRank"`, 1)))
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPersesFixtureStorageRoundTrip exercises the typed → map[string]any →
|
||||
// typed cycle that the create/get path performs against the kitchen-sink
|
||||
// fixture. Catches plugin specs whose UnmarshalJSON expects a different shape
|
||||
@@ -2431,7 +2485,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/TopListPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
@@ -2482,6 +2536,13 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
// Composite sub-queries
|
||||
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"TopList+ClickHouse", mkQuery("signoz/TopListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"TopList+PromQL", mkQuery("signoz/TopListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"TopList+Formula", mkQuery("signoz/TopListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), false},
|
||||
{"TopList+TraceOperator", mkQuery("signoz/TopListPanel", "signoz/TraceOperator", `{"name":"T1","expression":"A => B"}`), false},
|
||||
{"TopList+Composite(clickhouse)", mkComposite("signoz/TopListPanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"TopList+Composite(formula)", mkComposite("signoz/TopListPanel", "builder_formula", `{"name":"F1","expression":"A+B"}`), false},
|
||||
{"TopList+Composite(promql)", mkComposite("signoz/TopListPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -39,6 +39,7 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
string(PanelKindTopList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
PanelPluginVariant[TopListPanelSpec]{Kind: string(PanelKindTopList)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +242,7 @@ var (
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
PanelKindTopList: func() any { return new(TopListPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -266,6 +269,7 @@ var (
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindTopList: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -178,10 +178,11 @@ const (
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
PanelKindTopList PanelPluginKind = "signoz/TopListPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot, PanelKindTopList}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -249,6 +250,18 @@ type PieChartPanelSpec struct {
|
||||
Legend Legend `json:"legend"`
|
||||
}
|
||||
|
||||
type TopListPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Appearance TopListAppearance `json:"appearance"`
|
||||
Thresholds []ComparisonThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
type TopListAppearance struct {
|
||||
ShowRank bool `json:"showRank" description:"Numbers each row by its position in the list."`
|
||||
ShowShare bool `json:"showShare" description:"Shows each row's share of the listed rows' total."`
|
||||
}
|
||||
|
||||
type TablePanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
|
||||
Reference in New Issue
Block a user