mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-17 07:30:45 +01:00
Compare commits
33 Commits
feat/scatt
...
feat/geo-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee46d6ceb | ||
|
|
2e097e122d | ||
|
|
17b740015a | ||
|
|
38d1b69fd4 | ||
|
|
cd3ae16360 | ||
|
|
a82c241ea2 | ||
|
|
9b40cf8b5c | ||
|
|
e9235a10fd | ||
|
|
68476e25f7 | ||
|
|
5ba7a4c86c | ||
|
|
b7b3e48a5d | ||
|
|
ab4bb5a6a5 | ||
|
|
ab5d035022 | ||
|
|
87ec6e9cf8 | ||
|
|
8d93e46b2a | ||
|
|
af55cc43cc | ||
|
|
11d91eb9eb | ||
|
|
917e5da2b7 | ||
|
|
de3d950716 | ||
|
|
bdf7e1708b | ||
|
|
76489f4b43 | ||
|
|
6bf9153612 | ||
|
|
2962c900cc | ||
|
|
b1bcca1332 | ||
|
|
e3f53736ed | ||
|
|
7b05bc3ed4 | ||
|
|
38fe40bbec | ||
|
|
d4fb1bfcfc | ||
|
|
f56517abd4 | ||
|
|
135097d609 | ||
|
|
230d24416f | ||
|
|
652e765f88 | ||
|
|
7ee496c537 |
@@ -3739,6 +3739,11 @@ components:
|
||||
timePreference:
|
||||
$ref: '#/components/schemas/DashboardtypesTimePreference'
|
||||
type: object
|
||||
DashboardtypesBubbleRadius:
|
||||
maximum: 64
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesBuilderQuerySpec:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -3911,6 +3916,159 @@ components:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesGeoMapBubbles:
|
||||
properties:
|
||||
maxRadius:
|
||||
$ref: '#/components/schemas/DashboardtypesBubbleRadius'
|
||||
minRadius:
|
||||
$ref: '#/components/schemas/DashboardtypesBubbleRadius'
|
||||
type: object
|
||||
DashboardtypesGeoMapChartAppearance:
|
||||
properties:
|
||||
bubbles:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapBubbles'
|
||||
colors:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapColors'
|
||||
labels:
|
||||
description: Shows country and state names once each fits inside its shape.
|
||||
nullable: true
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesGeoMapColors:
|
||||
properties:
|
||||
fill:
|
||||
type: string
|
||||
max:
|
||||
nullable: true
|
||||
type: number
|
||||
min:
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
palette:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
steps:
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesGeoMapCountryFormat:
|
||||
enum:
|
||||
- auto
|
||||
- alpha2
|
||||
- alpha3
|
||||
- name
|
||||
type: string
|
||||
DashboardtypesGeoMapLayer:
|
||||
enum:
|
||||
- regions
|
||||
- bubbles
|
||||
- points
|
||||
type: string
|
||||
DashboardtypesGeoMapLocation:
|
||||
properties:
|
||||
country:
|
||||
description: Group-by key holding the country, for the country and subdivision
|
||||
modes.
|
||||
type: string
|
||||
countryFormat:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapCountryFormat'
|
||||
lat:
|
||||
description: Group-by key holding the latitude, for the coordinates mode.
|
||||
type: string
|
||||
lon:
|
||||
description: Group-by key holding the longitude, for the coordinates mode.
|
||||
type: string
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapLocationMode'
|
||||
region:
|
||||
description: Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code),
|
||||
for the subdivision mode.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesGeoMapLocationMode:
|
||||
enum:
|
||||
- auto
|
||||
- country
|
||||
- subdivision
|
||||
- coordinates
|
||||
type: string
|
||||
DashboardtypesGeoMapPanelSpec:
|
||||
properties:
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
location:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapLocation'
|
||||
map:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapSettings'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesGeoMapProjection:
|
||||
enum:
|
||||
- mercator
|
||||
- equal_earth
|
||||
- natural_earth
|
||||
type: string
|
||||
DashboardtypesGeoMapSettings:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Value column key that sets the colour. Empty uses the first
|
||||
value column.
|
||||
type: string
|
||||
layer:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapLayer'
|
||||
projection:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapProjection'
|
||||
sizeBy:
|
||||
description: Value column key that sets the bubble or point size. Empty
|
||||
uses colorBy.
|
||||
type: string
|
||||
view:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapView'
|
||||
viewCountry:
|
||||
description: ISO 3166-1 alpha-2 code of the country the country view shows.
|
||||
type: string
|
||||
required:
|
||||
- projection
|
||||
type: object
|
||||
DashboardtypesGeoMapThreshold:
|
||||
properties:
|
||||
color:
|
||||
type: string
|
||||
operator:
|
||||
$ref: '#/components/schemas/DashboardtypesComparisonOperator'
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesGeoMapView:
|
||||
enum:
|
||||
- world
|
||||
- fit
|
||||
- africa
|
||||
- asia
|
||||
- europe
|
||||
- north_america
|
||||
- south_america
|
||||
- oceania
|
||||
- country
|
||||
type: string
|
||||
DashboardtypesGettableDashboardV2:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -4446,6 +4604,7 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/GeoMapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -4455,6 +4614,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'
|
||||
@@ -4468,6 +4628,8 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4482,6 +4644,8 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/TopListPanel
|
||||
- signoz/GeoMapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4507,6 +4671,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/GeoMapPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4615,6 +4791,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:
|
||||
@@ -5143,6 +5331,35 @@ components:
|
||||
timePreference:
|
||||
$ref: '#/components/schemas/DashboardtypesTimePreference'
|
||||
type: object
|
||||
DashboardtypesTopListAppearance:
|
||||
properties:
|
||||
barColor:
|
||||
description: Hex color of every bar; a matching background threshold overrides
|
||||
it.
|
||||
type: string
|
||||
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
|
||||
required:
|
||||
- barColor
|
||||
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:
|
||||
|
||||
@@ -5821,6 +5821,46 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTOKind {
|
||||
'signoz/TopListPanel' = 'signoz/TopListPanel',
|
||||
}
|
||||
export interface DashboardtypesTopListAppearanceDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Hex color of every bar; a matching background threshold overrides it.
|
||||
*/
|
||||
barColor: string;
|
||||
/**
|
||||
* @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
|
||||
@@ -5832,7 +5872,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6773,6 +6814,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
'signoz/TopListPanel' = 'signoz/TopListPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
|
||||
@@ -54,6 +54,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
case PANEL_TYPES.SCATTER:
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
case PANEL_TYPES.TOP_LIST:
|
||||
return 'scalar';
|
||||
case PANEL_TYPES.TRACE:
|
||||
return 'trace';
|
||||
@@ -281,7 +282,8 @@ export function createAggregation(
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE);
|
||||
panelType === PANEL_TYPES.VALUE ||
|
||||
panelType === PANEL_TYPES.TOP_LIST);
|
||||
|
||||
if (queryData.dataSource === DataSource.METRICS) {
|
||||
return [
|
||||
|
||||
@@ -146,10 +146,21 @@ function QueryAddOns({
|
||||
[resolvedFields],
|
||||
);
|
||||
|
||||
// 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 placeholderOf = useCallback(
|
||||
(key: InputAddOnField): string =>
|
||||
resolvedFields.get(key)?.placeholder ?? ADD_ON_DEFINITIONS[key].placeholder,
|
||||
[resolvedFields],
|
||||
(key === QueryBuilderField.Limit && !isLoneQuery
|
||||
? undefined
|
||||
: resolvedFields.get(key)?.placeholder) ??
|
||||
ADD_ON_DEFINITIONS[key].placeholder,
|
||||
[resolvedFields, isLoneQuery],
|
||||
);
|
||||
|
||||
const isDisabled = useCallback(
|
||||
|
||||
@@ -49,7 +49,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, pinned: false };
|
||||
}
|
||||
@@ -71,7 +72,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
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE),
|
||||
panelType === PANEL_TYPES.VALUE ||
|
||||
panelType === PANEL_TYPES.TOP_LIST),
|
||||
[dataSource, panelType],
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
|
||||
// Mocks: only what is required for this component to render and for us to assert handler calls
|
||||
const mockHandleChangeQueryData = jest.fn();
|
||||
const mockHandleSetQueryData = jest.fn();
|
||||
let mockCurrentQuery: { unit: string | undefined; builder?: unknown } = {
|
||||
unit: undefined,
|
||||
};
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
useQueryOperations: (): {
|
||||
@@ -27,10 +30,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
handleSetQueryData: typeof mockHandleSetQueryData;
|
||||
currentQuery: { unit: string | undefined };
|
||||
currentQuery: typeof mockCurrentQuery;
|
||||
} => ({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
currentQuery: { unit: undefined },
|
||||
currentQuery: mockCurrentQuery,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -222,6 +225,59 @@ 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('auto-opens Order By and Limit when present in query', () => {
|
||||
const query = baseQuery({
|
||||
orderBy: [{ columnName: 'duration', order: 'desc' }],
|
||||
|
||||
@@ -34,6 +34,7 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
[PANEL_TYPES.TOP_LIST]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.SCATTER]: null,
|
||||
|
||||
@@ -341,6 +341,7 @@ export enum PANEL_TYPES {
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
TOP_LIST = 'top_list',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
HEATMAP: false,
|
||||
TOP_LIST: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -24,4 +24,5 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
[PANEL_TYPES.TOP_LIST]: null,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export type PartialPanelTypes = {
|
||||
[PANEL_TYPES.SCATTER]: 'scatter';
|
||||
[PANEL_TYPES.HISTOGRAM]: 'histogram';
|
||||
[PANEL_TYPES.HEATMAP]: 'heatmap';
|
||||
[PANEL_TYPES.TOP_LIST]: 'top_list';
|
||||
};
|
||||
|
||||
export type BuilderField = keyof IBuilderQuery;
|
||||
@@ -152,6 +153,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.TOP_LIST]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
|
||||
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
|
||||
};
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
|
||||
import { ScatterTooltipProps } from '../types';
|
||||
import TooltipCard from './components/TooltipCard/TooltipCard';
|
||||
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
|
||||
|
||||
import Styles from './ScatterTooltip.module.scss';
|
||||
|
||||
/** Own component: the shared Tooltip lists one row per series at a shared x. */
|
||||
export default function ScatterTooltip({
|
||||
uPlotInstance,
|
||||
@@ -42,69 +39,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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,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;
|
||||
@@ -60,7 +60,7 @@
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -79,11 +79,11 @@
|
||||
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);
|
||||
}
|
||||
|
||||
.rowMuted {
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
|
||||
import Styles from './TooltipCard.module.scss';
|
||||
|
||||
export interface TooltipCardRow {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TooltipCardProps {
|
||||
title: string;
|
||||
/** The marker beside the title, matching the item's mark on the chart. */
|
||||
color: string;
|
||||
/** What the item says. */
|
||||
rows: TooltipCardRow[];
|
||||
/** What names the item, below the rows and muted. */
|
||||
mutedRows?: TooltipCardRow[];
|
||||
isPinned?: boolean;
|
||||
footer?: ReactNode;
|
||||
/** Prefixes the test ids of the title, status, rows and muted rows. */
|
||||
testId: string;
|
||||
}
|
||||
|
||||
/** One item's tooltip: a marked title, then label/value rows, on the shared tooltip surface. */
|
||||
export default function TooltipCard({
|
||||
title,
|
||||
color,
|
||||
rows,
|
||||
mutedRows = [],
|
||||
isPinned = false,
|
||||
footer,
|
||||
testId,
|
||||
}: TooltipCardProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={Styles.header}>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ backgroundColor: color, borderColor: color }}
|
||||
/>
|
||||
<span
|
||||
className={Styles.title}
|
||||
title={title}
|
||||
data-testid={`${testId}-title`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{isPinned && (
|
||||
<span className={Styles.status} data-testid={`${testId}-status`}>
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className={Styles.row} data-testid={`${testId}-row`}>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mutedRows.length > 0 && (
|
||||
<>
|
||||
<span className={Styles.divider} />
|
||||
<div className={Styles.rows}>
|
||||
{mutedRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={cx(Styles.row, Styles.rowMuted)}
|
||||
data-testid={`${testId}-label`}
|
||||
>
|
||||
<span className={Styles.rowLabel} title={row.label}>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className={Styles.rowValue} title={row.value}>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ interface TooltipFooterProps {
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
canDrilldown?: boolean;
|
||||
/** False for a tooltip that can't be pinned, which drops the pin hint. */
|
||||
canPin?: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
@@ -20,6 +22,7 @@ export default function TooltipFooter({
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
isPinned,
|
||||
canDrilldown = true,
|
||||
canPin = true,
|
||||
dismiss,
|
||||
}: TooltipFooterProps): JSX.Element {
|
||||
const handleUnpinClick = (): void => {
|
||||
@@ -53,11 +56,13 @@ export default function TooltipFooter({
|
||||
<span>Click to drilldown</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>to pin the tooltip</span>
|
||||
</div>
|
||||
{canPin && (
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
<span>to pin the tooltip</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,13 @@ describe('TooltipFooter', () => {
|
||||
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the pin hint when canPin is false', () => {
|
||||
render(<TooltipFooter {...defaultProps} canPin={false} />);
|
||||
|
||||
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
|
||||
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a custom pin key in uppercase', () => {
|
||||
render(<TooltipFooter {...defaultProps} pinKey="x" />);
|
||||
|
||||
|
||||
@@ -26,6 +26,22 @@ export const SWITCH_SKETCHES = {
|
||||
))}
|
||||
</SketchSvg>
|
||||
),
|
||||
rank: (
|
||||
<SketchSvg>
|
||||
{[5, 11, 17].map((y, index) => (
|
||||
<g key={y}>
|
||||
<circle cx={4} cy={y} r={1.2} fill="var(--l3-foreground)" />
|
||||
<path d={`M9 ${y} H${36 - index * 9}`} />
|
||||
</g>
|
||||
))}
|
||||
</SketchSvg>
|
||||
),
|
||||
share: (
|
||||
<SketchSvg>
|
||||
<path d="M3 11 H37" strokeDasharray="3 3" />
|
||||
<rect x={3} y={8} width={22} height={6} fill="var(--l3-foreground)" />
|
||||
</SketchSvg>
|
||||
),
|
||||
combine: (
|
||||
<SketchSvg>
|
||||
<rect x={4} y={10} width={6} height={10} />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
|
||||
import type { SectionEditorContext } from './sectionContext';
|
||||
import AppearanceSection from './sections/AppearanceSection/AppearanceSection';
|
||||
import AxesSection from './sections/AxesSection/AxesSection';
|
||||
import BucketsSection from './sections/BucketsSection/BucketsSection';
|
||||
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
|
||||
@@ -118,6 +119,13 @@ export const SECTION_REGISTRY: {
|
||||
update: (spec, chartAppearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
|
||||
},
|
||||
[SectionKind.Appearance]: {
|
||||
Component: AppearanceSection,
|
||||
get: (spec): SectionSpecMap[SectionKind.Appearance] | undefined =>
|
||||
getPluginSlice<SectionSpecMap[SectionKind.Appearance]>(spec, 'appearance'),
|
||||
update: (spec, appearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'appearance', appearance),
|
||||
},
|
||||
[SectionKind.Visualization]: {
|
||||
Component: VisualizationSection,
|
||||
get: (spec): PanelVisualizationSlice | undefined =>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { DEFAULT_BAR_COLOR } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TopListPanel/constants';
|
||||
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import BarColorField from './BarColorField';
|
||||
|
||||
/**
|
||||
* Edits the `appearance` slice of a Top List panel spec: bar color, 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);
|
||||
const appearance = {
|
||||
...value,
|
||||
barColor: value?.barColor || DEFAULT_BAR_COLOR,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.barColor && (
|
||||
<BarColorField
|
||||
{...reset('barColor')}
|
||||
value={appearance.barColor}
|
||||
onChange={(barColor): void => onChange({ ...appearance, barColor })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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={appearance.showRank ?? false}
|
||||
onChange={(showRank): void => onChange({ ...appearance, 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={appearance.showShare ?? false}
|
||||
onChange={(showShare): void => onChange({ ...appearance, showShare })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppearanceSection;
|
||||
@@ -0,0 +1,42 @@
|
||||
import ColorSwatches from '../../controls/ColorSwatches/ColorSwatches';
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import type { FieldResetProps } from '../../utils/changes';
|
||||
import { BAR_COLOR_OPTIONS, findBarColorPreset } from './barColorOptions';
|
||||
|
||||
interface BarColorFieldProps extends Partial<FieldResetProps> {
|
||||
value: string;
|
||||
onChange: (barColor: string) => void;
|
||||
}
|
||||
|
||||
function BarColorField({
|
||||
value,
|
||||
onChange,
|
||||
changed,
|
||||
onReset,
|
||||
}: BarColorFieldProps): JSX.Element {
|
||||
const preset = findBarColorPreset(value);
|
||||
|
||||
return (
|
||||
<ConfigField
|
||||
label="Bar color"
|
||||
help="Fills every row's bar."
|
||||
changed={changed}
|
||||
onReset={onReset}
|
||||
>
|
||||
<ColorSwatches
|
||||
testId="panel-editor-v2-bar-color"
|
||||
label="Bar color"
|
||||
value={preset}
|
||||
options={BAR_COLOR_OPTIONS}
|
||||
onChange={onChange}
|
||||
custom={{
|
||||
value: preset ? undefined : value,
|
||||
initial: value,
|
||||
onChange,
|
||||
}}
|
||||
/>
|
||||
</ConfigField>
|
||||
);
|
||||
}
|
||||
|
||||
export default BarColorField;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { render as rtlRender, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import AppearanceSection from '../AppearanceSection';
|
||||
|
||||
// Color swatches use a radix tooltip, which needs a TooltipProvider ancestor.
|
||||
const render = (ui: JSX.Element): ReturnType<typeof rtlRender> =>
|
||||
rtlRender(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
|
||||
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, barColor: '#4E74F8' }}
|
||||
controls={{ showRank: true, showShare: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId(testId));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
showRank: false,
|
||||
showShare: false,
|
||||
barColor: '#4E74F8',
|
||||
[field]: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('picks a bar color, keeping the other fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<AppearanceSection
|
||||
value={{ showRank: true, barColor: '#4E74F8' }}
|
||||
controls={{ barColor: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-bar-color-forest'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
showRank: true,
|
||||
barColor: Color.BG_FOREST_500,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the default bar color when the spec has none', () => {
|
||||
render(
|
||||
<AppearanceSection
|
||||
value={undefined}
|
||||
controls={{ barColor: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-bar-color-robin').querySelector('input'),
|
||||
).toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
import type { ColorSwatchOption } from '../../controls/ColorSwatches/ColorSwatches';
|
||||
|
||||
export const BAR_COLOR_OPTIONS: ColorSwatchOption<string>[] = [
|
||||
{ id: 'robin', color: Color.BG_ROBIN_500 },
|
||||
{ id: 'aqua', color: Color.BG_AQUA_500 },
|
||||
{ id: 'forest', color: Color.BG_FOREST_500 },
|
||||
{ id: 'amber', color: Color.BG_AMBER_500 },
|
||||
{ id: 'sienna', color: Color.BG_SIENNA_500 },
|
||||
].map(({ id, color }) => ({
|
||||
value: color,
|
||||
id,
|
||||
label: id.charAt(0).toUpperCase() + id.slice(1),
|
||||
fill: color,
|
||||
}));
|
||||
|
||||
/** The preset a hex matches, ignoring case. */
|
||||
export function findBarColorPreset(hex: string): string | undefined {
|
||||
return BAR_COLOR_OPTIONS.find(
|
||||
(option) => option.value.toLowerCase() === hex.toLowerCase(),
|
||||
)?.value;
|
||||
}
|
||||
@@ -34,6 +34,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],
|
||||
@@ -81,6 +82,7 @@ const EXPECTED_QUERY_MODES: Record<PanelKind, QueryMode[]> = {
|
||||
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE, QueryMode.AI_QUERY_BUILDER],
|
||||
'signoz/ListPanel': [QUERY_BUILDER, QueryMode.AI_QUERY_BUILDER],
|
||||
'signoz/ScatterPlotPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/TopListPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/TextPanel': [],
|
||||
};
|
||||
|
||||
@@ -93,6 +95,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],
|
||||
@@ -159,6 +162,14 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/TopListPanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
defaultRowLimit: 10,
|
||||
},
|
||||
// Only Table and Scatter Plot ask the server to join their scalar results into UI rows.
|
||||
'signoz/TablePanel': {
|
||||
requestType: scalar,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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, labelColumnNames } = 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}
|
||||
labelName={labelColumnNames.join(', ')}
|
||||
unit={spec.formatting?.unit}
|
||||
precision={precision}
|
||||
thresholds={thresholds}
|
||||
barColor={spec.appearance?.barColor ?? undefined}
|
||||
showRank={spec.appearance?.showRank}
|
||||
showShare={spec.appearance?.showShare}
|
||||
onSelect={enableDrillDown ? handleSelect : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopListPanelRenderer;
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type DashboardtypesTopListPanelSpecDTO,
|
||||
type QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import type {
|
||||
PanelOfKind,
|
||||
PanelRendererProps,
|
||||
} from '../../../types/rendererProps';
|
||||
import TopListPanelRenderer from '../Renderer';
|
||||
import { mockListLayout } from './mockListLayout';
|
||||
|
||||
function panelWith(
|
||||
spec: DashboardtypesTopListPanelSpecDTO,
|
||||
): PanelOfKind<'signoz/TopListPanel'> {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TopListPanel', spec }, queries: [] },
|
||||
} as unknown as PanelOfKind<'signoz/TopListPanel'>;
|
||||
}
|
||||
|
||||
function dataWith(rows: [string, number][]): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: [
|
||||
{ name: 'service.name', queryName: 'A', columnType: 'group' },
|
||||
{
|
||||
name: '__result',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
const SERVICES: [string, number][] = [
|
||||
['checkout', 50],
|
||||
['payment', 100],
|
||||
['auth', 25],
|
||||
];
|
||||
|
||||
function renderPanel(
|
||||
props: Partial<PanelRendererProps<'signoz/TopListPanel'>>,
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TopListPanelRenderer
|
||||
panelId="panel-1"
|
||||
panel={panelWith({})}
|
||||
data={dataWith(SERVICES)}
|
||||
isFetching={false}
|
||||
error={null}
|
||||
panelMode={PanelMode.DASHBOARD_VIEW}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const rowLabels = (): string[] =>
|
||||
screen
|
||||
.getAllByTestId('top-list-row')
|
||||
.map((row) => row.getAttribute('aria-label') ?? '');
|
||||
|
||||
mockListLayout();
|
||||
|
||||
describe('TopListPanelRenderer', () => {
|
||||
it('ranks rows by value with bars relative to the largest value', () => {
|
||||
renderPanel({});
|
||||
|
||||
expect(rowLabels()).toStrictEqual([
|
||||
'payment: 100',
|
||||
'checkout: 50',
|
||||
'auth: 25',
|
||||
]);
|
||||
expect(
|
||||
screen.getAllByTestId('top-list-row-fill').map((fill) => fill.style.width),
|
||||
).toStrictEqual(['100%', '50%', '25%']);
|
||||
});
|
||||
|
||||
it('formats values with the panel unit', () => {
|
||||
renderPanel({ panel: panelWith({ formatting: { unit: 'ms' } }) });
|
||||
|
||||
expect(screen.getAllByTestId('top-list-row-value')[0]).toHaveTextContent(
|
||||
'100 ms',
|
||||
);
|
||||
});
|
||||
|
||||
it('recolours the bar for a background threshold and the value for a text threshold', () => {
|
||||
renderPanel({
|
||||
panel: panelWith({
|
||||
thresholds: [
|
||||
{
|
||||
color: '#F1575F',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 90,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
{
|
||||
color: '#F5B225',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
value: 40,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const [paymentFill, checkoutFill] =
|
||||
screen.getAllByTestId('top-list-row-fill');
|
||||
const [paymentValue, checkoutValue] =
|
||||
screen.getAllByTestId('top-list-row-value');
|
||||
expect(paymentFill.style.getPropertyValue('--fill-color')).toBe('#F1575F');
|
||||
expect(paymentValue.style.color).toBe('');
|
||||
expect(checkoutValue).toHaveStyle({ color: '#F5B225' });
|
||||
expect(checkoutFill.style.getPropertyValue('--fill-color')).toBe('');
|
||||
});
|
||||
|
||||
it('opens the drilldown with the clicked row', () => {
|
||||
const onClick = jest.fn();
|
||||
renderPanel({ onClick, enableDrillDown: true });
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('top-list-row')[1], {
|
||||
detail: 1,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
});
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(onClick.mock.calls[0][0]).toMatchObject({
|
||||
coordinates: { x: 10, y: 20 },
|
||||
context: { queryName: 'A', label: 'checkout' },
|
||||
});
|
||||
});
|
||||
|
||||
it('anchors the drilldown under the row when opened from the keyboard', () => {
|
||||
const onClick = jest.fn();
|
||||
renderPanel({ onClick, enableDrillDown: true });
|
||||
const row = screen.getAllByTestId('top-list-row')[0];
|
||||
jest
|
||||
.spyOn(row, 'getBoundingClientRect')
|
||||
.mockReturnValue({ left: 30, bottom: 60 } as DOMRect);
|
||||
|
||||
fireEvent.click(row, { detail: 0 });
|
||||
|
||||
expect(onClick.mock.calls[0][0]).toMatchObject({
|
||||
coordinates: { x: 30, y: 60 },
|
||||
context: { label: 'payment' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not drill down when drilldown is disabled', () => {
|
||||
const onClick = jest.fn();
|
||||
renderPanel({ onClick });
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('top-list-row')[0]);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves focus between rows with the arrow keys', () => {
|
||||
renderPanel({});
|
||||
const rows = screen.getAllByTestId('top-list-row');
|
||||
rows[0].focus();
|
||||
|
||||
fireEvent.keyDown(rows[0], { key: 'ArrowDown' });
|
||||
expect(rows[1]).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(rows[1], { key: 'ArrowUp' });
|
||||
expect(rows[0]).toHaveFocus();
|
||||
});
|
||||
|
||||
it('renders No Data when the query returns no rows', () => {
|
||||
renderPanel({ data: dataWith([]) });
|
||||
|
||||
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('top-list')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { act, fireEvent, render, screen, userEvent } 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('paints bars the panel color unless a background threshold matches', () => {
|
||||
render(
|
||||
<TopList
|
||||
panelId="panel-1"
|
||||
rows={rowsOf(2)}
|
||||
valueName="count()"
|
||||
barColor="#00C853"
|
||||
thresholds={[
|
||||
{ value: 2, operator: '>=', color: 'Red', format: 'background' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const fills = screen.getAllByTestId('top-list-row-fill');
|
||||
expect(fills[0].style.getPropertyValue('--fill-color')).not.toBe('#00C853');
|
||||
expect(fills[1].style.getPropertyValue('--fill-color')).toBe('#00C853');
|
||||
});
|
||||
|
||||
it('heads the columns that are shown', () => {
|
||||
render(
|
||||
<TopList
|
||||
panelId="panel-1"
|
||||
rows={rowsOf(2)}
|
||||
valueName="count()"
|
||||
labelName="service.name"
|
||||
thresholds={[]}
|
||||
showRank
|
||||
showShare
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('top-list-header').textContent).toBe(
|
||||
'#service.nameSharecount()',
|
||||
);
|
||||
});
|
||||
|
||||
it('explains a header on hover with its full name', async () => {
|
||||
render(
|
||||
<TopList
|
||||
panelId="panel-1"
|
||||
rows={rowsOf(2)}
|
||||
valueName="sum(signoz_calls_total)"
|
||||
labelName="service.name"
|
||||
thresholds={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.hover(screen.getByText('sum(signoz_calls_total)'));
|
||||
|
||||
const tooltip = await screen.findAllByTestId(
|
||||
'top-list-header-tooltip',
|
||||
{},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
expect(tooltip[0]).toHaveTextContent(
|
||||
'sum(signoz_calls_total)The value each row is ranked by.',
|
||||
);
|
||||
});
|
||||
|
||||
it('follows the pointer with the hovered row in the tooltip', async () => {
|
||||
const rows = rowsOf(2).map((row) => ({ ...row, share: 0.5 }));
|
||||
render(
|
||||
<TopList
|
||||
panelId="panel-1"
|
||||
rows={rows}
|
||||
valueName="count()"
|
||||
thresholds={[]}
|
||||
showShare
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.mouseMove(renderedRows()[1], { clientX: 40, clientY: 30 });
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('top-list-tooltip-title'),
|
||||
).resolves.toHaveTextContent('service-1');
|
||||
expect(
|
||||
screen.getAllByTestId('top-list-tooltip-row').map((row) => row.textContent),
|
||||
).toStrictEqual(['count()1', 'Share of listed total50%']);
|
||||
});
|
||||
|
||||
it('hides the tooltip over the empty space below the rows', async () => {
|
||||
renderList(2);
|
||||
fireEvent.mouseMove(renderedRows()[1]);
|
||||
await screen.findByTestId('top-list-tooltip');
|
||||
|
||||
fireEvent.mouseMove(screen.getByTestId('top-list'));
|
||||
|
||||
expect(screen.queryByTestId('top-list-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the tooltip when the pointer leaves the list', async () => {
|
||||
renderList(2);
|
||||
fireEvent.mouseMove(renderedRows()[0]);
|
||||
await screen.findByTestId('top-list-tooltip');
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId('top-list').parentElement as Element);
|
||||
|
||||
expect(screen.queryByTestId('top-list-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hints at drilldown, not pinning, when rows are clickable', async () => {
|
||||
render(
|
||||
<TopList
|
||||
panelId="panel-1"
|
||||
rows={rowsOf(2)}
|
||||
valueName="count()"
|
||||
thresholds={[]}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.mouseMove(renderedRows()[0]);
|
||||
|
||||
const footer = await screen.findByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toHaveTextContent('Click to drilldown');
|
||||
expect(footer).not.toHaveTextContent('to pin the tooltip');
|
||||
});
|
||||
|
||||
it('leaves the footer out when rows are not clickable', async () => {
|
||||
renderList(2);
|
||||
fireEvent.mouseMove(renderedRows()[0]);
|
||||
await screen.findByTestId('top-list-tooltip');
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-footer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows neither rank nor share by default', () => {
|
||||
renderList(3);
|
||||
|
||||
expect(screen.queryByTestId('top-list-row-rank')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('top-list-row-share')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cuts the middle out of a label wider than its bar', () => {
|
||||
// 100px bar, 17px of label inset, 10px per character: 8 characters fit.
|
||||
jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(100);
|
||||
jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
font: '',
|
||||
measureText: (text: string) => ({ width: text.length * 10 }),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
|
||||
renderList(1);
|
||||
|
||||
expect(screen.getByText('serv…e-0')).toBeInTheDocument();
|
||||
expect(renderedRows()[0]).toHaveAttribute('aria-label', 'service-0: 1');
|
||||
});
|
||||
|
||||
it('focuses a row that was out of view once it renders', async () => {
|
||||
renderList(1000);
|
||||
renderedRows()[0].focus();
|
||||
|
||||
await pressKey('End');
|
||||
|
||||
expect(focusedRowIndex()).toBe('999');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
DashboardtypesTopListPanelSpecDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { getTopListCsvRows } from '../csv';
|
||||
|
||||
const panelWith = (
|
||||
spec: DashboardtypesTopListPanelSpecDTO,
|
||||
): PanelOfKind<'signoz/TopListPanel'> =>
|
||||
({
|
||||
spec: { plugin: { kind: 'signoz/TopListPanel', spec } },
|
||||
}) as unknown as PanelOfKind<'signoz/TopListPanel'>;
|
||||
|
||||
function dataWith(groupColumns: string[], rows: unknown[][]): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: [
|
||||
...groupColumns.map((name) => ({
|
||||
name,
|
||||
queryName: 'A',
|
||||
columnType: 'group',
|
||||
})),
|
||||
{
|
||||
name: '__result',
|
||||
queryName: 'A',
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: undefined,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('getTopListCsvRows', () => {
|
||||
it('exports the rows in ranked order with formatted values', () => {
|
||||
const data = dataWith(
|
||||
['service.name'],
|
||||
[
|
||||
['auth', 25],
|
||||
['payment', 1500],
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
getTopListCsvRows(panelWith({ formatting: { unit: 'ms' } }), data),
|
||||
).toStrictEqual([
|
||||
{ 'service.name': 'payment', A: '1.5 s' },
|
||||
{ 'service.name': 'auth', A: '25 ms' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the label column after every group-by key', () => {
|
||||
const data = dataWith(['http.route', 'region'], [['GET /orders', 'eu', 3]]);
|
||||
|
||||
expect(Object.keys(getTopListCsvRows(panelWith({}), data)[0])).toStrictEqual([
|
||||
'http.route · region',
|
||||
'A',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to a generic label header without a group-by', () => {
|
||||
const data = dataWith([], [[42]]);
|
||||
|
||||
expect(getTopListCsvRows(panelWith({}), data)).toStrictEqual([
|
||||
{ Label: 'A', A: '42' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds rank and share columns when the panel shows them', () => {
|
||||
const data = dataWith(
|
||||
['service.name'],
|
||||
[
|
||||
['auth', 25],
|
||||
['payment', 75],
|
||||
],
|
||||
);
|
||||
const panel = panelWith({
|
||||
appearance: { showRank: true, showShare: true, barColor: '#4E74F8' },
|
||||
});
|
||||
|
||||
expect(getTopListCsvRows(panel, data)).toStrictEqual([
|
||||
{ Rank: '1', 'service.name': 'payment', A: '75', Share: '75%' },
|
||||
{ Rank: '2', 'service.name': 'auth', A: '25', Share: '25%' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('exports nothing for an empty result', () => {
|
||||
expect(getTopListCsvRows(panelWith({}), dataWith([], []))).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const CONTAINER_HEIGHT = 280;
|
||||
const CONTAINER_WIDTH = 600;
|
||||
const ROW_HEIGHT = 28;
|
||||
const SCROLL_HEIGHT = ROW_HEIGHT * 1000;
|
||||
|
||||
const heightOf = (element: Element): number =>
|
||||
element.hasAttribute('data-index') ? ROW_HEIGHT : CONTAINER_HEIGHT;
|
||||
|
||||
/**
|
||||
* jsdom has no layout, so the virtualized list would render no rows. Gives the scroll
|
||||
* container and the measured rows (`data-index`) real heights, and makes `scrollTo`
|
||||
* move `scrollTop` and fire `scroll` the way a browser does.
|
||||
*/
|
||||
export function mockListLayout(): void {
|
||||
const originalScrollTo = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'scrollTo',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function rect(this: HTMLElement): DOMRect {
|
||||
const height = heightOf(this);
|
||||
return {
|
||||
width: CONTAINER_WIDTH,
|
||||
height,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: height,
|
||||
right: CONTAINER_WIDTH,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
});
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
||||
.mockImplementation(function height(this: HTMLElement): number {
|
||||
return heightOf(this);
|
||||
});
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
|
||||
.mockReturnValue(CONTAINER_WIDTH);
|
||||
jest
|
||||
.spyOn(Element.prototype, 'clientHeight', 'get')
|
||||
.mockReturnValue(CONTAINER_HEIGHT);
|
||||
jest
|
||||
.spyOn(Element.prototype, 'scrollHeight', 'get')
|
||||
.mockReturnValue(SCROLL_HEIGHT);
|
||||
Element.prototype.scrollTo = function scrollTo(
|
||||
this: Element,
|
||||
options?: ScrollToOptions | number,
|
||||
): void {
|
||||
if (typeof options === 'object' && options.top !== undefined) {
|
||||
this.scrollTop = options.top;
|
||||
this.dispatchEvent(new Event('scroll'));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
if (originalScrollTo) {
|
||||
Object.defineProperty(Element.prototype, 'scrollTo', originalScrollTo);
|
||||
} else {
|
||||
delete (Element.prototype as Partial<Element>).scrollTo;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { prepareTopListData } from '../prepareData';
|
||||
import { getTopListDataWarning } from '../warnings';
|
||||
|
||||
const panelWith = (
|
||||
appearance: { showShare?: boolean } = {},
|
||||
): PanelOfKind<'signoz/TopListPanel'> =>
|
||||
({
|
||||
spec: { plugin: { spec: { appearance } } },
|
||||
}) as unknown as PanelOfKind<'signoz/TopListPanel'>;
|
||||
const panel = panelWith();
|
||||
|
||||
interface Result {
|
||||
queryName: string;
|
||||
rows: [string, number][];
|
||||
}
|
||||
|
||||
interface Envelope {
|
||||
type: 'builder_query' | 'builder_formula';
|
||||
spec: {
|
||||
name: string;
|
||||
limit?: number;
|
||||
expression?: string;
|
||||
disabled?: boolean;
|
||||
order?: { key: { name: string }; direction: 'asc' | 'desc' }[];
|
||||
groupBy?: { name: string }[];
|
||||
aggregations?: Partial<Record<string, string>>[];
|
||||
};
|
||||
}
|
||||
|
||||
function dataWith(results: Result[], queries: Envelope[]): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: results.map(({ queryName, rows }) => ({
|
||||
queryName,
|
||||
columns: [
|
||||
{ name: 'service.name', queryName, columnType: 'group' },
|
||||
{
|
||||
name: '__result',
|
||||
queryName,
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex: 0,
|
||||
},
|
||||
],
|
||||
data: rows,
|
||||
})),
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload: {
|
||||
compositeQuery: { queries },
|
||||
} as unknown as Querybuildertypesv5QueryRangeRequestDTO,
|
||||
legendMap: {},
|
||||
};
|
||||
}
|
||||
|
||||
const query = (name: string, limit?: number): Envelope => ({
|
||||
type: 'builder_query',
|
||||
spec: { name, limit },
|
||||
});
|
||||
const formula = (name: string, expression: string): Envelope => ({
|
||||
type: 'builder_formula',
|
||||
spec: { name, expression },
|
||||
});
|
||||
|
||||
const A: Result = { queryName: 'A', rows: [['checkout', 10]] };
|
||||
const B: Result = { queryName: 'B', rows: [['checkout', 2]] };
|
||||
const F1: Result = { queryName: 'F1', rows: [['checkout', 5]] };
|
||||
|
||||
describe('top list with several queries', () => {
|
||||
it('ranks the first query in builder order, whatever order the response lists them in', () => {
|
||||
const data = dataWith(
|
||||
[F1, B, A],
|
||||
[query('A'), query('B'), formula('F1', 'A / B')],
|
||||
);
|
||||
|
||||
const { rows, ignoredResults } = prepareTopListData(data);
|
||||
|
||||
expect(rows.map((row) => [row.queryName, row.value])).toStrictEqual([
|
||||
['A', 10],
|
||||
]);
|
||||
expect(ignoredResults).toStrictEqual(['B', 'F1']);
|
||||
});
|
||||
|
||||
it('ranks the formula when its inputs are disabled', () => {
|
||||
// The server leaves disabled queries out of the response.
|
||||
const data = dataWith([F1], [query('A'), query('B'), formula('F1', 'A / B')]);
|
||||
|
||||
expect(prepareTopListData(data).rows[0].queryName).toBe('F1');
|
||||
expect(getTopListDataWarning(panel, data)).toBeNull();
|
||||
});
|
||||
|
||||
it('names the results the ranking leaves out', () => {
|
||||
const data = dataWith([A, B], [query('A'), query('B')]);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'The list ranks "A" only; "B" is not shown. Disable the queries you don\'t want to rank.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns when a formula reads inputs cut to their own limit', () => {
|
||||
const data = dataWith(
|
||||
[F1],
|
||||
[query('A', 10), query('B', 10), query('C', 5), formula('F1', 'A / B')],
|
||||
);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'"A", "B" are limited before the formula combines them, so the formula only sees groups in every input\'s top N. Set the limit on the formula instead.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists rows in the query's ascending order for a bottom N", () => {
|
||||
const data = dataWith(
|
||||
[
|
||||
{
|
||||
queryName: 'A',
|
||||
rows: [
|
||||
['checkout', 10],
|
||||
['auth', 2],
|
||||
['search', 5],
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
order: [{ key: { name: 'count()' }, direction: 'asc' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(prepareTopListData(data).rows.map((row) => row.value)).toStrictEqual([
|
||||
2, 5, 10,
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns when the query orders by a group key instead of the value', () => {
|
||||
const data = dataWith(
|
||||
[
|
||||
{
|
||||
queryName: 'A',
|
||||
rows: [
|
||||
['auth', 2],
|
||||
['checkout', 10],
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
order: [{ key: { name: 'service.name' }, direction: 'asc' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const { rows } = prepareTopListData(data);
|
||||
expect(rows.map((row) => row.value)).toStrictEqual([10, 2]);
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'The query orders by "service.name", so the list shows the first groups by that key rather than the top values. Order by the value instead.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('top list value name', () => {
|
||||
const aggregated = (
|
||||
aggregation: Partial<Record<string, string>>,
|
||||
): Envelope => ({
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', aggregations: [aggregation] },
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenario: 'an alias',
|
||||
aggregation: { alias: 'requests', expression: 'count()' },
|
||||
expected: 'requests',
|
||||
},
|
||||
{
|
||||
scenario: 'an expression',
|
||||
aggregation: { expression: 'p99(duration_nano)' },
|
||||
expected: 'p99(duration_nano)',
|
||||
},
|
||||
{
|
||||
scenario: 'a metric',
|
||||
aggregation: { metricName: 'signoz_calls_total', spaceAggregation: 'sum' },
|
||||
expected: 'sum(signoz_calls_total)',
|
||||
},
|
||||
])('names the value after $scenario', ({ aggregation, expected }) => {
|
||||
expect(
|
||||
prepareTopListData(dataWith([A], [aggregated(aggregation)])).valueName,
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it('names a ranked formula after its expression', () => {
|
||||
const data = dataWith([F1], [query('A'), query('B'), formula('F1', 'A / B')]);
|
||||
|
||||
expect(prepareTopListData(data).valueName).toBe('A / B');
|
||||
});
|
||||
|
||||
it('falls back to the query name with nothing to describe it', () => {
|
||||
expect(prepareTopListData(dataWith([A], [query('A')])).valueName).toBe('A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('share of total warning', () => {
|
||||
const ranked = (aggregation: Partial<Record<string, string>>): Envelope => ({
|
||||
type: 'builder_query',
|
||||
spec: { name: 'A', aggregations: [aggregation] },
|
||||
});
|
||||
const sharePanel = panelWith({ showShare: true });
|
||||
const shareMessage = (data: PanelQueryData): string | undefined =>
|
||||
getTopListDataWarning(sharePanel, data)?.messages?.find((message) =>
|
||||
message.startsWith('Share of total'),
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenario: 'a percentile',
|
||||
aggregation: { expression: 'p99(duration_nano)' },
|
||||
},
|
||||
{ scenario: 'an average', aggregation: { expression: 'avg(duration_nano)' } },
|
||||
{ scenario: 'a maximum', aggregation: { expression: 'max(duration_nano)' } },
|
||||
{
|
||||
scenario: 'a metric averaged across series',
|
||||
aggregation: { metricName: 'cpu', spaceAggregation: 'avg' },
|
||||
},
|
||||
])('warns when ranking $scenario', ({ aggregation }) => {
|
||||
expect(shareMessage(dataWith([A], [ranked(aggregation)]))).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scenario: 'a count', aggregation: { expression: 'count()' } },
|
||||
{ scenario: 'a sum', aggregation: { expression: 'sum(bytes)' } },
|
||||
{
|
||||
scenario: 'a metric summed across series',
|
||||
aggregation: { metricName: 'signoz_calls_total', spaceAggregation: 'sum' },
|
||||
},
|
||||
])('stays quiet when ranking $scenario', ({ aggregation }) => {
|
||||
expect(shareMessage(dataWith([A], [ranked(aggregation)]))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays quiet for a formula, whose aggregation it cannot tell', () => {
|
||||
const data = dataWith([F1], [query('A'), query('B'), formula('F1', 'A / B')]);
|
||||
|
||||
expect(shareMessage(data)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays quiet when the panel does not show shares', () => {
|
||||
const data = dataWith([A], [ranked({ expression: 'p99(duration_nano)' })]);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import type {
|
||||
PanelTable,
|
||||
PanelTableColumn,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import { EMPTY_LABEL, prepareTopListRows } from '../prepareData';
|
||||
|
||||
const groupColumn = (name: string): PanelTableColumn => ({
|
||||
name,
|
||||
id: name,
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
});
|
||||
const valueColumn = (name = 'count()'): PanelTableColumn => ({
|
||||
name,
|
||||
id: name,
|
||||
queryName: 'A',
|
||||
isValueColumn: true,
|
||||
});
|
||||
|
||||
function serviceTable(
|
||||
rows: [unknown, unknown][],
|
||||
overrides: Partial<PanelTable> = {},
|
||||
): PanelTable {
|
||||
return {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [groupColumn('service.name'), valueColumn()],
|
||||
rows: rows.map(([service, value]) => ({
|
||||
data: { 'service.name': service, 'count()': value },
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const summarize = (table: PanelTable): [string, number | null, number][] =>
|
||||
prepareTopListRows([table]).rows.map((row) => [
|
||||
row.label,
|
||||
row.value,
|
||||
row.ratio,
|
||||
]);
|
||||
|
||||
describe('prepareTopListRows', () => {
|
||||
it('ranks rows by value, descending, with bars relative to the largest value', () => {
|
||||
const table = serviceTable([
|
||||
['auth', 30],
|
||||
['payment', 40],
|
||||
['checkout', 10],
|
||||
]);
|
||||
|
||||
expect(summarize(table)).toStrictEqual([
|
||||
['payment', 40, 1],
|
||||
['auth', 30, 0.75],
|
||||
['checkout', 10, 0.25],
|
||||
]);
|
||||
});
|
||||
|
||||
it('breaks ties by label so the order is stable across refreshes', () => {
|
||||
const table = serviceTable([
|
||||
['search', 5],
|
||||
['auth', 5],
|
||||
['checkout', 5],
|
||||
]);
|
||||
|
||||
expect(summarize(table).map(([label]) => label)).toStrictEqual([
|
||||
'auth',
|
||||
'checkout',
|
||||
'search',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps non-numeric values without a bar, sorted last, with the raw cell for display', () => {
|
||||
const table = serviceTable([
|
||||
['nan', 'NaN'],
|
||||
['inf', 'Inf'],
|
||||
['missing', 'n/a'],
|
||||
['null', null],
|
||||
['ok', 7],
|
||||
]);
|
||||
|
||||
const { rows } = prepareTopListRows([table]);
|
||||
|
||||
expect(rows.map((row) => [row.label, row.value, row.ratio])).toStrictEqual([
|
||||
['ok', 7, 1],
|
||||
['inf', null, 0],
|
||||
['missing', null, 0],
|
||||
['nan', null, 0],
|
||||
['null', null, 0],
|
||||
]);
|
||||
expect(rows[1].rawValue).toBe('Inf');
|
||||
});
|
||||
|
||||
it('keeps zero and negative values with an empty bar', () => {
|
||||
const table = serviceTable([
|
||||
['down', -4],
|
||||
['flat', 0],
|
||||
['up', 8],
|
||||
]);
|
||||
|
||||
expect(summarize(table)).toStrictEqual([
|
||||
['up', 8, 1],
|
||||
['flat', 0, 0],
|
||||
['down', -4, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows rows with empty bars when no value is positive', () => {
|
||||
const table = serviceTable([
|
||||
['a', 0],
|
||||
['b', -1],
|
||||
]);
|
||||
|
||||
expect(summarize(table)).toStrictEqual([
|
||||
['a', 0, 0],
|
||||
['b', -1, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives each numeric row its share of the total', () => {
|
||||
const table = serviceTable([
|
||||
['auth', 30],
|
||||
['payment', 60],
|
||||
['missing', 'n/a'],
|
||||
['checkout', 10],
|
||||
]);
|
||||
|
||||
expect(
|
||||
prepareTopListRows([table]).rows.map((row) => [row.label, row.share]),
|
||||
).toStrictEqual([
|
||||
['payment', 0.6],
|
||||
['auth', 0.3],
|
||||
['checkout', 0.1],
|
||||
['missing', null],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scenario: 'any value is negative', values: [8, -4] },
|
||||
{ scenario: 'the total is zero', values: [0, 0] },
|
||||
])('gives no shares when $scenario', ({ values }) => {
|
||||
const table = serviceTable(
|
||||
values.map((value, index) => [`s${index}`, value]),
|
||||
);
|
||||
|
||||
expect(
|
||||
prepareTopListRows([table]).rows.map((row) => row.share),
|
||||
).toStrictEqual([null, null]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ legend: '', expected: 'count()' },
|
||||
{ legend: '{{service.name}}', expected: 'A' },
|
||||
])(
|
||||
'names the value $expected when the legend is "$legend"',
|
||||
({ legend, expected }) => {
|
||||
const table = serviceTable([['auth', 1]], {
|
||||
legend,
|
||||
columns: [groupColumn('service.name'), valueColumn(legend || 'count()')],
|
||||
});
|
||||
|
||||
expect(prepareTopListRows([table]).valueName).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('parses numeric strings', () => {
|
||||
const table = serviceTable([
|
||||
['a', '12'],
|
||||
['b', ' '],
|
||||
]);
|
||||
|
||||
expect(summarize(table)).toStrictEqual([
|
||||
['a', 12, 1],
|
||||
['b', null, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels a missing group value as empty for both null (traces/logs) and "" (metrics)', () => {
|
||||
const table = serviceTable([
|
||||
[null, 2],
|
||||
['', 1],
|
||||
]);
|
||||
|
||||
const { rows } = prepareTopListRows([table]);
|
||||
|
||||
expect(rows.map((row) => [row.label, row.isEmptyLabel])).toStrictEqual([
|
||||
[EMPTY_LABEL, true],
|
||||
[EMPTY_LABEL, true],
|
||||
]);
|
||||
expect(rows.map((row) => row.key)).toStrictEqual(['A-0', 'A-1']);
|
||||
});
|
||||
|
||||
it('joins several group values, marking only the missing ones', () => {
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [groupColumn('http.route'), groupColumn('region'), valueColumn()],
|
||||
rows: [
|
||||
{
|
||||
data: { 'http.route': 'GET /orders', region: 'us-east-1', 'count()': 3 },
|
||||
},
|
||||
{ data: { 'http.route': 'GET /cart', region: null, 'count()': 1 } },
|
||||
],
|
||||
};
|
||||
|
||||
expect(summarize(table).map(([label]) => label)).toStrictEqual([
|
||||
'GET /orders · us-east-1',
|
||||
`GET /cart · ${EMPTY_LABEL}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills a legend template, rendering a key the row lacks as empty', () => {
|
||||
const table = serviceTable([['checkout', 1]], {
|
||||
legend: '{{service.name}} ({{region}})',
|
||||
});
|
||||
|
||||
expect(summarize(table)[0][0]).toBe('checkout ()');
|
||||
});
|
||||
|
||||
it('keeps rows distinct when a legend without variables gives them all one label', () => {
|
||||
const table = serviceTable(
|
||||
[
|
||||
['a', 2],
|
||||
['b', 1],
|
||||
],
|
||||
{ legend: 'requests' },
|
||||
);
|
||||
|
||||
const { rows } = prepareTopListRows([table]);
|
||||
|
||||
expect(rows.map((row) => row.label)).toStrictEqual(['requests', 'requests']);
|
||||
expect(new Set(rows.map((row) => row.key)).size).toBe(2);
|
||||
});
|
||||
|
||||
it('labels a query without a group-by by its name', () => {
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [valueColumn()],
|
||||
rows: [{ data: { 'count()': 42 } }],
|
||||
};
|
||||
|
||||
const data = prepareTopListRows([table]);
|
||||
|
||||
expect(data.labelColumnNames).toStrictEqual([]);
|
||||
expect(data.rows.map((row) => [row.label, row.value])).toStrictEqual([
|
||||
['A', 42],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ranks by the first value column and reports the rest as ignored', () => {
|
||||
const table: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [groupColumn('host'), valueColumn('p99'), valueColumn('p50')],
|
||||
rows: [
|
||||
{ data: { host: 'a', p99: 1, p50: 9 } },
|
||||
{ data: { host: 'b', p99: 2, p50: 1 } },
|
||||
],
|
||||
};
|
||||
|
||||
const data = prepareTopListRows([table]);
|
||||
|
||||
expect(data.rows.map((row) => [row.label, row.value])).toStrictEqual([
|
||||
['b', 2],
|
||||
['a', 1],
|
||||
]);
|
||||
expect(data.ignoredValueColumns).toStrictEqual(['p50']);
|
||||
});
|
||||
|
||||
it('carries the query and group values for drilldown filters', () => {
|
||||
const table = serviceTable([
|
||||
['checkout', 1],
|
||||
['', 2],
|
||||
[null, 3],
|
||||
]);
|
||||
|
||||
const { rows } = prepareTopListRows([table]);
|
||||
|
||||
expect(rows.map((row) => [row.queryName, row.labels])).toStrictEqual([
|
||||
['A', {}],
|
||||
['A', { 'service.name': '' }],
|
||||
['A', { 'service.name': 'checkout' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no rows when no table has a value column', () => {
|
||||
expect(prepareTopListRows([])).toStrictEqual({
|
||||
rows: [],
|
||||
labelColumnNames: [],
|
||||
valueColumnName: '',
|
||||
valueName: '',
|
||||
ignoredValueColumns: [],
|
||||
ignoredResults: [],
|
||||
orderedByGroupKey: null,
|
||||
});
|
||||
expect(
|
||||
prepareTopListRows([
|
||||
{ queryName: 'A', legend: '', columns: [groupColumn('x')], rows: [] },
|
||||
]).rows,
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { TopListRow } from '../types';
|
||||
import {
|
||||
formatShare,
|
||||
getRowHeight,
|
||||
getTooltipContent,
|
||||
truncateMiddle,
|
||||
} from '../utils';
|
||||
|
||||
// Each case: rows that fit at a density, given a 1px gap and 12px of list padding.
|
||||
describe('getRowHeight', () => {
|
||||
it.each([
|
||||
{ count: 10, height: 10 * 32 + 9 + 12, expected: 32 },
|
||||
{ count: 10, height: 10 * 32 + 9 + 11, expected: 28 },
|
||||
{ count: 10, height: 10 * 28 + 9 + 12, expected: 28 },
|
||||
{ count: 10, height: 10 * 28 + 9 + 11, expected: 24 },
|
||||
{ count: 200, height: 400, expected: 24 },
|
||||
])(
|
||||
'uses $expected px rows for $count rows in $height px',
|
||||
({ count, height, expected }) => {
|
||||
expect(getRowHeight(count, height)).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('falls back to the default height before the panel is measured', () => {
|
||||
expect(getRowHeight(10, 0)).toBe(28);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShare', () => {
|
||||
it.each([
|
||||
{ share: 0.5, expected: '50%' },
|
||||
{ share: 0.12345, expected: '12%' },
|
||||
{ share: 0.0995, expected: '10%' },
|
||||
{ share: 0.0456, expected: '4.6%' },
|
||||
{ share: 0.0004, expected: '<0.1%' },
|
||||
{ share: 0, expected: '0%' },
|
||||
{ share: 1, expected: '100%' },
|
||||
{ share: null, expected: '—' },
|
||||
])('formats $share as $expected', ({ share, expected }) => {
|
||||
expect(formatShare(share)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateMiddle', () => {
|
||||
const byLength = (text: string): number => text.length;
|
||||
|
||||
it('keeps a label that fits', () => {
|
||||
expect(truncateMiddle('checkout', 8, byLength)).toBe('checkout');
|
||||
});
|
||||
|
||||
it('keeps both ends of a label that does not', () => {
|
||||
expect(truncateMiddle('recommendationservice', 9, byLength)).toBe(
|
||||
'reco…vice',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves only the ellipsis when nothing else fits', () => {
|
||||
expect(truncateMiddle('checkout', 1, byLength)).toBe('…');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTooltipContent', () => {
|
||||
const row = (overrides: Partial<TopListRow> = {}): TopListRow => ({
|
||||
key: 'A-0',
|
||||
label: 'checkout',
|
||||
isEmptyLabel: false,
|
||||
value: 1500,
|
||||
rawValue: 1500,
|
||||
ratio: 1,
|
||||
share: 0.25,
|
||||
queryName: 'A',
|
||||
labels: { 'service.name': 'checkout' },
|
||||
...overrides,
|
||||
});
|
||||
const options = {
|
||||
valueName: 'p99(duration_nano)',
|
||||
unit: 'ms',
|
||||
showShare: true,
|
||||
};
|
||||
|
||||
it('names the value and its share', () => {
|
||||
expect(getTooltipContent(row(), options).rows).toStrictEqual([
|
||||
{ key: 'value', label: 'p99(duration_nano)', value: '1.5 s' },
|
||||
{ key: 'share', label: 'Share of listed total', value: '25%' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the share out when the panel hides it or the row has none', () => {
|
||||
expect(
|
||||
getTooltipContent(row(), { ...options, showShare: false }).rows,
|
||||
).toHaveLength(1);
|
||||
expect(getTooltipContent(row({ share: null }), options).rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('lists the group values only when the label is not one already', () => {
|
||||
expect(getTooltipContent(row(), options).mutedRows).toStrictEqual([]);
|
||||
expect(
|
||||
getTooltipContent(
|
||||
row({
|
||||
label: 'checkout · eu',
|
||||
labels: { 'service.name': 'checkout', region: 'eu' },
|
||||
}),
|
||||
options,
|
||||
).mutedRows,
|
||||
).toStrictEqual([
|
||||
{ key: 'service.name', label: 'service.name', value: 'checkout' },
|
||||
{ key: 'region', label: 'region', value: 'eu' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { getTopListDataWarning } from '../warnings';
|
||||
|
||||
const panelWith = (
|
||||
appearance: { showShare?: boolean } = {},
|
||||
): PanelOfKind<'signoz/TopListPanel'> =>
|
||||
({
|
||||
spec: { plugin: { spec: { appearance } } },
|
||||
}) as unknown as PanelOfKind<'signoz/TopListPanel'>;
|
||||
const panel = panelWith();
|
||||
|
||||
interface Column {
|
||||
name: string;
|
||||
columnType: 'group' | 'aggregation';
|
||||
aggregationIndex?: number;
|
||||
}
|
||||
|
||||
const group = (name: string): Column => ({ name, columnType: 'group' });
|
||||
const aggregation = (name: string, aggregationIndex = 0): Column => ({
|
||||
name,
|
||||
columnType: 'aggregation',
|
||||
aggregationIndex,
|
||||
});
|
||||
|
||||
function dataWith(
|
||||
columns: Column[],
|
||||
rows: unknown[][],
|
||||
legend = '',
|
||||
requestPayload?: Querybuildertypesv5QueryRangeRequestDTO,
|
||||
): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'scalar',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
columns: columns.map((column) => ({ ...column, queryName: 'A' })),
|
||||
data: rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
requestPayload,
|
||||
legendMap: legend ? { A: legend } : {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('getTopListDataWarning', () => {
|
||||
it('stays quiet for a grouped single-value result', () => {
|
||||
const data = dataWith(
|
||||
[group('service.name'), aggregation('__result')],
|
||||
[
|
||||
['checkout', 2],
|
||||
['auth', 1],
|
||||
],
|
||||
);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)).toBeNull();
|
||||
});
|
||||
|
||||
it('stays quiet when there are no rows', () => {
|
||||
expect(getTopListDataWarning(panel, dataWith([], []))).toBeNull();
|
||||
});
|
||||
|
||||
it('warns when the query has no group by', () => {
|
||||
const data = dataWith([aggregation('__result')], [[42]]);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'The query has no group by, so the list shows a single total. Add a group by to rank its groups.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the value columns the ranking ignores', () => {
|
||||
const data = dataWith(
|
||||
[
|
||||
group('host'),
|
||||
aggregation('p99'),
|
||||
aggregation('p50', 1),
|
||||
aggregation('p90', 2),
|
||||
],
|
||||
[['a', 1, 2, 3]],
|
||||
'',
|
||||
// ClickHouse value columns keep their SQL alias as their name.
|
||||
{
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{ type: 'clickhouse_sql', spec: { name: 'A', query: 'SELECT 1' } },
|
||||
],
|
||||
},
|
||||
} as unknown as Querybuildertypesv5QueryRangeRequestDTO,
|
||||
);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'The list ranks the first value column only; "p50", "p90" are not shown.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('warns when every row shares one label', () => {
|
||||
const data = dataWith(
|
||||
[group('service.name'), aggregation('__result')],
|
||||
[
|
||||
['checkout', 2],
|
||||
['auth', 1],
|
||||
],
|
||||
'requests',
|
||||
);
|
||||
|
||||
expect(getTopListDataWarning(panel, data)?.messages).toStrictEqual([
|
||||
'Every row has the same label. Use {{group-by key}} variables in the legend to tell rows apart.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
@use '../../../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
// Rows share these columns through subgrid; the shown ones are set inline. Vertical
|
||||
// padding is set inline too, where it also stands in for unrendered rows.
|
||||
.list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
column-gap: 10px;
|
||||
row-gap: 1px;
|
||||
margin: 0;
|
||||
padding-inline: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
// Zero-height row holding the widest value, so the shared columns keep
|
||||
// their size while rows scroll in and out of the rendered window. The negative
|
||||
// margin cancels the row gap after it.
|
||||
.sizer {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
height: 0;
|
||||
margin-bottom: -1px;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sizerValue {
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sizerMuted {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Measured for label truncation, so it carries the label's font.
|
||||
.sizerBar {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// Lets the pointer pass through to the rows underneath; otherwise the tooltip catches
|
||||
// it and stops following.
|
||||
// Sized to its content like the chart tooltips, up to TooltipPlugin's maxWidth/maxHeight;
|
||||
// the floor keeps a short one from collapsing narrower than a scatter tooltip.
|
||||
.tooltip {
|
||||
width: max-content;
|
||||
min-width: 320px;
|
||||
max-width: 450px;
|
||||
max-height: 600px;
|
||||
overflow: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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 { HEADER_HEIGHT, 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 {
|
||||
getGridColumns,
|
||||
getNavigationTarget,
|
||||
getRowHeight,
|
||||
getWidestShare,
|
||||
getWidestValue,
|
||||
type RowColors,
|
||||
} from '../../utils';
|
||||
import TopListHeader from '../TopListHeader/TopListHeader';
|
||||
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;
|
||||
/** Heads the label column: the group-by keys behind each label. */
|
||||
labelName?: string;
|
||||
unit?: string;
|
||||
precision?: PrecisionOption;
|
||||
thresholds: PanelThreshold[];
|
||||
/** Bar color where no threshold recolours it; unset uses the accent. */
|
||||
barColor?: string;
|
||||
showRank?: boolean;
|
||||
showShare?: boolean;
|
||||
onSelect?: (
|
||||
row: TopListRowData,
|
||||
colors: RowColors,
|
||||
coordinates: { x: number; y: number },
|
||||
) => void;
|
||||
}
|
||||
|
||||
function TopList({
|
||||
panelId,
|
||||
rows,
|
||||
valueName,
|
||||
labelName = '',
|
||||
unit,
|
||||
precision,
|
||||
thresholds,
|
||||
barColor,
|
||||
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 - HEADER_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`,
|
||||
'--header-height': `${HEADER_HEIGHT}px`,
|
||||
gridTemplateColumns: getGridColumns(showRank, 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}
|
||||
>
|
||||
<TopListHeader
|
||||
valueName={valueName}
|
||||
labelName={labelName}
|
||||
showRank={showRank}
|
||||
showShare={showShare}
|
||||
/>
|
||||
<li aria-hidden className={styles.sizer}>
|
||||
{showRank && (
|
||||
<span className={styles.sizerMuted}>{String(rows.length)}</span>
|
||||
)}
|
||||
<span ref={sizerBarRef} className={styles.sizerBar} />
|
||||
{showShare && <span className={styles.sizerMuted}>{widestShare}</span>}
|
||||
<span className={styles.sizerValue}>{widestValue}</span>
|
||||
</li>
|
||||
{virtualItems.map(({ index }) => (
|
||||
<TopListRow
|
||||
key={rows[index].key}
|
||||
row={rows[index]}
|
||||
index={index}
|
||||
unit={unit}
|
||||
precision={precision}
|
||||
thresholds={thresholds}
|
||||
barColor={barColor}
|
||||
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}
|
||||
barColor={barColor}
|
||||
showShare={showShare}
|
||||
footer={
|
||||
onSelect && (
|
||||
<TooltipFooter
|
||||
id={panelId}
|
||||
isPinned={false}
|
||||
canPin={false}
|
||||
dismiss={tooltip.hide}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tooltip.TooltipInPortal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopList;
|
||||
@@ -0,0 +1,17 @@
|
||||
import styles from './TopListHeader.module.scss';
|
||||
|
||||
interface HeaderTooltipProps {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function HeaderTooltip({ name, description }: HeaderTooltipProps): JSX.Element {
|
||||
return (
|
||||
<div data-testid="top-list-header-tooltip" className={styles.tooltip}>
|
||||
<span className={styles.tooltipName}>{name}</span>
|
||||
<span className={styles.tooltipDescription}>{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeaderTooltip;
|
||||
@@ -0,0 +1,55 @@
|
||||
// Stays on top while rows scroll under it, so it needs the panel's surface.
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
align-items: center;
|
||||
height: var(--header-height);
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Capped so a long value name doesn't widen its column past the values.
|
||||
.cell {
|
||||
justify-self: end;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// Lines up with the labels inside the bars.
|
||||
.label {
|
||||
justify-self: start;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.tooltipName {
|
||||
font-weight: var(--font-weight-medium);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tooltipDescription {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
|
||||
import HeaderTooltip from './HeaderTooltip';
|
||||
|
||||
import styles from './TopListHeader.module.scss';
|
||||
|
||||
interface TopListHeaderProps {
|
||||
valueName: string;
|
||||
/** The group-by keys behind each label; empty when the query has no group-by. */
|
||||
labelName: string;
|
||||
showRank: boolean;
|
||||
showShare: boolean;
|
||||
}
|
||||
|
||||
function TopListHeader({
|
||||
valueName,
|
||||
labelName,
|
||||
showRank,
|
||||
showShare,
|
||||
}: TopListHeaderProps): JSX.Element {
|
||||
return (
|
||||
<li aria-hidden data-testid="top-list-header" className={styles.header}>
|
||||
{showRank && (
|
||||
<Tooltip
|
||||
title={<HeaderTooltip name="Rank" description="Position in the list." />}
|
||||
>
|
||||
<span className={styles.cell}>#</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{labelName ? (
|
||||
<Tooltip
|
||||
title={
|
||||
<HeaderTooltip
|
||||
name={labelName}
|
||||
description="The group each row stands for."
|
||||
/>
|
||||
}
|
||||
align="start"
|
||||
>
|
||||
<span className={styles.label}>{labelName}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{showShare && (
|
||||
<Tooltip
|
||||
title={
|
||||
<HeaderTooltip
|
||||
name="Share"
|
||||
description="Each row's value as a percentage of the listed rows' total."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className={styles.cell}>Share</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
title={
|
||||
<HeaderTooltip
|
||||
name={valueName}
|
||||
description="The value each row is ranked by."
|
||||
/>
|
||||
}
|
||||
align="end"
|
||||
>
|
||||
<span className={styles.cell}>{valueName}</span>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopListHeader;
|
||||
@@ -0,0 +1,116 @@
|
||||
.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.
|
||||
// The track sits a level above the panel surface (--l2-background).
|
||||
.bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: calc(var(--row-height) - 4px);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
transition: background-color 120ms;
|
||||
|
||||
.row:hover & {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 35%, transparent);
|
||||
|
||||
.row:hover & {
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 48%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.hasFillColor {
|
||||
background: color-mix(in srgb, var(--fill-color) 35%, transparent);
|
||||
|
||||
.row:hover & {
|
||||
background: color-mix(in srgb, var(--fill-color) 48%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.row:hover & {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.isEmptyLabel {
|
||||
font-style: italic;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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[];
|
||||
/** Bar color where no threshold recolours it; unset uses the accent. */
|
||||
barColor?: string;
|
||||
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,
|
||||
barColor,
|
||||
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, barColor),
|
||||
[row.value, thresholds, unit, barColor],
|
||||
);
|
||||
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 className={styles.bar}>
|
||||
<span
|
||||
data-testid="top-list-row-fill"
|
||||
className={cx(styles.fill, {
|
||||
[styles.hasFillColor]: !!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>
|
||||
{displayShare && (
|
||||
<span data-testid="top-list-row-share" className={styles.muted}>
|
||||
{displayShare}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
data-testid="top-list-row-value"
|
||||
className={cx(styles.value, {
|
||||
[styles.isNonNumeric]: row.value === null,
|
||||
})}
|
||||
style={{ color: colors.valueColor }}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TopListRow);
|
||||
@@ -0,0 +1,51 @@
|
||||
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[];
|
||||
/** Bar color where no threshold recolours it; unset uses the accent. */
|
||||
barColor?: string;
|
||||
showShare: boolean;
|
||||
/** Hints for what a click does; none when rows aren't clickable. */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
function TopListTooltip({
|
||||
row,
|
||||
valueName,
|
||||
unit,
|
||||
precision,
|
||||
thresholds,
|
||||
barColor,
|
||||
showShare,
|
||||
footer,
|
||||
}: TopListTooltipProps): JSX.Element {
|
||||
const { rows, mutedRows } = useMemo(
|
||||
() => getTooltipContent(row, { valueName, unit, precision, showShare }),
|
||||
[row, valueName, unit, precision, showShare],
|
||||
);
|
||||
const colors = resolveRowColors(row.value, thresholds, unit, barColor);
|
||||
|
||||
return (
|
||||
<TooltipCard
|
||||
title={row.label}
|
||||
color={colors.barColor ?? ACCENT_COLOR}
|
||||
rows={rows}
|
||||
mutedRows={mutedRows}
|
||||
footer={footer}
|
||||
testId="top-list-tooltip"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TopListTooltip;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
/** 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;
|
||||
/** The sticky column header row. */
|
||||
export const HEADER_HEIGHT = 24;
|
||||
/** Matches the server's default for an unset `appearance.barColor`. */
|
||||
export const DEFAULT_BAR_COLOR = Color.BG_ROBIN_500;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../types/rendererProps';
|
||||
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
|
||||
|
||||
import { prepareTopListData } from './prepareData';
|
||||
import { formatRowValue, formatShare } from './utils';
|
||||
|
||||
const RANK_HEADER = 'Rank';
|
||||
const LABEL_HEADER = 'Label';
|
||||
const VALUE_HEADER = 'Value';
|
||||
const SHARE_HEADER = 'Share';
|
||||
|
||||
/** The list as shown: rows in ranked order with their rank, label, formatted value and share. */
|
||||
export function getTopListCsvRows(
|
||||
panel: PanelOfKind<'signoz/TopListPanel'>,
|
||||
data: PanelQueryData,
|
||||
): Record<string, string>[] {
|
||||
const { formatting, appearance } = panel.spec.plugin.spec;
|
||||
const { rows, labelColumnNames, valueColumnName } = prepareTopListData(data);
|
||||
const labelHeader = labelColumnNames.join(' · ') || LABEL_HEADER;
|
||||
const valueHeader = valueColumnName || VALUE_HEADER;
|
||||
const precision = resolveDecimalPrecision(formatting?.decimalPrecision);
|
||||
|
||||
return rows.map((row, index) => ({
|
||||
...(appearance?.showRank && { [RANK_HEADER]: String(index + 1) }),
|
||||
[labelHeader]: row.label,
|
||||
[valueHeader]: formatRowValue(row, formatting?.unit, precision),
|
||||
...(appearance?.showShare && {
|
||||
[SHARE_HEADER]: formatShare(row.share),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ChartBarDecreasing } from '@signozhq/icons';
|
||||
|
||||
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
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 { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TopListPanel'> = {
|
||||
kind: 'signoz/TopListPanel',
|
||||
displayName: 'Top List',
|
||||
mode: 'query',
|
||||
icon: ChartBarDecreasing,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
},
|
||||
queryBuilderFields: {
|
||||
// A list without a group by is a single row.
|
||||
[QueryBuilderField.GroupBy]: { state: 'pinned' },
|
||||
// Picks top or bottom N on the server; a client-side flip would show the top N reversed.
|
||||
[QueryBuilderField.OrderBy]: {
|
||||
state: 'pinned',
|
||||
placeholder: 'Default highest first',
|
||||
},
|
||||
[QueryBuilderField.Limit]: {
|
||||
state: 'pinned',
|
||||
placeholder: `Default ${DEFAULT_LIMIT} for Top List`,
|
||||
},
|
||||
// Ignored for logs and traces scalars; for metrics they apply before reduceTo.
|
||||
[QueryBuilderField.Functions]: { state: 'hidden' },
|
||||
},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
defaultRowLimit: DEFAULT_LIMIT,
|
||||
},
|
||||
// Formulas join their inputs on group values, so every query groups by the same keys.
|
||||
syncedQueryBuilderFields: [QueryBuilderField.GroupBy],
|
||||
getDataWarning: getTopListDataWarning,
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: true, png: true, svg: true },
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type RefObject, useCallback, useMemo } from 'react';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
|
||||
import { truncateMiddle } from '../utils';
|
||||
|
||||
/** The label's horizontal padding inside the bar, plus a pixel for canvas/DOM rounding. */
|
||||
const LABEL_INSET_X = 17;
|
||||
const RESIZE_DEBOUNCE_MS = 100;
|
||||
|
||||
let canvasContext: CanvasRenderingContext2D | null | undefined;
|
||||
|
||||
function getCanvasContext(): CanvasRenderingContext2D | null {
|
||||
if (canvasContext === undefined) {
|
||||
canvasContext = document.createElement('canvas').getContext('2d');
|
||||
}
|
||||
return canvasContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middle-truncates labels to the bar column, measured on `barRef`, which must carry the
|
||||
* label's font. Labels pass through unchanged until the column has a width.
|
||||
*/
|
||||
export function useLabelFitter(
|
||||
barRef: RefObject<HTMLElement>,
|
||||
): (label: string) => string {
|
||||
const { width } = useResizeObserver(barRef, RESIZE_DEBOUNCE_MS);
|
||||
const maxWidth = width - LABEL_INSET_X;
|
||||
|
||||
const font = useMemo(() => {
|
||||
const node = barRef.current;
|
||||
if (!node || maxWidth <= 0) {
|
||||
return null;
|
||||
}
|
||||
const { fontWeight, fontSize, fontFamily } = getComputedStyle(node);
|
||||
return `${fontWeight} ${fontSize} ${fontFamily}`;
|
||||
}, [barRef, maxWidth]);
|
||||
|
||||
return useCallback(
|
||||
(label: string): string => {
|
||||
const context = font ? getCanvasContext() : null;
|
||||
if (!context || !font) {
|
||||
return label;
|
||||
}
|
||||
context.font = font;
|
||||
return truncateMiddle(
|
||||
label,
|
||||
maxWidth,
|
||||
(value) => context.measureText(value).width,
|
||||
);
|
||||
},
|
||||
[font, maxWidth],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
type FC,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||
import type { TooltipInPortalProps } from '@visx/tooltip/lib/hooks/useTooltipInPortal';
|
||||
|
||||
/** Above the dashboard grid, like the chart tooltips. */
|
||||
const TOOLTIP_Z_INDEX = 1000;
|
||||
|
||||
interface TopListTooltip {
|
||||
/** The hovered row, or null when the tooltip is hidden. */
|
||||
rowIndex: number | null;
|
||||
top?: number;
|
||||
left?: number;
|
||||
show: (index: number, event: ReactMouseEvent<HTMLElement>) => void;
|
||||
hide: () => void;
|
||||
/** Hides the tooltip when the pointer is over the list but not on a row. */
|
||||
hideOffRow: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||
/** The element the tooltip is positioned against. */
|
||||
listRef: (element: HTMLElement | null) => void;
|
||||
TooltipInPortal: FC<TooltipInPortalProps>;
|
||||
}
|
||||
|
||||
/** One tooltip for the whole list, following the pointer the way the chart tooltips do. */
|
||||
export function useTopListTooltip(): TopListTooltip {
|
||||
const {
|
||||
tooltipOpen,
|
||||
tooltipData,
|
||||
tooltipTop,
|
||||
tooltipLeft,
|
||||
showTooltip,
|
||||
hideTooltip,
|
||||
} = useTooltip<number>();
|
||||
const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal({
|
||||
scroll: true,
|
||||
detectBounds: true,
|
||||
zIndex: TOOLTIP_Z_INDEX,
|
||||
});
|
||||
|
||||
// Read at event time so `show` stays stable for the memoised rows.
|
||||
const boundsRef = useRef(containerBounds);
|
||||
boundsRef.current = containerBounds;
|
||||
|
||||
const show = useCallback(
|
||||
(index: number, event: ReactMouseEvent<HTMLElement>): void => {
|
||||
const { left, top } = boundsRef.current;
|
||||
showTooltip({
|
||||
tooltipData: index,
|
||||
tooltipLeft: event.clientX - left,
|
||||
tooltipTop: event.clientY - top,
|
||||
});
|
||||
},
|
||||
[showTooltip],
|
||||
);
|
||||
|
||||
const hideOffRow = useCallback(
|
||||
(event: ReactMouseEvent<HTMLElement>): void => {
|
||||
if (!(event.target as Element).closest('[data-row-index]')) {
|
||||
hideTooltip();
|
||||
}
|
||||
},
|
||||
[hideTooltip],
|
||||
);
|
||||
|
||||
return {
|
||||
rowIndex: tooltipOpen ? (tooltipData ?? null) : null,
|
||||
top: tooltipTop,
|
||||
left: tooltipLeft,
|
||||
show,
|
||||
hide: hideTooltip,
|
||||
hideOffRow,
|
||||
listRef: containerRef,
|
||||
TooltipInPortal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { type RefObject, useCallback, useEffect, useRef } from 'react';
|
||||
import { useVirtualizer, type VirtualItem } from '@tanstack/react-virtual';
|
||||
|
||||
import { HEADER_HEIGHT, 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,
|
||||
// Keeps a row scrolled to from landing under the sticky header.
|
||||
scrollPaddingStart: HEADER_HEIGHT,
|
||||
overscan: OVERSCAN,
|
||||
initialRect: INITIAL_RECT,
|
||||
});
|
||||
const focusFrame = useRef<number>();
|
||||
|
||||
// Every row takes the density's height, so a density change re-lays out every row.
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [virtualizer, rowHeight]);
|
||||
|
||||
useEffect(() => (): void => cancelAnimationFrame(focusFrame.current ?? 0), []);
|
||||
|
||||
const focusRow = useCallback(
|
||||
(index: number): void => {
|
||||
cancelAnimationFrame(focusFrame.current ?? 0);
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' });
|
||||
let attempts = 0;
|
||||
const tryFocus = (): void => {
|
||||
const row = containerRef.current?.querySelector<HTMLElement>(
|
||||
`[data-row-index="${index}"]`,
|
||||
);
|
||||
if (row) {
|
||||
// The virtualizer already scrolled; a native focus scroll would fight it.
|
||||
row.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (attempts < FOCUS_RETRY_FRAMES) {
|
||||
attempts += 1;
|
||||
focusFrame.current = requestAnimationFrame(tryFocus);
|
||||
}
|
||||
};
|
||||
tryFocus();
|
||||
},
|
||||
[virtualizer, containerRef],
|
||||
);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const first = virtualItems[0];
|
||||
const last = virtualItems[virtualItems.length - 1];
|
||||
return {
|
||||
virtualItems,
|
||||
paddingTop: first ? first.start : 0,
|
||||
paddingBottom: last ? virtualizer.getTotalSize() - last.end : 0,
|
||||
focusRow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelTable,
|
||||
PanelTableColumn,
|
||||
PanelTableRow,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import type { TopListData, TopListRow } from './types';
|
||||
|
||||
export const EMPTY_LABEL = '(empty)';
|
||||
|
||||
const LABEL_SEPARATOR = ' · ';
|
||||
const LEGEND_VARIABLE = /\{\{\s*([^}]+?)\s*\}\}/g;
|
||||
|
||||
const EMPTY_DATA: TopListData = {
|
||||
rows: [],
|
||||
labelColumnNames: [],
|
||||
valueColumnName: '',
|
||||
valueName: '',
|
||||
ignoredValueColumns: [],
|
||||
ignoredResults: [],
|
||||
orderedByGroupKey: null,
|
||||
};
|
||||
|
||||
/** A missing group value is `null` for traces/logs and `""` for metrics. */
|
||||
function isMissing(value: unknown): boolean {
|
||||
return value == null || value === '';
|
||||
}
|
||||
|
||||
/** Finite numbers only: the server sends NaN/±Inf as "NaN"/"Inf"/"-Inf" and a missing value as "n/a". */
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toGroupValue(value: unknown): string {
|
||||
return typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value as string | number | boolean);
|
||||
}
|
||||
|
||||
function getCell(row: PanelTableRow, column: PanelTableColumn): unknown {
|
||||
return row.data[column.id || column.name];
|
||||
}
|
||||
|
||||
function resolveLabel(
|
||||
labelColumns: PanelTableColumn[],
|
||||
row: PanelTableRow,
|
||||
labels: Record<string, string>,
|
||||
table: PanelTable,
|
||||
): Pick<TopListRow, 'label' | 'isEmptyLabel'> {
|
||||
if (table.legend) {
|
||||
// Unlike `getLabelName`, a key the row doesn't carry renders empty, not "undefined".
|
||||
const label = table.legend.replace(
|
||||
LEGEND_VARIABLE,
|
||||
(_, key: string) => labels[key] ?? '',
|
||||
);
|
||||
return { label, isEmptyLabel: false };
|
||||
}
|
||||
if (labelColumns.length === 0) {
|
||||
return { label: table.queryName, isEmptyLabel: false };
|
||||
}
|
||||
|
||||
const parts = labelColumns.map((column) => getCell(row, column));
|
||||
if (parts.every(isMissing)) {
|
||||
return { label: EMPTY_LABEL, isEmptyLabel: true };
|
||||
}
|
||||
return {
|
||||
label: parts
|
||||
.map((part) => (isMissing(part) ? EMPTY_LABEL : toGroupValue(part)))
|
||||
.join(LABEL_SEPARATOR),
|
||||
isEmptyLabel: false,
|
||||
};
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
/** By value in the query's direction; non-numeric values last; ties by label so the order is stable across refreshes. */
|
||||
function compareRows(direction: SortDirection) {
|
||||
return (a: TopListRow, b: TopListRow): number => {
|
||||
if (a.value === null || b.value === null) {
|
||||
if (a.value === b.value) {
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
return a.value === null ? 1 : -1;
|
||||
}
|
||||
const byValue = direction === 'asc' ? a.value - b.value : b.value - a.value;
|
||||
return byValue || a.label.localeCompare(b.label);
|
||||
};
|
||||
}
|
||||
|
||||
interface NamedQuerySpec {
|
||||
name?: string;
|
||||
/** Formulas. */
|
||||
expression?: string;
|
||||
aggregations?: {
|
||||
alias?: string;
|
||||
expression?: string;
|
||||
metricName?: string;
|
||||
spaceAggregation?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
function findQuerySpec(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
queryName: string,
|
||||
): NamedQuerySpec | undefined {
|
||||
return (requestPayload?.compositeQuery?.queries ?? [])
|
||||
.map((envelope) => envelope.spec as NamedQuerySpec | undefined)
|
||||
.find((candidate) => candidate?.name === queryName);
|
||||
}
|
||||
|
||||
const AGGREGATION_FUNCTION = /^\s*([a-z_]\w*)\s*\(/i;
|
||||
|
||||
/**
|
||||
* The ranked aggregation's function, lowercased: `p99` for `p99(duration_nano)`, the space
|
||||
* aggregation for a metric. Null for formulas and ClickHouse SQL, which have none to read.
|
||||
*/
|
||||
export function getRankedAggregationFunction(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
queryName: string,
|
||||
): string | null {
|
||||
const [aggregation] =
|
||||
findQuerySpec(requestPayload, queryName)?.aggregations ?? [];
|
||||
const name =
|
||||
aggregation?.expression?.match(AGGREGATION_FUNCTION)?.[1] ??
|
||||
aggregation?.spaceAggregation;
|
||||
return name ? name.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/** The ranked aggregation as the query writes it: its alias, expression or formula. */
|
||||
function describeValue(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
queryName: string,
|
||||
): string | null {
|
||||
const spec = findQuerySpec(requestPayload, queryName);
|
||||
const [aggregation] = spec?.aggregations ?? [];
|
||||
if (aggregation?.alias || aggregation?.expression) {
|
||||
return aggregation.alias || aggregation.expression || null;
|
||||
}
|
||||
if (aggregation?.metricName) {
|
||||
return aggregation.spaceAggregation
|
||||
? `${aggregation.spaceAggregation}(${aggregation.metricName})`
|
||||
: aggregation.metricName;
|
||||
}
|
||||
return spec?.expression || null;
|
||||
}
|
||||
|
||||
interface OrderedQuerySpec {
|
||||
name?: string;
|
||||
order?: { key?: { name?: string }; direction?: string }[];
|
||||
groupBy?: { name?: string }[];
|
||||
}
|
||||
|
||||
interface RankedOrder {
|
||||
direction: SortDirection;
|
||||
groupKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the ranked query orders its rows. The server orders by value, descending, when
|
||||
* no order is set; ordering by a group-by key returns the first N groups by that key.
|
||||
*/
|
||||
function getRankedOrder(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
queryName: string | undefined,
|
||||
): RankedOrder {
|
||||
const spec = (requestPayload?.compositeQuery?.queries ?? [])
|
||||
.map((envelope) => envelope.spec as OrderedQuerySpec | undefined)
|
||||
.find((candidate) => candidate?.name === queryName);
|
||||
const [order] = spec?.order ?? [];
|
||||
const keyName = order?.key?.name;
|
||||
if (!keyName) {
|
||||
return { direction: 'desc', groupKey: null };
|
||||
}
|
||||
if (spec?.groupBy?.some((key) => key.name === keyName)) {
|
||||
return { direction: 'desc', groupKey: keyName };
|
||||
}
|
||||
return {
|
||||
direction: order.direction === 'asc' ? 'asc' : 'desc',
|
||||
groupKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The request's queries and formulas, in builder order. */
|
||||
function getQueryOrder(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): string[] {
|
||||
return (requestPayload?.compositeQuery?.queries ?? [])
|
||||
.map((envelope) => envelope.spec?.name)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
/** Tables in builder order; the response lists them in no particular order. */
|
||||
function orderTables(tables: PanelTable[], queryOrder: string[]): PanelTable[] {
|
||||
const position = (table: PanelTable): number => {
|
||||
const index = queryOrder.indexOf(table.queryName);
|
||||
return index === -1 ? queryOrder.length : index;
|
||||
};
|
||||
return [...tables].sort((a, b) => position(a) - position(b));
|
||||
}
|
||||
|
||||
const hasValueColumn = (table: PanelTable): boolean =>
|
||||
table.columns.some((column) => column.isValueColumn);
|
||||
|
||||
/**
|
||||
* Ranks the first value column of the first table that has one. The server already
|
||||
* orders and limits, but its order isn't stable on ties, so the rows are sorted again here.
|
||||
* A query ordered by a group key still lists by value: its rows are the first N groups.
|
||||
*/
|
||||
export function prepareTopListRows(
|
||||
tables: PanelTable[],
|
||||
direction: SortDirection = 'desc',
|
||||
): TopListData {
|
||||
const [table, ...otherTables] = tables.filter(hasValueColumn);
|
||||
if (!table) {
|
||||
return EMPTY_DATA;
|
||||
}
|
||||
|
||||
const [valueColumn, ...ignoredColumns] = table.columns.filter(
|
||||
(column) => column.isValueColumn,
|
||||
);
|
||||
const labelColumns = table.columns.filter((column) => !column.isValueColumn);
|
||||
|
||||
const rows = table.rows.map((row, index): TopListRow => {
|
||||
const labels: Record<string, string> = {};
|
||||
labelColumns.forEach((column) => {
|
||||
const value = getCell(row, column);
|
||||
if (value != null) {
|
||||
labels[column.name] = toGroupValue(value);
|
||||
}
|
||||
});
|
||||
|
||||
const rawValue = getCell(row, valueColumn);
|
||||
return {
|
||||
key: `${table.queryName}-${index}`,
|
||||
...resolveLabel(labelColumns, row, labels, table),
|
||||
value: toFiniteNumber(rawValue),
|
||||
rawValue,
|
||||
ratio: 0,
|
||||
share: null,
|
||||
queryName: valueColumn.queryName,
|
||||
labels,
|
||||
};
|
||||
});
|
||||
|
||||
const values = rows
|
||||
.map((row) => row.value)
|
||||
.filter((value): value is number => value !== null);
|
||||
const max = Math.max(0, ...values);
|
||||
const total = values.some((value) => value < 0)
|
||||
? 0
|
||||
: values.reduce((sum, value) => sum + value, 0);
|
||||
rows.forEach((row) => {
|
||||
row.ratio =
|
||||
max > 0 && row.value !== null && row.value > 0 ? row.value / max : 0;
|
||||
row.share = total > 0 && row.value !== null ? row.value / total : null;
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rows.sort(compareRows(direction)),
|
||||
labelColumnNames: labelColumns.map((column) => column.name),
|
||||
valueColumnName: valueColumn.name,
|
||||
// A lone aggregation's column takes the legend, which labels rows here, not values.
|
||||
valueName:
|
||||
table.legend && valueColumn.name === table.legend
|
||||
? valueColumn.queryName
|
||||
: valueColumn.name,
|
||||
ignoredValueColumns: ignoredColumns.map((column) => column.name),
|
||||
ignoredResults: otherTables.map((other) => other.queryName),
|
||||
orderedByGroupKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ranks the first enabled query or formula, in builder order, in its order direction. */
|
||||
export function prepareTopListData(data: PanelQueryData): TopListData {
|
||||
const tables = prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
});
|
||||
const ordered = orderTables(tables, getQueryOrder(data.requestPayload));
|
||||
const { direction, groupKey } = getRankedOrder(
|
||||
data.requestPayload,
|
||||
ordered.find(hasValueColumn)?.queryName,
|
||||
);
|
||||
const prepared = prepareTopListRows(ordered, direction);
|
||||
const rankedQuery = prepared.rows[0]?.queryName;
|
||||
// A bare query name ("A") says nothing about the value; the query spec can.
|
||||
const valueName =
|
||||
rankedQuery && prepared.valueName === rankedQuery
|
||||
? (describeValue(data.requestPayload, rankedQuery) ?? rankedQuery)
|
||||
: prepared.valueName;
|
||||
return { ...prepared, valueName, orderedByGroupKey: groupKey };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
SectionKind,
|
||||
ThresholdVariant,
|
||||
type SectionConfig,
|
||||
} from '../../types/sections';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.Appearance,
|
||||
controls: { showRank: true, showShare: true, barColor: true },
|
||||
},
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{
|
||||
kind: SectionKind.Thresholds,
|
||||
controls: { variant: ThresholdVariant.COMPARISON },
|
||||
},
|
||||
{ kind: SectionKind.ContextLinks },
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface TopListRow {
|
||||
/** Unique per row; labels can repeat (e.g. a legend with no `{{…}}`). */
|
||||
key: string;
|
||||
label: string;
|
||||
/** True when every group value behind the label was missing. */
|
||||
isEmptyLabel: boolean;
|
||||
/** The ranked value, or null when the cell isn't a finite number ("NaN", "Inf", "n/a", null). */
|
||||
value: number | null;
|
||||
/** The cell as the server sent it, shown when `value` is null. */
|
||||
rawValue: unknown;
|
||||
/** Bar length: `value` over the largest positive value, 0–1. Zero for values ≤ 0 and null. */
|
||||
ratio: number;
|
||||
/** `value` over the sum of every row's value; null for null values and when any value is negative. */
|
||||
share: number | null;
|
||||
/** Source query, the drilldown target. */
|
||||
queryName: string;
|
||||
/** Group-by key → value, the drilldown filters. */
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TopListData {
|
||||
rows: TopListRow[];
|
||||
/** Group-by columns behind each label; empty when the query has no group-by. */
|
||||
labelColumnNames: string[];
|
||||
/** The ranked value column. */
|
||||
valueColumnName: string;
|
||||
/** What the ranked value measures. Unlike `valueColumnName`, never the legend template. */
|
||||
valueName: string;
|
||||
/** Value columns after the first, which the ranking ignores. */
|
||||
ignoredValueColumns: string[];
|
||||
/** Other enabled queries and formulas, which the ranking ignores. */
|
||||
ignoredResults: string[];
|
||||
/** The group-by key the ranked query orders by, when it orders by one instead of its value. */
|
||||
orderedByGroupKey: string | null;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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`).
|
||||
* Bars no threshold recolours take `barColor`.
|
||||
*/
|
||||
export function resolveRowColors(
|
||||
value: number | null,
|
||||
thresholds: PanelThreshold[],
|
||||
unit?: string,
|
||||
barColor?: string,
|
||||
): RowColors {
|
||||
const base = { barColor };
|
||||
if (value === null || thresholds.length === 0) {
|
||||
return base;
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(thresholds, value, unit);
|
||||
if (!threshold) {
|
||||
return base;
|
||||
}
|
||||
return threshold.format === 'background'
|
||||
? { barColor: threshold.color }
|
||||
: { ...base, 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];
|
||||
}
|
||||
|
||||
/** Grid columns in display order: rank, the bar with its label, share, then value. */
|
||||
export function getGridColumns(showRank: boolean, showShare: boolean): string {
|
||||
return [
|
||||
showRank && 'max-content',
|
||||
'minmax(0, 1fr)',
|
||||
showShare && 'max-content',
|
||||
'max-content',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelStatusDetail } from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelStatus/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../types/rendererProps';
|
||||
|
||||
import {
|
||||
getRankedAggregationFunction,
|
||||
prepareTopListData,
|
||||
} from './prepareData';
|
||||
import type { TopListData } from './types';
|
||||
|
||||
/** Aggregations whose group values don't add up to a meaningful total. */
|
||||
const NON_ADDITIVE_FUNCTION = /^(avg|min|max|median|p\d+|quantile)/;
|
||||
|
||||
const quoteAll = (names: string[]): string =>
|
||||
names.map((name) => `"${name}"`).join(', ');
|
||||
const verbFor = (names: string[]): string =>
|
||||
names.length === 1 ? 'is' : 'are';
|
||||
|
||||
/**
|
||||
* Builder queries with a limit that an enabled formula reads. Each input is cut to its
|
||||
* own top N before the formula joins them, so the formula only sees groups that made
|
||||
* every input's cut.
|
||||
*/
|
||||
function getLimitedFormulaInputs(
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): string[] {
|
||||
const envelopes = requestPayload?.compositeQuery?.queries ?? [];
|
||||
const expressions = envelopes
|
||||
.filter((envelope) => envelope.type === 'builder_formula')
|
||||
.map(
|
||||
(envelope) => envelope.spec as { expression?: string; disabled?: boolean },
|
||||
)
|
||||
.filter((formula) => !formula.disabled && formula.expression)
|
||||
.map((formula) => formula.expression as string);
|
||||
if (expressions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return envelopes
|
||||
.filter((envelope) => envelope.type === 'builder_query')
|
||||
.map((envelope) => envelope.spec as { name?: string; limit?: number })
|
||||
.filter(
|
||||
(query): query is { name: string; limit: number } =>
|
||||
Boolean(query.name) && (query.limit ?? 0) > 0,
|
||||
)
|
||||
.filter((query) =>
|
||||
expressions.some((expression) =>
|
||||
new RegExp(`\\b${query.name}\\b`).test(expression),
|
||||
),
|
||||
)
|
||||
.map((query) => query.name);
|
||||
}
|
||||
|
||||
function collectIssues(
|
||||
{
|
||||
rows,
|
||||
labelColumnNames,
|
||||
ignoredValueColumns,
|
||||
ignoredResults,
|
||||
orderedByGroupKey,
|
||||
}: TopListData,
|
||||
limitedFormulaInputs: string[],
|
||||
): string[] {
|
||||
const issues: string[] = [];
|
||||
if (ignoredResults.length > 0) {
|
||||
issues.push(
|
||||
`The list ranks "${rows[0].queryName}" only; ${quoteAll(ignoredResults)} ${verbFor(ignoredResults)} not shown. Disable the queries you don't want to rank.`,
|
||||
);
|
||||
}
|
||||
if (limitedFormulaInputs.length > 0) {
|
||||
issues.push(
|
||||
`${quoteAll(limitedFormulaInputs)} ${verbFor(limitedFormulaInputs)} limited before the formula combines them, so the formula only sees groups in every input's top N. Set the limit on the formula instead.`,
|
||||
);
|
||||
}
|
||||
if (orderedByGroupKey) {
|
||||
issues.push(
|
||||
`The query orders by "${orderedByGroupKey}", so the list shows the first groups by that key rather than the top values. Order by the value instead.`,
|
||||
);
|
||||
}
|
||||
if (labelColumnNames.length === 0) {
|
||||
issues.push(
|
||||
'The query has no group by, so the list shows a single total. Add a group by to rank its groups.',
|
||||
);
|
||||
}
|
||||
if (ignoredValueColumns.length > 0) {
|
||||
issues.push(
|
||||
`The list ranks the first value column only; ${quoteAll(ignoredValueColumns)} ${verbFor(ignoredValueColumns)} not shown.`,
|
||||
);
|
||||
}
|
||||
if (rows.length > 1 && rows.every((row) => row.label === rows[0].label)) {
|
||||
issues.push(
|
||||
'Every row has the same label. Use {{group-by key}} variables in the legend to tell rows apart.',
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Share of total sums the rows, which says nothing for averages, percentiles, min or max. */
|
||||
function getShareIssue(
|
||||
{ rows, valueName }: TopListData,
|
||||
requestPayload: Querybuildertypesv5QueryRangeRequestDTO | undefined,
|
||||
): string | null {
|
||||
const aggregation = getRankedAggregationFunction(
|
||||
requestPayload,
|
||||
rows[0].queryName,
|
||||
);
|
||||
if (!aggregation || !NON_ADDITIVE_FUNCTION.test(aggregation)) {
|
||||
return null;
|
||||
}
|
||||
return `Share of total adds up "${valueName}" across rows, which isn't meaningful for averages, percentiles, minimums or maximums. Turn it off in Appearance, or rank a count or sum.`;
|
||||
}
|
||||
|
||||
export function getTopListDataWarning(
|
||||
panel: PanelOfKind<'signoz/TopListPanel'>,
|
||||
data: PanelQueryData,
|
||||
): PanelStatusDetail | null {
|
||||
const topList = prepareTopListData(data);
|
||||
if (topList.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shareIssue = panel.spec.plugin.spec.appearance?.showShare
|
||||
? getShareIssue(topList, data.requestPayload)
|
||||
: null;
|
||||
const issues = [
|
||||
...collectIssues(topList, getLimitedFormulaInputs(data.requestPayload)),
|
||||
...(shareIssue ? [shareIssue] : []),
|
||||
];
|
||||
if (issues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
message: 'This list may not rank what you expect.',
|
||||
messages: issues,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { definition as NumberValue } from './kinds/NumberPanel/definition';
|
||||
import { definition as PieChart } from './kinds/PieChartPanel/definition';
|
||||
import { definition as ScatterPlot } from './kinds/ScatterPlotPanel/definition';
|
||||
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
|
||||
import { definition as TopList } from './kinds/TopListPanel/definition';
|
||||
import { definition as Table } from './kinds/TablePanel/definition';
|
||||
import { definition as List } from './kinds/ListPanel/definition';
|
||||
import { definition as Text } from './kinds/TextPanel/definition';
|
||||
@@ -27,6 +28,7 @@ export const PANELS: PanelRegistry = {
|
||||
[AreaChart.kind]: AreaChart,
|
||||
[PieChart.kind]: PieChart,
|
||||
[ScatterPlot.kind]: ScatterPlot,
|
||||
[TopList.kind]: TopList,
|
||||
[Histogram.kind]: Histogram,
|
||||
[Heatmap.kind]: Heatmap,
|
||||
[List.kind]: List,
|
||||
|
||||
@@ -37,6 +37,7 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/HeatmapPanel': { onDragSelect?: DragSelect };
|
||||
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/TopListPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/ScatterPlotPanel': {
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
|
||||
@@ -25,6 +25,7 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
|
||||
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
|
||||
'signoz/ListPanel': PANEL_TYPES.LIST,
|
||||
'signoz/HeatmapPanel': PANEL_TYPES.HEATMAP,
|
||||
'signoz/TopListPanel': PANEL_TYPES.TOP_LIST,
|
||||
'signoz/TextPanel': PANEL_TYPES.TEXT,
|
||||
'signoz/ScatterPlotPanel': PANEL_TYPES.SCATTER,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
DashboardtypesTextPresentationDTO,
|
||||
DashboardtypesThresholdWithLabelDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
DashboardtypesTopListAppearanceDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { LegendSeriesResolver } from '../utils/legendSeries';
|
||||
@@ -72,6 +73,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',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +151,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,6 +211,11 @@ export interface SectionControls {
|
||||
// Editor discriminator (not a spec field): which threshold variant a kind edits.
|
||||
// axis → a `label` threshold picks the axis it marks (Scatter Plot).
|
||||
[SectionKind.Thresholds]: { variant?: ThresholdVariant; axis?: boolean };
|
||||
[SectionKind.Appearance]: {
|
||||
showRank?: boolean;
|
||||
showShare?: boolean;
|
||||
barColor?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ControlledSectionKind = keyof SectionControls;
|
||||
@@ -255,6 +264,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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DEFAULT_BAR_COLOR } from '../../kinds/TopListPanel/constants';
|
||||
import { defaultColumnsForSignal } from '../../../PanelEditor/ListColumnsEditor/selectFields';
|
||||
import { sections as areaSections } from '../../kinds/AreaChartPanel/sections';
|
||||
import { sections as listSections } from '../../kinds/ListPanel/sections';
|
||||
@@ -326,6 +327,33 @@ describe('buildPluginSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('appearance seed', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Appearance,
|
||||
controls: { showRank: true, showShare: true, barColor: true },
|
||||
},
|
||||
];
|
||||
|
||||
it('carries the toggles and bar color across a switch between kinds that declare them', () => {
|
||||
const oldSpec = oldSpecWith({
|
||||
appearance: { showRank: true, showShare: false, barColor: '#00C853' },
|
||||
});
|
||||
|
||||
expect(buildPluginSpec(sections, { oldSpec }).appearance).toStrictEqual({
|
||||
barColor: '#00C853',
|
||||
showRank: true,
|
||||
showShare: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds only the default bar color for a new panel, where unset toggles read as off', () => {
|
||||
expect(buildPluginSpec(sections)).toStrictEqual({
|
||||
appearance: { barColor: DEFAULT_BAR_COLOR },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartAppearance seed', () => {
|
||||
it('seeds only the declared defaulting controls', () => {
|
||||
const sections: SectionConfig[] = [
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { DEFAULT_BAR_COLOR } from '../kinds/TopListPanel/constants';
|
||||
import { defaultColumnsForSignal } from '../../PanelEditor/ListColumnsEditor/selectFields';
|
||||
import {
|
||||
type AnyThreshold,
|
||||
@@ -37,6 +38,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'
|
||||
@@ -166,6 +168,23 @@ 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 {
|
||||
barColor: (controls.barColor && old?.barColor) || DEFAULT_BAR_COLOR,
|
||||
...(controls.showRank &&
|
||||
old?.showRank !== undefined && { showRank: old.showRank }),
|
||||
...(controls.showShare &&
|
||||
old?.showShare !== undefined && { showShare: old.showShare }),
|
||||
};
|
||||
},
|
||||
},
|
||||
[SectionKind.TextLayout]: {
|
||||
specKey: 'presentation',
|
||||
// Explicit alignment defaults (not the API's implicit ones) so the controls
|
||||
|
||||
@@ -45,6 +45,11 @@ const PANEL_TYPE_META: Record<PanelKind, PanelTypeMeta> = {
|
||||
description: 'Two values per group, plotted against each other',
|
||||
isNew: true,
|
||||
},
|
||||
'signoz/TopListPanel': {
|
||||
group: 'compare',
|
||||
description: 'Groups ranked by a single value',
|
||||
isNew: true,
|
||||
},
|
||||
'signoz/HistogramPanel': {
|
||||
group: 'distributions',
|
||||
description: 'Distribution of values into buckets',
|
||||
|
||||
@@ -10,6 +10,8 @@ const AREA_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.22 };
|
||||
const RING_TRACK = { stroke: 'var(--l3-background)' };
|
||||
const RING_PRIMARY = { stroke: 'var(--bg-robin-500)' };
|
||||
const RING_SECONDARY = { stroke: 'var(--bg-robin-400)', strokeOpacity: 0.55 };
|
||||
const RING_TRACK_FILL = { fill: 'var(--l3-background)' };
|
||||
const BAR_FILL = { fill: 'var(--bg-robin-500)' };
|
||||
const DOT_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.55 };
|
||||
|
||||
/** `[cx, cy, r]`: a loose up-and-right cloud with one large outlier. */
|
||||
@@ -55,6 +57,8 @@ const HEATMAP_CELLS = [
|
||||
[0.4, 0.85, 0.7, 0.2],
|
||||
[0.3, 0.65, 0.9, 0.45],
|
||||
];
|
||||
// Bar lengths (% of the track) for the top-list sketch, longest first.
|
||||
const TOP_LIST_BARS = [100, 76, 54, 34];
|
||||
const TEXT_ROWS = [45, 100, 92, 64].map((width, i) => [
|
||||
{ ...(i === 0 ? HEAD : CELL), width },
|
||||
]);
|
||||
@@ -88,6 +92,30 @@ export const PANEL_TYPE_PREVIEWS: Record<PanelKind, JSX.Element> = {
|
||||
'signoz/BarChartPanel': (
|
||||
<PreviewBars heights={[80, 58, 44, 30, 18]} fade className={styles.bars} />
|
||||
),
|
||||
'signoz/TopListPanel': (
|
||||
<svg viewBox="0 0 120 44" preserveAspectRatio="none" className={styles.svg}>
|
||||
{TOP_LIST_BARS.map((width, row) => (
|
||||
<g key={width}>
|
||||
<rect
|
||||
x={0}
|
||||
y={row * 11 + 2}
|
||||
width={120}
|
||||
height={7}
|
||||
rx={1.5}
|
||||
style={RING_TRACK_FILL}
|
||||
/>
|
||||
<rect
|
||||
x={0}
|
||||
y={row * 11 + 2}
|
||||
width={(width / 100) * 120}
|
||||
height={7}
|
||||
rx={1.5}
|
||||
style={BAR_FILL}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
),
|
||||
'signoz/HistogramPanel': (
|
||||
<PreviewBars
|
||||
heights={[14, 26, 52, 88, 100, 70, 40, 20, 10]}
|
||||
|
||||
@@ -3,6 +3,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { getTableCsvRows } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TablePanel/tableCsv';
|
||||
import { getTopListCsvRows } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TopListPanel/csv';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { downloadCsv } from 'pages/DashboardPage/DashboardContainer/Panels/utils/downloadCsv';
|
||||
@@ -15,10 +16,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;
|
||||
}
|
||||
@@ -38,10 +38,13 @@ export function useDownloadPanelCsv({
|
||||
if (!canDownloadCsv) {
|
||||
return;
|
||||
}
|
||||
const rows = getTableCsvRows(
|
||||
panel as PanelOfKind<'signoz/TablePanel' | 'signoz/ScatterPlotPanel'>,
|
||||
data,
|
||||
);
|
||||
const rows =
|
||||
panel.spec.plugin.kind === 'signoz/TopListPanel'
|
||||
? getTopListCsvRows(panel as PanelOfKind<'signoz/TopListPanel'>, data)
|
||||
: getTableCsvRows(
|
||||
panel as PanelOfKind<'signoz/TablePanel' | 'signoz/ScatterPlotPanel'>,
|
||||
data,
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export function panelTypeToRequestType(
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
case PANEL_TYPES.SCATTER:
|
||||
case PANEL_TYPES.TOP_LIST:
|
||||
return Querybuildertypesv5RequestTypeDTO.scalar;
|
||||
case PANEL_TYPES.LIST:
|
||||
return Querybuildertypesv5RequestTypeDTO.raw;
|
||||
|
||||
@@ -77,6 +77,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,
|
||||
|
||||
@@ -2220,6 +2220,212 @@ 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, "barColor": "#4E74F8"},
|
||||
"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, BarColor: "#4E74F8"}, 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("MissingBarColor_Defaulted", func(t *testing.T) {
|
||||
d, err := unmarshalDashboard([]byte(strings.Replace(string(data), `, "barColor": "#4E74F8"`, ``, 1)))
|
||||
require.NoError(t, err)
|
||||
output, err := json.Marshal(d.Panels["p1"].Spec.Plugin.Spec)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(output), `"barColor":"#4E74F8"`)
|
||||
})
|
||||
|
||||
t.Run("InvalidBarColor_Rejected", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard([]byte(strings.Replace(string(data), `"#4E74F8"`, `"blue"`, 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)
|
||||
})
|
||||
}
|
||||
|
||||
func geoMapDashboard(spec, queryPlugin string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/GeoMapPanel", "spec": ` + spec + `},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": ` + queryPlugin + `}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
|
||||
const geoMapClickHouseQuery = `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`
|
||||
|
||||
func TestGeoMapPanelSpec(t *testing.T) {
|
||||
data := geoMapDashboard(`{
|
||||
"formatting": {"columnUnits": {"A": "percent"}, "decimalPrecision": "2"},
|
||||
"location": {"mode": "subdivision", "country": "geo.country.iso_code", "countryFormat": "alpha2", "region": "geo.region.iso_code"},
|
||||
"map": {"layer": "bubbles", "view": "country", "viewCountry": "FR", "projection": "equal_earth", "colorBy": "A", "sizeBy": "A"},
|
||||
"chartAppearance": {
|
||||
"colors": {"mode": "opacity", "fill": "#4E74F8", "palette": "ice", "scale": "linear", "steps": 16, "min": 0, "max": 10},
|
||||
"bubbles": {"minRadius": 2, "maxRadius": 30},
|
||||
"labels": false
|
||||
},
|
||||
"legend": {"position": "right"},
|
||||
"thresholds": [{"value": 5, "operator": "above", "unit": "percent", "color": "#F24769"}]
|
||||
}`, geoMapClickHouseQuery)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &GeoMapPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*GeoMapPanelSpec)
|
||||
assert.Equal(t, "subdivision", spec.Location.Mode.ValueOrDefault())
|
||||
assert.Equal(t, "country", spec.Map.View.ValueOrDefault())
|
||||
assert.Equal(t, "FR", spec.Map.ViewCountry)
|
||||
assert.Equal(t, "equal_earth", spec.Map.Projection.ValueOrDefault())
|
||||
require.NotNil(t, spec.ChartAppearance.Labels)
|
||||
assert.False(t, *spec.ChartAppearance.Labels)
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
roundTripped, err := unmarshalDashboard(output)
|
||||
require.NoError(t, err, "round trip failed")
|
||||
roundTrippedSpec := roundTripped.Panels["p1"].Spec.Plugin.Spec.(*GeoMapPanelSpec)
|
||||
assert.Equal(t, spec.Formatting, roundTrippedSpec.Formatting)
|
||||
assert.Equal(t, spec.Location, roundTrippedSpec.Location)
|
||||
assert.Equal(t, spec.Map, roundTrippedSpec.Map)
|
||||
assert.Equal(t, spec.ChartAppearance, roundTrippedSpec.ChartAppearance)
|
||||
assert.Equal(t, spec.Thresholds, roundTrippedSpec.Thresholds)
|
||||
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
d, err := unmarshalDashboard(geoMapDashboard(`{}`, geoMapClickHouseQuery))
|
||||
require.NoError(t, err)
|
||||
output, err := json.Marshal(d.Panels["p1"].Spec.Plugin.Spec)
|
||||
require.NoError(t, err)
|
||||
for _, field := range []string{`"mode":"palette"`, `"mode":"auto"`, `"countryFormat":"auto"`, `"layer":"regions"`, `"view":"world"`, `"projection":"mercator"`, `"palette":"lava"`, `"scale":"log"`, `"labels":null`} {
|
||||
assert.Contains(t, string(output), field)
|
||||
}
|
||||
})
|
||||
|
||||
invalid := []struct {
|
||||
scenario string
|
||||
spec string
|
||||
}{
|
||||
{"unknown field", `{"unit": "ms"}`},
|
||||
{"threshold format", `{"thresholds": [{"value": 5, "color": "Red", "format": "background"}]}`},
|
||||
{"unknown layer", `{"map": {"layer": "heat"}}`},
|
||||
{"unknown view", `{"map": {"view": "emea"}}`},
|
||||
{"unknown projection", `{"map": {"projection": "robinson"}}`},
|
||||
{"country view without a country", `{"map": {"view": "country"}}`},
|
||||
{"lowercase view country", `{"map": {"view": "country", "viewCountry": "fr"}}`},
|
||||
{"unknown location mode", `{"location": {"mode": "geohash"}}`},
|
||||
{"unknown country format", `{"location": {"countryFormat": "numeric"}}`},
|
||||
{"colour min above max", `{"chartAppearance": {"colors": {"min": 10, "max": 1}}}`},
|
||||
{"unknown color mode", `{"chartAppearance": {"colors": {"mode": "gradient"}}}`},
|
||||
{"too few steps", `{"chartAppearance": {"colors": {"steps": 1}}}`},
|
||||
{"radius out of range", `{"chartAppearance": {"bubbles": {"maxRadius": 65}}}`},
|
||||
{"min radius above max", `{"chartAppearance": {"bubbles": {"minRadius": 20, "maxRadius": 10}}}`},
|
||||
}
|
||||
for _, test := range invalid {
|
||||
t.Run("Rejects "+test.scenario, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(geoMapDashboard(test.spec, geoMapClickHouseQuery))
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoMapPanelBindsToQuery(t *testing.T) {
|
||||
builderQuery := `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "geo.country.iso_code"}]}}`
|
||||
tests := []struct {
|
||||
scenario string
|
||||
queryPlugin string
|
||||
spec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "location and value columns of the query",
|
||||
queryPlugin: builderQuery,
|
||||
spec: `{"location": {"mode": "country", "country": "geo.country.iso_code"}, "map": {"colorBy": "A", "sizeBy": "A"}}`,
|
||||
},
|
||||
{
|
||||
scenario: "a location key the query does not group by",
|
||||
queryPlugin: builderQuery,
|
||||
spec: `{"location": {"mode": "coordinates", "lat": "geo.location.lat"}}`,
|
||||
expectedErrorSubstring: "location.lat: \"geo.location.lat\" is not a group-by key of the panel's query; group-by keys: `geo.country.iso_code`",
|
||||
},
|
||||
{
|
||||
scenario: "a value column the query does not have",
|
||||
queryPlugin: builderQuery,
|
||||
spec: `{"map": {"sizeBy": "B"}}`,
|
||||
expectedErrorSubstring: "map.sizeBy: \"B\" is not a value column of the panel's query; value columns: `A`",
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
|
||||
queryPlugin: geoMapClickHouseQuery,
|
||||
spec: `{"location": {"country": "country"}, "map": {"colorBy": "errors"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(geoMapDashboard(test.spec, test.queryPlugin))
|
||||
if test.expectedErrorSubstring == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -2541,7 +2747,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel", "signoz/TopListPanel", "signoz/GeoMapPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
@@ -2598,6 +2804,19 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), 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},
|
||||
{"GeoMap+ClickHouse", mkQuery("signoz/GeoMapPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"GeoMap+PromQL", mkQuery("signoz/GeoMapPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"GeoMap+Formula", mkQuery("signoz/GeoMapPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), false},
|
||||
{"GeoMap+TraceOperator", mkQuery("signoz/GeoMapPanel", "signoz/TraceOperator", `{"name":"T1","expression":"A => B"}`), false},
|
||||
{"GeoMap+Composite(formula)", mkComposite("signoz/GeoMapPanel", "builder_formula", `{"name":"F1","expression":"A+B"}`), false},
|
||||
{"GeoMap+Composite(promql)", mkComposite("signoz/GeoMapPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -39,6 +39,8 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
string(PanelKindTopList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec"),
|
||||
string(PanelKindGeoMap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +75,8 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
PanelPluginVariant[TopListPanelSpec]{Kind: string(PanelKindTopList)},
|
||||
PanelPluginVariant[GeoMapPanelSpec]{Kind: string(PanelKindGeoMap)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +251,8 @@ 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) },
|
||||
PanelKindGeoMap: func() any { return new(GeoMapPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -274,6 +280,8 @@ var (
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTopList: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindGeoMap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -200,10 +200,12 @@ const (
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
PanelKindTopList PanelPluginKind = "signoz/TopListPanel"
|
||||
PanelKindGeoMap PanelPluginKind = "signoz/GeoMapPanel"
|
||||
)
|
||||
|
||||
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, PanelKindGeoMap}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -271,6 +273,35 @@ 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."`
|
||||
BarColor TopListBarColor `json:"barColor" required:"true" validate:"omitempty,hexcolor" description:"Hex color of every bar; a matching background threshold overrides it."`
|
||||
}
|
||||
|
||||
// TopListBarColor is a hex color; empty resolves to the default.
|
||||
type TopListBarColor string
|
||||
|
||||
const DefaultTopListBarColor TopListBarColor = "#4E74F8"
|
||||
|
||||
func (c TopListBarColor) ValueOrDefault() string {
|
||||
if c == "" {
|
||||
return string(DefaultTopListBarColor)
|
||||
}
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func (c TopListBarColor) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(c.ValueOrDefault())
|
||||
}
|
||||
|
||||
type TablePanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
@@ -442,6 +473,144 @@ func (p ScatterPlotPoints) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type GeoMapPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Location GeoMapLocation `json:"location"`
|
||||
Map GeoMapSettings `json:"map"`
|
||||
ChartAppearance GeoMapChartAppearance `json:"chartAppearance"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []GeoMapThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
func (s *GeoMapPanelSpec) validateAgainstQuery(query Query, path string) error {
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s: %s", path, err.Error())
|
||||
}
|
||||
keys, known := composite.CollectResultColumnKeys()
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
for _, binding := range []struct{ field, key string }{{"location.country", s.Location.Country}, {"location.region", s.Location.Region}, {"location.lat", s.Location.Lat}, {"location.lon", s.Location.Lon}} {
|
||||
// Empty is unset: the renderer detects the column by name.
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Groups[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.%s: %q is not a group-by key of the panel's query; group-by keys: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
|
||||
}
|
||||
}
|
||||
for _, binding := range []struct{ field, key string }{{"map.colorBy", s.Map.ColorBy}, {"map.sizeBy", s.Map.SizeBy}} {
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Aggregations[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeoMapLocation names the group-by columns that place a row on the map. Empty
|
||||
// columns are detected by name (geo.country.iso_code, a lat/lon pair, ...).
|
||||
type GeoMapLocation struct {
|
||||
Mode GeoMapLocationMode `json:"mode"`
|
||||
Country string `json:"country" description:"Group-by key holding the country, for the country and subdivision modes."`
|
||||
CountryFormat GeoMapCountryFormat `json:"countryFormat"`
|
||||
Region string `json:"region" description:"Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code), for the subdivision mode."`
|
||||
Lat string `json:"lat" description:"Group-by key holding the latitude, for the coordinates mode."`
|
||||
Lon string `json:"lon" description:"Group-by key holding the longitude, for the coordinates mode."`
|
||||
}
|
||||
|
||||
// GeoMapSettings uses the Table column key for colorBy and sizeBy, so
|
||||
// formatting.columnUnits carries over between the two kinds.
|
||||
type GeoMapSettings struct {
|
||||
Layer GeoMapLayer `json:"layer"`
|
||||
View GeoMapView `json:"view"`
|
||||
ViewCountry string `json:"viewCountry" validate:"omitempty,len=2,alpha,uppercase" description:"ISO 3166-1 alpha-2 code of the country the country view shows."`
|
||||
Projection GeoMapProjection `json:"projection" required:"true"`
|
||||
ColorBy string `json:"colorBy" description:"Value column key that sets the colour. Empty uses the first value column."`
|
||||
SizeBy string `json:"sizeBy" description:"Value column key that sets the bubble or point size. Empty uses colorBy."`
|
||||
}
|
||||
|
||||
func (m *GeoMapSettings) UnmarshalJSON(data []byte) error {
|
||||
type alias GeoMapSettings
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid map")
|
||||
}
|
||||
*m = GeoMapSettings(tmp)
|
||||
return m.validate()
|
||||
}
|
||||
|
||||
func (m GeoMapSettings) validate() error {
|
||||
if m.View == GeoMapViewCountry && m.ViewCountry == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid map: viewCountry is required when view is `country`")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GeoMapChartAppearance struct {
|
||||
Colors GeoMapColors `json:"colors"`
|
||||
Bubbles GeoMapBubbles `json:"bubbles"`
|
||||
// Labels is a pointer so an omitted field resolves to shown at render time.
|
||||
Labels *bool `json:"labels" description:"Shows country and state names once each fits inside its shape."`
|
||||
}
|
||||
|
||||
type GeoMapColors struct {
|
||||
Mode HeatmapColorMode `json:"mode"`
|
||||
Palette HeatmapPalette `json:"palette"`
|
||||
Scale HeatmapColorScale `json:"scale"`
|
||||
Steps int `json:"steps" validate:"omitempty,min=2,max=128"`
|
||||
// Min and Max clamp the colour scale; nil derives them from the data.
|
||||
Min *float64 `json:"min"`
|
||||
Max *float64 `json:"max"`
|
||||
// Fill applies in opacity mode; empty means the default fill.
|
||||
Fill string `json:"fill"`
|
||||
}
|
||||
|
||||
func (c *GeoMapColors) UnmarshalJSON(data []byte) error {
|
||||
type alias GeoMapColors
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map colors")
|
||||
}
|
||||
*c = GeoMapColors(tmp)
|
||||
return c.validate()
|
||||
}
|
||||
|
||||
func (c GeoMapColors) validate() error {
|
||||
if c.Min != nil && c.Max != nil && *c.Min > *c.Max {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "geo map colors.min %v is greater than colors.max %v", *c.Min, *c.Max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeoMapBubbles is the circle radius range for the bubbles and points layers.
|
||||
// Nil fields resolve to the layer default.
|
||||
type GeoMapBubbles struct {
|
||||
MinRadius *BubbleRadius `json:"minRadius"`
|
||||
MaxRadius *BubbleRadius `json:"maxRadius"`
|
||||
}
|
||||
|
||||
func (b *GeoMapBubbles) UnmarshalJSON(data []byte) error {
|
||||
type alias GeoMapBubbles
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid bubbles")
|
||||
}
|
||||
*b = GeoMapBubbles(tmp)
|
||||
return b.validate()
|
||||
}
|
||||
|
||||
func (b GeoMapBubbles) validate() error {
|
||||
if b.MinRadius != nil && b.MaxRadius != nil && *b.MinRadius > *b.MaxRadius {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid bubbles: minRadius %d must not exceed maxRadius %d", *b.MinRadius, *b.MaxRadius)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
@@ -533,6 +702,18 @@ type TableThreshold struct {
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true"`
|
||||
}
|
||||
|
||||
// GeoMapThreshold is ComparisonThreshold without format: a match always sets
|
||||
// the colour of the shape, bubble or point.
|
||||
type GeoMapThreshold struct {
|
||||
// Value is always present in the schema (required:"true"), but 0 is a legitimate
|
||||
// threshold, so it drops validate:"required" — go-playground's required treats a
|
||||
// zero float as unset and would wrongly reject value: 0.
|
||||
Value float64 `json:"value" required:"true"`
|
||||
Operator ComparisonOperator `json:"operator"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Constrained scalar types — with default value
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -1056,6 +1237,31 @@ func (d *PointDiameter) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
minBubbleRadius = 1
|
||||
maxBubbleRadius = 64
|
||||
)
|
||||
|
||||
// BubbleRadius is a geo map circle's radius in whole CSS pixels.
|
||||
type BubbleRadius int
|
||||
|
||||
func (BubbleRadius) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minBubbleRadius).WithMaximum(maxBubbleRadius)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BubbleRadius) UnmarshalJSON(data []byte) error {
|
||||
var v int
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid bubble radius: must be a whole number between %d and %d", minBubbleRadius, maxBubbleRadius)
|
||||
}
|
||||
if v < minBubbleRadius || v > maxBubbleRadius {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid bubble radius %d: must be a whole number between %d and %d", v, minBubbleRadius, maxBubbleRadius)
|
||||
}
|
||||
*r = BubbleRadius(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPointOpacity = 0.1
|
||||
|
||||
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
|
||||
@@ -1442,3 +1648,201 @@ func (s *HeatmapColorScale) UnmarshalJSON(data []byte) error {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid heatmap color scale %q: must be `log`, `sqrt`, or `linear`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapLocationMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapLocationModeAuto = GeoMapLocationMode{valuer.NewString("auto")} // default
|
||||
GeoMapLocationModeCountry = GeoMapLocationMode{valuer.NewString("country")}
|
||||
GeoMapLocationModeSubdivision = GeoMapLocationMode{valuer.NewString("subdivision")}
|
||||
GeoMapLocationModeCoordinates = GeoMapLocationMode{valuer.NewString("coordinates")}
|
||||
)
|
||||
|
||||
func (GeoMapLocationMode) Enum() []any {
|
||||
return []any{GeoMapLocationModeAuto, GeoMapLocationModeCountry, GeoMapLocationModeSubdivision, GeoMapLocationModeCoordinates}
|
||||
}
|
||||
|
||||
func (v GeoMapLocationMode) ValueOrDefault() string {
|
||||
if v.IsZero() {
|
||||
return GeoMapLocationModeAuto.StringValue()
|
||||
}
|
||||
return v.StringValue()
|
||||
}
|
||||
|
||||
func (v GeoMapLocationMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (v *GeoMapLocationMode) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map location mode: must be a string, one of `auto`, `country`, `subdivision`, or `coordinates`")
|
||||
}
|
||||
parsed := GeoMapLocationMode{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapLocationModeAuto, GeoMapLocationModeCountry, GeoMapLocationModeSubdivision, GeoMapLocationModeCoordinates:
|
||||
*v = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map location mode %q: must be `auto`, `country`, `subdivision`, or `coordinates`", s)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapCountryFormat struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapCountryFormatAuto = GeoMapCountryFormat{valuer.NewString("auto")} // default
|
||||
GeoMapCountryFormatAlpha2 = GeoMapCountryFormat{valuer.NewString("alpha2")}
|
||||
GeoMapCountryFormatAlpha3 = GeoMapCountryFormat{valuer.NewString("alpha3")}
|
||||
GeoMapCountryFormatName = GeoMapCountryFormat{valuer.NewString("name")}
|
||||
)
|
||||
|
||||
func (GeoMapCountryFormat) Enum() []any {
|
||||
return []any{GeoMapCountryFormatAuto, GeoMapCountryFormatAlpha2, GeoMapCountryFormatAlpha3, GeoMapCountryFormatName}
|
||||
}
|
||||
|
||||
func (v GeoMapCountryFormat) ValueOrDefault() string {
|
||||
if v.IsZero() {
|
||||
return GeoMapCountryFormatAuto.StringValue()
|
||||
}
|
||||
return v.StringValue()
|
||||
}
|
||||
|
||||
func (v GeoMapCountryFormat) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (v *GeoMapCountryFormat) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map country format: must be a string, one of `auto`, `alpha2`, `alpha3`, or `name`")
|
||||
}
|
||||
parsed := GeoMapCountryFormat{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapCountryFormatAuto, GeoMapCountryFormatAlpha2, GeoMapCountryFormatAlpha3, GeoMapCountryFormatName:
|
||||
*v = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map country format %q: must be `auto`, `alpha2`, `alpha3`, or `name`", s)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapLayer struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapLayerRegions = GeoMapLayer{valuer.NewString("regions")} // default
|
||||
GeoMapLayerBubbles = GeoMapLayer{valuer.NewString("bubbles")}
|
||||
GeoMapLayerPoints = GeoMapLayer{valuer.NewString("points")}
|
||||
)
|
||||
|
||||
func (GeoMapLayer) Enum() []any {
|
||||
return []any{GeoMapLayerRegions, GeoMapLayerBubbles, GeoMapLayerPoints}
|
||||
}
|
||||
|
||||
func (v GeoMapLayer) ValueOrDefault() string {
|
||||
if v.IsZero() {
|
||||
return GeoMapLayerRegions.StringValue()
|
||||
}
|
||||
return v.StringValue()
|
||||
}
|
||||
|
||||
func (v GeoMapLayer) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (v *GeoMapLayer) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map layer: must be a string, one of `regions`, `bubbles`, or `points`")
|
||||
}
|
||||
parsed := GeoMapLayer{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapLayerRegions, GeoMapLayerBubbles, GeoMapLayerPoints:
|
||||
*v = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map layer %q: must be `regions`, `bubbles`, or `points`", s)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapView struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapViewWorld = GeoMapView{valuer.NewString("world")} // default
|
||||
GeoMapViewFit = GeoMapView{valuer.NewString("fit")}
|
||||
GeoMapViewAfrica = GeoMapView{valuer.NewString("africa")}
|
||||
GeoMapViewAsia = GeoMapView{valuer.NewString("asia")}
|
||||
GeoMapViewEurope = GeoMapView{valuer.NewString("europe")}
|
||||
GeoMapViewNorthAmerica = GeoMapView{valuer.NewString("north_america")}
|
||||
GeoMapViewSouthAmerica = GeoMapView{valuer.NewString("south_america")}
|
||||
GeoMapViewOceania = GeoMapView{valuer.NewString("oceania")}
|
||||
GeoMapViewCountry = GeoMapView{valuer.NewString("country")}
|
||||
)
|
||||
|
||||
func (GeoMapView) Enum() []any {
|
||||
return []any{GeoMapViewWorld, GeoMapViewFit, GeoMapViewAfrica, GeoMapViewAsia, GeoMapViewEurope, GeoMapViewNorthAmerica, GeoMapViewSouthAmerica, GeoMapViewOceania, GeoMapViewCountry}
|
||||
}
|
||||
|
||||
func (v GeoMapView) ValueOrDefault() string {
|
||||
if v.IsZero() {
|
||||
return GeoMapViewWorld.StringValue()
|
||||
}
|
||||
return v.StringValue()
|
||||
}
|
||||
|
||||
func (v GeoMapView) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (v *GeoMapView) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map view: must be a string, one of `world`, `fit`, `africa`, `asia`, `europe`, `north_america`, `south_america`, `oceania`, or `country`")
|
||||
}
|
||||
parsed := GeoMapView{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapViewWorld, GeoMapViewFit, GeoMapViewAfrica, GeoMapViewAsia, GeoMapViewEurope, GeoMapViewNorthAmerica, GeoMapViewSouthAmerica, GeoMapViewOceania, GeoMapViewCountry:
|
||||
*v = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map view %q: must be `world`, `fit`, `africa`, `asia`, `europe`, `north_america`, `south_america`, `oceania`, or `country`", s)
|
||||
}
|
||||
}
|
||||
|
||||
type GeoMapProjection struct{ valuer.String }
|
||||
|
||||
var (
|
||||
GeoMapProjectionMercator = GeoMapProjection{valuer.NewString("mercator")} // default
|
||||
GeoMapProjectionEqualEarth = GeoMapProjection{valuer.NewString("equal_earth")}
|
||||
GeoMapProjectionNaturalEarth = GeoMapProjection{valuer.NewString("natural_earth")}
|
||||
)
|
||||
|
||||
func (GeoMapProjection) Enum() []any {
|
||||
return []any{GeoMapProjectionMercator, GeoMapProjectionEqualEarth, GeoMapProjectionNaturalEarth}
|
||||
}
|
||||
|
||||
func (p GeoMapProjection) ValueOrDefault() string {
|
||||
if p.IsZero() {
|
||||
return GeoMapProjectionMercator.StringValue()
|
||||
}
|
||||
return p.StringValue()
|
||||
}
|
||||
|
||||
func (p GeoMapProjection) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(p.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (p *GeoMapProjection) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid geo map projection: must be a string, one of `mercator`, `equal_earth`, or `natural_earth`")
|
||||
}
|
||||
parsed := GeoMapProjection{valuer.NewString(s)}
|
||||
switch parsed {
|
||||
case GeoMapProjectionMercator, GeoMapProjectionEqualEarth, GeoMapProjectionNaturalEarth:
|
||||
*p = parsed
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid geo map projection %q: must be `mercator`, `equal_earth`, or `natural_earth`", s)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user