mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-01 15:50:41 +01:00
Compare commits
1 Commits
feat/scatt
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f5ddeb04d |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -38,6 +38,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- basepath
|
||||
@@ -63,7 +64,6 @@ jobs:
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
- ruler
|
||||
- savedview
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
|
||||
@@ -3379,13 +3379,6 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -4049,7 +4042,6 @@ components:
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -4064,7 +4056,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4077,7 +4068,6 @@ components:
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4151,18 +4141,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4242,16 +4220,6 @@ components:
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -4425,87 +4393,6 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
color:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
size:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
|
||||
@@ -3,22 +3,15 @@ import {
|
||||
MessageActionKindDTO,
|
||||
SavedViewEntityDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
getSavedView,
|
||||
listSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
GetSavedView200,
|
||||
ListSavedViews200,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import type { History } from 'history';
|
||||
|
||||
import {
|
||||
@@ -38,7 +31,8 @@ import {
|
||||
} from '../resolveOpenResource';
|
||||
import { resourceRoute, ResourceType } from '../resourceRoute';
|
||||
|
||||
jest.mock('api/generated/services/saved-view');
|
||||
jest.mock('api/saveView/getAllViews');
|
||||
jest.mock('api/saveView/getViewById');
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
@@ -54,45 +48,43 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
const mockedListSavedViews = listSavedViews as jest.MockedFunction<
|
||||
typeof listSavedViews
|
||||
const mockedGetAllViews = getAllViews as jest.MockedFunction<
|
||||
typeof getAllViews
|
||||
>;
|
||||
const mockedGetSavedView = getSavedView as jest.MockedFunction<
|
||||
typeof getSavedView
|
||||
const mockedGetViewById = getViewById as jest.MockedFunction<
|
||||
typeof getViewById
|
||||
>;
|
||||
|
||||
function makeView(
|
||||
id: string,
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): SavedviewtypesSavedViewDTO {
|
||||
function makeView(id: string, sourcePage: DataSource): ViewProps {
|
||||
return {
|
||||
id,
|
||||
name: `view-${id}`,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
name: `View ${id}`,
|
||||
category: 'test',
|
||||
createdAt: '2021-07-07T06:31:00.000Z',
|
||||
createdBy: 'user',
|
||||
updatedAt: '2021-07-07T06:33:00.000Z',
|
||||
updatedBy: 'user',
|
||||
spec: {
|
||||
displayName: `View ${id}`,
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: 'raw',
|
||||
queries: [{ type: 'builder_query', spec: { name: 'A', signal: source } }],
|
||||
},
|
||||
} as unknown as SavedviewtypesSavedViewDTO;
|
||||
sourcePage,
|
||||
tags: [],
|
||||
extraData: '',
|
||||
compositeQuery: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
} as ICompositeMetricQuery,
|
||||
};
|
||||
}
|
||||
|
||||
function mockViewsResponse(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
): ListSavedViews200 {
|
||||
return { status: 'success', data: views };
|
||||
function mockViewsResponse(views: ViewProps[]): AxiosResponse<AllViewsProps> {
|
||||
return {
|
||||
data: { status: 'success', data: views },
|
||||
} as AxiosResponse<AllViewsProps>;
|
||||
}
|
||||
|
||||
function mockViewByIdResponse(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 {
|
||||
return { status: 'success', data: view };
|
||||
view: ViewProps,
|
||||
): AxiosResponse<{ status: string; data: ViewProps }> {
|
||||
return {
|
||||
data: { status: 'success', data: view },
|
||||
} as AxiosResponse<{ status: string; data: ViewProps }>;
|
||||
}
|
||||
|
||||
describe('resourceRoute', () => {
|
||||
@@ -198,33 +190,18 @@ describe('resolveOpenResource', () => {
|
||||
|
||||
describe('findSavedViewInLists', () => {
|
||||
beforeEach(() => {
|
||||
mockedListSavedViews.mockReset();
|
||||
mockedGetAllViews.mockReset();
|
||||
});
|
||||
|
||||
it('loads only the hinted source when entity is provided', async () => {
|
||||
const tracesView = makeView('view-traces', SavedviewtypesSourceDTO.traces);
|
||||
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
const tracesView = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-traces', DataSource.TRACES);
|
||||
|
||||
expect(result).toStrictEqual(tracesView);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledWith({
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a null list as empty and probes the next source', async () => {
|
||||
const metricsView = makeView('view-metrics', SavedviewtypesSourceDTO.metrics);
|
||||
mockedListSavedViews
|
||||
.mockResolvedValueOnce({ status: 'success', data: null })
|
||||
.mockResolvedValueOnce(mockViewsResponse([]))
|
||||
.mockResolvedValueOnce(mockViewsResponse([metricsView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-metrics');
|
||||
|
||||
expect(result).toStrictEqual(metricsView);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledTimes(3);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,75 +227,52 @@ describe('openSavedView', () => {
|
||||
it('navigates with history.push and view query params', () => {
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
|
||||
openSavedView(view, history);
|
||||
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
const pushedUrl = push.mock.calls[0][0] as string;
|
||||
expect(pushedUrl).toContain(ROUTES.LOGS_EXPLORER);
|
||||
const params = new URLSearchParams(pushedUrl.split('?')[1]);
|
||||
expect(params.get(QueryParams.viewKey)).toBe('"view-logs"');
|
||||
expect(params.get(QueryParams.viewName)).toBe('"View view-logs"');
|
||||
expect(params.get(QueryParams.panelTypes)).toBe('"list"');
|
||||
});
|
||||
|
||||
it('throws when the view has no source', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
delete view.source;
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Unsupported saved view source');
|
||||
});
|
||||
|
||||
it('throws when the view has no queries', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
view.spec.queries = [];
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Saved view is missing query data');
|
||||
expect(pushedUrl).toContain(QueryParams.viewKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedViewByKey', () => {
|
||||
beforeEach(() => {
|
||||
mockedListSavedViews.mockReset();
|
||||
mockedGetSavedView.mockReset();
|
||||
mockedGetAllViews.mockReset();
|
||||
mockedGetViewById.mockReset();
|
||||
});
|
||||
|
||||
it('prefers the direct view lookup endpoint', async () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
mockedGetSavedView.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
mockedGetViewById.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-logs', DataSource.LOGS, history);
|
||||
|
||||
expect(mockedGetSavedView).toHaveBeenCalledWith({ id: 'view-logs' });
|
||||
expect(mockedListSavedViews).not.toHaveBeenCalled();
|
||||
expect(mockedGetViewById).toHaveBeenCalledWith('view-logs');
|
||||
expect(mockedGetAllViews).not.toHaveBeenCalled();
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to list probing when direct lookup fails', async () => {
|
||||
const view = makeView('view-traces', SavedviewtypesSourceDTO.traces);
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([view]));
|
||||
const view = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([view]));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-traces', DataSource.TRACES, history);
|
||||
|
||||
expect(mockedListSavedViews).toHaveBeenCalledWith({
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the saved view does not exist', async () => {
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.mockResolvedValue(mockViewsResponse([]));
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValue(mockViewsResponse([]));
|
||||
|
||||
await expect(
|
||||
openSavedViewByKey('missing', DataSource.LOGS, {
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import {
|
||||
getSavedView,
|
||||
listSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
findSavedView,
|
||||
getSavedViewQuery,
|
||||
SavedViewSourcePage,
|
||||
toSavedViewSource,
|
||||
} from 'container/SavedViews/utils';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { History } from 'history';
|
||||
|
||||
type SavedViewSourceHint = SavedViewSourcePage;
|
||||
type SavedViewSourceHint = DataSource | 'meter';
|
||||
|
||||
const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
DataSource.LOGS,
|
||||
@@ -27,15 +20,13 @@ const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
export async function findSavedViewInLists(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<SavedviewtypesSavedViewDTO | null> {
|
||||
): Promise<ViewProps | null> {
|
||||
const sources = sourceHint ? [sourceHint] : DEFAULT_PROBE_SOURCES;
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await listSavedViews({
|
||||
source: toSavedViewSource(source),
|
||||
});
|
||||
const match = findSavedView(response.data, viewKey);
|
||||
const response = await getAllViews(source);
|
||||
const match = response.data.data.find((view) => view.id === viewKey);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
@@ -50,11 +41,11 @@ export async function findSavedViewInLists(
|
||||
async function loadSavedView(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<SavedviewtypesSavedViewDTO> {
|
||||
): Promise<ViewProps> {
|
||||
try {
|
||||
const response = await getSavedView({ id: viewKey });
|
||||
if (response.data) {
|
||||
return response.data;
|
||||
const response = await getViewById(viewKey);
|
||||
if (response.data?.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to list probing when the direct lookup fails.
|
||||
@@ -94,23 +85,20 @@ export function buildExplorerNavigationUrl(
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
history: History,
|
||||
): void {
|
||||
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
|
||||
export function openSavedView(view: ViewProps, history: History): void {
|
||||
const route = explorerRouteForSourcePage(view.sourcePage);
|
||||
if (!route) {
|
||||
throw new Error('Unsupported saved view source');
|
||||
}
|
||||
|
||||
if (!view.spec.queries?.length) {
|
||||
if (!view.compositeQuery) {
|
||||
throw new Error('Saved view is missing query data');
|
||||
}
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
const query = mapQueryDataFromApi(view.compositeQuery);
|
||||
const url = buildExplorerNavigationUrl(route, query, {
|
||||
[QueryParams.panelTypes]: view.spec.panelType as unknown as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.spec.displayName,
|
||||
[QueryParams.panelTypes]: view.compositeQuery.panelType as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.name,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
history.push(url);
|
||||
@@ -124,3 +112,6 @@ export async function openSavedViewByKey(
|
||||
const view = await loadSavedView(viewKey, sourceHint);
|
||||
openSavedView(view, history);
|
||||
}
|
||||
|
||||
/** @deprecated Use findSavedViewInLists — kept for tests. */
|
||||
export const findSavedView = findSavedViewInLists;
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import floppyDiscUrl from '@/assets/Icons/floppy-disc.svg';
|
||||
@@ -36,40 +35,38 @@ export default function SavedViews({
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [selectedEntity, setSelectedEntity] = useState<string>('logs');
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<
|
||||
SavedviewtypesSavedViewDTO[]
|
||||
>([]);
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<any[]>([]);
|
||||
|
||||
const {
|
||||
data: logsViewsData,
|
||||
isLoading: logsViewsLoading,
|
||||
isError: logsViewsError,
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.logs });
|
||||
} = useGetAllViews(DataSource.LOGS);
|
||||
|
||||
const {
|
||||
data: tracesViewsData,
|
||||
isLoading: tracesViewsLoading,
|
||||
isError: tracesViewsError,
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.traces });
|
||||
} = useGetAllViews(DataSource.TRACES);
|
||||
|
||||
const {
|
||||
data: metricsViewsData,
|
||||
isLoading: metricsViewsLoading,
|
||||
isError: metricsViewsError,
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.metrics });
|
||||
} = useGetAllViews(DataSource.METRICS);
|
||||
|
||||
const logsViews = useMemo(
|
||||
() => [...(logsViewsData?.data || [])],
|
||||
() => [...(logsViewsData?.data.data || [])],
|
||||
[logsViewsData],
|
||||
);
|
||||
|
||||
const tracesViews = useMemo(
|
||||
() => [...(tracesViewsData?.data || [])],
|
||||
() => [...(tracesViewsData?.data.data || [])],
|
||||
[tracesViewsData],
|
||||
);
|
||||
|
||||
const metricsViews = useMemo(
|
||||
() => [...(metricsViewsData?.data || [])],
|
||||
() => [...(metricsViewsData?.data.data || [])],
|
||||
[metricsViewsData],
|
||||
);
|
||||
|
||||
@@ -91,22 +88,39 @@ export default function SavedViews({
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const handleRedirectQuery = (view: SavedviewtypesSavedViewDTO): void => {
|
||||
const handleRedirectQuery = (view: ViewProps): void => {
|
||||
logEvent('Homepage: Saved view clicked', {
|
||||
viewId: view.id,
|
||||
viewName: view.spec.displayName,
|
||||
viewName: view.name,
|
||||
entity: selectedEntity,
|
||||
});
|
||||
|
||||
handleExplorerTabChange(
|
||||
view.spec.panelType,
|
||||
{
|
||||
query: getSavedViewQuery(view),
|
||||
viewName: view.spec.displayName,
|
||||
viewKey: view.id,
|
||||
},
|
||||
SOURCEPAGE_VS_ROUTES[selectedEntity],
|
||||
);
|
||||
let currentViews: ViewProps[] = [];
|
||||
if (selectedEntity === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (selectedEntity === 'traces') {
|
||||
currentViews = tracesViews;
|
||||
} else if (selectedEntity === 'metrics') {
|
||||
currentViews = metricsViews;
|
||||
}
|
||||
|
||||
const currentViewDetails = getViewDetailsUsingViewKey(view.id, currentViews);
|
||||
if (!currentViewDetails) {
|
||||
return;
|
||||
}
|
||||
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
|
||||
|
||||
if (selectedEntity) {
|
||||
handleExplorerTabChange(
|
||||
currentPanelType,
|
||||
{
|
||||
query,
|
||||
viewName: name,
|
||||
viewKey: id,
|
||||
},
|
||||
SOURCEPAGE_VS_ROUTES[selectedEntity],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -225,10 +239,24 @@ export default function SavedViews({
|
||||
/>
|
||||
|
||||
<div className="saved-view-item-name home-data-item-name">
|
||||
{view.spec.displayName}
|
||||
{view.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="saved-view-item-description home-data-item-tag">
|
||||
{view.tags?.map((tag: string) => {
|
||||
if (tag === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color="sienna" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -279,7 +307,7 @@ export default function SavedViews({
|
||||
logEvent('Homepage: Saved views switched', {
|
||||
tab,
|
||||
});
|
||||
let currentViews: SavedviewtypesSavedViewDTO[] = [];
|
||||
let currentViews: ViewProps[] = [];
|
||||
if (tab === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (tab === 'traces') {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import {
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function makeView(): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id: 'view-1',
|
||||
name: 'errors-by-service-abc123',
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdBy: 'a@b.c',
|
||||
updatedBy: 'a@b.c',
|
||||
spec: {
|
||||
displayName: 'Errors by service',
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: 'raw',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
stepInterval: 60,
|
||||
filter: { expression: 'has_error = true' },
|
||||
// v2 reads back fully defaulted envelopes; nulls must not break the mapper
|
||||
groupBy: null,
|
||||
order: null,
|
||||
selectFields: null,
|
||||
functions: null,
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedFields: [{ name: 'service.name' }],
|
||||
display: { color: 'red' },
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
describe('getSavedViewQuery', () => {
|
||||
it('maps the v2 spec through the v5 branch of mapQueryDataFromApi', () => {
|
||||
const query = getSavedViewQuery(makeView());
|
||||
|
||||
expect(query.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(query.promql).toStrictEqual([]);
|
||||
expect(query.clickhouse_sql).toStrictEqual([]);
|
||||
expect(query.builder.queryData).toHaveLength(1);
|
||||
|
||||
const [queryData] = query.builder.queryData;
|
||||
expect(queryData.queryName).toBe('A');
|
||||
expect(queryData.dataSource).toBe(DataSource.TRACES);
|
||||
expect(queryData.filter).toStrictEqual({ expression: 'has_error = true' });
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('keeps formulas alongside builder queries', () => {
|
||||
const view = makeView();
|
||||
view.spec.queries.push({
|
||||
type: 'builder_formula',
|
||||
spec: { name: 'F1', expression: 'A / 2' },
|
||||
} as SavedviewtypesSavedViewDTO['spec']['queries'][number]);
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
|
||||
expect(query.builder.queryData).toHaveLength(1);
|
||||
expect(query.builder.queryFormulas).toHaveLength(1);
|
||||
expect(query.builder.queryFormulas[0].queryName).toBe('F1');
|
||||
});
|
||||
|
||||
it('does not read the panel type into the query', () => {
|
||||
const view = makeView();
|
||||
view.spec.panelType = SavedviewtypesPanelTypeDTO.graph;
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
|
||||
// panelType travels separately (url param), the Query itself has no such field
|
||||
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [
|
||||
{ ...makeView(), id: 'a' },
|
||||
{ ...makeView(), id: 'b' },
|
||||
];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
@@ -1,18 +1,11 @@
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { deleteView } from 'api/saveView/deleteView';
|
||||
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
|
||||
|
||||
export const useDeleteView = (
|
||||
uuid: string,
|
||||
): UseMutationResult<DeleteViewPayloadProps, Error, string> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
): UseMutationResult<DeleteViewPayloadProps, Error, string> =>
|
||||
useMutation({
|
||||
mutationKey: [uuid],
|
||||
mutationFn: () => deleteView(uuid),
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { saveView } from 'api/saveView/saveView';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
|
||||
@@ -14,14 +13,8 @@ export const useSaveView = ({
|
||||
Error,
|
||||
SaveViewProps,
|
||||
SaveViewPayloadProps
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
> =>
|
||||
useMutation({
|
||||
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||
mutationFn: saveView,
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { updateView } from 'api/saveView/updateView';
|
||||
import {
|
||||
UpdateViewPayloadProps,
|
||||
@@ -17,10 +16,8 @@ export const useUpdateView = ({
|
||||
Error,
|
||||
UpdateViewProps,
|
||||
UpdateViewPayloadProps
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
> =>
|
||||
useMutation({
|
||||
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||
mutationFn: () =>
|
||||
updateView({
|
||||
@@ -30,8 +27,4 @@ export const useUpdateView = ({
|
||||
sourcePage,
|
||||
viewKey,
|
||||
}),
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import uPlot, { Axis } from 'uplot';
|
||||
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
|
||||
import getGridColor from '../../uPlotLib/utils/getGridColor';
|
||||
import { buildYAxisSizeCalculator } from '../utils/axis';
|
||||
import { keepDecadeSplits } from '../utils/logGridSplits';
|
||||
import { AxisProps, ConfigBuilder } from './types';
|
||||
|
||||
/**
|
||||
@@ -21,9 +20,8 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
const { grid, isDarkMode, isLogScale } = this.props;
|
||||
|
||||
const defaultStroke = getGridColor(isDarkMode ?? false);
|
||||
const defaultWidth = 0.2;
|
||||
const defaultWidth = isLogScale ? 0.1 : 0.2;
|
||||
const defaultShow = true;
|
||||
const filter = isLogScale ? { filter: keepDecadeSplits } : {};
|
||||
|
||||
// Merge partial or full grid config with defaults
|
||||
if (grid) {
|
||||
@@ -31,7 +29,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
stroke: grid.stroke ?? defaultStroke,
|
||||
width: grid.width ?? defaultWidth,
|
||||
show: grid.show ?? defaultShow,
|
||||
...filter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +36,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
stroke: defaultStroke,
|
||||
width: defaultWidth,
|
||||
show: defaultShow,
|
||||
...filter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,8 +165,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
show = true,
|
||||
side = 2, // bottom by default
|
||||
space,
|
||||
splits,
|
||||
filter,
|
||||
gap = 5, // default gap is 5
|
||||
} = this.props;
|
||||
|
||||
@@ -208,12 +202,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (space !== undefined) {
|
||||
axisConfig.space = space;
|
||||
}
|
||||
if (splits) {
|
||||
axisConfig.splits = splits;
|
||||
}
|
||||
if (filter) {
|
||||
axisConfig.filter = filter;
|
||||
}
|
||||
if (size) {
|
||||
axisConfig.size = size;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { AxisProps } from '../types';
|
||||
import { keepDecadeSplits } from '../../utils/logGridSplits';
|
||||
import { UPlotAxisBuilder } from '../UPlotAxisBuilder';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
@@ -118,29 +117,9 @@ describe('UPlotAxisBuilder', () => {
|
||||
width: 1,
|
||||
// show falls back to default when not provided
|
||||
show: true,
|
||||
filter: keepDecadeSplits,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes explicit splits and a label filter through to uPlot', () => {
|
||||
const splits = jest.fn();
|
||||
const filter = jest.fn();
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ splits, filter }),
|
||||
).getConfig();
|
||||
|
||||
expect(config.splits).toBe(splits);
|
||||
expect(config.filter).toBe(filter);
|
||||
});
|
||||
|
||||
it('draws log grid lines at powers of ten only, and leaves a linear grid unfiltered', () => {
|
||||
const log = new UPlotAxisBuilder(createAxisProps({ isLogScale: true }));
|
||||
const linear = new UPlotAxisBuilder(createAxisProps({ isLogScale: false }));
|
||||
|
||||
expect(log.getConfig().grid?.filter).toBe(keepDecadeSplits);
|
||||
expect(linear.getConfig().grid).not.toHaveProperty('filter');
|
||||
});
|
||||
|
||||
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
|
||||
const customTicks = { width: 1, show: false };
|
||||
const withTicks = new UPlotAxisBuilder(
|
||||
|
||||
@@ -78,10 +78,6 @@ export interface AxisProps {
|
||||
};
|
||||
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Explicit tick positions, replacing uPlot's per-distribution default. */
|
||||
splits?: uPlot.Axis.Splits;
|
||||
/** Which splits get a label, replacing uPlot's per-distribution default. */
|
||||
filter?: uPlot.Axis.Filter;
|
||||
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
|
||||
gap?: number;
|
||||
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
|
||||
@@ -90,7 +86,7 @@ export interface AxisProps {
|
||||
space?: number;
|
||||
/** Picks the dark or light default for stroke and grid color. */
|
||||
isDarkMode?: boolean;
|
||||
/** Axis is on a log scale — grid lines fall on powers of ten only. */
|
||||
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
|
||||
isLogScale?: boolean;
|
||||
/** Unit the value ticks are formatted in (`spec.formatting.unit`). Named for the
|
||||
* y axis, the only value axis until scatter; a non-time x axis reads it too. */
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('createScatterPlugin', () => {
|
||||
expect(scan(u, plugin)).toStrictEqual([null, 0]);
|
||||
});
|
||||
|
||||
it('rings the hit disc, in CSS pixels, with a gap around it', () => {
|
||||
it('sizes the hover marker from the hit disc, in CSS pixels', () => {
|
||||
const plugin = createScatterPlugin({
|
||||
pointSize: { fixed: 8, min: 4, max: 20 },
|
||||
});
|
||||
@@ -197,50 +197,10 @@ describe('createScatterPlugin', () => {
|
||||
scan(u, plugin);
|
||||
|
||||
const bbox = plugin.cursor.points?.bbox;
|
||||
// The 8px disc spans 6..14; the ring sits 3px out and is 2px wide.
|
||||
expect(bbox?.(u, 1)).toStrictEqual({
|
||||
left: 1,
|
||||
top: 81,
|
||||
width: 18,
|
||||
height: 18,
|
||||
});
|
||||
expect(bbox?.(u, 1)).toStrictEqual({ left: 6, top: 86, width: 8, height: 8 });
|
||||
expect(bbox?.(u, 2)).toMatchObject({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('snaps the ring to whole pixels without moving its centre', () => {
|
||||
const plugin = createScatterPlugin({
|
||||
pointSize: { fixed: 7, min: 4, max: 20 },
|
||||
});
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10], ys: [10] }],
|
||||
cursor: { left: 10, top: 90 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
scan(u, plugin);
|
||||
|
||||
// The 7px disc starts at 6.5, so the ring's edge would land on 1.5.
|
||||
const ring = plugin.cursor.points?.bbox?.(u, 1);
|
||||
expect(ring).toMatchObject({ left: 1, width: 18 });
|
||||
expect((ring?.left ?? 0) + (ring?.width ?? 0) / 2).toBe(10);
|
||||
});
|
||||
|
||||
it('draws the hover marker as an outline in the series colour', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = {
|
||||
series: [{}, { stroke: (): string => '#E5484D' }, { stroke: '#30A46C' }],
|
||||
} as unknown as uPlot;
|
||||
const points = plugin.cursor.points as {
|
||||
fill: (u: uPlot, seriesIdx: number) => string;
|
||||
stroke: (u: uPlot, seriesIdx: number) => string;
|
||||
width: (u: uPlot, seriesIdx: number, size: number) => number;
|
||||
};
|
||||
|
||||
expect(points.fill(u, 1)).toBe('transparent');
|
||||
expect(points.stroke(u, 1)).toBe('#E5484D');
|
||||
expect(points.stroke(u, 2)).toBe('#30A46C');
|
||||
expect(points.width(u, 1, 0)).toBe(2);
|
||||
});
|
||||
|
||||
it('drawClear drops cached paths on data series only', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({ series: [{ xs: [1], ys: [1] }] });
|
||||
|
||||
@@ -28,26 +28,6 @@ const HIDDEN_BBOX: uPlot.BBox = { left: -10, top: -10, width: 0, height: 0 };
|
||||
|
||||
const TWO_PI = 2 * Math.PI;
|
||||
|
||||
/** CSS pixels between a hovered disc and the outline ring drawn around it. */
|
||||
const HOVER_RING_GAP_PX = 3;
|
||||
const HOVER_RING_WIDTH_PX = 2;
|
||||
|
||||
/**
|
||||
* uPlot `ceil`s a cursor point's left/top but not its size, which pulls a ring
|
||||
* up to 1px right and down of the disc. Snapping the edge to a whole pixel and
|
||||
* growing the span by twice the snap keeps the centre exact.
|
||||
*/
|
||||
function snapToPixel(start: number, length: number): [number, number] {
|
||||
const snapped = Math.floor(start);
|
||||
return [snapped, length + (start - snapped) * 2];
|
||||
}
|
||||
|
||||
export function getSeriesStroke(u: uPlot, seriesIdx: number): string {
|
||||
const { stroke } = u.series[seriesIdx];
|
||||
const color = typeof stroke === 'function' ? stroke(u, seriesIdx) : stroke;
|
||||
return typeof color === 'string' ? color : '';
|
||||
}
|
||||
|
||||
/** uPlot caches built paths on the series; the field is internal to it. */
|
||||
type SeriesWithPaths = Series & { _paths?: Series.Paths | null };
|
||||
|
||||
@@ -178,21 +158,13 @@ export function createScatterPlugin({
|
||||
return HIDDEN_BBOX;
|
||||
}
|
||||
const { pxRatio } = uPlot;
|
||||
const inset = HOVER_RING_GAP_PX + HOVER_RING_WIDTH_PX;
|
||||
const [left, width] = snapToPixel(
|
||||
hit.x / pxRatio - inset,
|
||||
hit.w / pxRatio + inset * 2,
|
||||
);
|
||||
const [top, height] = snapToPixel(
|
||||
hit.y / pxRatio - inset,
|
||||
hit.h / pxRatio + inset * 2,
|
||||
);
|
||||
return { left, top, width, height };
|
||||
return {
|
||||
left: hit.x / pxRatio,
|
||||
top: hit.y / pxRatio,
|
||||
width: hit.w / pxRatio,
|
||||
height: hit.h / pxRatio,
|
||||
};
|
||||
},
|
||||
// An outline around the disc, so the point itself stays readable.
|
||||
fill: (): string => 'transparent',
|
||||
stroke: getSeriesStroke,
|
||||
width: (): number => HOVER_RING_WIDTH_PX,
|
||||
},
|
||||
// uPlot only measures series that returned a data index, i.e. the hit one.
|
||||
focus: { prox: DEFAULT_FOCUS_PROXIMITY_VALUE, dist: (): number => 0 },
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { buildClickData } from '../utils';
|
||||
|
||||
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
getFocusedSeriesAtPosition: jest.fn(() => ({ seriesIndex: 1 })),
|
||||
}));
|
||||
|
||||
const event = {
|
||||
offsetX: 10,
|
||||
offsetY: 20,
|
||||
clientX: 110,
|
||||
clientY: 220,
|
||||
} as MouseEvent;
|
||||
|
||||
describe('buildClickData', () => {
|
||||
it('skips the x-index lookups on a faceted plot, which has no shared x series', () => {
|
||||
const posToIdx = jest.fn(() => {
|
||||
throw new Error('faceted data[0] is null');
|
||||
});
|
||||
const plot = {
|
||||
data: [null],
|
||||
posToVal: (pos: number): number => pos * 2,
|
||||
posToIdx,
|
||||
} as unknown as uPlot;
|
||||
|
||||
expect(buildClickData(event, plot)).toStrictEqual({
|
||||
xValue: 20,
|
||||
yValue: 40,
|
||||
focusedSeries: null,
|
||||
clickedDataTimestamp: 20,
|
||||
mouseX: 10,
|
||||
mouseY: 20,
|
||||
absoluteMouseX: 110,
|
||||
absoluteMouseY: 220,
|
||||
});
|
||||
expect(posToIdx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the focused series and clicked timestamp on an aligned plot', () => {
|
||||
const plot = {
|
||||
data: [[100, 200, 300]],
|
||||
posToVal: (pos: number): number => pos,
|
||||
posToIdx: (): number => 1,
|
||||
} as unknown as uPlot;
|
||||
|
||||
expect(buildClickData(event, plot)).toMatchObject({
|
||||
focusedSeries: { seriesIndex: 1 },
|
||||
clickedDataTimestamp: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -174,25 +174,6 @@ export function createLayoutObserver(
|
||||
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
|
||||
const xValue = plot.posToVal(event.offsetX, 'x');
|
||||
const yValue = plot.posToVal(event.offsetY, 'y');
|
||||
const pointer = {
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
};
|
||||
|
||||
// A faceted plot (scatter) has no shared x series, so `data[0]` is null and
|
||||
// `posToIdx` would throw; the hit is the plot's own to resolve.
|
||||
if (plot.data[0] == null) {
|
||||
return {
|
||||
xValue,
|
||||
yValue,
|
||||
focusedSeries: null,
|
||||
clickedDataTimestamp: xValue,
|
||||
...pointer,
|
||||
};
|
||||
}
|
||||
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
|
||||
|
||||
const dataIndex = plot.posToIdx(event.offsetX);
|
||||
@@ -212,6 +193,9 @@ export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
|
||||
yValue,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp,
|
||||
...pointer,
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
keepDecadeSplits,
|
||||
logScaleSplits,
|
||||
spacedLogLabels,
|
||||
} from '../logGridSplits';
|
||||
|
||||
const filter = (splits: number[]): (number | null)[] =>
|
||||
keepDecadeSplits({} as uPlot, splits, 0, 0, 0) as (number | null)[];
|
||||
|
||||
describe('keepDecadeSplits', () => {
|
||||
it('keeps the powers of ten and drops the minor splits between them', () => {
|
||||
expect(filter([1, 2, 5, 9, 10, 20, 100, 300])).toStrictEqual([
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
10,
|
||||
null,
|
||||
100,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps sub-unit decades despite float noise', () => {
|
||||
expect(filter([0.001, 0.0010000000000000002, 0.002, 0.1])).toStrictEqual([
|
||||
0.001,
|
||||
0.0010000000000000002,
|
||||
null,
|
||||
0.1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps zero and negative decades on a symmetric log', () => {
|
||||
expect(filter([-100, -50, -1, 0, 1, 5])).toStrictEqual([
|
||||
-100,
|
||||
null,
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logScaleSplits', () => {
|
||||
const splitsFor = (min: number, max: number): number[] =>
|
||||
(
|
||||
logScaleSplits as (
|
||||
u: uPlot,
|
||||
axisIdx: number,
|
||||
min: number,
|
||||
max: number,
|
||||
) => number[]
|
||||
)({} as uPlot, 0, min, max);
|
||||
|
||||
it('splits at 1, 2 and 5 × 10ⁿ inside an unsnapped range', () => {
|
||||
expect(splitsFor(10.6, 990)).toStrictEqual([20, 50, 100, 200, 500]);
|
||||
});
|
||||
|
||||
it('includes the ends when they fall on a split', () => {
|
||||
expect(splitsFor(0.1, 10)).toStrictEqual([0.1, 0.2, 0.5, 1, 2, 5, 10]);
|
||||
});
|
||||
|
||||
it('is empty for a range a log scale cannot hold', () => {
|
||||
expect(splitsFor(0, 10)).toStrictEqual([]);
|
||||
expect(splitsFor(5, 5)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spacedLogLabels', () => {
|
||||
// 100px per decade.
|
||||
const plot = {
|
||||
axes: [{ scale: 'x' }],
|
||||
valToPos: (value: number): number => Math.log10(value) * 100,
|
||||
} as unknown as uPlot;
|
||||
const label = (splits: number[], space: number): (number | null)[] =>
|
||||
spacedLogLabels(plot, splits, 0, space, 0) as (number | null)[];
|
||||
|
||||
it('always labels the powers of ten', () => {
|
||||
expect(label([10, 20, 50, 100], 200)).toStrictEqual([10, null, null, 100]);
|
||||
});
|
||||
|
||||
it('adds a 2 or 5 that clears the labels already kept', () => {
|
||||
// 20 is 30px from 10; 50 is 70px from 10 and 30px from 100.
|
||||
expect(label([10, 20, 50, 100], 25)).toStrictEqual([10, 20, 50, 100]);
|
||||
expect(label([10, 20, 50, 100], 40)).toStrictEqual([10, null, null, 100]);
|
||||
});
|
||||
|
||||
it('labels both ends of a range spanning under a decade', () => {
|
||||
// 100 sits at 200px; 20 (130px) and 500 (270px) clear it, 50 and 200 do not.
|
||||
expect(label([20, 50, 100, 200, 500], 60)).toStrictEqual([
|
||||
20,
|
||||
null,
|
||||
100,
|
||||
null,
|
||||
500,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
const DECADE_TOLERANCE = 1e-9;
|
||||
|
||||
function isDecade(value: number): boolean {
|
||||
if (value === 0) {
|
||||
return true;
|
||||
}
|
||||
const exponent = Math.log10(Math.abs(value));
|
||||
return Math.abs(exponent - Math.round(exponent)) < DECADE_TOLERANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* A log axis splits at every 1–9 × 10ⁿ; a line on each buries the data under
|
||||
* a mesh. Keeps the powers of ten (and 0, which a symmetric log can include).
|
||||
*/
|
||||
export const keepDecadeSplits: uPlot.Axis.Filter = (_u, splits) =>
|
||||
splits.map((split) => (split != null && isDecade(split) ? split : null));
|
||||
|
||||
const LOG_SPLIT_MULTIPLES = [1, 2, 5];
|
||||
|
||||
/**
|
||||
* Splits at 1, 2 and 5 × 10ⁿ inside the scale. uPlot's own log splits step
|
||||
* from the scale's minimum, so a range not snapped to a power of ten would
|
||||
* label 10.79, 21.58, … instead.
|
||||
*/
|
||||
export const logScaleSplits: uPlot.Axis.Splits = (_u, _axisIdx, min, max) => {
|
||||
if (!(min > 0) || !(max > min)) {
|
||||
return [];
|
||||
}
|
||||
const splits: number[] = [];
|
||||
for (
|
||||
let exponent = Math.floor(Math.log10(min));
|
||||
exponent <= Math.ceil(Math.log10(max));
|
||||
exponent++
|
||||
) {
|
||||
LOG_SPLIT_MULTIPLES.forEach((multiple) => {
|
||||
// Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max].
|
||||
const split = Number((multiple * 10 ** exponent).toPrecision(12));
|
||||
if (split >= min && split <= max) {
|
||||
splits.push(split);
|
||||
}
|
||||
});
|
||||
}
|
||||
return splits;
|
||||
};
|
||||
|
||||
/**
|
||||
* Labels for `logScaleSplits`: every power of ten, then any 2 or 5 that sits
|
||||
* at least `space` px from the labels already kept, so a range spanning under
|
||||
* one decade still reads at both ends.
|
||||
*/
|
||||
export const spacedLogLabels: uPlot.Axis.Filter = (
|
||||
u,
|
||||
splits,
|
||||
axisIdx,
|
||||
space,
|
||||
) => {
|
||||
const scaleKey = u.axes[axisIdx]?.scale ?? 'x';
|
||||
const positions = splits.map((split) =>
|
||||
split == null ? null : u.valToPos(split, scaleKey),
|
||||
);
|
||||
const kept = splits.map((split) => split != null && isDecade(split));
|
||||
const isClear = (index: number): boolean =>
|
||||
kept.every(
|
||||
(isKept, other) =>
|
||||
!isKept ||
|
||||
Math.abs((positions[other] ?? 0) - (positions[index] ?? 0)) >= space,
|
||||
);
|
||||
splits.forEach((split, index) => {
|
||||
if (split != null && !kept[index] && isClear(index)) {
|
||||
kept[index] = true;
|
||||
}
|
||||
});
|
||||
return splits.map((split, index) => (kept[index] ? split : null));
|
||||
};
|
||||
@@ -37,7 +37,6 @@ export default function ChartWrapper({
|
||||
syncKey,
|
||||
syncFilterMode,
|
||||
onDestroy = noop,
|
||||
plotRef,
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
@@ -115,7 +114,6 @@ export default function ChartWrapper({
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
plotRef?.(plot);
|
||||
}}
|
||||
onDestroy={(plot: uPlot): void => {
|
||||
plotInstanceRef.current = null;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
import ChartWrapper from '../ChartWrapper';
|
||||
|
||||
const FAKE_PLOT = { id: 'plot' } as unknown as uPlot;
|
||||
|
||||
jest.mock('lib/uPlotV2/components/UPlotChart/UPlotChart', () => ({
|
||||
__esModule: true,
|
||||
default: ({ plotRef }: { plotRef?: (plot: uPlot | null) => void }): null => {
|
||||
plotRef?.(FAKE_PLOT);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ChartWrapper', () => {
|
||||
it('hands the plot instance to the caller', () => {
|
||||
const plotRef = jest.fn();
|
||||
render(
|
||||
<ChartWrapper
|
||||
config={new UPlotConfigBuilder({ id: 'chart' })}
|
||||
data={[[1], [2]]}
|
||||
width={400}
|
||||
height={300}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
showLegend={false}
|
||||
showTooltip={false}
|
||||
plotRef={plotRef}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(plotRef).toHaveBeenCalledWith(FAKE_PLOT);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,6 @@ import Scatter from './Scatter';
|
||||
import {
|
||||
buildScatterConfig,
|
||||
prepareScatterChartData,
|
||||
ScatterAxisScale,
|
||||
ScatterSeries,
|
||||
} from './utils';
|
||||
|
||||
@@ -33,8 +32,8 @@ interface ScatterStoryProps {
|
||||
pointsPerGroup: number;
|
||||
/** Adds an error-count size column. */
|
||||
sized: boolean;
|
||||
xScale: ScatterAxisScale;
|
||||
yScale: ScatterAxisScale;
|
||||
xLog: boolean;
|
||||
yLog: boolean;
|
||||
/** Zeroes a share of y values, which forces the symmetric log. */
|
||||
withZeros: boolean;
|
||||
shape: Shape;
|
||||
@@ -107,8 +106,8 @@ const THRESHOLDS: Threshold[] = [
|
||||
|
||||
function ScatterStory(props: ScatterStoryProps): JSX.Element {
|
||||
const {
|
||||
xScale,
|
||||
yScale,
|
||||
xLog,
|
||||
yLog,
|
||||
sized,
|
||||
thresholds,
|
||||
pointSize,
|
||||
@@ -128,8 +127,8 @@ function ScatterStory(props: ScatterStoryProps): JSX.Element {
|
||||
id: 'scatter-story',
|
||||
series,
|
||||
isDarkMode,
|
||||
x: { unit: 'reqps', scale: xScale },
|
||||
y: { unit: 'ms', scale: yScale },
|
||||
x: { unit: 'reqps', isLogScale: xLog },
|
||||
y: { unit: 'ms', isLogScale: yLog },
|
||||
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
|
||||
fillOpacity,
|
||||
thresholds: thresholds ? THRESHOLDS : undefined,
|
||||
@@ -142,7 +141,7 @@ function ScatterStory(props: ScatterStoryProps): JSX.Element {
|
||||
setDrawMs(performance.now() - started);
|
||||
});
|
||||
return builder;
|
||||
}, [series, isDarkMode, xScale, yScale, pointSize, fillOpacity, thresholds]);
|
||||
}, [series, isDarkMode, xLog, yLog, pointSize, fillOpacity, thresholds]);
|
||||
|
||||
const data = useMemo(() => prepareScatterChartData(series), [series]);
|
||||
|
||||
@@ -188,8 +187,8 @@ const meta = {
|
||||
groups: 1,
|
||||
pointsPerGroup: 10,
|
||||
sized: false,
|
||||
xScale: ScatterAxisScale.Linear,
|
||||
yScale: ScatterAxisScale.Linear,
|
||||
xLog: false,
|
||||
yLog: false,
|
||||
withZeros: false,
|
||||
shape: 'spread',
|
||||
thresholds: false,
|
||||
@@ -200,8 +199,6 @@ const meta = {
|
||||
},
|
||||
argTypes: {
|
||||
shape: { control: 'radio', options: ['spread', 'single', 'sameX'] },
|
||||
xScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
|
||||
yScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
|
||||
fillOpacity: { control: { type: 'range', min: 0, max: 1, step: 0.05 } },
|
||||
pointSize: { control: { type: 'range', min: 2, max: 16, step: 1 } },
|
||||
},
|
||||
@@ -229,8 +226,8 @@ export const LogAxes: Story = {
|
||||
args: {
|
||||
groups: 5,
|
||||
pointsPerGroup: 60,
|
||||
xScale: ScatterAxisScale.Log,
|
||||
yScale: ScatterAxisScale.Log,
|
||||
xLog: true,
|
||||
yLog: true,
|
||||
withZeros: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import ScatterTooltip from 'lib/uPlotV2/components/Tooltip/ScatterTooltip';
|
||||
import {
|
||||
ScatterTooltipProps,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ScatterChartProps } from 'lib/visualization/charts/types';
|
||||
|
||||
import { getSeriesStroke } from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
|
||||
|
||||
import { getCursorHit } from './utils';
|
||||
|
||||
// Faceted uPlot reads series 1's facets at init, so a chart with no series cannot
|
||||
// mount; empty aligned data makes the shell show its no-data state instead.
|
||||
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
|
||||
@@ -25,39 +20,9 @@ export default function Scatter(props: ScatterChartProps): JSX.Element {
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
pinnedTooltipElement,
|
||||
onPointClick,
|
||||
plotRef,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const handlePlotRef = useCallback(
|
||||
(plot: uPlot | null): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
plotRef?.(plot);
|
||||
},
|
||||
[plotRef],
|
||||
);
|
||||
|
||||
// The shared click data finds its series by x position, which a faceted plot
|
||||
// has no single axis for; the hit comes from the cursor instead.
|
||||
const handleClick = useCallback(
|
||||
(click: ChartClickData): void => {
|
||||
const plot = plotInstanceRef.current;
|
||||
const hit = plot ? getCursorHit(plot) : null;
|
||||
if (!plot || !hit || !onPointClick) {
|
||||
return;
|
||||
}
|
||||
onPointClick({
|
||||
...hit,
|
||||
color: getSeriesStroke(plot, hit.seriesIndex),
|
||||
coordinates: { x: click.absoluteMouseX, y: click.absoluteMouseY },
|
||||
});
|
||||
},
|
||||
[onPointClick],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customTooltip) {
|
||||
@@ -91,8 +56,6 @@ export default function Scatter(props: ScatterChartProps): JSX.Element {
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
data={hasSeries ? rest.data : EMPTY_ALIGNED_DATA}
|
||||
plotRef={handlePlotRef}
|
||||
onClick={onPointClick ? handleClick : rest.onClick}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { createScatterRange, type ScatterRangeOptions } from '../scatterRange';
|
||||
|
||||
/** A 220px-tall, 420px-wide plot at a device pixel ratio of 1. */
|
||||
const plot = { bbox: { width: 420, height: 220 } } as unknown as uPlot;
|
||||
|
||||
function rangeOf(
|
||||
options: Partial<ScatterRangeOptions>,
|
||||
dataMin: number | null,
|
||||
dataMax: number | null,
|
||||
): [number, number] {
|
||||
const range = createScatterRange({
|
||||
dimension: 'y',
|
||||
marginPx: 10,
|
||||
distribution: DistributionType.Linear,
|
||||
...options,
|
||||
}) as (u: uPlot, min: number | null, max: number | null) => [number, number];
|
||||
return range(plot, dataMin, dataMax);
|
||||
}
|
||||
|
||||
describe('createScatterRange', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(uPlot, 'pxRatio', { value: 1, configurable: true });
|
||||
});
|
||||
|
||||
it('widens a linear range so the data sits marginPx inside each edge', () => {
|
||||
// 200px of data inside 220px: 10px at each end is 1/20 of the span.
|
||||
expect(rangeOf({}, 0, 100)).toStrictEqual([-5, 105]);
|
||||
});
|
||||
|
||||
it('measures the margin along its own axis', () => {
|
||||
// 400px of data inside 420px.
|
||||
expect(rangeOf({ dimension: 'x' }, 0, 100)).toStrictEqual([-2.5, 102.5]);
|
||||
});
|
||||
|
||||
it('pads a log range in decades, without snapping to the next power of ten', () => {
|
||||
const [min, max] = rangeOf(
|
||||
{ distribution: DistributionType.Logarithmic },
|
||||
1,
|
||||
10,
|
||||
);
|
||||
expect(Math.log10(min)).toBeCloseTo(-0.05);
|
||||
expect(Math.log10(max)).toBeCloseTo(1.05);
|
||||
});
|
||||
|
||||
it('pads a symmetric log range in its asinh space', () => {
|
||||
const [min, max] = rangeOf(
|
||||
{ distribution: DistributionType.SymmetricLog, asinhThreshold: 2 },
|
||||
0,
|
||||
20,
|
||||
);
|
||||
const span = Math.asinh(10);
|
||||
expect(Math.asinh(min / 2)).toBeCloseTo(-span / 20);
|
||||
expect(Math.asinh(max / 2)).toBeCloseTo(span + span / 20);
|
||||
});
|
||||
|
||||
it('stretches to the soft limits before padding', () => {
|
||||
expect(rangeOf({ softMin: 0, softMax: 200 }, 50, 100)).toStrictEqual([
|
||||
-10, 210,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a soft min at or below zero on a log scale', () => {
|
||||
const [min] = rangeOf(
|
||||
{ distribution: DistributionType.Logarithmic, softMin: 0 },
|
||||
10,
|
||||
100,
|
||||
);
|
||||
expect(min).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('spreads a single value around itself', () => {
|
||||
const [min, max] = rangeOf({}, 5, 5);
|
||||
expect(min).toBeLessThan(5);
|
||||
expect(max).toBeGreaterThan(5);
|
||||
expect(5 - min).toBeCloseTo(max - 5);
|
||||
});
|
||||
|
||||
it('falls back to a unit range with no data', () => {
|
||||
expect(rangeOf({}, null, null)).toStrictEqual([0, 1]);
|
||||
expect(
|
||||
rangeOf({ distribution: DistributionType.Logarithmic }, null, null),
|
||||
).toStrictEqual([1, 10]);
|
||||
});
|
||||
|
||||
it('skips the margin on a plot too small to hold it', () => {
|
||||
const tiny = { bbox: { width: 15, height: 15 } } as unknown as uPlot;
|
||||
const range = createScatterRange({
|
||||
dimension: 'y',
|
||||
marginPx: 10,
|
||||
distribution: DistributionType.Linear,
|
||||
}) as (u: uPlot, min: number, max: number) => [number, number];
|
||||
expect(range(tiny, 0, 100)).toStrictEqual([0, 100]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,9 @@
|
||||
import type uPlot from 'uplot';
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
buildScatterConfig,
|
||||
getCursorHit,
|
||||
prepareScatterChartData,
|
||||
resolveAxisDistribution,
|
||||
ScatterAxisScale,
|
||||
ScatterSeries,
|
||||
} from '../utils';
|
||||
|
||||
@@ -33,92 +30,32 @@ describe('prepareScatterChartData', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCursorHit', () => {
|
||||
const plotWith = (idxs: Array<number | null>): uPlot =>
|
||||
({ cursor: { idxs } }) as unknown as uPlot;
|
||||
|
||||
it('reads the series the scatter plugin answered for', () => {
|
||||
expect(getCursorHit(plotWith([null, null, 4, null]))).toStrictEqual({
|
||||
seriesIndex: 2,
|
||||
dataIndex: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes a data index of 0 as a hit', () => {
|
||||
expect(getCursorHit(plotWith([0, 0]))).toStrictEqual({
|
||||
seriesIndex: 1,
|
||||
dataIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('is null off every dot', () => {
|
||||
expect(getCursorHit(plotWith([null, null]))).toBeNull();
|
||||
expect(getCursorHit({ cursor: {} } as unknown as uPlot)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAxisDistribution', () => {
|
||||
it('is linear when asked, whatever the values', () => {
|
||||
expect(
|
||||
resolveAxisDistribution([1, 1e6], ScatterAxisScale.Linear),
|
||||
).toStrictEqual({ distribution: DistributionType.Linear });
|
||||
it('is linear unless log is asked for', () => {
|
||||
expect(resolveAxisDistribution([0, 1], false)).toStrictEqual({
|
||||
distribution: DistributionType.Linear,
|
||||
});
|
||||
});
|
||||
|
||||
it('is a plain log when every value is positive', () => {
|
||||
expect(resolveAxisDistribution([1, 100], ScatterAxisScale.Log)).toStrictEqual(
|
||||
{ distribution: DistributionType.Logarithmic },
|
||||
);
|
||||
expect(resolveAxisDistribution([1, 100], true)).toStrictEqual({
|
||||
distribution: DistributionType.Logarithmic,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back from log to a symmetric log around the smallest magnitude when zero is present', () => {
|
||||
expect(
|
||||
resolveAxisDistribution([0, 0.05, 300], ScatterAxisScale.Log),
|
||||
).toStrictEqual({
|
||||
it('falls back to a symmetric log around the smallest magnitude when zero is present', () => {
|
||||
expect(resolveAxisDistribution([0, 0.05, 300], true)).toStrictEqual({
|
||||
distribution: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 0.01,
|
||||
});
|
||||
});
|
||||
|
||||
it('is a symmetric log when asked, even with only positive values', () => {
|
||||
expect(
|
||||
resolveAxisDistribution([2, 300], ScatterAxisScale.SymLog),
|
||||
).toStrictEqual({
|
||||
distribution: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a unit threshold when nothing is positive', () => {
|
||||
expect(
|
||||
resolveAxisDistribution([0, -5], ScatterAxisScale.SymLog),
|
||||
).toStrictEqual({
|
||||
expect(resolveAxisDistribution([0, -5], true)).toStrictEqual({
|
||||
distribution: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 1,
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto', () => {
|
||||
it.each([
|
||||
[
|
||||
'positive values spanning three decades',
|
||||
[1, 1000],
|
||||
DistributionType.Logarithmic,
|
||||
],
|
||||
['positive values spanning less', [1, 999], DistributionType.Linear],
|
||||
['a wide span that includes zero', [0, 1, 1e6], DistributionType.Linear],
|
||||
['no finite values', [Number.NaN], DistributionType.Linear],
|
||||
])('picks a scale for %s', (_, values, distribution) => {
|
||||
expect(resolveAxisDistribution(values, ScatterAxisScale.Auto)).toStrictEqual(
|
||||
{ distribution },
|
||||
);
|
||||
});
|
||||
|
||||
it('is the default', () => {
|
||||
expect(resolveAxisDistribution([1, 1e4])).toStrictEqual({
|
||||
distribution: DistributionType.Logarithmic,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildScatterConfig', () => {
|
||||
@@ -130,21 +67,10 @@ describe('buildScatterConfig', () => {
|
||||
series: SERIES,
|
||||
isDarkMode: true,
|
||||
x: { unit: 'reqps' },
|
||||
y: { unit: 'ms', scale: ScatterAxisScale.Log },
|
||||
y: { unit: 'ms', isLogScale: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('titles each axis with its label, and leaves an unlabelled one bare', () => {
|
||||
const config = build({
|
||||
x: { unit: 'reqps', label: 'Throughput' },
|
||||
y: { unit: 'ms', label: '' },
|
||||
}).getConfig();
|
||||
const [xAxis, yAxis] = config.axes ?? [];
|
||||
|
||||
expect(xAxis?.label).toBe('Throughput');
|
||||
expect(yAxis?.label).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits a faceted plot with two value scales', () => {
|
||||
const config = build().getConfig();
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { AxisDistribution } from './utils';
|
||||
|
||||
interface ScaleTransform {
|
||||
forward: (value: number) => number;
|
||||
inverse: (position: number) => number;
|
||||
}
|
||||
|
||||
/** The space uPlot lays the scale out in, so a pixel margin is the same size along it. */
|
||||
function getScaleTransform({
|
||||
distribution,
|
||||
asinhThreshold = 1,
|
||||
}: AxisDistribution): ScaleTransform {
|
||||
switch (distribution) {
|
||||
case DistributionType.Logarithmic:
|
||||
return {
|
||||
forward: (value): number => Math.log10(value),
|
||||
inverse: (position): number => 10 ** position,
|
||||
};
|
||||
case DistributionType.SymmetricLog:
|
||||
return {
|
||||
forward: (value): number => Math.asinh(value / asinhThreshold),
|
||||
inverse: (position): number => Math.sinh(position) * asinhThreshold,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
forward: (value): number => value,
|
||||
inverse: (position): number => position,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Half the span a single value spreads to, in transformed units. */
|
||||
const SINGLE_VALUE_HALF_SPAN = 0.5;
|
||||
|
||||
/** The data's extent stretched to the soft limits; null with neither. */
|
||||
function resolveExtent(
|
||||
dataMin: number | null,
|
||||
dataMax: number | null,
|
||||
softMin: number | null | undefined,
|
||||
softMax: number | null | undefined,
|
||||
): [number, number] | null {
|
||||
const lows = [dataMin, softMin].filter(
|
||||
(value): value is number => value != null && Number.isFinite(value),
|
||||
);
|
||||
const highs = [dataMax, softMax].filter(
|
||||
(value): value is number => value != null && Number.isFinite(value),
|
||||
);
|
||||
return lows.length > 0 && highs.length > 0
|
||||
? [Math.min(...lows), Math.max(...highs)]
|
||||
: null;
|
||||
}
|
||||
|
||||
export interface ScatterRangeOptions extends AxisDistribution {
|
||||
/** The axis this scale is laid out along. */
|
||||
dimension: 'x' | 'y';
|
||||
/** CSS pixels kept clear at each end so the largest dot draws whole. */
|
||||
marginPx: number;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fits the scale to the data and then widens it by `marginPx` at each end.
|
||||
* uPlot clips series to the plot area, so a dot at the extreme value would
|
||||
* otherwise lose the half that hangs past the edge. Log scales are left
|
||||
* unsnapped: rounding out to the next power of ten after the margin can add a
|
||||
* whole empty decade.
|
||||
*/
|
||||
export function createScatterRange({
|
||||
dimension,
|
||||
marginPx,
|
||||
softMin,
|
||||
softMax,
|
||||
...distribution
|
||||
}: ScatterRangeOptions): uPlot.Scale.Range {
|
||||
const transform = getScaleTransform(distribution);
|
||||
const isLog = distribution.distribution === DistributionType.Logarithmic;
|
||||
|
||||
// A log scale cannot place a soft limit at or below zero.
|
||||
const usableSoftMin = isLog && (softMin ?? 0) <= 0 ? null : softMin;
|
||||
|
||||
return (u, dataMin, dataMax): uPlot.Range.MinMax => {
|
||||
const extent = resolveExtent(dataMin, dataMax, usableSoftMin, softMax);
|
||||
if (!extent) {
|
||||
return isLog ? [1, 10] : [0, 1];
|
||||
}
|
||||
|
||||
let start = transform.forward(extent[0]);
|
||||
let end = transform.forward(extent[1]);
|
||||
if (start === end) {
|
||||
start -= SINGLE_VALUE_HALF_SPAN;
|
||||
end += SINGLE_VALUE_HALF_SPAN;
|
||||
}
|
||||
|
||||
const plotPx =
|
||||
(dimension === 'x' ? u.bbox.width : u.bbox.height) / uPlot.pxRatio;
|
||||
if (plotPx > 2 * marginPx) {
|
||||
const pad = ((end - start) * marginPx) / (plotPx - 2 * marginPx);
|
||||
start -= pad;
|
||||
end += pad;
|
||||
}
|
||||
|
||||
return [transform.inverse(start), transform.inverse(end)];
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
type AxisProps,
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
SelectionPreferencesSource,
|
||||
@@ -18,15 +17,8 @@ import {
|
||||
ScatterPointSize,
|
||||
ScatterSeriesData,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
import {
|
||||
logScaleSplits,
|
||||
spacedLogLabels,
|
||||
} from 'lib/uPlotV2/utils/logGridSplits';
|
||||
import { adjustSoftLimitsWithThresholds } from 'lib/uPlotV2/utils/scale';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { createScatterRange } from './scatterRange';
|
||||
|
||||
/** Circle outline; the fill carries the colour. */
|
||||
const POINT_STROKE_WIDTH = 1;
|
||||
|
||||
@@ -43,25 +35,11 @@ export interface ScatterSeries {
|
||||
sizes?: Array<number | null>;
|
||||
}
|
||||
|
||||
export enum ScatterAxisScale {
|
||||
/** Log when the values are positive and span several decades, else linear. */
|
||||
Auto = 'auto',
|
||||
Linear = 'linear',
|
||||
Log = 'log',
|
||||
/** Log-like, but places zero and negatives. */
|
||||
SymLog = 'symlog',
|
||||
}
|
||||
|
||||
/** Decades of positive values `auto` needs before it picks a log axis. */
|
||||
export const AUTO_LOG_MIN_DECADES = 3;
|
||||
|
||||
export interface ScatterAxisOptions {
|
||||
/** Axis title. */
|
||||
label?: string;
|
||||
unit?: string;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
scale?: ScatterAxisScale;
|
||||
isLogScale?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildScatterConfigArgs {
|
||||
@@ -81,23 +59,6 @@ export interface BuildScatterConfigArgs {
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dot under the cursor, read off uPlot's cursor: the scatter plugin's
|
||||
* `dataIdx` answers only for the hit series, so its index is the one set.
|
||||
*/
|
||||
export function getCursorHit(
|
||||
plot: uPlot,
|
||||
): { seriesIndex: number; dataIndex: number } | null {
|
||||
const idxs = plot.cursor.idxs ?? [];
|
||||
for (let seriesIndex = 1; seriesIndex < idxs.length; seriesIndex++) {
|
||||
const dataIndex = idxs[seriesIndex];
|
||||
if (dataIndex != null) {
|
||||
return { seriesIndex, dataIndex };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `[null, [xs, ys, sizes?], …]`: uPlot's faceted layout, series 0 empty. */
|
||||
export function prepareScatterChartData(
|
||||
series: ScatterSeries[],
|
||||
@@ -117,79 +78,40 @@ export interface AxisDistribution {
|
||||
asinhThreshold?: number;
|
||||
}
|
||||
|
||||
function getPositiveRange(values: number[]): {
|
||||
minPositive: number;
|
||||
maxPositive: number;
|
||||
hasNonPositive: boolean;
|
||||
} {
|
||||
/**
|
||||
* A log axis needs every value above zero; a rate that is sometimes 0 would drop
|
||||
* those points. Zero or negatives switch to a symmetric log whose linear band
|
||||
* ends at the smallest non-zero magnitude, so nothing is lost and the small
|
||||
* values still spread out.
|
||||
*/
|
||||
export function resolveAxisDistribution(
|
||||
values: number[],
|
||||
isLogScale?: boolean,
|
||||
): AxisDistribution {
|
||||
if (!isLogScale) {
|
||||
return { distribution: DistributionType.Linear };
|
||||
}
|
||||
let minPositive = Infinity;
|
||||
let maxPositive = -Infinity;
|
||||
let hasNonPositive = false;
|
||||
let needsSymmetric = false;
|
||||
for (const value of values) {
|
||||
if (!Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
if (value <= 0) {
|
||||
hasNonPositive = true;
|
||||
needsSymmetric = true;
|
||||
} else {
|
||||
minPositive = Math.min(minPositive, value);
|
||||
maxPositive = Math.max(maxPositive, value);
|
||||
}
|
||||
}
|
||||
return { minPositive, maxPositive, hasNonPositive };
|
||||
}
|
||||
|
||||
/**
|
||||
* The symmetric log's linear band ends at the smallest non-zero magnitude, so
|
||||
* the small values still spread out.
|
||||
*/
|
||||
function symmetricLogDistribution(minPositive: number): AxisDistribution {
|
||||
if (!needsSymmetric) {
|
||||
return { distribution: DistributionType.Logarithmic };
|
||||
}
|
||||
const asinhThreshold = Number.isFinite(minPositive)
|
||||
? 10 ** Math.floor(Math.log10(minPositive))
|
||||
: 1;
|
||||
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain log axis cannot place zero or negatives, so `log` falls back to the
|
||||
* symmetric log rather than lose those points; callers that would rather drop
|
||||
* them filter first.
|
||||
*/
|
||||
export function resolveAxisDistribution(
|
||||
values: number[],
|
||||
scale: ScatterAxisScale = ScatterAxisScale.Auto,
|
||||
): AxisDistribution {
|
||||
const { minPositive, maxPositive, hasNonPositive } = getPositiveRange(values);
|
||||
switch (scale) {
|
||||
case ScatterAxisScale.Linear:
|
||||
return { distribution: DistributionType.Linear };
|
||||
case ScatterAxisScale.SymLog:
|
||||
return symmetricLogDistribution(minPositive);
|
||||
case ScatterAxisScale.Log:
|
||||
return hasNonPositive
|
||||
? symmetricLogDistribution(minPositive)
|
||||
: { distribution: DistributionType.Logarithmic };
|
||||
case ScatterAxisScale.Auto:
|
||||
default: {
|
||||
const spansDecades =
|
||||
Number.isFinite(minPositive) &&
|
||||
Math.log10(maxPositive / minPositive) >= AUTO_LOG_MIN_DECADES;
|
||||
return !hasNonPositive && spansDecades
|
||||
? { distribution: DistributionType.Logarithmic }
|
||||
: { distribution: DistributionType.Linear };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The scatter range is unsnapped, so a log axis places and labels its own ticks. */
|
||||
function getLogAxisTicks({
|
||||
distribution,
|
||||
}: AxisDistribution): Pick<AxisProps, 'splits' | 'filter'> {
|
||||
return distribution === DistributionType.Logarithmic
|
||||
? { splits: logScaleSplits, filter: spacedLogLabels }
|
||||
: {};
|
||||
}
|
||||
|
||||
export function buildScatterConfig({
|
||||
id,
|
||||
series,
|
||||
@@ -217,11 +139,11 @@ export function buildScatterConfig({
|
||||
|
||||
const xDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.xs),
|
||||
x.scale,
|
||||
x.isLogScale,
|
||||
);
|
||||
const yDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.ys),
|
||||
y.scale,
|
||||
y.isLogScale,
|
||||
);
|
||||
|
||||
const yThresholds =
|
||||
@@ -229,30 +151,11 @@ export function buildScatterConfig({
|
||||
? { scaleKey: 'y', thresholds, yAxisUnit: y.unit }
|
||||
: undefined;
|
||||
|
||||
// The largest disc drawn, plus its outline, kept clear of each plot edge.
|
||||
const largestDiameter = series.some((entry) => entry.sizes)
|
||||
? pointSize.max
|
||||
: pointSize.fixed;
|
||||
const marginPx = largestDiameter / 2 + POINT_STROKE_WIDTH;
|
||||
const ySoftLimits = adjustSoftLimitsWithThresholds(
|
||||
y.softMin ?? null,
|
||||
y.softMax ?? null,
|
||||
thresholds,
|
||||
y.unit,
|
||||
);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
softMin: x.softMin ?? undefined,
|
||||
softMax: x.softMax ?? undefined,
|
||||
range: createScatterRange({
|
||||
dimension: 'x',
|
||||
marginPx,
|
||||
softMin: x.softMin,
|
||||
softMax: x.softMax,
|
||||
...xDistribution,
|
||||
}),
|
||||
...xDistribution,
|
||||
});
|
||||
builder.addScale({
|
||||
@@ -261,36 +164,26 @@ export function buildScatterConfig({
|
||||
softMin: y.softMin ?? undefined,
|
||||
softMax: y.softMax ?? undefined,
|
||||
thresholds: yThresholds,
|
||||
range: createScatterRange({
|
||||
dimension: 'y',
|
||||
marginPx,
|
||||
...ySoftLimits,
|
||||
...yDistribution,
|
||||
}),
|
||||
...yDistribution,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
label: x.label || undefined,
|
||||
isDarkMode,
|
||||
isTimeAxis: false,
|
||||
yAxisUnit: x.unit ?? '',
|
||||
decimalPrecision,
|
||||
isLogScale: xDistribution.distribution !== DistributionType.Linear,
|
||||
...getLogAxisTicks(xDistribution),
|
||||
space: X_AXIS_TICK_SPACE_PX,
|
||||
});
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
label: y.label || undefined,
|
||||
isDarkMode,
|
||||
yAxisUnit: y.unit ?? '',
|
||||
decimalPrecision,
|
||||
isLogScale: yDistribution.distribution !== DistributionType.Linear,
|
||||
...getLogAxisTicks(yDistribution),
|
||||
});
|
||||
|
||||
series.forEach((entry) => {
|
||||
|
||||
@@ -78,16 +78,6 @@ export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
/** The dot a click landed on; `seriesIndex` is uPlot's, so the first series is 1. */
|
||||
export interface ScatterPointClick {
|
||||
seriesIndex: number;
|
||||
dataIndex: number;
|
||||
/** The series' drawn colour. */
|
||||
color: string;
|
||||
/** Viewport coordinates of the click. */
|
||||
coordinates: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** `data` is mode-2 (`prepareScatterChartData`); `config` comes from `buildScatterConfig`. */
|
||||
export interface ScatterChartProps extends ChartWrapperProps {
|
||||
channels: ScatterChannels;
|
||||
@@ -95,8 +85,6 @@ export interface ScatterChartProps extends ChartWrapperProps {
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
) => ScatterPointLabel[];
|
||||
/** Fires for a click on a dot; a click on empty plot space fires nothing. */
|
||||
onPointClick?: (click: ScatterPointClick) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
background-color: var(--l1-background);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
padding-bottom: 44px;
|
||||
|
||||
@include custom-scrollbar;
|
||||
@@ -43,12 +42,11 @@
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
padding: 18px 20px 12px;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 16px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.group {
|
||||
@@ -75,5 +73,9 @@
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
& > * {
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
@@ -8,7 +7,6 @@ import type {
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { buildPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -27,7 +25,6 @@ interface ConfigPaneProps {
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Switch the panel to another visualization kind. */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
originalPanelKind?: PanelKind;
|
||||
/**
|
||||
* Active query type from the query-builder provider (the selected tab). Drives which
|
||||
* panel types the visualization switcher disables — read from the provider, not the
|
||||
@@ -60,7 +57,6 @@ function ConfigPane({
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
@@ -80,11 +76,6 @@ function ConfigPane({
|
||||
(config) => config.kind !== SectionKind.PanelHeader,
|
||||
);
|
||||
|
||||
const defaults = useMemo(
|
||||
() => buildPluginSpec(definition.sections),
|
||||
[definition],
|
||||
);
|
||||
|
||||
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
|
||||
|
||||
// Title/description are just a slice of the spec — edit them through the same
|
||||
@@ -129,13 +120,11 @@ function ConfigPane({
|
||||
config={headerSection}
|
||||
spec={spec}
|
||||
onChangeSpec={onChangeSpec}
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
@@ -155,13 +144,11 @@ function ConfigPane({
|
||||
config={config}
|
||||
spec={spec}
|
||||
onChangeSpec={onChangeSpec}
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
|
||||
@@ -1,56 +1,6 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 5px 5px 12px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.triggerName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.triggerAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.revert {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.drawerBody {
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import ConfigField from '../controls/ConfigField/ConfigField';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
@@ -20,96 +15,33 @@ interface PanelTypeSwitcherProps {
|
||||
queryType: EQueryType;
|
||||
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Kind the panel was opened with; a revert button appears once it differs. */
|
||||
originalPanelKind?: PanelKind;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-type selector (rendered inside the Visualization section): opens the
|
||||
* panel type browser in a drawer. A type is disabled when the active query type or
|
||||
* signal is incompatible with it — resolved through the capabilities guard.
|
||||
* Visualization-type selector (rendered inside the Visualization section). A type is
|
||||
* disabled when the active query type or signal is incompatible with it — resolved
|
||||
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
|
||||
* those query types still disable kinds that only support Query Builder (e.g. List).
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
originalPanelKind,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { displayName, icon: Icon } = getPanelDefinition(panelKind);
|
||||
|
||||
const getDisabledReason = useCallback(
|
||||
(kind: PanelKind): string | undefined =>
|
||||
getPanelTypeDisabledReason({
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label: getPanelDefinition(kind).displayName,
|
||||
}),
|
||||
[queryType, signal],
|
||||
);
|
||||
|
||||
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
|
||||
const revertBlockedReason = canRevert
|
||||
? getDisabledReason(originalPanelKind)
|
||||
: undefined;
|
||||
|
||||
const handleSelect = (kind: PanelKind): void => {
|
||||
setIsOpen(false);
|
||||
if (kind !== panelKind) {
|
||||
onChange(kind);
|
||||
}
|
||||
};
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
|
||||
return (
|
||||
<ConfigField label="Panel type" plain>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={(): void => setIsOpen(true)}
|
||||
data-testid="panel-editor-v2-type-switcher"
|
||||
>
|
||||
<Icon size={14} className={styles.triggerIcon} />
|
||||
<span className={styles.triggerName}>{displayName}</span>
|
||||
<span className={styles.triggerAction}>
|
||||
<ArrowRightLeft size={14} />
|
||||
Change
|
||||
</span>
|
||||
</button>
|
||||
{canRevert && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Undo2 />}
|
||||
className={styles.revert}
|
||||
disabled={!!revertBlockedReason}
|
||||
title={revertBlockedReason}
|
||||
onClick={(): void => onChange(originalPanelKind)}
|
||||
testId="panel-editor-v2-type-revert"
|
||||
>
|
||||
Revert to {getPanelDefinition(originalPanelKind).displayName}
|
||||
</Button>
|
||||
)}
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="Change panel type"
|
||||
subTitle="Pick a visualization for this panel."
|
||||
direction="right"
|
||||
width="wide"
|
||||
testId="panel-type-switcher-drawer"
|
||||
drawerDescriptionProps={{ className: styles.drawerBody }}
|
||||
>
|
||||
<PanelTypeBrowser
|
||||
selectedKind={panelKind}
|
||||
onSelect={handleSelect}
|
||||
getDisabledReason={getDisabledReason}
|
||||
/>
|
||||
</DrawerWrapper>
|
||||
</ConfigField>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-type-switcher"
|
||||
value={panelKind}
|
||||
items={items}
|
||||
onChange={(value): void => onChange(value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
const OPTIONS = [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
{ kind: 'signoz/TextPanel', displayName: 'Text' },
|
||||
].map((option) => ({ ...option, icon: (): null => null }));
|
||||
|
||||
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
get PANEL_OPTIONS(): unknown {
|
||||
return OPTIONS;
|
||||
},
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
@@ -36,34 +28,14 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
|
||||
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
};
|
||||
|
||||
type User = ReturnType<typeof userEvent.setup>;
|
||||
|
||||
async function renderSwitcher(
|
||||
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
|
||||
): Promise<{ onChange: jest.Mock; user: User }> {
|
||||
// The open drawer sets `pointer-events: none` on the body.
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
await user.click(screen.getByTestId('panel-editor-v2-type-switcher'));
|
||||
return { onChange, user };
|
||||
function disabledLabels(): (string | null)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
}
|
||||
|
||||
function disabledKinds(): (string | undefined)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
|
||||
)
|
||||
.filter((el) => el.getAttribute('aria-disabled') === 'true')
|
||||
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
@@ -72,8 +44,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
...OPTIONS.find((option) => option.kind === kind),
|
||||
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
|
||||
mode: 'query',
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
@@ -86,94 +57,83 @@ describe('PanelTypeSwitcher', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('shows the current type and switches to the chosen one', async () => {
|
||||
const { onChange, user } = await renderSwitcher();
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
|
||||
'Time SeriesChange',
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('does not fire onChange when the current type is picked again', async () => {
|
||||
const { onChange, user } = await renderSwitcher();
|
||||
|
||||
await user.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current signal', async () => {
|
||||
const { onChange, user } = await renderSwitcher({
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
});
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not disable any type when the signal is unknown (builder, no signal)', async () => {
|
||||
await renderSwitcher();
|
||||
|
||||
expect(disabledKinds()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', async () => {
|
||||
await renderSwitcher({ queryType: EQueryType.PROM });
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'signoz/ListPanel',
|
||||
'signoz/TablePanel',
|
||||
'signoz/PieChartPanel',
|
||||
]),
|
||||
it('disables types whose supported signals exclude the current signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
|
||||
expect(disabledKinds()).not.toContain('signoz/TextPanel');
|
||||
|
||||
openDropdown();
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', async () => {
|
||||
await renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
});
|
||||
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('revert', () => {
|
||||
it('is hidden while the type is the original one', async () => {
|
||||
await renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.PROM}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-type-revert'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
openDropdown();
|
||||
// List/Table/Pie can't be authored in PromQL; Time Series can.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).toContain('Table');
|
||||
expect(disabledLabels()).toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('switches back to the original type', async () => {
|
||||
const { onChange, user } = await renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
originalPanelKind: 'signoz/TimeSeriesPanel',
|
||||
});
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TablePanel"
|
||||
queryType={EQueryType.CLICKHOUSE}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const revert = screen.getByTestId('panel-editor-v2-type-revert');
|
||||
expect(revert).toHaveTextContent('Revert to Time Series');
|
||||
await user.click(revert);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
|
||||
});
|
||||
|
||||
it('is disabled when the original type no longer fits the query', async () => {
|
||||
await renderSwitcher({
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
originalPanelKind: 'signoz/ListPanel',
|
||||
queryType: EQueryType.PROM,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
|
||||
});
|
||||
openDropdown();
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Table');
|
||||
expect(disabledLabels()).not.toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,8 @@ interface UsePanelTypeSelectItemsArgs {
|
||||
/**
|
||||
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
|
||||
* tooltip) when the active query type or signal is incompatible — resolved through
|
||||
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
|
||||
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
|
||||
@@ -6,20 +6,16 @@ import {
|
||||
type SectionConfig,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
|
||||
import type { SectionEditorContext } from '../sectionContext';
|
||||
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
import SettingsSection from '../SettingsSection/SettingsSection';
|
||||
import { isChanged } from '../utils/changes';
|
||||
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
|
||||
|
||||
type SectionSlotProps = {
|
||||
config: SectionConfig;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** The kind's seeded plugin spec (`buildPluginSpec`). */
|
||||
defaults: SeededPluginSpec;
|
||||
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
|
||||
bare?: boolean;
|
||||
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
|
||||
@@ -56,14 +52,12 @@ function SectionSlot({
|
||||
config,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
defaults,
|
||||
bare,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
@@ -109,10 +103,8 @@ function SectionSlot({
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title } = SECTION_METADATA[config.kind];
|
||||
const { Component, get, update, summarize } = editor;
|
||||
const value = get(spec);
|
||||
const defaultValue = getSectionDefault(editor, spec, defaults);
|
||||
const { title, icon: Icon } = SECTION_METADATA[config.kind];
|
||||
const { Component, get, update } = editor;
|
||||
// Atomic sections carry no `controls`; controlled ones do.
|
||||
const controls = 'controls' in config ? config.controls : undefined;
|
||||
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
|
||||
@@ -121,26 +113,20 @@ function SectionSlot({
|
||||
|
||||
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
|
||||
|
||||
const context: SectionEditorContext = {
|
||||
legendSeries,
|
||||
yAxisUnit,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
};
|
||||
|
||||
const editorElement = (
|
||||
<Component
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
onChange={(next): void => onChangeSpec(update(spec, next))}
|
||||
{...context}
|
||||
legendSeries={legendSeries}
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
/>
|
||||
);
|
||||
@@ -152,8 +138,7 @@ function SectionSlot({
|
||||
return (
|
||||
<SettingsSection
|
||||
title={title}
|
||||
summary={summarize?.(value, controls, context)}
|
||||
changed={isChanged(value, defaultValue)}
|
||||
icon={<Icon size={15} />}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
headerSlot={headerSlot}
|
||||
|
||||
@@ -28,12 +28,7 @@ function Harness({ initial = [] }: { initial?: unknown[] } = {}): JSX.Element {
|
||||
makeSpec(initial),
|
||||
);
|
||||
return (
|
||||
<SectionSlot
|
||||
config={THRESHOLDS_CONFIG}
|
||||
spec={spec}
|
||||
defaults={{}}
|
||||
onChangeSpec={setSpec}
|
||||
/>
|
||||
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,48 +70,3 @@ describe('SectionSlot header action', () => {
|
||||
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SectionSlot header summary', () => {
|
||||
it('summarises a collapsed section and marks it changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<Harness initial={[{ value: 80, color: '#F5B225', label: 'High' }]} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('config-section-thresholds'));
|
||||
|
||||
expect(screen.getByText('1 threshold')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('config-changed-dot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no summary or changed marker for an untouched section', () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/threshold$/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SectionSlot rendered defaults', () => {
|
||||
it('does not mark an explicit value that matches what unset renders as', () => {
|
||||
const spec = {
|
||||
display: { name: 'CPU' },
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { decimalPrecision: '2' } },
|
||||
},
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
render(
|
||||
<SectionSlot
|
||||
config={{ kind: SectionKind.Formatting, controls: { decimals: true } }}
|
||||
spec={spec}
|
||||
defaults={{}}
|
||||
onChangeSpec={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,53 +1,51 @@
|
||||
.section {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 20px 0 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
// Disclosure control (icon tile + title); fills the row so the action slot and chevron sit right.
|
||||
.toggle {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 11px;
|
||||
min-width: 0;
|
||||
padding: 18px 0 18px 20px;
|
||||
padding: 0 !important;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: var(--text-vanilla-100);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.iconTile {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
flex: none;
|
||||
border-radius: 3px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l3-foreground);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.iconTileOpen {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 14%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--l3-foreground);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.open {
|
||||
@@ -58,6 +56,6 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 26px;
|
||||
padding: 4px 20px 26px;
|
||||
gap: 16px;
|
||||
padding: 2px 0 18px;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import ChangedDot from '../controls/ChangedDot/ChangedDot';
|
||||
|
||||
import styles from './SettingsSection.module.scss';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state; when set, the section defers to `onOpenChange`. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** One-line digest of the section's values, shown while collapsed. */
|
||||
summary?: string;
|
||||
changed?: boolean;
|
||||
/** Rendered between the title and the chevron. */
|
||||
headerSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -25,11 +23,10 @@ interface SettingsSectionProps {
|
||||
*/
|
||||
function SettingsSection({
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
summary,
|
||||
changed,
|
||||
headerSlot,
|
||||
children,
|
||||
}: SettingsSectionProps): JSX.Element {
|
||||
@@ -54,18 +51,30 @@ function SettingsSection({
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
|
||||
data-testid={`config-section-${serializedTitle}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className={styles.title}>{title}</span>
|
||||
{changed && <ChangedDot title="Has changed settings" />}
|
||||
<span className={styles.summary}>{isOpen ? '' : summary}</span>
|
||||
{icon && (
|
||||
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
</button>
|
||||
{headerSlot}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cx(styles.chevron, { [styles.open]: isOpen })}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={cx(styles.chevron, { [styles.open]: isOpen })}
|
||||
/>
|
||||
}
|
||||
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
|
||||
tabIndex={-1}
|
||||
onClick={toggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('ConfigPane', () => {
|
||||
|
||||
const toggle = screen.getByTestId('panel-header-hide');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(screen.getByText('Hide panel header')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hide header')).toBeInTheDocument();
|
||||
// No collapsible wrapper of its own.
|
||||
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
flex: none;
|
||||
grid-template-columns: repeat(3, 28px);
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
&:has(:focus-visible) {
|
||||
outline: 1px solid var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 10px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: var(--bg-robin-500);
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 12%, transparent);
|
||||
|
||||
.mark {
|
||||
width: 14px;
|
||||
background: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useId } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { type Alignment, ALIGNMENT_CELLS, alignmentLabel } from './alignment';
|
||||
|
||||
import styles from './AlignmentGrid.module.scss';
|
||||
|
||||
interface AlignmentGridProps {
|
||||
testId: string;
|
||||
value: Alignment;
|
||||
description?: string;
|
||||
onChange: (next: Alignment) => void;
|
||||
}
|
||||
|
||||
function AlignmentGrid({
|
||||
testId,
|
||||
value,
|
||||
description,
|
||||
onChange,
|
||||
}: AlignmentGridProps): JSX.Element {
|
||||
const name = useId();
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Text position"
|
||||
data-testid={testId}
|
||||
className={styles.grid}
|
||||
>
|
||||
{ALIGNMENT_CELLS.map((cell) => {
|
||||
const label = alignmentLabel(cell);
|
||||
const selected =
|
||||
cell.textAlign === value.textAlign &&
|
||||
cell.verticalAlign === value.verticalAlign;
|
||||
return (
|
||||
<label
|
||||
key={label}
|
||||
title={label}
|
||||
data-testid={`${testId}-${cell.verticalAlign}-${cell.textAlign}`}
|
||||
className={cx(styles.cell, { [styles.selected]: selected })}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={styles.input}
|
||||
name={name}
|
||||
checked={selected}
|
||||
aria-label={label}
|
||||
onChange={(): void => onChange(cell)}
|
||||
/>
|
||||
<span className={styles.mark} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.text}>
|
||||
<span className={styles.label}>{alignmentLabel(value)}</span>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlignmentGrid;
|
||||
@@ -1,37 +0,0 @@
|
||||
import {
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const VERTICAL_LABELS: Record<DashboardtypesVerticalAlignDTO, string> = {
|
||||
[DashboardtypesVerticalAlignDTO.top]: 'Top',
|
||||
[DashboardtypesVerticalAlignDTO.center]: 'Middle',
|
||||
[DashboardtypesVerticalAlignDTO.bottom]: 'Bottom',
|
||||
};
|
||||
|
||||
const HORIZONTAL_LABELS: Record<DashboardtypesTextAlignDTO, string> = {
|
||||
[DashboardtypesTextAlignDTO.left]: 'left',
|
||||
[DashboardtypesTextAlignDTO.center]: 'center',
|
||||
[DashboardtypesTextAlignDTO.right]: 'right',
|
||||
};
|
||||
|
||||
export interface Alignment {
|
||||
textAlign: DashboardtypesTextAlignDTO;
|
||||
verticalAlign: DashboardtypesVerticalAlignDTO;
|
||||
}
|
||||
|
||||
export const ALIGNMENT_CELLS: Alignment[] = Object.values(
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
).flatMap((verticalAlign) =>
|
||||
Object.values(DashboardtypesTextAlignDTO).map((textAlign) => ({
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
})),
|
||||
);
|
||||
|
||||
export function alignmentLabel({
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
}: Alignment): string {
|
||||
return `${VERTICAL_LABELS[verticalAlign]} ${HORIZONTAL_LABELS[textAlign]}`;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 6px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
|
||||
@@ -8,14 +8,31 @@ import {
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
|
||||
import type {
|
||||
PanelTheme,
|
||||
TextBackgroundPreset,
|
||||
TextBackgroundSelection,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
|
||||
import { BASE_TITLES, type BaseSelection, PRESET_TITLES } from './titles';
|
||||
|
||||
import styles from './BackgroundSwatches.module.scss';
|
||||
|
||||
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
|
||||
robin: 'Robin',
|
||||
purple: 'Purple',
|
||||
sakura: 'Sakura',
|
||||
cherry: 'Cherry',
|
||||
amber: 'Amber',
|
||||
forest: 'Forest',
|
||||
sienna: 'Sienna',
|
||||
slate: 'Slate',
|
||||
};
|
||||
|
||||
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
|
||||
|
||||
const BASE_TITLES: Record<BaseSelection, string> = {
|
||||
none: 'Transparent',
|
||||
default: 'Default panel',
|
||||
};
|
||||
|
||||
/** Neither base swatch shows a colour, so its tooltip says what it does. */
|
||||
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
|
||||
none: 'Transparent — no card, border or title bar',
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import {
|
||||
type ResolvedTextBackground,
|
||||
TextBackgroundKind,
|
||||
type TextBackgroundPreset,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
|
||||
export const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
|
||||
robin: 'Robin',
|
||||
purple: 'Purple',
|
||||
sakura: 'Sakura',
|
||||
cherry: 'Cherry',
|
||||
amber: 'Amber',
|
||||
forest: 'Forest',
|
||||
sienna: 'Sienna',
|
||||
slate: 'Slate',
|
||||
};
|
||||
|
||||
export type BaseSelection =
|
||||
| TextBackgroundKind.None
|
||||
| TextBackgroundKind.Default;
|
||||
|
||||
export const BASE_TITLES: Record<BaseSelection, string> = {
|
||||
none: 'Transparent',
|
||||
default: 'Default panel',
|
||||
};
|
||||
|
||||
export function backgroundTitle(background: ResolvedTextBackground): string {
|
||||
switch (background.kind) {
|
||||
case TextBackgroundKind.Preset:
|
||||
return background.preset ? PRESET_TITLES[background.preset] : 'Custom';
|
||||
case TextBackgroundKind.Custom:
|
||||
return `Custom ${background.surface?.toUpperCase() ?? ''}`.trim();
|
||||
case TextBackgroundKind.None:
|
||||
return BASE_TITLES.none;
|
||||
default:
|
||||
return BASE_TITLES.default;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-robin-500);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import styles from './ChangedDot.module.scss';
|
||||
|
||||
function ChangedDot({
|
||||
title = 'Changed from default',
|
||||
}: {
|
||||
title?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={styles.dot}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
data-testid="config-changed-dot"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangedDot;
|
||||
@@ -1,48 +0,0 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.plainLabel {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.reset {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--bg-robin-400);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ConfigField.module.scss';
|
||||
|
||||
interface ConfigFieldProps {
|
||||
label: ReactNode;
|
||||
/** Shown under the control. */
|
||||
help?: ReactNode;
|
||||
changed?: boolean;
|
||||
onReset?: () => void;
|
||||
/** Plain body label instead of the uppercase field heading. */
|
||||
plain?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ConfigField({
|
||||
label,
|
||||
help,
|
||||
changed,
|
||||
onReset,
|
||||
plain,
|
||||
className,
|
||||
children,
|
||||
}: ConfigFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.field, className)}>
|
||||
<div className={styles.header}>
|
||||
<span className={plain ? styles.plainLabel : styles.label}>{label}</span>
|
||||
{changed && onReset && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.reset}
|
||||
data-testid="config-field-reset"
|
||||
onClick={onReset}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
{help && <span className={styles.help}>{help}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigField;
|
||||
@@ -1,5 +0,0 @@
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import styles from './ConfigFieldRow.module.scss';
|
||||
|
||||
/** Side-by-side fields that wrap when the pane is narrow. */
|
||||
function ConfigFieldRow({ children }: { children: ReactNode }): JSX.Element {
|
||||
return <div className={styles.row}>{children}</div>;
|
||||
}
|
||||
|
||||
export default ConfigFieldRow;
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import ConfigField from '../ConfigField/ConfigField';
|
||||
|
||||
interface ConfigNumberInputProps {
|
||||
testId: string;
|
||||
label: string;
|
||||
value: number | null | undefined;
|
||||
onChange: (next: number | null) => void;
|
||||
}
|
||||
|
||||
/** Numeric input where empty means "auto"; transient non-numeric input (e.g. "-") clears. */
|
||||
function ConfigNumberInput({
|
||||
testId,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: ConfigNumberInputProps): JSX.Element {
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = e.target.value;
|
||||
onChange(raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw));
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigField label={label} plain>
|
||||
<Input
|
||||
data-testid={testId}
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value ?? ''}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</ConfigField>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigNumberInput;
|
||||
@@ -0,0 +1,10 @@
|
||||
.group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
|
||||
import styles from './ConfigSegmented.module.scss';
|
||||
|
||||
export interface ConfigSegmentedItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: SegmentIconName;
|
||||
}
|
||||
|
||||
interface ConfigSegmentedProps<T extends string = string> {
|
||||
testId: string;
|
||||
value: T | undefined;
|
||||
items: ConfigSegmentedItem[];
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline segmented control for short option sets in the config pane (line style, fill
|
||||
* mode, axis scale, legend position). Each segment carries an optional muted glyph that
|
||||
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
|
||||
* the Periscope ToggleGroup so it stays theme-faithful.
|
||||
*/
|
||||
function ConfigSegmented<T extends string = string>({
|
||||
testId,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
}: ConfigSegmentedProps<T>): JSX.Element {
|
||||
return (
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
testId={testId}
|
||||
className={styles.group}
|
||||
value={value}
|
||||
items={items.map((item) => ({
|
||||
value: item.value,
|
||||
'aria-label': item.label,
|
||||
label: (
|
||||
<span className={styles.segment}>
|
||||
{item.icon && <SegmentIcon name={item.icon} />}
|
||||
{item.label}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
|
||||
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
|
||||
onChange={(next: T): void => {
|
||||
if (next) {
|
||||
onChange(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSegmented;
|
||||
@@ -1,31 +1,30 @@
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background-60);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import ChangedDot from '../ChangedDot/ChangedDot';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ConfigSwitch.module.scss';
|
||||
|
||||
interface ConfigSwitchProps {
|
||||
testId: string;
|
||||
/** Shown uppercased as the card title. */
|
||||
title: string;
|
||||
/** Optional helper line under the title. */
|
||||
description?: string;
|
||||
sketch?: ReactNode;
|
||||
changed?: boolean;
|
||||
value: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean toggle rendered as a bordered card: an uppercase title with an optional
|
||||
* description on the left and a Switch on the right. The standard presentation for
|
||||
* on/off panel-config controls (e.g. "Show points").
|
||||
*/
|
||||
function ConfigSwitch({
|
||||
testId,
|
||||
title,
|
||||
description,
|
||||
sketch,
|
||||
changed,
|
||||
value,
|
||||
onChange,
|
||||
}: ConfigSwitchProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
{sketch}
|
||||
<div className={styles.text}>
|
||||
<span className={styles.title}>
|
||||
{title}
|
||||
{changed && <ChangedDot />}
|
||||
</span>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
<span className={styles.title}>{title}</span>
|
||||
{description && (
|
||||
<Typography.Text className={styles.description}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<Switch testId={testId} value={value} onChange={onChange} />
|
||||
</div>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
.container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
height: 52px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
background 150ms;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
&:has(:focus-visible) {
|
||||
outline: 1px solid var(--bg-robin-500);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: var(--bg-robin-500);
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 12%, var(--l1-background));
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@mixin stacked-tiles($columns) {
|
||||
grid-template-columns: repeat($columns, minmax(0, 1fr));
|
||||
|
||||
.tile {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: 88px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 200px) {
|
||||
.grid[data-count='2'] {
|
||||
@include stacked-tiles(2);
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 216px) {
|
||||
.grid[data-count='3'] {
|
||||
@include stacked-tiles(3);
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 220px) {
|
||||
.grid[data-count='4'] {
|
||||
@include stacked-tiles(2);
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 420px) {
|
||||
.grid[data-count='4'] {
|
||||
@include stacked-tiles(4);
|
||||
}
|
||||
}
|
||||
|
||||
.grid.compact {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.tile {
|
||||
justify-content: center;
|
||||
height: 36px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { type ReactNode, useId } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ConfigTiles.module.scss';
|
||||
|
||||
export interface ConfigTileItem<T extends string = string> {
|
||||
value: T;
|
||||
label: string;
|
||||
drawing?: ReactNode;
|
||||
}
|
||||
|
||||
interface ConfigTilesProps<T extends string> {
|
||||
testId: string;
|
||||
value: T | undefined;
|
||||
items: ConfigTileItem<T>[];
|
||||
onChange: (value: T) => void;
|
||||
/** Compact text-only tiles on one row (e.g. decimal places). */
|
||||
compact?: boolean;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-choice tiles. Columns follow the container width: rows of 4 fold to 2×2 and
|
||||
* any row too narrow for its tiles becomes a full-width list.
|
||||
*/
|
||||
function ConfigTiles<T extends string>({
|
||||
testId,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
compact,
|
||||
'aria-label': ariaLabel,
|
||||
}: ConfigTilesProps<T>): JSX.Element {
|
||||
const name = useId();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
data-count={items.length}
|
||||
className={cx(styles.grid, { [styles.compact]: compact })}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const selected = item.value === value;
|
||||
return (
|
||||
<label
|
||||
key={item.value}
|
||||
data-testid={`${testId}-${item.value}`}
|
||||
className={cx(styles.tile, { [styles.selected]: selected })}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={styles.input}
|
||||
name={name}
|
||||
value={item.value}
|
||||
checked={selected}
|
||||
aria-label={item.label}
|
||||
onChange={(): void => onChange(item.value)}
|
||||
/>
|
||||
{item.drawing}
|
||||
<span className={styles.label}>{item.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigTiles;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ColorPicker } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './LegendColors.module.scss';
|
||||
|
||||
@@ -40,9 +41,9 @@ function LegendColorRow({
|
||||
data-testid={`legend-color-${label}`}
|
||||
>
|
||||
<span className={styles.swatch} style={{ backgroundColor: color }} />
|
||||
<span className={styles.label} title={label}>
|
||||
<Typography.Text className={styles.label} title={label}>
|
||||
{label}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</button>
|
||||
</ColorPicker>
|
||||
{isOverridden && (
|
||||
|
||||
@@ -42,9 +42,7 @@
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -40,7 +40,7 @@ function LegendColors({
|
||||
if (series.length === 0) {
|
||||
return (
|
||||
<Typography.Text className={styles.empty}>
|
||||
Run the query to set a color for each series.
|
||||
Run the panel to customise series colors.
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('LegendColors', () => {
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-legend-colors'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/run the query/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/run the panel/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the search box once series are present', () => {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
function LegendFrame({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={56}
|
||||
height={30}
|
||||
viewBox="0 0 56 30"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
style={{ flex: 'none' }}
|
||||
aria-hidden
|
||||
>
|
||||
<rect
|
||||
x={1}
|
||||
y={1}
|
||||
width={54}
|
||||
height={28}
|
||||
rx={2}
|
||||
stroke="var(--l3-border)"
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegendFrame;
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
function SketchSvg({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={40}
|
||||
height={22}
|
||||
viewBox="0 0 40 22"
|
||||
fill="none"
|
||||
stroke="var(--l3-foreground)"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ flex: 'none' }}
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SketchSvg;
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type Tone = 'robin' | 'sakura' | 'forest' | 'amber';
|
||||
|
||||
const TONE_COLOR: Record<Tone, string> = {
|
||||
robin: 'var(--bg-robin-300)',
|
||||
sakura: 'var(--bg-sakura-400)',
|
||||
forest: 'var(--bg-forest-400)',
|
||||
amber: 'var(--bg-amber-400)',
|
||||
};
|
||||
|
||||
function TileSvg({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: Tone;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={64}
|
||||
height={24}
|
||||
viewBox="0 0 64 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: TONE_COLOR[tone], flex: 'none' }}
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default TileSvg;
|
||||
@@ -1,37 +0,0 @@
|
||||
import SketchSvg from './SketchSvg';
|
||||
|
||||
/** Muted sketches for on/off cards. */
|
||||
export const SWITCH_SKETCHES = {
|
||||
hideHeader: (
|
||||
<SketchSvg>
|
||||
<rect x={3} y={3} width={34} height={16} rx={2} />
|
||||
<path d="M3 8 H37" strokeDasharray="3 3" />
|
||||
</SketchSvg>
|
||||
),
|
||||
fillGaps: (
|
||||
<SketchSvg>
|
||||
<path d="M3 9 L11 7 L16 19 L26 19 L30 8 L37 6" />
|
||||
</SketchSvg>
|
||||
),
|
||||
points: (
|
||||
<SketchSvg>
|
||||
<path d="M4 17 L14 9 L24 13 L36 6" />
|
||||
{[
|
||||
[4, 17],
|
||||
[14, 9],
|
||||
[24, 13],
|
||||
[36, 6],
|
||||
].map(([cx, cy]) => (
|
||||
<circle key={cx} cx={cx} cy={cy} r={2} fill="var(--l3-foreground)" />
|
||||
))}
|
||||
</SketchSvg>
|
||||
),
|
||||
combine: (
|
||||
<SketchSvg>
|
||||
<rect x={4} y={10} width={6} height={10} />
|
||||
<rect x={13} y={4} width={6} height={16} />
|
||||
<rect x={22} y={8} width={6} height={12} />
|
||||
<rect x={31} y={13} width={6} height={7} />
|
||||
</SketchSvg>
|
||||
),
|
||||
} satisfies Record<string, JSX.Element>;
|
||||
@@ -1,164 +0,0 @@
|
||||
import LegendFrame from './LegendFrame';
|
||||
import TileSvg from './TileSvg';
|
||||
|
||||
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
|
||||
const LINE = 'M4 17 L18 9 L30 14 L44 5 L60 11';
|
||||
const POINTS = [
|
||||
[4, 18],
|
||||
[22, 8],
|
||||
[40, 14],
|
||||
[58, 6],
|
||||
];
|
||||
|
||||
function interpDrawing(path: string): JSX.Element {
|
||||
return (
|
||||
<TileSvg tone="robin">
|
||||
<path d={path} />
|
||||
{POINTS.map(([cx, cy]) => (
|
||||
<circle key={cx} cx={cx} cy={cy} r={2.2} {...FILLED} />
|
||||
))}
|
||||
</TileSvg>
|
||||
);
|
||||
}
|
||||
|
||||
export const TILE_DRAWINGS = {
|
||||
lineSolid: (
|
||||
<TileSvg tone="sakura">
|
||||
<path d={LINE} />
|
||||
</TileSvg>
|
||||
),
|
||||
lineDashed: (
|
||||
<TileSvg tone="sakura">
|
||||
<path d={LINE} strokeDasharray="5 5" />
|
||||
</TileSvg>
|
||||
),
|
||||
interpLinear: interpDrawing('M4 18 L22 8 L40 14 L58 6'),
|
||||
interpSpline: interpDrawing(
|
||||
'M4 18 C 12 18, 14 8, 22 8 S 34 14, 40 14 S 52 6, 58 6',
|
||||
),
|
||||
interpStepBefore: interpDrawing('M4 18 V8 H22 V14 H40 V6 H58'),
|
||||
interpStepAfter: interpDrawing('M4 18 H22 V8 H40 V14 H58 V6'),
|
||||
fillNone: (
|
||||
<TileSvg tone="forest">
|
||||
<path d={LINE} />
|
||||
</TileSvg>
|
||||
),
|
||||
fillSolid: (
|
||||
<TileSvg tone="forest">
|
||||
<path d={`${LINE} L60 22 L4 22Z`} {...FILLED} opacity={0.4} />
|
||||
<path d={LINE} />
|
||||
</TileSvg>
|
||||
),
|
||||
fillGradient: (
|
||||
<TileSvg tone="forest">
|
||||
<defs>
|
||||
<linearGradient id="config-tile-fill-gradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="currentColor" stopOpacity={0.55} />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={`${LINE} L60 22 L4 22Z`}
|
||||
fill="url(#config-tile-fill-gradient)"
|
||||
stroke="none"
|
||||
/>
|
||||
<path d={LINE} />
|
||||
</TileSvg>
|
||||
),
|
||||
gapsConnect: (
|
||||
<TileSvg tone="amber">
|
||||
<path d="M4 16 L14 11 L22 14" />
|
||||
<path d="M22 14 L42 8" strokeDasharray="3 4" />
|
||||
<path d="M42 8 L50 11 L60 6" />
|
||||
</TileSvg>
|
||||
),
|
||||
gapsBreak: (
|
||||
<TileSvg tone="amber">
|
||||
<path d="M4 16 L14 11 L22 14" />
|
||||
<path d="M42 8 L50 11 L60 6" />
|
||||
</TileSvg>
|
||||
),
|
||||
scaleLinear: (
|
||||
<TileSvg tone="robin">
|
||||
<path d="M4 20 L60 4" />
|
||||
</TileSvg>
|
||||
),
|
||||
scaleLog: (
|
||||
<TileSvg tone="robin">
|
||||
<path d="M4 20 C 8 8, 20 5, 60 4" />
|
||||
</TileSvg>
|
||||
),
|
||||
barsSideBySide: (
|
||||
<TileSvg tone="robin">
|
||||
<rect x={10} y={10} width={7} height={12} {...FILLED} opacity={0.5} />
|
||||
<rect x={18} y={4} width={7} height={18} {...FILLED} />
|
||||
<rect x={36} y={12} width={7} height={10} {...FILLED} opacity={0.5} />
|
||||
<rect x={44} y={7} width={7} height={15} {...FILLED} />
|
||||
</TileSvg>
|
||||
),
|
||||
barsStacked: (
|
||||
<TileSvg tone="robin">
|
||||
<rect x={14} y={13} width={11} height={9} {...FILLED} opacity={0.5} />
|
||||
<rect x={14} y={4} width={11} height={8} {...FILLED} />
|
||||
<rect x={38} y={15} width={11} height={7} {...FILLED} opacity={0.5} />
|
||||
<rect x={38} y={8} width={11} height={6} {...FILLED} />
|
||||
</TileSvg>
|
||||
),
|
||||
areaOverlap: (
|
||||
<TileSvg tone="robin">
|
||||
<path d="M4 15 L18 9 L32 13 L46 6 L60 9" />
|
||||
<path d="M4 20 L18 15 L32 18 L46 13 L60 15" opacity={0.5} />
|
||||
</TileSvg>
|
||||
),
|
||||
areaStacked: (
|
||||
<TileSvg tone="robin">
|
||||
<path
|
||||
d="M4 22 L4 17 L18 15 L32 16 L46 14 L60 15 L60 22Z"
|
||||
{...FILLED}
|
||||
opacity={0.45}
|
||||
/>
|
||||
<path
|
||||
d="M4 17 L18 15 L32 16 L46 14 L60 15 L60 8 L46 5 L32 9 L18 6 L4 10Z"
|
||||
{...FILLED}
|
||||
/>
|
||||
</TileSvg>
|
||||
),
|
||||
areaPercent: (
|
||||
<TileSvg tone="robin">
|
||||
<path
|
||||
d="M4 22 L4 12 L18 10 L32 13 L46 9 L60 11 L60 22Z"
|
||||
{...FILLED}
|
||||
opacity={0.45}
|
||||
/>
|
||||
<path d="M4 12 L18 10 L32 13 L46 9 L60 11 L60 2 L4 2Z" {...FILLED} />
|
||||
</TileSvg>
|
||||
),
|
||||
legendBottom: (
|
||||
<LegendFrame>
|
||||
<path
|
||||
d="M8 15 L18 10 L28 13 L38 7 L48 9"
|
||||
stroke="var(--bg-robin-300)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<path
|
||||
d="M8 23 H16 M22 23 H30 M36 23 H44"
|
||||
stroke="var(--l2-foreground)"
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
</LegendFrame>
|
||||
),
|
||||
legendRight: (
|
||||
<LegendFrame>
|
||||
<path
|
||||
d="M6 20 L14 12 L22 16 L32 8"
|
||||
stroke="var(--bg-robin-300)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<path
|
||||
d="M40 9 H48 M40 15 H48 M40 21 H48"
|
||||
stroke="var(--l2-foreground)"
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
</LegendFrame>
|
||||
),
|
||||
} satisfies Record<string, JSX.Element>;
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Small glyph icons for the panel-editor segmented/select controls, ported from the
|
||||
* Configure-panel design. They render at 14px and inherit `currentColor` so the
|
||||
* surrounding control can dim them when unselected and brighten them when active.
|
||||
*/
|
||||
export type SegmentIconName =
|
||||
| 'solid-line'
|
||||
| 'dashed-line'
|
||||
| 'fill-none'
|
||||
| 'fill-solid'
|
||||
| 'fill-gradient'
|
||||
| 'pos-bottom'
|
||||
| 'pos-right'
|
||||
| 'scale-linear'
|
||||
| 'scale-log'
|
||||
| 'interp-linear'
|
||||
| 'interp-spline'
|
||||
| 'interp-step-before'
|
||||
| 'interp-step-after';
|
||||
|
||||
function Svg({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ flex: 'none' }}
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
|
||||
|
||||
export function SegmentIcon({
|
||||
name,
|
||||
}: {
|
||||
name: SegmentIconName;
|
||||
}): JSX.Element | null {
|
||||
switch (name) {
|
||||
case 'solid-line':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 8 H14" />
|
||||
</Svg>
|
||||
);
|
||||
case 'dashed-line':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 8 H4.5" />
|
||||
<path d="M6.75 8 H9.25" />
|
||||
<path d="M11.5 8 H14" />
|
||||
</Svg>
|
||||
);
|
||||
case 'fill-none':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 11 L6 6 L10 9 L14 5" />
|
||||
</Svg>
|
||||
);
|
||||
case 'fill-solid':
|
||||
return (
|
||||
<Svg>
|
||||
<path
|
||||
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
|
||||
fill="currentColor"
|
||||
fillOpacity={0.85}
|
||||
stroke="none"
|
||||
/>
|
||||
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
|
||||
</Svg>
|
||||
);
|
||||
case 'fill-gradient':
|
||||
return (
|
||||
<Svg>
|
||||
<path
|
||||
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
|
||||
fill="currentColor"
|
||||
fillOpacity={0.3}
|
||||
stroke="none"
|
||||
/>
|
||||
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
|
||||
</Svg>
|
||||
);
|
||||
case 'pos-bottom':
|
||||
return (
|
||||
<Svg>
|
||||
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
|
||||
<rect x={2} y={9} width={12} height={2.5} {...FILLED} />
|
||||
</Svg>
|
||||
);
|
||||
case 'pos-right':
|
||||
return (
|
||||
<Svg>
|
||||
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
|
||||
<rect x={10.5} y={2.5} width={3.5} height={9} {...FILLED} />
|
||||
</Svg>
|
||||
);
|
||||
case 'scale-linear':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2.5 13 L13.5 3" />
|
||||
</Svg>
|
||||
);
|
||||
case 'scale-log':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2.5 13 C5 13, 8 4.5, 13.5 3" />
|
||||
</Svg>
|
||||
);
|
||||
case 'interp-linear':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 12 L6 5 L10 9 L14 4" />
|
||||
</Svg>
|
||||
);
|
||||
case 'interp-spline':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 12 C5 3, 9 3, 14 8" />
|
||||
</Svg>
|
||||
);
|
||||
case 'interp-step-before':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 6 H6 V10 H10 V4.5 H14" />
|
||||
</Svg>
|
||||
);
|
||||
case 'interp-step-after':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 10 H6 V5 H10 V9.5 H14" />
|
||||
</Svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ export interface SectionEditorContext {
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
panelKind?: PanelKind;
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
/** Kind the panel was opened with, offered as a revert target. */
|
||||
originalPanelKind?: PanelKind;
|
||||
yAxisUnit?: string;
|
||||
queryType?: EQueryType;
|
||||
stepInterval?: number;
|
||||
|
||||
@@ -12,32 +12,21 @@ import {
|
||||
type PanelChartAppearanceSlice,
|
||||
type PanelFormattingSlice,
|
||||
type PanelVisualizationSlice,
|
||||
type SectionControlsOf,
|
||||
type SectionEditorProps,
|
||||
type SectionSpecMap,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
|
||||
import type { SectionEditorContext } from './sectionContext';
|
||||
import AxesSection from './sections/AxesSection/AxesSection';
|
||||
import { summarizeAxes } from './sections/AxesSection/summary';
|
||||
import BucketsSection from './sections/BucketsSection/BucketsSection';
|
||||
import { summarizeBuckets } from './sections/BucketsSection/summary';
|
||||
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
|
||||
import { summarizeChartAppearance } from './sections/ChartAppearanceSection/summary';
|
||||
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
|
||||
import FormattingSection from './sections/FormattingSection/FormattingSection';
|
||||
import { DEFAULT_DECIMAL_PRECISION } from './sections/FormattingSection/options';
|
||||
import { summarizeFormatting } from './sections/FormattingSection/summary';
|
||||
import LegendSection from './sections/LegendSection/LegendSection';
|
||||
import { summarizeLegend } from './sections/LegendSection/summary';
|
||||
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
|
||||
import { summarizeTextLayout } from './sections/TextLayoutSection/summary';
|
||||
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
|
||||
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
|
||||
import { summarizeVisualization } from './sections/VisualizationSection/summary';
|
||||
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
|
||||
import { countSummary } from './utils/summary';
|
||||
|
||||
type PanelSpec = DashboardtypesPanelSpecDTO;
|
||||
|
||||
@@ -50,14 +39,6 @@ export interface SectionDescriptor<K extends SectionKind> {
|
||||
Component: ComponentType<SectionEditorProps<K>>;
|
||||
get: (spec: PanelSpec) => SectionSpecMap[K] | undefined;
|
||||
update: (spec: PanelSpec, value: SectionSpecMap[K]) => PanelSpec;
|
||||
/** Values an unset field renders with, when the kind's seed leaves it unset. */
|
||||
renderedDefaults?: Partial<SectionSpecMap[K]>;
|
||||
/** Collapsed-header digest of the slice. */
|
||||
summarize?: (
|
||||
value: SectionSpecMap[K] | undefined,
|
||||
controls: SectionControlsOf<K>,
|
||||
ctx: SectionEditorContext,
|
||||
) => string;
|
||||
}
|
||||
|
||||
// The plugin spec is a discriminated union over panel kinds; reading/writing a shared
|
||||
@@ -97,15 +78,12 @@ export const SECTION_REGISTRY: {
|
||||
getPluginSlice<PanelFormattingSlice>(spec, 'formatting'),
|
||||
update: (spec, formatting): PanelSpec =>
|
||||
updatePluginSlice(spec, 'formatting', formatting),
|
||||
renderedDefaults: { decimalPrecision: DEFAULT_DECIMAL_PRECISION },
|
||||
summarize: summarizeFormatting,
|
||||
},
|
||||
[SectionKind.Axes]: {
|
||||
Component: AxesSection,
|
||||
get: (spec): DashboardtypesAxesDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
|
||||
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
|
||||
summarize: summarizeAxes,
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
Component: LegendSection,
|
||||
@@ -113,7 +91,6 @@ export const SECTION_REGISTRY: {
|
||||
getPluginSlice<DashboardtypesLegendDTO>(spec, 'legend'),
|
||||
update: (spec, legend): PanelSpec =>
|
||||
updatePluginSlice(spec, 'legend', legend),
|
||||
summarize: summarizeLegend,
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
Component: ChartAppearanceSection,
|
||||
@@ -121,7 +98,6 @@ export const SECTION_REGISTRY: {
|
||||
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
|
||||
update: (spec, chartAppearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
|
||||
summarize: summarizeChartAppearance,
|
||||
},
|
||||
[SectionKind.Visualization]: {
|
||||
Component: VisualizationSection,
|
||||
@@ -129,7 +105,6 @@ export const SECTION_REGISTRY: {
|
||||
getPluginSlice<PanelVisualizationSlice>(spec, 'visualization'),
|
||||
update: (spec, visualization): PanelSpec =>
|
||||
updatePluginSlice(spec, 'visualization', visualization),
|
||||
summarize: summarizeVisualization,
|
||||
},
|
||||
[SectionKind.Buckets]: {
|
||||
Component: BucketsSection,
|
||||
@@ -137,7 +112,6 @@ export const SECTION_REGISTRY: {
|
||||
getPluginSlice<DashboardtypesHistogramBucketsDTO>(spec, 'histogramBuckets'),
|
||||
update: (spec, buckets): PanelSpec =>
|
||||
updatePluginSlice(spec, 'histogramBuckets', buckets),
|
||||
summarize: summarizeBuckets,
|
||||
},
|
||||
[SectionKind.TextLayout]: {
|
||||
Component: TextLayoutSection,
|
||||
@@ -145,7 +119,6 @@ export const SECTION_REGISTRY: {
|
||||
getPluginSlice<SectionSpecMap[SectionKind.TextLayout]>(spec, 'presentation'),
|
||||
update: (spec, presentation): PanelSpec =>
|
||||
updatePluginSlice(spec, 'presentation', presentation),
|
||||
summarize: summarizeTextLayout,
|
||||
},
|
||||
[SectionKind.PanelHeader]: {
|
||||
Component: PanelHeaderSection,
|
||||
@@ -162,7 +135,6 @@ export const SECTION_REGISTRY: {
|
||||
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
|
||||
get: (spec): DashboardtypesLinkDTO[] => spec.links || [],
|
||||
update: (spec, links): PanelSpec => ({ ...spec, links }),
|
||||
summarize: (links): string => countSummary(links, 'link'),
|
||||
},
|
||||
// One editor for every threshold variant (label / comparison / table); the kind's
|
||||
// `controls.variant` picks the row editor + element shape. All persist to the same
|
||||
@@ -173,7 +145,6 @@ export const SECTION_REGISTRY: {
|
||||
getPluginSlice<AnyThreshold[]>(spec, 'thresholds'),
|
||||
update: (spec, thresholds): PanelSpec =>
|
||||
updatePluginSlice(spec, 'thresholds', thresholds),
|
||||
summarize: (thresholds): string => countSummary(thresholds, 'threshold'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -188,19 +159,12 @@ export interface ErasedSectionDescriptor {
|
||||
Component: ComponentType<
|
||||
{
|
||||
value: unknown;
|
||||
defaultValue?: unknown;
|
||||
controls?: unknown;
|
||||
onChange: (next: unknown) => void;
|
||||
} & SectionEditorContext
|
||||
>;
|
||||
get: (spec: PanelSpec) => unknown;
|
||||
update: (spec: PanelSpec, value: unknown) => PanelSpec;
|
||||
renderedDefaults?: object;
|
||||
summarize?: (
|
||||
value: unknown,
|
||||
controls: unknown,
|
||||
ctx: SectionEditorContext,
|
||||
) => string;
|
||||
}
|
||||
|
||||
export function resolveSectionEditor(
|
||||
@@ -210,20 +174,3 @@ export function resolveSectionEditor(
|
||||
| ErasedSectionDescriptor
|
||||
| undefined;
|
||||
}
|
||||
|
||||
/** A slice's default, read through the same lens from the kind's seeded plugin spec. */
|
||||
export function getSectionDefault(
|
||||
editor: ErasedSectionDescriptor,
|
||||
spec: PanelSpec,
|
||||
defaults: SeededPluginSpec,
|
||||
): unknown {
|
||||
const seeded = editor.get({
|
||||
...spec,
|
||||
links: [],
|
||||
plugin: { ...spec.plugin, spec: defaults },
|
||||
} as PanelSpec);
|
||||
if (!editor.renderedDefaults) {
|
||||
return seeded;
|
||||
}
|
||||
return { ...editor.renderedDefaults, ...(seeded as object | undefined) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.bounds {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigFieldRow from '../../controls/ConfigFieldRow/ConfigFieldRow';
|
||||
import ConfigNumberInput from '../../controls/ConfigNumberInput/ConfigNumberInput';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import { AxisScale, SCALE_OPTIONS } from './options';
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
|
||||
import styles from './AxesSection.module.scss';
|
||||
|
||||
type SoftBound = 'softMin' | 'softMax';
|
||||
|
||||
const SCALE_OPTIONS = [
|
||||
{ value: 'linear', label: 'Linear', icon: 'scale-linear' as const },
|
||||
{ value: 'log', label: 'Log', icon: 'scale-log' as const },
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds and the
|
||||
@@ -16,53 +23,58 @@ import { AxisScale, SCALE_OPTIONS } from './options';
|
||||
*/
|
||||
function AxesSection({
|
||||
value,
|
||||
defaultValue,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.Axes>): JSX.Element {
|
||||
const reset = createFieldResetter(value, defaultValue, onChange);
|
||||
// An empty field clears the bound (null); otherwise parse to a number, ignoring
|
||||
// transient non-numeric input (e.g. a lone "-") by leaving the bound unset.
|
||||
const handleBound =
|
||||
(bound: SoftBound) =>
|
||||
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = e.target.value;
|
||||
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
|
||||
onChange({ ...value, [bound]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.minMax && (
|
||||
<ConfigField
|
||||
label="Y-axis range"
|
||||
help="The axis always shows at least this range. Data outside it still stretches the axis."
|
||||
{...reset('softMin', 'softMax')}
|
||||
>
|
||||
<ConfigFieldRow>
|
||||
<ConfigNumberInput
|
||||
testId="panel-editor-v2-soft-min"
|
||||
label="Min"
|
||||
value={value?.softMin}
|
||||
onChange={(softMin): void => onChange({ ...value, softMin })}
|
||||
<div className={styles.bounds}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Soft min</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-soft-min"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.softMin ?? ''}
|
||||
onChange={handleBound('softMin')}
|
||||
/>
|
||||
<ConfigNumberInput
|
||||
testId="panel-editor-v2-soft-max"
|
||||
label="Max"
|
||||
value={value?.softMax}
|
||||
onChange={(softMax): void => onChange({ ...value, softMax })}
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Soft max</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-soft-max"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.softMax ?? ''}
|
||||
onChange={handleBound('softMax')}
|
||||
/>
|
||||
</ConfigFieldRow>
|
||||
</ConfigField>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.logScale && (
|
||||
<ConfigField
|
||||
label="Y-axis scale"
|
||||
help="Logarithmic spreads out values that span several orders of magnitude."
|
||||
{...reset('isLogScale')}
|
||||
>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Y-axis scale</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-log-scale"
|
||||
aria-label="Y-axis scale"
|
||||
value={value?.isLogScale ? AxisScale.LOG : AxisScale.LINEAR}
|
||||
value={value?.isLogScale ? 'log' : 'linear'}
|
||||
items={SCALE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, isLogScale: next === AxisScale.LOG })
|
||||
onChange({ ...value, isLogScale: next === 'log' })
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('AxesSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-log-scale-log'));
|
||||
await user.click(screen.getByText('Log'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ isLogScale: true });
|
||||
});
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
|
||||
|
||||
export enum AxisScale {
|
||||
LINEAR = 'linear',
|
||||
LOG = 'log',
|
||||
}
|
||||
|
||||
export const SCALE_OPTIONS: ConfigTileItem<AxisScale>[] = [
|
||||
{
|
||||
value: AxisScale.LINEAR,
|
||||
label: 'Linear',
|
||||
drawing: TILE_DRAWINGS.scaleLinear,
|
||||
},
|
||||
{
|
||||
value: AxisScale.LOG,
|
||||
label: 'Logarithmic',
|
||||
drawing: TILE_DRAWINGS.scaleLog,
|
||||
},
|
||||
];
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { DashboardtypesAxesDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionControlsOf,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { joinSummary } from '../../utils/summary';
|
||||
|
||||
function describeRange({ softMin, softMax }: DashboardtypesAxesDTO): string {
|
||||
const hasMin = typeof softMin === 'number';
|
||||
const hasMax = typeof softMax === 'number';
|
||||
if (!hasMin && !hasMax) {
|
||||
return 'auto range';
|
||||
}
|
||||
return `${hasMin ? softMin : 'auto'} to ${hasMax ? softMax : 'auto'}`;
|
||||
}
|
||||
|
||||
export function summarizeAxes(
|
||||
value: DashboardtypesAxesDTO | undefined,
|
||||
controls: SectionControlsOf<SectionKind.Axes>,
|
||||
): string {
|
||||
return joinSummary([
|
||||
controls.logScale && (value?.isLogScale ? 'Log' : 'Linear'),
|
||||
controls.minMax && describeRange(value ?? {}),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigFieldRow from '../../controls/ConfigFieldRow/ConfigFieldRow';
|
||||
import ConfigNumberInput from '../../controls/ConfigNumberInput/ConfigNumberInput';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
|
||||
import styles from './BucketsSection.module.scss';
|
||||
|
||||
// The two numeric bounds of the histogram-buckets spec (derived from the BE DTO).
|
||||
type NumericBound = keyof Pick<
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
'bucketCount' | 'bucketWidth'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Edits the `histogramBuckets` slice of a Histogram panel spec: bucket count / width
|
||||
@@ -17,51 +24,55 @@ import { createFieldResetter } from '../../utils/changes';
|
||||
*/
|
||||
function BucketsSection({
|
||||
value,
|
||||
defaultValue,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.Buckets>): JSX.Element {
|
||||
const reset = createFieldResetter(value, defaultValue, onChange);
|
||||
// Empty clears the bound to null (chart auto-sizes); otherwise parse to a number,
|
||||
// ignoring transient non-numeric input by leaving it unset.
|
||||
const handleNumber =
|
||||
(bound: NumericBound) =>
|
||||
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = e.target.value;
|
||||
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
|
||||
onChange({ ...value, [bound]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(controls.count || controls.width) && (
|
||||
<ConfigField
|
||||
label="Buckets"
|
||||
help="How values are grouped into bars. Leave on Auto to size buckets from the data."
|
||||
{...reset('bucketCount', 'bucketWidth')}
|
||||
>
|
||||
<ConfigFieldRow>
|
||||
{controls.count && (
|
||||
<ConfigNumberInput
|
||||
testId="panel-editor-v2-bucket-count"
|
||||
label="Count"
|
||||
value={value?.bucketCount}
|
||||
onChange={(bucketCount): void => onChange({ ...value, bucketCount })}
|
||||
/>
|
||||
)}
|
||||
{controls.width && (
|
||||
<ConfigNumberInput
|
||||
testId="panel-editor-v2-bucket-width"
|
||||
label="Width"
|
||||
value={value?.bucketWidth}
|
||||
onChange={(bucketWidth): void => onChange({ ...value, bucketWidth })}
|
||||
/>
|
||||
)}
|
||||
</ConfigFieldRow>
|
||||
</ConfigField>
|
||||
{controls.count && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Bucket count</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-bucket-count"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.bucketCount ?? ''}
|
||||
onChange={handleNumber('bucketCount')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.width && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Bucket width</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-bucket-width"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.bucketWidth ?? ''}
|
||||
onChange={handleNumber('bucketWidth')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.mergeQueries && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-merge-queries"
|
||||
title="Combine queries"
|
||||
description="Puts every active query into one distribution. Hides the legend."
|
||||
sketch={SWITCH_SKETCHES.combine}
|
||||
changed={reset('mergeAllActiveQueries').changed}
|
||||
title="Merge active queries"
|
||||
description="Bucket all active queries together into one distribution"
|
||||
value={value?.mergeAllActiveQueries ?? false}
|
||||
onChange={(mergeAllActiveQueries): void =>
|
||||
onChange({ ...value, mergeAllActiveQueries })
|
||||
onChange={(checked): void =>
|
||||
onChange({ ...value, mergeAllActiveQueries: checked })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { joinSummary } from '../../utils/summary';
|
||||
|
||||
export function summarizeBuckets(
|
||||
value: DashboardtypesHistogramBucketsDTO | undefined,
|
||||
): string {
|
||||
const custom =
|
||||
typeof value?.bucketCount === 'number' ||
|
||||
typeof value?.bucketWidth === 'number';
|
||||
return joinSummary([
|
||||
custom ? 'Custom' : 'Auto',
|
||||
value?.mergeAllActiveQueries && 'combined',
|
||||
]);
|
||||
}
|
||||
@@ -1,27 +1,7 @@
|
||||
.inset {
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.insetRow {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.insetLabel {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.opacitySlider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thresholdField {
|
||||
|
||||
@@ -1,31 +1,91 @@
|
||||
import cx from 'classnames';
|
||||
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
import { SegmentIcon } from '../../controls/segmentIcons';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import DisconnectValuesField from './DisconnectValuesField';
|
||||
import {
|
||||
FILL_MODE_OPTIONS,
|
||||
FILLED_FILL_MODE_OPTIONS,
|
||||
LINE_INTERPOLATION_HELP,
|
||||
LINE_INTERPOLATION_OPTIONS,
|
||||
LINE_STYLE_OPTIONS,
|
||||
} from './options';
|
||||
import { formatOpacity } from './utils';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
const LINE_STYLE_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.solid,
|
||||
label: 'Solid',
|
||||
icon: 'solid-line' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.dashed,
|
||||
label: 'Dashed',
|
||||
icon: 'dashed-line' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const LINE_INTERPOLATION_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.linear,
|
||||
label: 'Linear',
|
||||
icon: <SegmentIcon name="interp-linear" />,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.spline,
|
||||
label: 'Spline',
|
||||
icon: <SegmentIcon name="interp-spline" />,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_before,
|
||||
label: 'Step before',
|
||||
icon: <SegmentIcon name="interp-step-before" />,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_after,
|
||||
label: 'Step after',
|
||||
icon: <SegmentIcon name="interp-step-after" />,
|
||||
},
|
||||
];
|
||||
|
||||
const FILL_MODE_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.none,
|
||||
label: 'None',
|
||||
icon: 'fill-none' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.solid,
|
||||
label: 'Solid',
|
||||
icon: 'fill-solid' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.gradient,
|
||||
label: 'Gradient',
|
||||
icon: 'fill-gradient' as const,
|
||||
},
|
||||
];
|
||||
|
||||
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
|
||||
const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
|
||||
(option) => option.value !== DashboardtypesFillModeDTO.none,
|
||||
);
|
||||
|
||||
const FILL_OPACITY_STEP = 0.01;
|
||||
|
||||
function formatOpacity(opacity: number): string {
|
||||
return `${Math.round(opacity * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
|
||||
* interpolation, fill mode, fill opacity, point markers, and the connect-null-gaps
|
||||
@@ -33,90 +93,84 @@ const FILL_OPACITY_STEP = 0.01;
|
||||
*/
|
||||
function ChartAppearanceSection({
|
||||
value,
|
||||
defaultValue,
|
||||
controls,
|
||||
onChange,
|
||||
stepInterval,
|
||||
}: SectionEditorProps<SectionKind.ChartAppearance> &
|
||||
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
|
||||
const reset = createFieldResetter(value, defaultValue, onChange);
|
||||
const interpolation = value?.lineInterpolation;
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.lineStyle && (
|
||||
<ConfigField label="Line" {...reset('lineStyle')}>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Line style</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-line-style"
|
||||
aria-label="Line style"
|
||||
value={value?.lineStyle}
|
||||
items={LINE_STYLE_OPTIONS}
|
||||
onChange={(lineStyle): void => onChange({ ...value, lineStyle })}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, lineStyle: next as DashboardtypesLineStyleDTO })
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.lineInterpolation && (
|
||||
<ConfigField
|
||||
label="How points connect"
|
||||
help={interpolation && LINE_INTERPOLATION_HELP[interpolation]}
|
||||
{...reset('lineInterpolation')}
|
||||
>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Line interpolation</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-line-interpolation"
|
||||
aria-label="How points connect"
|
||||
value={interpolation}
|
||||
placeholder="Select interpolation…"
|
||||
value={value?.lineInterpolation}
|
||||
items={LINE_INTERPOLATION_OPTIONS}
|
||||
onChange={(lineInterpolation): void =>
|
||||
onChange({ ...value, lineInterpolation })
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
lineInterpolation: next,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.fillMode && (
|
||||
<ConfigField
|
||||
label="Area under the line"
|
||||
{...reset('fillMode', 'fillOpacity')}
|
||||
>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Fill mode</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-fill-mode"
|
||||
aria-label="Area under the line"
|
||||
value={value?.fillMode}
|
||||
items={
|
||||
controls.fillOpacity ? FILLED_FILL_MODE_OPTIONS : FILL_MODE_OPTIONS
|
||||
}
|
||||
onChange={(fillMode): void => onChange({ ...value, fillMode })}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
|
||||
}
|
||||
/>
|
||||
{controls.fillOpacity && (
|
||||
<div className={cx(styles.inset, styles.insetRow)}>
|
||||
<span className={styles.insetLabel}>Opacity</span>
|
||||
<div className={styles.opacitySlider}>
|
||||
<ConfigSlider
|
||||
testId="panel-editor-v2-fill-opacity"
|
||||
// The chart's own default, so the thumb starts where an unset fill renders.
|
||||
value={resolveFillOpacity(value?.fillOpacity)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={FILL_OPACITY_STEP}
|
||||
formatValue={formatOpacity}
|
||||
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.fillOpacity && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Fill opacity</Typography.Text>
|
||||
<ConfigSlider
|
||||
testId="panel-editor-v2-fill-opacity"
|
||||
// The chart's own default, so the thumb starts where an unset fill renders.
|
||||
value={resolveFillOpacity(value?.fillOpacity)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={FILL_OPACITY_STEP}
|
||||
formatValue={formatOpacity}
|
||||
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.showPoints && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-show-points"
|
||||
title="Show points"
|
||||
description="Marks each sample the query returned."
|
||||
sketch={SWITCH_SKETCHES.points}
|
||||
changed={reset('showPoints').changed}
|
||||
description="Display individual data points on the chart"
|
||||
value={value?.showPoints ?? false}
|
||||
onChange={(showPoints): void => onChange({ ...value, showPoints })}
|
||||
onChange={(checked): void => onChange({ ...value, showPoints: checked })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -125,7 +179,6 @@ function ChartAppearanceSection({
|
||||
testId="panel-editor-v2-span-gaps"
|
||||
value={value?.spanGaps}
|
||||
stepInterval={stepInterval}
|
||||
{...reset('spanGaps')}
|
||||
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesSpanGapsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import type { FieldResetProps } from '../../utils/changes';
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
|
||||
import { DISCONNECT_MODE_OPTIONS, DisconnectValuesMode } from './options';
|
||||
import { defaultDisconnectDuration } from './utils';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
interface DisconnectValuesFieldProps extends FieldResetProps {
|
||||
const DEFAULT_THRESHOLD = '1m';
|
||||
enum DisconnectValuesMode {
|
||||
NEVER = 'never',
|
||||
THRESHOLD = 'threshold',
|
||||
}
|
||||
const MODE_OPTIONS = [
|
||||
{ value: DisconnectValuesMode.NEVER, label: 'Never' },
|
||||
{ value: DisconnectValuesMode.THRESHOLD, label: 'Threshold' },
|
||||
];
|
||||
|
||||
interface DisconnectValuesFieldProps {
|
||||
testId: string;
|
||||
value: DashboardtypesSpanGapsDTO | undefined;
|
||||
/** Query step interval (seconds): seeds the default threshold and floors it. */
|
||||
@@ -18,6 +26,13 @@ interface DisconnectValuesFieldProps extends FieldResetProps {
|
||||
onChange: (next: DashboardtypesSpanGapsDTO | undefined) => void;
|
||||
}
|
||||
|
||||
/** Default threshold duration: the step interval (smallest meaningful), else 1m. */
|
||||
function defaultDuration(stepInterval?: number): string {
|
||||
return stepInterval && stepInterval > 0
|
||||
? rangeUtil.secondsToHms(stepInterval)
|
||||
: DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Disconnect values": Never (span every gap — the chart default) vs Threshold
|
||||
* (only bridge gaps shorter than a duration). The threshold persists as a
|
||||
@@ -28,8 +43,6 @@ function DisconnectValuesField({
|
||||
testId,
|
||||
value,
|
||||
stepInterval,
|
||||
changed,
|
||||
onReset,
|
||||
onChange,
|
||||
}: DisconnectValuesFieldProps): JSX.Element {
|
||||
const duration = value?.fillLessThan || undefined;
|
||||
@@ -50,7 +63,7 @@ function DisconnectValuesField({
|
||||
...value,
|
||||
fillOnlyBelow: true,
|
||||
// Seed from the live stepInterval (async — undefined until results load), not mount.
|
||||
fillLessThan: lastDuration ?? defaultDisconnectDuration(stepInterval),
|
||||
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -59,24 +72,21 @@ function DisconnectValuesField({
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigField
|
||||
label="When data is missing"
|
||||
help="Breaking the line makes outages and restarts visible."
|
||||
changed={changed}
|
||||
onReset={onReset}
|
||||
>
|
||||
<ConfigTiles
|
||||
testId={testId}
|
||||
aria-label="When data is missing"
|
||||
value={
|
||||
isThreshold ? DisconnectValuesMode.THRESHOLD : DisconnectValuesMode.NEVER
|
||||
}
|
||||
items={DISCONNECT_MODE_OPTIONS}
|
||||
onChange={handleMode}
|
||||
/>
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Disconnect values</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId={testId}
|
||||
value={
|
||||
isThreshold ? DisconnectValuesMode.THRESHOLD : DisconnectValuesMode.NEVER
|
||||
}
|
||||
items={MODE_OPTIONS}
|
||||
onChange={handleMode}
|
||||
/>
|
||||
</div>
|
||||
{isThreshold && duration && (
|
||||
<div className={styles.inset}>
|
||||
<span className={styles.insetLabel}>Break when a gap is longer than</span>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Threshold value</Typography.Text>
|
||||
<DisconnectValuesThresholdInput
|
||||
testId={`${testId}-value`}
|
||||
value={duration}
|
||||
@@ -87,7 +97,7 @@ function DisconnectValuesField({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ConfigField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@ function StatefulSpanGaps({
|
||||
);
|
||||
}
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label. The
|
||||
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
|
||||
// only used for the line-interpolation ConfigSelect.
|
||||
async function pickOption(triggerTestId: string, label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId(triggerTestId);
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
await user.click(await screen.findByRole('option', { name: label }));
|
||||
}
|
||||
|
||||
const ALL_CONTROLS = {
|
||||
lineStyle: true,
|
||||
lineInterpolation: true,
|
||||
@@ -99,7 +109,7 @@ describe('ChartAppearanceSection', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ fillMode: true, fillOpacity: true }}
|
||||
controls={{ fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -116,7 +126,7 @@ describe('ChartAppearanceSection', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ fillOpacity: 0.25 }}
|
||||
controls={{ fillMode: true, fillOpacity: true }}
|
||||
controls={{ fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -155,7 +165,7 @@ describe('ChartAppearanceSection', () => {
|
||||
expect(screen.getByText('Gradient')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen line interpolation through the tiles', async () => {
|
||||
it('writes the chosen line interpolation through the dropdown', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
@@ -165,9 +175,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByTestId('panel-editor-v2-line-interpolation-spline'));
|
||||
await pickOption('panel-editor-v2-line-interpolation', 'Spline');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ lineInterpolation: 'spline' });
|
||||
});
|
||||
@@ -197,9 +205,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-span-gaps-never'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -216,7 +222,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
|
||||
@@ -278,7 +284,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
|
||||
await user.click(screen.getByText('Never'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
|
||||
@@ -354,7 +360,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
|
||||
@@ -382,7 +388,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
// Regression: a value seeded at mount would still be the 1m fallback.
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
@@ -436,7 +442,7 @@ describe('ChartAppearanceSection', () => {
|
||||
|
||||
// Focus the input first so clicking Never also fires its blur (the toggle race).
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-value'));
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
|
||||
await user.click(screen.getByText('Never'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
|
||||
@@ -447,8 +453,8 @@ describe('ChartAppearanceSection', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
|
||||
await user.click(screen.getByText('Never'));
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
|
||||
'5m',
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
|
||||
|
||||
export const LINE_STYLE_OPTIONS: ConfigTileItem<DashboardtypesLineStyleDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.solid,
|
||||
label: 'Solid',
|
||||
drawing: TILE_DRAWINGS.lineSolid,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.dashed,
|
||||
label: 'Dashed',
|
||||
drawing: TILE_DRAWINGS.lineDashed,
|
||||
},
|
||||
];
|
||||
|
||||
export const LINE_INTERPOLATION_OPTIONS: ConfigTileItem<DashboardtypesLineInterpolationDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.linear,
|
||||
label: 'Straight',
|
||||
drawing: TILE_DRAWINGS.interpLinear,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.spline,
|
||||
label: 'Smooth',
|
||||
drawing: TILE_DRAWINGS.interpSpline,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_before,
|
||||
label: 'Step before',
|
||||
drawing: TILE_DRAWINGS.interpStepBefore,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_after,
|
||||
label: 'Step after',
|
||||
drawing: TILE_DRAWINGS.interpStepAfter,
|
||||
},
|
||||
];
|
||||
|
||||
export const LINE_INTERPOLATION_HELP: Record<
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
string
|
||||
> = {
|
||||
[DashboardtypesLineInterpolationDTO.linear]:
|
||||
'Joins each point with a straight line.',
|
||||
[DashboardtypesLineInterpolationDTO.spline]:
|
||||
'Curves through each point. Can overshoot between points.',
|
||||
[DashboardtypesLineInterpolationDTO.step_before]:
|
||||
'Jumps to each new value before its timestamp.',
|
||||
[DashboardtypesLineInterpolationDTO.step_after]:
|
||||
'Holds each value until the next point arrives.',
|
||||
};
|
||||
|
||||
export const FILL_MODE_OPTIONS: ConfigTileItem<DashboardtypesFillModeDTO>[] = [
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.none,
|
||||
label: 'None',
|
||||
drawing: TILE_DRAWINGS.fillNone,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.solid,
|
||||
label: 'Solid',
|
||||
drawing: TILE_DRAWINGS.fillSolid,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.gradient,
|
||||
label: 'Gradient',
|
||||
drawing: TILE_DRAWINGS.fillGradient,
|
||||
},
|
||||
];
|
||||
|
||||
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
|
||||
export const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
|
||||
(option) => option.value !== DashboardtypesFillModeDTO.none,
|
||||
);
|
||||
|
||||
export enum DisconnectValuesMode {
|
||||
NEVER = 'never',
|
||||
THRESHOLD = 'threshold',
|
||||
}
|
||||
|
||||
export const DISCONNECT_MODE_OPTIONS: ConfigTileItem<DisconnectValuesMode>[] = [
|
||||
{
|
||||
value: DisconnectValuesMode.NEVER,
|
||||
label: 'Connect the line',
|
||||
drawing: TILE_DRAWINGS.gapsConnect,
|
||||
},
|
||||
{
|
||||
value: DisconnectValuesMode.THRESHOLD,
|
||||
label: 'Break long gaps',
|
||||
drawing: TILE_DRAWINGS.gapsBreak,
|
||||
},
|
||||
];
|
||||
@@ -1,41 +0,0 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
PanelChartAppearanceSlice,
|
||||
SectionControlsOf,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { joinSummary } from '../../utils/summary';
|
||||
import { FILL_MODE_OPTIONS, LINE_INTERPOLATION_OPTIONS } from './options';
|
||||
|
||||
function describeLine(value: PanelChartAppearanceSlice): string | undefined {
|
||||
const interpolation = LINE_INTERPOLATION_OPTIONS.find(
|
||||
(option) => option.value === value.lineInterpolation,
|
||||
)?.label;
|
||||
const dashed = value.lineStyle === DashboardtypesLineStyleDTO.dashed;
|
||||
if (!interpolation) {
|
||||
return dashed ? 'Dashed' : undefined;
|
||||
}
|
||||
return dashed ? `${interpolation}, dashed` : interpolation;
|
||||
}
|
||||
|
||||
export function summarizeChartAppearance(
|
||||
value: PanelChartAppearanceSlice | undefined,
|
||||
controls: SectionControlsOf<SectionKind.ChartAppearance>,
|
||||
): string {
|
||||
const appearance = value ?? {};
|
||||
const fill = FILL_MODE_OPTIONS.find(
|
||||
(option) =>
|
||||
option.value === (appearance.fillMode ?? DashboardtypesFillModeDTO.none),
|
||||
);
|
||||
return joinSummary([
|
||||
(controls.lineStyle || controls.lineInterpolation) &&
|
||||
describeLine(appearance),
|
||||
controls.fillMode && fill && `${fill.label} fill`,
|
||||
controls.showPoints && appearance.showPoints && 'points',
|
||||
controls.spanGaps && appearance.spanGaps?.fillOnlyBelow && 'breaks gaps',
|
||||
]);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
|
||||
const DEFAULT_DISCONNECT_DURATION = '1m';
|
||||
|
||||
/** The step interval (smallest meaningful gap), else 1m. */
|
||||
export function defaultDisconnectDuration(stepInterval?: number): string {
|
||||
return stepInterval && stepInterval > 0
|
||||
? rangeUtil.secondsToHms(stepInterval)
|
||||
: DEFAULT_DISCONNECT_DURATION;
|
||||
}
|
||||
|
||||
export function formatOpacity(opacity: number): string {
|
||||
return `${Math.round(opacity * 100)}%`;
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
color: var(--l2-foreground);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Saved-link list item --- */
|
||||
@@ -15,15 +9,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 8px 10px 8px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.listItemText {
|
||||
@@ -43,13 +32,12 @@
|
||||
|
||||
.listItemLabel {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.listItemUrl {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.listItemActions {
|
||||
|
||||
@@ -61,10 +61,6 @@ function ContextLinksSection({
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
<span className={styles.help}>
|
||||
Links appear in a menu when someone clicks a data point. Variables pass the
|
||||
clicked series and time range along.
|
||||
</span>
|
||||
{links.map((link, index) => (
|
||||
<ContextLinkListItem
|
||||
// Links have no stable id on the wire; index is the row identity here.
|
||||
@@ -85,7 +81,7 @@ function ContextLinksSection({
|
||||
data-testid="panel-editor-v2-add-link"
|
||||
onClick={openAddDialog}
|
||||
>
|
||||
Add link
|
||||
Add Context Link
|
||||
</Button>
|
||||
|
||||
<ContextLinkDialog
|
||||
|
||||
@@ -31,7 +31,7 @@ function ColumnUnits({
|
||||
if (columns.length === 0) {
|
||||
return (
|
||||
<Typography.Text className={styles.columnUnitsHint}>
|
||||
Run the query to set a unit for each value column.
|
||||
Run the panel to set per-column units.
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ function ColumnUnits({
|
||||
<div className={styles.columnUnits}>
|
||||
{columns.map((column) => (
|
||||
<div className={styles.columnField} key={column.key}>
|
||||
<span className={styles.columnLabel}>{column.label}</span>
|
||||
<Typography.Text>{column.label}</Typography.Text>
|
||||
<YAxisUnitSelector
|
||||
data-testid={`panel-editor-v2-column-unit-${column.key}`}
|
||||
placeholder="Select unit"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { resolveDecimalPrecision } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
|
||||
import { formatPanelValue } from 'pages/DashboardPage/DashboardContainer/Panels/utils/formatPanelValue';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import type { FieldResetProps } from '../../utils/changes';
|
||||
import {
|
||||
DECIMAL_OPTIONS,
|
||||
DECIMALS_PREVIEW_VALUE,
|
||||
DEFAULT_DECIMAL_PRECISION,
|
||||
} from './options';
|
||||
|
||||
import styles from './FormattingSection.module.scss';
|
||||
|
||||
interface DecimalsFieldProps extends FieldResetProps {
|
||||
value: DashboardtypesPrecisionOptionDTO | undefined;
|
||||
unit?: string;
|
||||
onChange: (next: DashboardtypesPrecisionOptionDTO) => void;
|
||||
}
|
||||
|
||||
function DecimalsField({
|
||||
value = DEFAULT_DECIMAL_PRECISION,
|
||||
unit,
|
||||
changed,
|
||||
onReset,
|
||||
onChange,
|
||||
}: DecimalsFieldProps): JSX.Element {
|
||||
return (
|
||||
<ConfigField label="Decimal places" changed={changed} onReset={onReset}>
|
||||
<ConfigTiles
|
||||
compact
|
||||
testId="panel-editor-v2-decimals"
|
||||
aria-label="Decimal places"
|
||||
value={value}
|
||||
items={DECIMAL_OPTIONS}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div className={styles.preview}>
|
||||
<span className={styles.previewLabel}>Shows as</span>
|
||||
<span className={styles.previewValue} data-testid="decimals-preview">
|
||||
{formatPanelValue(
|
||||
DECIMALS_PREVIEW_VALUE,
|
||||
unit,
|
||||
resolveDecimalPrecision(value),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</ConfigField>
|
||||
);
|
||||
}
|
||||
|
||||
export default DecimalsField;
|
||||
@@ -1,3 +1,9 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unitSelector {
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
@@ -7,31 +13,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 15px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Stacked per-column unit pickers; each column keeps the standard field layout.
|
||||
.columnUnits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
@@ -45,15 +31,10 @@
|
||||
|
||||
.columnField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.columnLabel {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.columnUnitSelector {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import type {
|
||||
@@ -5,17 +7,28 @@ import type {
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import ColumnUnits from './ColumnUnits';
|
||||
import DecimalsField from './DecimalsField';
|
||||
|
||||
import styles from './FormattingSection.module.scss';
|
||||
|
||||
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
|
||||
Pick<SectionEditorContext, 'tableColumns' | 'metricUnit'>;
|
||||
|
||||
// `full` means "show the raw value, no rounding"; the digits round to that many places.
|
||||
const DECIMAL_OPTIONS: {
|
||||
value: DashboardtypesPrecisionOptionDTO;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_0, label: '0 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_1, label: '1 decimal' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_2, label: '2 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_3, label: '3 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_4, label: '4 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.full, label: 'Full' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `formatting` slice of a panel spec (unit + decimal precision). Which
|
||||
* controls show is driven by the per-kind `controls` flags; the spec slice itself
|
||||
@@ -23,22 +36,16 @@ type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
|
||||
*/
|
||||
function FormattingSection({
|
||||
value,
|
||||
defaultValue,
|
||||
controls,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
metricUnit,
|
||||
}: FormattingSectionProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, defaultValue, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.unit && (
|
||||
<ConfigField
|
||||
label="Unit"
|
||||
help="Labels values, axis ticks and tooltips. With milliseconds, 1500 shows as 1.5 s."
|
||||
{...reset('unit')}
|
||||
>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Unit</Typography.Text>
|
||||
<YAxisUnitSelector
|
||||
containerClassName={styles.unitSelector}
|
||||
data-testid="panel-editor-v2-unit"
|
||||
@@ -47,29 +54,37 @@ function FormattingSection({
|
||||
initialValue={metricUnit}
|
||||
onChange={(unit): void => onChange({ ...value, unit })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.decimals && (
|
||||
<DecimalsField
|
||||
value={value?.decimalPrecision}
|
||||
unit={value?.unit}
|
||||
{...reset('decimalPrecision')}
|
||||
onChange={(decimalPrecision): void =>
|
||||
onChange({ ...value, decimalPrecision })
|
||||
}
|
||||
/>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Decimals</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-decimals"
|
||||
placeholder="Select decimals…"
|
||||
value={value?.decimalPrecision}
|
||||
items={DECIMAL_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
decimalPrecision: next,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.columnUnits && (
|
||||
<ConfigField label="Column units" {...reset('columnUnits')}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Column units</Typography.Text>
|
||||
<ColumnUnits
|
||||
columns={tableColumns}
|
||||
value={value?.columnUnits ?? {}}
|
||||
metricUnit={metricUnit}
|
||||
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import FormattingSection from '../FormattingSection';
|
||||
|
||||
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
|
||||
|
||||
async function pickDecimal(value: string): Promise<void> {
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByTestId(`panel-editor-v2-decimals-${value}`));
|
||||
// Open the Decimals select (clicking its antd selector) and pick the option with the
|
||||
// given visible label.
|
||||
async function pickDecimal(label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId('panel-editor-v2-decimals');
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
await user.click(await screen.findByRole('option', { name: label }));
|
||||
}
|
||||
|
||||
describe('FormattingSection', () => {
|
||||
@@ -49,7 +51,7 @@ describe('FormattingSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await pickDecimal('full');
|
||||
await pickDecimal('Full');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ decimalPrecision: 'full' });
|
||||
});
|
||||
@@ -64,45 +66,11 @@ describe('FormattingSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await pickDecimal('3');
|
||||
await pickDecimal('2 decimals');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
unit: 'bytes',
|
||||
decimalPrecision: '3',
|
||||
});
|
||||
});
|
||||
|
||||
it('previews a sample value at the chosen precision', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ decimalPrecision: DashboardtypesPrecisionOptionDTO.NUMBER_1 }}
|
||||
controls={{ decimals: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('decimals-preview')).toHaveTextContent('1,234.5');
|
||||
});
|
||||
|
||||
it('resets a changed precision back to the default', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{
|
||||
unit: 'bytes',
|
||||
decimalPrecision: DashboardtypesPrecisionOptionDTO.NUMBER_4,
|
||||
}}
|
||||
defaultValue={{}}
|
||||
controls={{ decimals: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId('config-field-reset'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
unit: 'bytes',
|
||||
decimalPrecision: undefined,
|
||||
decimalPrecision: '2',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
|
||||
|
||||
/** What an unset precision renders with. */
|
||||
export const DEFAULT_DECIMAL_PRECISION =
|
||||
DashboardtypesPrecisionOptionDTO.NUMBER_2;
|
||||
|
||||
export const DECIMALS_PREVIEW_VALUE = 1234.56789;
|
||||
|
||||
export const DECIMAL_OPTIONS: ConfigTileItem<DashboardtypesPrecisionOptionDTO>[] =
|
||||
[
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_0, label: '0' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_1, label: '1' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_2, label: '2' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_3, label: '3' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_4, label: '4' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.full, label: 'Full' },
|
||||
];
|
||||
@@ -1,33 +0,0 @@
|
||||
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import type {
|
||||
PanelFormattingSlice,
|
||||
SectionControlsOf,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { joinSummary } from '../../utils/summary';
|
||||
import { DEFAULT_DECIMAL_PRECISION } from './options';
|
||||
|
||||
function describeDecimals(precision: DashboardtypesPrecisionOptionDTO): string {
|
||||
if (precision === DashboardtypesPrecisionOptionDTO.full) {
|
||||
return 'full precision';
|
||||
}
|
||||
return precision === DashboardtypesPrecisionOptionDTO.NUMBER_1
|
||||
? '1 decimal'
|
||||
: `${precision} decimals`;
|
||||
}
|
||||
|
||||
export function summarizeFormatting(
|
||||
value: PanelFormattingSlice | undefined,
|
||||
controls: SectionControlsOf<SectionKind.Formatting>,
|
||||
): string {
|
||||
const hasColumnUnits = Object.keys(value?.columnUnits ?? {}).length > 0;
|
||||
return joinSummary([
|
||||
controls.unit &&
|
||||
(value?.unit ? getUniversalNameFromMetricUnit(value.unit) : 'No unit'),
|
||||
controls.columnUnits && hasColumnUnits && 'Per-column units',
|
||||
controls.decimals &&
|
||||
describeDecimals(value?.decimalPrecision ?? DEFAULT_DECIMAL_PRECISION),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import LegendColors from '../../controls/LegendColors/LegendColors';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import { POSITION_OPTIONS } from './options';
|
||||
|
||||
import styles from './LegendSection.module.scss';
|
||||
|
||||
type LegendSectionProps = SectionEditorProps<SectionKind.Legend> &
|
||||
Pick<SectionEditorContext, 'legendSeries'>;
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.bottom,
|
||||
label: 'Bottom',
|
||||
icon: 'pos-bottom' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.right,
|
||||
label: 'Right',
|
||||
icon: 'pos-right' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `legend` slice of a panel spec: legend position and per-series color
|
||||
* overrides. The colors control reads the panel's resolved series from context (the
|
||||
@@ -20,35 +34,38 @@ type LegendSectionProps = SectionEditorProps<SectionKind.Legend> &
|
||||
*/
|
||||
function LegendSection({
|
||||
value,
|
||||
defaultValue,
|
||||
controls,
|
||||
onChange,
|
||||
legendSeries,
|
||||
}: LegendSectionProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, defaultValue, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.position && (
|
||||
<ConfigField label="Position" {...reset('position')}>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Position</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-legend-position"
|
||||
aria-label="Legend position"
|
||||
items={POSITION_OPTIONS}
|
||||
value={value?.position ?? defaultValue?.position}
|
||||
onChange={(position): void => onChange({ ...value, position })}
|
||||
value={value?.position}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
position: next as DashboardtypesLegendPositionDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.colors && (
|
||||
<ConfigField label="Series colors" {...reset('customColors')}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Series colors</Typography.Text>
|
||||
<LegendColors
|
||||
series={legendSeries ?? []}
|
||||
value={value?.customColors}
|
||||
onChange={(customColors): void => onChange({ ...value, customColors })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,8 +16,8 @@ describe('LegendSection', () => {
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-legend-position'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Below chart')).toBeInTheDocument();
|
||||
expect(screen.getByText('Right of chart')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bottom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Right')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when position is not enabled', () => {
|
||||
@@ -40,7 +40,7 @@ describe('LegendSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-legend-position-right'));
|
||||
fireEvent.click(screen.getByText('Right'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ position: 'right' });
|
||||
});
|
||||
@@ -58,7 +58,7 @@ describe('LegendSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-legend-position-right'));
|
||||
fireEvent.click(screen.getByText('Right'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
position: 'right',
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
|
||||
|
||||
export const POSITION_OPTIONS: ConfigTileItem<DashboardtypesLegendPositionDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.bottom,
|
||||
label: 'Below chart',
|
||||
drawing: TILE_DRAWINGS.legendBottom,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.right,
|
||||
label: 'Right of chart',
|
||||
drawing: TILE_DRAWINGS.legendRight,
|
||||
},
|
||||
];
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { DashboardtypesLegendDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionControlsOf,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { countSummary, joinSummary } from '../../utils/summary';
|
||||
import { POSITION_OPTIONS } from './options';
|
||||
|
||||
export function summarizeLegend(
|
||||
value: DashboardtypesLegendDTO | undefined,
|
||||
controls: SectionControlsOf<SectionKind.Legend>,
|
||||
): string {
|
||||
const position =
|
||||
POSITION_OPTIONS.find((option) => option.value === value?.position) ??
|
||||
POSITION_OPTIONS[0];
|
||||
return joinSummary([
|
||||
controls.position && position.label,
|
||||
countSummary(Object.keys(value?.customColors ?? {}), 'custom color'),
|
||||
]);
|
||||
}
|
||||
@@ -4,24 +4,19 @@ import type {
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
|
||||
/** Edits the Text panel's `headerOptions` slice: the panel card's title strip. */
|
||||
function PanelHeaderSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.PanelHeader>): JSX.Element {
|
||||
const hide = value?.hide === true;
|
||||
|
||||
return (
|
||||
<ConfigSwitch
|
||||
testId="panel-header-hide"
|
||||
title="Hide panel header"
|
||||
description="Removes the title strip on the dashboard. Drag and actions appear on hover."
|
||||
sketch={SWITCH_SKETCHES.hideHeader}
|
||||
changed={hide}
|
||||
value={hide}
|
||||
onChange={(next): void => onChange({ ...value, hide: next })}
|
||||
title="Hide header"
|
||||
description="Drop the title strip on the dashboard; hovering the panel shows controls for drag and actions."
|
||||
value={value?.hide === true}
|
||||
onChange={(hide): void => onChange({ ...value, hide })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user