Compare commits

...

23 Commits

Author SHA1 Message Date
Abhi Kumar
af76f1c16c feat(dashboard): warn when a top list shows shares of a non-additive value
Formulas and ClickHouse SQL get no warning, since their aggregation can't be read.
2026-10-09 16:20:39 +05:30
Abhi Kumar
46d6541cf4 fix(dashboard): hide the top list tooltip off its rows
The empty space below the rows is still inside the list, so the tooltip stayed on the last row.
2026-10-09 16:20:35 +05:30
Abhi Kumar
86b9ce27e3 feat(dashboard): hint the top list's default order 2026-10-09 15:07:28 +05:30
Abhi Kumar
be08a42a8d feat(query-builder): show a pinned order by's placeholder 2026-10-09 15:07:28 +05:30
Abhi Kumar
a8594d72a6 feat(dashboard): show rank, share and a row tooltip in top lists
Share is of the listed rows' total, not the whole result, to avoid a second query.
Labels are cut in the middle so both ends stay readable; the tooltip has them in full.
2026-10-09 15:07:28 +05:30
Abhi Kumar
529cff16db feat(dashboard): add a top list appearance section with rank and share toggles 2026-10-09 15:07:28 +05:30
Abhi Kumar
7dc07f2e3c feat(charts): let a tooltip footer leave out the pin hint 2026-10-09 15:07:27 +05:30
Abhi Kumar
d81c660607 fix(charts): use theme text colours in the tooltip card
The vanilla tokens are white in both themes, so the text vanished in light mode.
2026-10-09 15:07:27 +05:30
Abhi Kumar
ad765ee76c refactor(charts): extract the scatter tooltip card for reuse 2026-10-09 15:07:27 +05:30
Abhi Kumar
c30083e982 feat(dashboard): pin order by and limit in the top list query builder
Top or bottom N is picked on the server, so the order and limit are the panel's sort controls.
2026-10-09 15:07:27 +05:30
Abhi Kumar
1233c67b8f feat(query-builder): let pinned fields show a default placeholder
A panel's default limit applies only to a lone query, so the placeholder falls back to
'Enter limit' once formulas or more queries are added.
2026-10-09 15:07:27 +05:30
Abhi Kumar
ca3baf5598 chore(api): regenerate client types 2026-10-09 15:07:27 +05:30
Abhi Kumar
b523aa7291 chore(openapi): regenerate specs 2026-10-09 15:07:27 +05:30
Abhi Kumar
bdb8152e8c feat(dashboards): add rank and share toggles to the top list spec 2026-10-09 15:07:27 +05:30
Abhi Kumar
fe2363237d feat(dashboard): rank top lists across multiple queries and formulas
Ranks the first enabled result in builder order, since the response lists
results in no particular order. Rows follow the query's value order, so an
ascending order gives a bottom N.
2026-10-08 23:09:34 +05:30
Abhi Kumar
901b30ee46 feat(dashboard): fill top list bars with their labels and fit row density
Rows pick the roomiest of 32, 28 and 24px that fits every row in the panel.
2026-10-08 23:09:33 +05:30
Abhi Kumar
3f7cdcf139 feat(dashboard): download top list panels as csv 2026-10-08 23:09:33 +05:30
Abhi Kumar
d9a12c5368 feat(dashboard): warn when a top list can't rank its result 2026-10-08 23:09:33 +05:30
Abhi Kumar
43f27f453b feat(dashboard): add the top list panel
Ranks one query's single aggregation for now. Rows share one grid so bars
align, and the list is virtualized.
2026-10-08 23:09:33 +05:30
Abhi Kumar
02b0ef7df1 chore(api): regenerate client types 2026-10-08 23:09:33 +05:30
Abhi Kumar
f2cef094a1 feat(dashboards): allow formulas and trace operators in top list panels 2026-10-08 23:08:51 +05:30
Abhi Kumar
5a02fb10fd chore(openapi): regenerate specs 2026-10-08 17:55:19 +05:30
Abhi Kumar
51bb0d0617 feat(dashboards): add plugin schema for top list panel 2026-10-08 17:55:05 +05:30
66 changed files with 3687 additions and 99 deletions

View File

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

View File

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

View File

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

View File

@@ -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) && (

View File

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

View File

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

View File

@@ -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' }],

View File

@@ -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' },

View File

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

View File

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

View File

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

View File

@@ -341,6 +341,7 @@ export enum PANEL_TYPES {
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TOP_LIST = 'top_list',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.',
]);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>;
/**

View File

@@ -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[] = [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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