Compare commits

..

39 Commits

Author SHA1 Message Date
Abhi Kumar
2249367b2e feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-07 18:39:46 +05:30
Abhi Kumar
79d7fb0ef7 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
2be3488bda feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
bf3a36099f chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
14833699d5 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
ddf847d343 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-07 13:56:36 +05:30
Abhi Kumar
ed65aa29cc feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-07 13:55:23 +05:30
Abhi Kumar
954988d401 chore(storybook): cover heatmap config and changed settings 2026-10-07 13:29:32 +05:30
Abhi Kumar
dff09e6950 fix(dashboard): tighten config pane helper text 2026-10-07 13:29:32 +05:30
Abhi Kumar
4132972bb3 refactor(dashboard): remove ConfigSegmented and segment icons
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
3348ee3bd2 feat(dashboard): redesign heatmap config controls
Opacity base colour reuses the threshold swatches and count range the
axis range input, in place of bespoke cards and inputs.

Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
b01ea8eb06 fix(dashboard): restore "Global Time" time range label
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
08731f448d feat(dashboard): compare config changes against the saved panel
Section dots and field resets now reflect unsaved edits instead of
deviation from kind defaults; Reset reverts to the saved value.
Collapsed section summaries are dropped.

Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
330fb7c985 feat(dashboard): confirm before leaving the panel editor with unsaved changes
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
3b3462a2ed feat(dashboard): redesign thresholds and context links sections
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
375879ea2b style(dashboard): draw config tile sketches in the tile's text color
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
7cfd4f6d9e feat(dashboard): add text position grid and restyle text panel options
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
40bdb1e9e5 feat(dashboard): redesign buckets section
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
c9974d4a68 feat(dashboard): redesign chart appearance section
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
bb491a932b feat(dashboard): redesign legend section and match series colors to legend font
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
faf665b6af feat(dashboard): redesign axes section
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
5997038816 feat(dashboard): redesign formatting section with decimal tiles and preview
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
aa530894d9 feat(dashboard): redesign visualization section
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
687b20a5c2 feat(dashboard): summarise config sections and mark changed settings
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
3b02ee9815 feat(dashboard): add change tracking and shared config pane controls
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:32 +05:30
Abhi Kumar
ea7282973b chore(storybook): cover the new-panel drawer and panel type switch 2026-10-07 13:29:30 +05:30
Abhi Kumar
86f9ca51ab chore: pr review changes 2026-10-07 13:29:30 +05:30
Abhi Kumar
a94786c589 chore: pr review changes 2026-10-07 13:29:30 +05:30
Abhi Kumar
c370d2cc84 test(dashboard): wait for picker store effects after starting a new section 2026-10-07 13:29:30 +05:30
Abhi Kumar
0c5f5cbee1 test(dashboard): use userEvent in panel type switcher tests
Assisted-by: Claude Opus 5.5
2026-10-07 13:29:30 +05:30
Abhi Kumar
64b624aaac feat(dashboard): add-to-section split button in the new panel drawer
Replaces the antd section select; the CTA names its destination.

Assisted-by: Claude Opus 5.5
2026-10-07 13:29:30 +05:30
Abhi Kumar
d53eaf436d feat(dashboard): switch panel type in the editor via the type browser
A revert button returns to the type the panel was opened with.
2026-10-07 13:29:30 +05:30
Abhi Kumar
1c612342e1 fix(dashboard): scroll to a placeholder that grows the dashboard
OverlayScrollbars marks its viewport scrollable only after noticing the
overflow, and the grid animates its height, so the reveal either scrolled
html or stopped short.
2026-10-07 13:29:30 +05:30
Abhi Kumar
5d389e10f9 feat(dashboard): preview the new panel where the picker will add it 2026-10-07 13:29:30 +05:30
Abhi Kumar
c2691226f2 feat(dashboard): preview a new section on the dashboard while naming it 2026-10-07 13:29:30 +05:30
Abhi Kumar
3833623a0a feat(dashboard): pick or create the section from the new-panel drawer footer
The main New Panel button defaults to the dashboard root.
2026-10-07 13:29:30 +05:30
Abhi Kumar
c83e1dac83 feat(dashboard): let a new panel's save create its section or the root
Placement travels in the editor URL as a NewPanelTarget, so nothing is
written until the panel is saved.
2026-10-07 13:29:30 +05:30
Abhi Kumar
b5dddfef17 feat(dashboard): highlight the picker's target section behind the drawer 2026-10-07 13:29:30 +05:30
Abhi Kumar
eae11aeee7 feat(dashboard): redesign the new-panel picker as a searchable drawer 2026-10-07 13:29:30 +05:30
292 changed files with 7862 additions and 8481 deletions

View File

@@ -68,7 +68,6 @@ jobs:
- semconvfamilies
- serviceaccount
- spanmapper
- tracedetail
- querier_json_body
- querier_skip_resource_fingerprint
- ttl

View File

@@ -3425,6 +3425,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4167,6 +4174,7 @@ 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'
@@ -4182,6 +4190,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4195,6 +4204,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4280,6 +4290,18 @@ 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:
@@ -4359,6 +4381,16 @@ 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:
@@ -4532,6 +4564,87 @@ 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:
colorBy:
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
sizeBy:
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
@@ -9844,40 +9957,6 @@ components:
required:
- aggregations
type: object
SpantypesGettableTraceSummary:
properties:
ai:
$ref: '#/components/schemas/SpantypesTraceAISummary'
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
rootSpanStatusCode:
type: string
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
required:
- startTimestampMillis
- endTimestampMillis
- rootServiceName
- rootServiceEntryPoint
- rootSpanStatusCode
- totalSpansCount
- totalErrorSpansCount
- hasMissingSpans
type: object
SpantypesGettableWaterfallTrace:
properties:
endTimestampMillis:
@@ -10192,39 +10271,6 @@ components:
nullable: true
type: object
type: object
SpantypesTraceAISummary:
properties:
tokens:
$ref: '#/components/schemas/SpantypesTraceAITokens'
totalCost:
type: number
required:
- tokens
type: object
SpantypesTraceAITokens:
properties:
cacheRead:
minimum: 0
type: integer
cacheWrite:
minimum: 0
type: integer
input:
minimum: 0
type: integer
output:
minimum: 0
type: integer
reasoning:
minimum: 0
type: integer
required:
- input
- output
- cacheRead
- cacheWrite
- reasoning
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -15949,66 +15995,6 @@ paths:
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/traces/{traceID}/summary:
get:
deprecated: false
description: Returns the trace's time range, root span, span and error counts,
and whether any spans are missing. AI traces also include token and cost totals.
operationId: GetTraceSummary
parameters:
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get summary for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/user/me:
get:
deprecated: true

View File

@@ -11335,82 +11335,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
aggregations: SpantypesSpanAggregationResultDTO[];
}
export interface SpantypesTraceAITokensDTO {
/**
* @type integer
* @minimum 0
*/
cacheRead: number;
/**
* @type integer
* @minimum 0
*/
cacheWrite: number;
/**
* @type integer
* @minimum 0
*/
input: number;
/**
* @type integer
* @minimum 0
*/
output: number;
/**
* @type integer
* @minimum 0
*/
reasoning: number;
}
export interface SpantypesTraceAISummaryDTO {
tokens: SpantypesTraceAITokensDTO;
/**
* @type number
*/
totalCost?: number;
}
export interface SpantypesGettableTraceSummaryDTO {
ai?: SpantypesTraceAISummaryDTO;
/**
* @type integer
* @minimum 0
*/
endTimestampMillis: number;
/**
* @type boolean
*/
hasMissingSpans: boolean;
/**
* @type string
*/
rootServiceEntryPoint: string;
/**
* @type string
*/
rootServiceName: string;
/**
* @type string
*/
rootSpanStatusCode: string;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount: number;
}
export interface SpantypesOtelSpanRefDTO {
/**
* @type string
@@ -13099,17 +13023,6 @@ export type GetTraceAggregations200 = {
status: string;
};
export type GetTraceSummaryPathParameters = {
traceID: string;
};
export type GetTraceSummary200 = {
data: SpantypesGettableTraceSummaryDTO;
/**
* @type string
*/
status: string;
};
export type ListUserPreferences200 = {
/**
* @type array

View File

@@ -4,17 +4,11 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
@@ -22,8 +16,6 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetTraceSummary200,
GetTraceSummaryPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -35,26 +27,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* Computes span aggregations grouped by requested field.
* @summary Get aggregations for a trace
@@ -155,108 +127,6 @@ export const useGetTraceAggregations = <
> => {
return useMutation(getGetTraceAggregationsMutationOptions(options));
};
/**
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
* @summary Get summary for a trace
*/
export const getTraceSummary = (
{ traceID }: GetTraceSummaryPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceSummary200>({
url: `/api/v1/traces/${traceID}/summary`,
method: 'GET',
signal,
});
};
export const getGetTraceSummaryQueryKey = ({
traceID,
}: GetTraceSummaryPathParameters) => {
return [`/api/v1/traces/${traceID}/summary`] as const;
};
export const getGetTraceSummaryQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
signal,
}) => getTraceSummary({ traceID }, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceSummaryQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceSummary>>
>;
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get summary for a trace
*/
export function useGetTraceSummary<
TData = Awaited<ReturnType<typeof getTraceSummary>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceSummaryPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceSummary>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get summary for a trace
*/
export const invalidateGetTraceSummary = async (
queryClient: QueryClient,
{ traceID }: GetTraceSummaryPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
options,
);
return queryClient;
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace

View File

@@ -32,7 +32,6 @@ export const REACT_QUERY_KEY = {
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_TRACE_V3_FLAMEGRAPH: 'GET_TRACE_V3_FLAMEGRAPH',
GET_TRACE_THREAD: 'GET_TRACE_THREAD',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',
GET_DEPLOYMENT_LIST: 'GET_DEPLOYMENT_LIST',

View File

@@ -8,6 +8,7 @@
background-color: var(--l1-background);
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
padding-bottom: 44px;
@include custom-scrollbar;
@@ -42,11 +43,12 @@
.eyebrow {
display: block;
font-size: 11px;
font-weight: 600;
padding: 16px;
letter-spacing: 0.06em;
color: var(--l1-foreground);
padding: 18px 20px 12px;
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--l2-foreground);
}
.group {
@@ -73,9 +75,5 @@
.sections {
display: flex;
flex-direction: column;
& > * {
padding: 0 16px;
border-top: 1px solid var(--l1-border);
}
border-bottom: 1px solid var(--l1-border);
}

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
@@ -7,6 +8,7 @@ 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';
@@ -22,9 +24,12 @@ import { PanelKind } from '../../Panels/types/panelKind';
interface ConfigPaneProps {
/** The panel spec — the single editing surface (title/description + section slices). */
spec: DashboardtypesPanelSpecDTO;
/** Last saved spec, the baseline for each section's changed marker. */
savedSpec: DashboardtypesPanelSpecDTO;
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
@@ -55,8 +60,10 @@ interface ConfigPaneProps {
*/
function ConfigPane({
spec,
savedSpec,
onChangeSpec,
onChangePanelKind,
originalPanelKind,
queryType,
legendSeries,
tableColumns,
@@ -76,6 +83,11 @@ 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
@@ -119,12 +131,15 @@ function ConfigPane({
bare
config={headerSection}
spec={spec}
savedSpec={savedSpec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
@@ -143,12 +158,15 @@ function ConfigPane({
key={config.kind}
config={config}
spec={spec}
savedSpec={savedSpec}
onChangeSpec={onChangeSpec}
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}

View File

@@ -1,6 +1,54 @@
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
@use '../../../../../../styles/scrollbar' as *;
.trigger {
--button-height: 36px;
--button-padding: 0 5px 0 12px;
--button-gap: 8px;
--button-justify-content: flex-start;
--button-variant-outlined-border: 1px solid var(--l2-border);
--button-variant-outlined-background-color: var(--l2-background);
--button-variant-outlined-hover-background-color: var(--l2-background);
width: 100%;
text-align: left;
&:hover {
--button-variant-outlined-border: 1px solid 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 {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow-y: auto;
@include custom-scrollbar;
}

View File

@@ -1,12 +1,17 @@
import { Typography } from '@signozhq/ui/typography';
import { useCallback, useState } from 'react';
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
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 ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import ConfigField from '../controls/ConfigField/ConfigField';
import styles from './PanelTypeSwitcher.module.scss';
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
import { getPanelTypeDisabledReason } from './utils';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
@@ -15,33 +20,97 @@ 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). 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).
* 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.
*/
function PanelTypeSwitcher({
panelKind,
queryType,
signal,
originalPanelKind,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
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);
}
};
return (
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
<ConfigField label="Panel type">
<Button
variant="outlined"
color="secondary"
className={styles.trigger}
onClick={(): void => setIsOpen(true)}
testId="panel-editor-v2-type-switcher"
value={panelKind}
items={items}
onChange={(value): void => onChange(value)}
/>
</div>
>
<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>
);
}

View File

@@ -1,22 +1,30 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
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(),
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 })),
get PANEL_OPTIONS(): unknown {
return OPTIONS;
},
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
@@ -28,14 +36,34 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
};
function disabledLabels(): (string | null)[] {
return Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
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 openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
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-', ''));
}
describe('PanelTypeSwitcher', () => {
@@ -44,7 +72,8 @@ 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) => ({
mode: 'query',
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
@@ -57,83 +86,94 @@ describe('PanelTypeSwitcher', () => {
}));
});
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
it('shows the current type and switches to the chosen one', async () => {
const { onChange, user } = await renderSwitcher();
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
'Time SeriesChange',
);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
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()}
/>,
);
it('does not fire onChange when the current type is picked again', async () => {
const { onChange, user } = await renderSwitcher();
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');
await user.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
expect(onChange).not.toHaveBeenCalled();
});
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()}
/>,
);
it('disables types whose supported signals exclude the current signal', async () => {
const { onChange, user } = await renderSwitcher({
signal: TelemetrytypesSignalDTO.metrics,
});
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
it('does not disable any type when the signal is unknown (builder, no signal)', async () => {
await renderSwitcher();
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');
expect(disabledKinds()).toHaveLength(0);
});
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);
it('disables Query-Builder-only kinds under PromQL even without a signal', async () => {
await renderSwitcher({ queryType: EQueryType.PROM });
openDropdown();
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Table');
expect(disabledLabels()).not.toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
expect(disabledKinds()).toStrictEqual(
expect.arrayContaining([
'signoz/ListPanel',
'signoz/TablePanel',
'signoz/PieChartPanel',
]),
);
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
expect(disabledKinds()).not.toContain('signoz/TextPanel');
});
it('disables List under ClickHouse while Table/Pie stay enabled', async () => {
await renderSwitcher({
panelKind: 'signoz/TablePanel',
queryType: EQueryType.CLICKHOUSE,
});
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
});
describe('revert', () => {
it('is hidden while the type is the original one', async () => {
await renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
expect(
screen.queryByTestId('panel-editor-v2-type-revert'),
).not.toBeInTheDocument();
});
it('switches back to the original type', async () => {
const { onChange, user } = await renderSwitcher({
panelKind: 'signoz/TablePanel',
originalPanelKind: 'signoz/TimeSeriesPanel',
});
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();
});
});
});

View File

@@ -18,8 +18,7 @@ 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. Shared by the editor's `PanelTypeSwitcher` and the View
* modal's header so the two selectors apply the same rule and can't drift.
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
*/
export function usePanelTypeSelectItems({
queryType,

View File

@@ -6,16 +6,22 @@ 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 { resolveSectionEditor } from '../sectionRegistry';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isDifferent } from '../utils/changes';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
/** Last saved spec; a section whose slice differs from it is marked changed. */
savedSpec: 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'>;
@@ -51,13 +57,16 @@ const SECTION_HEADER_SLOT: Partial<
function SectionSlot({
config,
spec,
savedSpec,
onChangeSpec,
defaults,
bare,
legendSeries,
tableColumns,
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
@@ -103,8 +112,11 @@ function SectionSlot({
return null;
}
const { title, icon: Icon } = SECTION_METADATA[config.kind];
const { title } = SECTION_METADATA[config.kind];
const { Component, get, update } = editor;
const value = get(spec);
const defaultValue = getSectionDefault(editor, spec, defaults);
const savedValue = get(savedSpec);
// 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).
@@ -113,20 +125,27 @@ 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={get(spec)}
value={value}
defaultValue={defaultValue}
savedValue={savedValue}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
{...context}
registerHeaderAction={registerHeaderAction}
/>
);
@@ -138,7 +157,7 @@ function SectionSlot({
return (
<SettingsSection
title={title}
icon={<Icon size={15} />}
changed={isDifferent(value, savedValue)}
open={open}
onOpenChange={setOpen}
headerSlot={headerSlot}

View File

@@ -24,11 +24,16 @@ function makeSpec(thresholds: unknown[] = []): DashboardtypesPanelSpecDTO {
// Stateful harness so onChange feeds back into the spec (as ConfigPane owns it).
function Harness({ initial = [] }: { initial?: unknown[] } = {}): JSX.Element {
const [spec, setSpec] = useState<DashboardtypesPanelSpecDTO>(
makeSpec(initial),
);
const [savedSpec] = useState(() => makeSpec(initial));
const [spec, setSpec] = useState<DashboardtypesPanelSpecDTO>(savedSpec);
return (
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
<SectionSlot
config={THRESHOLDS_CONFIG}
spec={spec}
savedSpec={savedSpec}
defaults={{}}
onChangeSpec={setSpec}
/>
);
}
@@ -70,3 +75,49 @@ describe('SectionSlot header action', () => {
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
});
});
describe('SectionSlot changed marker', () => {
it('shows no marker when the section matches the saved spec', () => {
render(
<Harness initial={[{ value: 80, color: '#F5B225', label: 'High' }]} />,
);
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
});
it('marks a section edited since the last save', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(screen.getByTestId('panel-editor-v2-add-threshold-header'));
expect(screen.getByTestId('config-changed-dot')).toBeInTheDocument();
});
it('treats an unset saved slice and an explicit empty one as unchanged', () => {
const savedSpec = {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
const spec = {
...savedSpec,
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { formatting: { unit: '' } },
},
} as unknown as DashboardtypesPanelSpecDTO;
render(
<SectionSlot
config={{ kind: SectionKind.Formatting, controls: { decimals: true } }}
spec={spec}
savedSpec={savedSpec}
defaults={{}}
onChangeSpec={jest.fn()}
/>,
);
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
});
});

View File

@@ -1,51 +1,42 @@
.section {
border-top: 1px solid var(--l1-border);
}
.header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 44px;
padding: 0 20px 0 0;
&:hover {
background: var(--l2-background);
}
}
// 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: 11px;
gap: 10px;
min-width: 0;
padding: 0 !important;
padding: 18px 0 18px 20px;
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 {
flex: 1;
text-align: left;
font-weight: 600;
color: var(--l2-foreground);
font-size: 13px;
font-weight: 400;
white-space: nowrap;
color: var(--l1-foreground);
}
.chevron {
flex: none;
color: var(--l3-foreground);
cursor: pointer;
transition: transform 0.15s ease;
&.open {
@@ -56,6 +47,6 @@
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 2px 0 18px;
gap: 26px;
padding: 4px 20px 26px;
}

View File

@@ -1,18 +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;
changed?: boolean;
/** Rendered between the title and the chevron. */
headerSlot?: ReactNode;
children: ReactNode;
@@ -23,10 +23,10 @@ interface SettingsSectionProps {
*/
function SettingsSection({
title,
icon,
defaultOpen = false,
open,
onOpenChange,
changed,
headerSlot,
children,
}: SettingsSectionProps): JSX.Element {
@@ -51,30 +51,17 @@ function SettingsSection({
type="button"
className={styles.toggle}
aria-expanded={isOpen}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
data-testid={`config-section-${serializedTitle}`}
onClick={toggle}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
<span className={styles.title}>{title}</span>
{changed && <ChangedDot title="Unsaved changes" />}
</button>
{headerSlot}
<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}
<ChevronDown
size={14}
className={cx(styles.chevron, { [styles.open]: isOpen })}
onClick={toggle}
/>
</div>

View File

@@ -47,6 +47,7 @@ function renderConfigPane(
): React.ComponentProps<typeof ConfigPane> {
const props: React.ComponentProps<typeof ConfigPane> = {
spec: spec(),
savedSpec: spec(),
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
queryType: EQueryType.QUERY_BUILDER,
@@ -108,7 +109,7 @@ describe('ConfigPane', () => {
const toggle = screen.getByTestId('panel-header-hide');
expect(toggle).toBeInTheDocument();
expect(screen.getByText('Hide header')).toBeInTheDocument();
expect(screen.getByText('Hide panel header')).toBeInTheDocument();
// No collapsible wrapper of its own.
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,80 @@
.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);
}

View File

@@ -0,0 +1,64 @@
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;

View File

@@ -0,0 +1,37 @@
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]}`;
}

View File

@@ -1,119 +0,0 @@
import { Fragment } from 'react';
import { Check } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import {
TEXT_BACKGROUND_PAIRS,
TEXT_BACKGROUND_PRESETS,
} 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 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',
default: 'Default panel colour',
};
const OPTIONS: TextBackgroundSelection[] = [
TextBackgroundKind.None,
TextBackgroundKind.Default,
...TEXT_BACKGROUND_PRESETS,
];
const DIVIDER_AFTER = 1;
interface BackgroundSwatchesProps {
testId: string;
/** Names the group for assistive tech — the row carries no visible label. */
label: string;
/** `undefined` while a custom colour is active: no swatch is selected. */
value: TextBackgroundSelection | undefined;
/** Swatches paint in this theme's pair, so what the user picks is what they see. */
theme: PanelTheme;
onChange: (value: TextBackgroundSelection) => void;
}
/**
* The Text panel's background choices as one radio group. Native radios sharing a
* `name`, so arrow-key movement, the single tab stop and selection-follows-focus
* are the platform's; each input is transparent and fills its swatch.
*/
function BackgroundSwatches({
testId,
label,
value,
theme,
onChange,
}: BackgroundSwatchesProps): JSX.Element {
return (
<div
className={styles.row}
role="radiogroup"
aria-label={label}
data-testid={testId}
>
{OPTIONS.map((option, index) => {
const isBase =
option === TextBackgroundKind.None ||
option === TextBackgroundKind.Default;
const pair = isBase ? undefined : TEXT_BACKGROUND_PAIRS[option][theme];
const title = isBase ? BASE_TITLES[option] : PRESET_TITLES[option];
return (
<Fragment key={option}>
<TooltipSimple title={isBase ? BASE_TOOLTIPS[option] : title} arrow>
<label
className={cx(styles.swatch, {
[styles.checkerboard]: option === TextBackgroundKind.None,
[styles.defaultSurface]: option === TextBackgroundKind.Default,
[styles.selected]: option === value,
})}
style={pair ? { background: pair.surface, color: pair.ink } : undefined}
data-testid={`${testId}-${option}`}
>
<input
type="radio"
className={styles.input}
name={testId}
value={option}
checked={option === value}
aria-label={title}
onChange={(): void => onChange(option)}
/>
{option === value && <Check size={14} />}
</label>
</TooltipSimple>
{index === DIVIDER_AFTER && <span className={styles.divider} />}
</Fragment>
);
})}
</div>
);
}
export default BackgroundSwatches;

View File

@@ -1,67 +0,0 @@
.row {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
text-align: left;
}
.active {
border-color: var(--bg-robin-500);
}
.chip {
flex: none;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
}
// No colour to show yet, so the chip advertises that it opens a picker.
.chipEmpty {
background: conic-gradient(
from 0deg,
var(--bg-cherry-400),
var(--bg-amber-400),
var(--bg-forest-400),
var(--bg-robin-400),
var(--bg-sakura-400),
var(--bg-cherry-400)
);
}
.label {
flex: 1;
font-size: 12px;
color: var(--l2-foreground);
}
.hex {
font-family: var(--font-family-sf-mono);
font-size: 12px;
color: var(--text-vanilla-400);
letter-spacing: 0.02em;
}
// Appended under the picker's own panel.
.contrast {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 4px 2px;
font-size: 12px;
color: var(--text-vanilla-400);
}
.warning {
color: var(--bg-amber-400);
}

View File

@@ -1,91 +0,0 @@
import type { ReactNode } from 'react';
import { Check, ChevronDown, TriangleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { ColorPicker } from 'antd';
import cx from 'classnames';
import {
contrastRatio,
inkForSurface,
MIN_CONTRAST_RATIO,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
import styles from './CustomBackgroundRow.module.scss';
const HEX_PLACEHOLDER = '#______';
/** What the picker opens on before a colour is chosen. */
const INITIAL_COLOR = '#3A2A63';
interface CustomBackgroundRowProps {
testId: string;
/** The stored hex while a custom colour is active; `undefined` otherwise. */
value: string | undefined;
onChange: (hex: string) => void;
}
/**
* The custom colour, as a row rather than a swatch: it opens a picker instead of
* committing a value in one click. The picker warns below the contrast floor but
* never blocks the choice.
*/
function CustomBackgroundRow({
testId,
value,
onChange,
}: CustomBackgroundRowProps): JSX.Element {
const color = value ?? INITIAL_COLOR;
const ratio = contrastRatio(inkForSurface(color), color);
const isLegible = ratio >= MIN_CONTRAST_RATIO;
const contrastMessage = isLegible
? `Contrast ${ratio.toFixed(1)}:1`
: `Contrast ${ratio.toFixed(1)}:1 — below ${MIN_CONTRAST_RATIO}:1`;
function renderPanel(panel: ReactNode): ReactNode {
return (
<>
{panel}
<div
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
data-testid={`${testId}-contrast`}
>
{!isLegible && <TriangleAlert size={12} />}
<span className="translate-safe">{contrastMessage}</span>
</div>
</>
);
}
return (
<ColorPicker
value={color}
size="small"
showText={false}
trigger="click"
panelRender={renderPanel}
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<button
type="button"
className={cx(styles.row, { [styles.active]: value !== undefined })}
data-testid={testId}
>
<span
className={cx(styles.chip, { [styles.chipEmpty]: value === undefined })}
style={
value ? { background: value, color: inkForSurface(value) } : undefined
}
>
{value !== undefined && <Check size={14} />}
</span>
<Typography.Text className={styles.label}>Custom</Typography.Text>
<span className={cx(styles.hex, 'translate-safe')}>
{value ?? HEX_PLACEHOLDER}
</span>
<ChevronDown size={14} />
</button>
</ColorPicker>
);
}
export default CustomBackgroundRow;

View File

@@ -1,118 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { TEXT_BACKGROUND_PAIRS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import {
PanelTheme,
TextBackgroundKind,
TextBackgroundPreset,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import BackgroundSwatches from '../BackgroundSwatches';
function renderRow(
props: Partial<React.ComponentProps<typeof BackgroundSwatches>> = {},
): jest.Mock {
const onChange = jest.fn();
render(
<TooltipProvider>
<BackgroundSwatches
testId="background"
label="Panel background"
theme={PanelTheme.Dark}
value={TextBackgroundKind.Default}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
return onChange;
}
describe('BackgroundSwatches', () => {
it('offers transparent, the default surface and the eight presets in order', () => {
renderRow();
expect(
screen
.getAllByRole('radio')
.map((swatch) => swatch.getAttribute('aria-label')),
).toStrictEqual([
'Transparent',
'Default panel',
'Robin',
'Purple',
'Sakura',
'Cherry',
'Amber',
'Forest',
'Sienna',
'Slate',
]);
});
it('is one labelled group', () => {
renderRow();
expect(
screen.getByRole('radiogroup', { name: 'Panel background' }),
).toBeInTheDocument();
});
it.each([
['background-none', 'Transparent — no card, border or title bar'],
['background-default', 'Default panel colour'],
['background-sakura', 'Sakura'],
])('explains %s on hover', async (swatchId, copy) => {
renderRow();
fireEvent.focus(screen.getByTestId(swatchId));
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
});
});
it('paints each preset in the given theme', () => {
renderRow({ theme: PanelTheme.Light });
expect(screen.getByTestId('background-amber')).toHaveStyle({
background: TEXT_BACKGROUND_PAIRS.amber.light.surface,
color: TEXT_BACKGROUND_PAIRS.amber.light.ink,
});
});
it('marks only the selected swatch, and checks it', () => {
renderRow({ value: TextBackgroundPreset.Forest });
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
expect(
screen.getByRole('radio', { name: 'Default panel' }),
).not.toBeChecked();
expect(
screen.getByTestId('background-forest').querySelector('svg'),
).toBeInTheDocument();
expect(
screen.getByTestId('background-default').querySelector('svg'),
).not.toBeInTheDocument();
});
it('reports the swatch that was clicked', () => {
const onChange = renderRow();
fireEvent.click(screen.getByRole('radio', { name: 'Sienna' }));
expect(onChange).toHaveBeenCalledWith('sienna');
});
// jsdom does not implement radio arrow navigation, so the shared name — what
// makes them one group — is what there is to assert.
it('groups every swatch under one radio name', () => {
renderRow();
const names = new Set(
screen.getAllByRole('radio').map((swatch) => swatch.getAttribute('name')),
);
expect(names).toStrictEqual(new Set(['background']));
});
});

View File

@@ -1,77 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import CustomBackgroundRow from '../CustomBackgroundRow';
function renderRow(value?: string): jest.Mock {
const onChange = jest.fn();
render(
<CustomBackgroundRow testId="custom" value={value} onChange={onChange} />,
);
return onChange;
}
describe('CustomBackgroundRow', () => {
it('stands in for the hex while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom')).toHaveTextContent('#______');
});
it('shows the stored hex once one is set', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom')).toHaveTextContent('#3A2A63');
});
it('checks the chip only while the custom colour is the selection', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom').querySelector('svg')).toBeInTheDocument();
});
it('leaves the chip unchecked while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom').querySelectorAll('svg')).toHaveLength(1);
});
describe('the picker', () => {
it('opens on the row', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toBeInTheDocument();
});
it('reports the contrast the colour achieves', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
// The derived ink is pure white, not purple's paired ink.
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'Contrast 12.4:1',
);
});
it('warns when no ink clears the floor, without disabling anything', () => {
renderRow('#808080');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'below 4.5:1',
);
expect(screen.getByTestId('custom')).toBeEnabled();
});
it('says nothing about the floor when the colour clears it', () => {
renderRow('#111111');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).not.toHaveTextContent('below');
});
});
});

View File

@@ -0,0 +1,7 @@
.dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-robin-500);
}

View File

@@ -0,0 +1,18 @@
import styles from './ChangedDot.module.scss';
function ChangedDot({
title = 'Unsaved changes',
}: {
title?: string;
}): JSX.Element {
return (
<span
className={styles.dot}
title={title}
aria-label={title}
data-testid="config-changed-dot"
/>
);
}
export default ChangedDot;

View File

@@ -1,32 +1,34 @@
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
gap: 8px;
}
.group {
display: contents;
}
.swatch {
position: relative;
display: block;
width: 24px;
height: 24px;
flex: none;
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: none;
cursor: pointer;
position: relative;
// The input carries focus, so the ring is drawn on the swatch around it.
&:has(.input:focus-visible) {
&:has(.input:focus-visible),
&:focus-visible {
outline: 2px solid var(--bg-robin-400);
outline-offset: 1px;
outline-offset: 2px;
}
}
// The real control, sized to the swatch and invisible over it: clicks and focus
// land on the radio, the swatch is what the user sees.
// Clicks and focus land on the radio; the swatch is what the user sees.
.input {
position: absolute;
inset: 0;
@@ -35,12 +37,13 @@
cursor: pointer;
}
.selected {
box-shadow: 0 0 0 2px var(--bg-robin-500);
.selected,
.selected:has(.input:focus-visible) {
outline: 2px solid var(--l1-foreground);
outline-offset: 2px;
}
// Transparency has no colour to show, so it reads as the conventional checkerboard.
.checkerboard {
.transparent {
background-color: var(--l2-background);
background-image:
linear-gradient(
@@ -63,14 +66,29 @@
4px 4px;
}
.defaultSurface {
.surface {
background: var(--l2-background);
}
.custom {
background: conic-gradient(
from 0deg,
var(--bg-cherry-400),
var(--bg-amber-400),
var(--bg-forest-400),
var(--bg-robin-400),
var(--bg-sakura-400),
var(--bg-cherry-400)
);
}
.customAnchor {
display: inline-flex;
}
.divider {
flex: none;
width: 1px;
height: 18px;
margin: 0 2px;
background: var(--l2-border);
}

View File

@@ -0,0 +1,80 @@
import { Fragment, type ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import CustomColorSwatch from './CustomColorSwatch';
import styles from './ColorSwatches.module.scss';
export interface ColorSwatchOption<T extends string> {
value: T;
id: string;
label: string;
tooltip?: string;
fill?: string;
pattern?: 'transparent' | 'surface';
}
interface CustomColor {
/** The active custom color; `undefined` while a preset is selected. */
value: string | undefined;
initial: string;
onChange: (hex: string) => void;
pickerFooter?: (hex: string) => ReactNode;
}
interface ColorSwatchesProps<T extends string> {
testId: string;
label: string;
value: T | undefined;
options: ColorSwatchOption<T>[];
dividerAfter?: number;
onChange: (value: T) => void;
custom: CustomColor;
}
function ColorSwatches<T extends string>({
testId,
label,
value,
options,
dividerAfter,
onChange,
custom,
}: ColorSwatchesProps<T>): JSX.Element {
return (
<div className={styles.row} data-testid={testId}>
<div role="radiogroup" aria-label={label} className={styles.group}>
{options.map((option, index) => (
<Fragment key={option.id}>
<TooltipSimple title={option.tooltip ?? option.label} arrow>
<label
className={cx(styles.swatch, {
[styles.transparent]: option.pattern === 'transparent',
[styles.surface]: option.pattern === 'surface',
[styles.selected]: option.value === value,
})}
style={option.fill ? { background: option.fill } : undefined}
data-testid={`${testId}-${option.id}`}
>
<input
type="radio"
className={styles.input}
name={testId}
value={option.value}
checked={option.value === value}
aria-label={option.label}
onChange={(): void => onChange(option.value)}
/>
</label>
</TooltipSimple>
{index === dividerAfter && <span className={styles.divider} />}
</Fragment>
))}
</div>
<CustomColorSwatch testId={`${testId}-custom`} {...custom} />
</div>
);
}
export default ColorSwatches;

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { ColorPicker } from 'antd';
import cx from 'classnames';
import styles from './ColorSwatches.module.scss';
interface CustomColorSwatchProps {
testId: string;
value: string | undefined;
initial: string;
onChange: (hex: string) => void;
pickerFooter?: (hex: string) => ReactNode;
}
function CustomColorSwatch({
testId,
value,
initial,
onChange,
pickerFooter,
}: CustomColorSwatchProps): JSX.Element {
const color = value ?? initial;
const title = value ? `Custom ${value.toUpperCase()}` : 'Custom color';
return (
<ColorPicker
value={color}
trigger="click"
panelRender={
pickerFooter
? (panel): ReactNode => (
<>
{panel}
{pickerFooter(color)}
</>
)
: undefined
}
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<span className={styles.customAnchor}>
<TooltipSimple title={title} arrow>
<button
type="button"
aria-label={title}
aria-pressed={value !== undefined}
className={cx(styles.swatch, styles.custom, {
[styles.selected]: value !== undefined,
})}
data-testid={testId}
/>
</TooltipSimple>
</span>
</ColorPicker>
);
}
export default CustomColorSwatch;

View File

@@ -0,0 +1,135 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import ColorSwatches, { type ColorSwatchOption } from '../ColorSwatches';
const OPTIONS: ColorSwatchOption<string>[] = [
{ value: 'none', id: 'none', label: 'Transparent', pattern: 'transparent' },
{
value: 'default',
id: 'default',
label: 'Default panel',
tooltip: 'Default panel color',
pattern: 'surface',
},
{ value: '#ff0000', id: 'red', label: 'Red', fill: '#ff0000' },
{ value: '#00ff00', id: 'green', label: 'Green', fill: '#00ff00' },
];
function renderSwatches(
props: Partial<React.ComponentProps<typeof ColorSwatches<string>>> = {},
): { onChange: jest.Mock; onCustom: jest.Mock } {
const onChange = jest.fn();
const onCustom = jest.fn();
render(
<TooltipProvider>
<ColorSwatches
testId="color"
label="Color"
value="default"
options={OPTIONS}
dividerAfter={1}
onChange={onChange}
custom={{
value: undefined,
initial: '#3A2A63',
onChange: onCustom,
pickerFooter: (hex) => <span data-testid="footer">{hex}</span>,
}}
{...props}
/>
</TooltipProvider>,
);
return { onChange, onCustom };
}
describe('ColorSwatches', () => {
it('is one labelled radio group, in option order', () => {
renderSwatches();
expect(screen.getByRole('radiogroup', { name: 'Color' })).toBeInTheDocument();
expect(
screen.getAllByRole('radio').map((r) => r.getAttribute('aria-label')),
).toStrictEqual(['Transparent', 'Default panel', 'Red', 'Green']);
});
it('paints each swatch its fill', () => {
renderSwatches();
expect(screen.getByTestId('color-red')).toHaveStyle({
background: '#ff0000',
});
});
it('checks only the selected swatch', () => {
renderSwatches({ value: '#00ff00' });
expect(screen.getByRole('radio', { name: 'Green' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Red' })).not.toBeChecked();
});
it('reports the swatch that was clicked', async () => {
const user = userEvent.setup();
const { onChange } = renderSwatches();
await user.click(screen.getByRole('radio', { name: 'Red' }));
expect(onChange).toHaveBeenCalledWith('#ff0000');
});
it.each([
['color-default', 'Default panel color'],
['color-red', 'Red'],
])('explains %s on hover', async (swatchId, copy) => {
const user = userEvent.setup();
renderSwatches();
await user.hover(screen.getByTestId(swatchId));
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
});
});
describe('the custom swatch', () => {
it('is pressed only while a custom color is active', () => {
renderSwatches();
expect(screen.getByTestId('color-custom')).toHaveAttribute(
'aria-pressed',
'false',
);
});
it('names the active custom color', () => {
renderSwatches({
value: undefined,
custom: { value: '#3a2a64', initial: '#000000', onChange: jest.fn() },
});
expect(
screen.getByRole('button', { name: 'Custom #3A2A64' }),
).toHaveAttribute('aria-pressed', 'true');
});
it('opens a picker with the footer, starting from the initial color', async () => {
const user = userEvent.setup();
renderSwatches();
await user.click(screen.getByTestId('color-custom'));
expect(screen.getByTestId('footer')).toHaveTextContent('#3A2A63');
});
it('reports the color picked', async () => {
const user = userEvent.setup();
const { onCustom } = renderSwatches();
await user.click(screen.getByTestId('color-custom'));
await user.clear(screen.getByRole('textbox'));
await user.type(screen.getByRole('textbox'), '3A2A64');
expect(onCustom).toHaveBeenCalledWith('#3a2a64');
});
});
});

View File

@@ -0,0 +1,60 @@
.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);
}
.aside {
flex: 1;
min-width: 0;
overflow: hidden;
font-family: 'Geist Mono', monospace;
font-size: 12px;
text-align: right;
white-space: nowrap;
text-overflow: ellipsis;
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);
}

View File

@@ -0,0 +1,52 @@
import type { ReactNode } from 'react';
import cx from 'classnames';
import styles from './ConfigField.module.scss';
interface ConfigFieldProps {
label: ReactNode;
/** Shown at the end of the heading row, before Reset. */
aside?: 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,
aside,
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>
{aside && <span className={styles.aside}>{aside}</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;

View File

@@ -0,0 +1,5 @@
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
}

View File

@@ -0,0 +1,10 @@
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;

View File

@@ -0,0 +1,24 @@
.row {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
align-items: center;
gap: 12px;
}
// Matches ConfigField's heading.
.label {
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
color: var(--l2-foreground);
}
.help {
grid-column: 2;
margin-top: -4px;
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,45 @@
import type { ReactNode } from 'react';
import { Callout } from '@signozhq/ui/callout';
import styles from './ConfigInlineField.module.scss';
interface ConfigInlineFieldProps {
label: string;
/** Shown under the control. */
help?: ReactNode;
helpTestId?: string;
/** Shown in place of the help. */
error?: ReactNode;
children: ReactNode;
}
/** A field whose heading sits beside the control rather than above it. */
function ConfigInlineField({
label,
help,
helpTestId,
error,
children,
}: ConfigInlineFieldProps): JSX.Element {
return (
<div className={styles.row}>
<span className={styles.label}>{label}</span>
{children}
{error ? (
<div className={styles.help} data-testid={helpTestId}>
<Callout type="error" size="small" showIcon>
{error}
</Callout>
</div>
) : (
help && (
<span className={styles.help} data-testid={helpTestId}>
{help}
</span>
)
)}
</div>
);
}
export default ConfigInlineField;

View File

@@ -0,0 +1,50 @@
import type { ChangeEvent } from 'react';
import { Input } from 'antd';
import ConfigField from '../ConfigField/ConfigField';
interface ConfigNumberInputProps {
testId: string;
/** Omitted for an input whose row already names it. */
label?: string;
placeholder?: string;
invalid?: boolean;
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,
placeholder = 'Auto',
invalid,
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));
};
const input = (
<Input
data-testid={testId}
type="number"
placeholder={placeholder}
status={invalid ? 'error' : undefined}
value={value ?? ''}
onChange={handleChange}
/>
);
return label ? (
<ConfigField label={label} plain>
{input}
</ConfigField>
) : (
input
);
}
export default ConfigNumberInput;

View File

@@ -0,0 +1,10 @@
.range {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.dash {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,47 @@
import ConfigNumberInput from '../ConfigNumberInput/ConfigNumberInput';
import { isRangeInverted } from './utils';
import styles from './ConfigRangeInput.module.scss';
interface ConfigRangeInputProps {
minTestId: string;
maxTestId: string;
min: number | null | undefined;
max: number | null | undefined;
onChangeMin: (next: number | null) => void;
onChangeMax: (next: number | null) => void;
}
function ConfigRangeInput({
minTestId,
maxTestId,
min,
max,
onChangeMin,
onChangeMax,
}: ConfigRangeInputProps): JSX.Element {
const inverted = isRangeInverted(min, max);
return (
<div className={styles.range}>
<ConfigNumberInput
testId={minTestId}
placeholder="Min"
invalid={inverted}
value={min}
onChange={onChangeMin}
/>
<span className={styles.dash}>–</span>
<ConfigNumberInput
testId={maxTestId}
placeholder="Max"
invalid={inverted}
value={max}
onChange={onChangeMax}
/>
</div>
);
}
export default ConfigRangeInput;

View File

@@ -0,0 +1,14 @@
import { isRangeInverted } from '../utils';
describe('isRangeInverted', () => {
it.each([
[23, 12, true],
[12, 23, false],
[5, 5, false],
[-10, -20, true],
[null, 12, false],
[23, undefined, false],
])('min %p, max %p → %p', (min, max, expected) => {
expect(isRangeInverted(min, max)).toBe(expected);
});
});

View File

@@ -0,0 +1,6 @@
export function isRangeInverted(
min: number | null | undefined,
max: number | null | undefined,
): boolean {
return min != null && max != null && min > max;
}

View File

@@ -1,10 +0,0 @@
.group {
width: 100%;
}
.segment {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}

View File

@@ -1,59 +0,0 @@
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;

View File

@@ -11,6 +11,8 @@ interface ConfigSliderProps {
/** Renders the current value beside the track (e.g. as a percentage). */
formatValue?: (value: number) => string;
onChange: (value: number) => void;
/** Fires once on release, for a value too costly to write on every step. */
onChangeEnd?: (value: number) => void;
}
/**
@@ -25,6 +27,7 @@ function ConfigSlider({
step,
formatValue,
onChange,
onChangeEnd,
}: ConfigSliderProps): JSX.Element {
return (
<div className={styles.row}>
@@ -36,6 +39,10 @@ function ConfigSlider({
max={max}
step={step}
onChange={(next): void => onChange(Array.isArray(next) ? next[0] : next)}
onAfterChange={
onChangeEnd &&
((next): void => onChangeEnd(Array.isArray(next) ? next[0] : next))
}
/>
<span className={styles.value}>
{formatValue ? formatValue(value) : value}

View File

@@ -1,30 +1,31 @@
.card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l2-background-60);
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
gap: 3px;
min-width: 0;
}
.title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l2-foreground);
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--l1-foreground);
}
.description {
font-size: 12px;
color: var(--l3-foreground);
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -1,39 +1,38 @@
import type { ReactNode } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import ChangedDot from '../ChangedDot/ChangedDot';
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}</span>
{description && (
<Typography.Text className={styles.description}>
{description}
</Typography.Text>
)}
<span className={styles.title}>
{title}
{changed && <ChangedDot />}
</span>
{description && <span className={styles.description}>{description}</span>}
</div>
<Switch testId={testId} value={value} onChange={onChange} />
</div>

View File

@@ -0,0 +1,110 @@
.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;
}
}

View File

@@ -0,0 +1,74 @@
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;
/** Accessible name when `label` alone is ambiguous (e.g. a bare symbol). */
ariaLabel?: 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.ariaLabel ?? item.label}
onChange={(): void => onChange(item.value)}
/>
{item.drawing}
<span className={styles.label}>{item.label}</span>
</label>
);
})}
</div>
</div>
);
}
export default ConfigTiles;

View File

@@ -1,6 +1,5 @@
import { ColorPicker } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './LegendColors.module.scss';
@@ -41,9 +40,9 @@ function LegendColorRow({
data-testid={`legend-color-${label}`}
>
<span className={styles.swatch} style={{ backgroundColor: color }} />
<Typography.Text className={styles.label} title={label}>
<span className={styles.label} title={label}>
{label}
</Typography.Text>
</span>
</button>
</ColorPicker>
{isOverridden && (

View File

@@ -42,7 +42,9 @@
.label {
overflow: hidden;
font-size: 12px;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
color: var(--l2-foreground);
white-space: nowrap;
text-overflow: ellipsis;

View File

@@ -40,7 +40,7 @@ function LegendColors({
if (series.length === 0) {
return (
<Typography.Text className={styles.empty}>
Run the panel to customise series colors.
Run the query to set a color for each series.
</Typography.Text>
);
}

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import LegendColors from '../LegendColors';
@@ -15,7 +16,7 @@ describe('LegendColors', () => {
expect(
screen.queryByTestId('panel-editor-v2-legend-colors'),
).not.toBeInTheDocument();
expect(screen.getByText(/run the panel/i)).toBeInTheDocument();
expect(screen.getByText(/run the query/i)).toBeInTheDocument();
});
it('renders the search box once series are present', () => {
@@ -28,14 +29,14 @@ describe('LegendColors', () => {
).toBeInTheDocument();
});
it('shows a no-match message when the search filters everything out', () => {
it('shows a no-match message when the search filters everything out', async () => {
const user = userEvent.setup();
render(
<LegendColors series={SERIES} value={undefined} onChange={jest.fn()} />,
);
fireEvent.change(screen.getByTestId('panel-editor-v2-legend-search'), {
target: { value: 'zzz' },
});
await user.clear(screen.getByTestId('panel-editor-v2-legend-search'));
await user.type(screen.getByTestId('panel-editor-v2-legend-search'), 'zzz');
expect(screen.getByText(/no series match/i)).toBeInTheDocument();
});

View File

@@ -0,0 +1,28 @@
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;

View File

@@ -0,0 +1,22 @@
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;

View File

@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
function TileSvg({ children }: { 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={{ flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
export default TileSvg;

View File

@@ -0,0 +1,37 @@
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>;

View File

@@ -0,0 +1,240 @@
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>
<path d={path} />
{POINTS.map(([cx, cy]) => (
<circle key={cx} cx={cx} cy={cy} r={2.2} {...FILLED} />
))}
</TileSvg>
);
}
/** Heatmap bucket rows over a y axis; `ys` sets the row spacing a scale gives. */
function bucketRowsDrawing(ys: number[], dashed = false): JSX.Element {
return (
<TileSvg>
<path d="M6 2 V22 H60" strokeWidth={1.4} opacity={0.35} />
{ys.map((y) => (
<path
key={y}
d={`M10 ${y} H58`}
strokeWidth={2}
strokeDasharray={dashed ? '3 3' : undefined}
/>
))}
</TileSvg>
);
}
export const TILE_DRAWINGS = {
lineSolid: (
<TileSvg>
<path d={LINE} />
</TileSvg>
),
lineDashed: (
<TileSvg>
<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>
<path d={LINE} />
</TileSvg>
),
fillSolid: (
<TileSvg>
<path d={`${LINE} L60 22 L4 22Z`} {...FILLED} opacity={0.4} />
<path d={LINE} />
</TileSvg>
),
fillGradient: (
<TileSvg>
<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>
<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>
<path d="M4 16 L14 11 L22 14" />
<path d="M42 8 L50 11 L60 6" />
</TileSvg>
),
scaleLinear: (
<TileSvg>
<path d="M4 20 L60 4" />
</TileSvg>
),
scaleLog: (
<TileSvg>
<path d="M4 20 C 8 8, 20 5, 60 4" />
</TileSvg>
),
scaleAuto: (
<TileSvg>
<path d="M4 20 L60 4" strokeDasharray="3 3" />
</TileSvg>
),
scaleSqrt: (
<TileSvg>
<path d="M4 20 C 10 10, 26 6, 60 4" />
</TileSvg>
),
colorPalette: (
<TileSvg>
<defs>
<linearGradient id="config-tile-color-palette" x1="0" x2="1">
<stop offset="0" style={{ stopColor: 'var(--bg-robin-700)' }} />
<stop offset="0.35" style={{ stopColor: 'var(--bg-sakura-400)' }} />
<stop offset="0.7" style={{ stopColor: 'var(--bg-amber-400)' }} />
<stop offset="1" style={{ stopColor: 'var(--bg-amber-100)' }} />
</linearGradient>
</defs>
<rect
x={4}
y={9}
width={56}
height={8}
rx={1}
fill="url(#config-tile-color-palette)"
stroke="none"
/>
</TileSvg>
),
colorOpacity: (
<TileSvg>
<defs>
<linearGradient id="config-tile-color-opacity" x1="0" x2="1">
<stop offset="0" stopColor="currentColor" stopOpacity={0.08} />
<stop offset="1" stopColor="currentColor" stopOpacity={1} />
</linearGradient>
</defs>
<rect
x={4}
y={9}
width={56}
height={8}
rx={1}
fill="url(#config-tile-color-opacity)"
stroke="none"
/>
</TileSvg>
),
bucketsAuto: bucketRowsDrawing([5, 10, 14, 18], true),
bucketsLinear: bucketRowsDrawing([4, 9, 14, 19]),
bucketsLog: bucketRowsDrawing([3, 5, 9, 17]),
bucketsSymlog: bucketRowsDrawing([3, 9, 11, 13, 19]),
scaleSymlog: (
<TileSvg>
<path d="M4 20 C 20 20, 22 12, 32 12 S 44 4, 60 4" />
</TileSvg>
),
barsSideBySide: (
<TileSvg>
<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>
<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>
<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>
<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>
<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>;

View File

@@ -1,152 +0,0 @@
/**
* 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'
| 'scale-sqrt'
| '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 'scale-sqrt':
return (
<Svg>
<path d="M2.5 13 C5.5 11, 8.5 6.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;
}
}

View File

@@ -16,6 +16,8 @@ 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;

View File

@@ -15,6 +15,7 @@ import {
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';
@@ -159,6 +160,8 @@ export interface ErasedSectionDescriptor {
Component: ComponentType<
{
value: unknown;
defaultValue?: unknown;
savedValue?: unknown;
controls?: unknown;
onChange: (next: unknown) => void;
} & SectionEditorContext
@@ -174,3 +177,16 @@ 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 {
return editor.get({
...spec,
links: [],
plugin: { ...spec.plugin, spec: defaults },
} as PanelSpec);
}

View File

@@ -1,11 +1,5 @@
.bounds {
.fields {
display: flex;
gap: 8px;
}
.field {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
gap: 14px;
}

View File

@@ -1,127 +1,81 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import { DashboardtypesHeatmapYScaleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
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 },
];
// Auto is the only one that reads the boundaries rather than overriding them.
const Y_SCALE_OPTIONS = [
{
value: DashboardtypesHeatmapYScaleDTO.auto,
label: 'Auto',
tooltip:
'Log when every bucket bound is positive, symmetric log when they cross zero',
},
{
value: DashboardtypesHeatmapYScaleDTO.linear,
label: 'Linear',
tooltip: 'Row heights proportional to the bucket range',
},
{
value: DashboardtypesHeatmapYScaleDTO.log,
label: 'Log',
tooltip:
'Log-proportional row heights; a zero bucket sits one bucket below the smallest positive bound',
},
{
value: DashboardtypesHeatmapYScaleDTO.symlog,
label: 'Symmetric log',
tooltip:
'Linear within the smallest non-zero bound, logarithmic beyond, mirrored across zero',
},
];
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { createFieldResetter } from '../../utils/changes';
import AxisFields from './AxisFields';
import AxisRangeField from './AxisRangeField';
import AxisScaleField from './AxisScaleField';
import {
BUCKET_SCALE_HELP,
BUCKET_SCALE_OPTIONS,
LOG_SCALE_OPTIONS,
SCALE_HELP,
} from './options';
/**
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds, the
* linear/logarithmic scale toggle, and the heatmap's bucket-axis distribution.
* Each control is gated by its `controls` flag.
* linear/logarithmic scale toggle, or — for the heatmap, which has no soft bounds —
* the bucket-axis distribution. Each control is gated by its `controls` flag.
*/
function AxesSection({
value,
savedValue,
controls,
onChange,
}: SectionEditorProps<SectionKind.Axes>): JSX.Element {
// 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 });
};
const reset = createFieldResetter(value, savedValue, onChange);
if (controls.y) {
const scale = value?.y?.scale ?? DashboardtypesHeatmapYScaleDTO.auto;
return (
<ConfigField
label="Y-axis scale"
help={BUCKET_SCALE_HELP[scale]}
{...reset('y')}
>
<ConfigTiles
testId="panel-editor-v2-y-scale"
aria-label="Y-axis scale"
value={scale}
items={BUCKET_SCALE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, y: { ...value?.y, scale: next } })
}
/>
</ConfigField>
);
}
return (
<>
{controls.minMax && (
<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')}
/>
</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')}
/>
</div>
</div>
)}
<ConfigField label="Y axis" {...reset('softMin', 'softMax', 'isLogScale')}>
<AxisFields>
{controls.minMax && (
<AxisRangeField
testIdPrefix="panel-editor-v2"
value={value}
onChange={(bounds): void => onChange({ ...value, ...bounds })}
/>
)}
{controls.logScale && (
<div className={styles.field}>
<Typography.Text>Y-axis scale</Typography.Text>
<ConfigSegmented
{controls.logScale && (
<AxisScaleField
testId="panel-editor-v2-log-scale"
aria-label="Y-axis scale"
value={value?.isLogScale ? 'log' : 'linear'}
items={SCALE_OPTIONS}
items={LOG_SCALE_OPTIONS}
help={SCALE_HELP}
onChange={(next): void =>
onChange({ ...value, isLogScale: next === 'log' })
}
/>
</div>
)}
{controls.y && (
<div className={styles.field}>
<Typography.Text>Y-axis scale</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-y-scale"
placeholder="Select scale…"
value={value?.y?.scale}
items={Y_SCALE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, y: { ...value?.y, scale: next } })
}
/>
</div>
)}
</>
)}
</AxisFields>
</ConfigField>
);
}

View File

@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import styles from './AxesSection.module.scss';
/** One axis's fields, stacked under its heading. */
function AxisFields({ children }: { children: ReactNode }): JSX.Element {
return <div className={styles.fields}>{children}</div>;
}
export default AxisFields;

View File

@@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
import ConfigInlineField from '../../controls/ConfigInlineField/ConfigInlineField';
import ConfigRangeInput from '../../controls/ConfigRangeInput/ConfigRangeInput';
import { isRangeInverted } from '../../controls/ConfigRangeInput/utils';
import { RANGE_HELP, RANGE_INVERTED_ERROR } from './options';
export interface AxisBounds {
softMin?: number | null;
softMax?: number | null;
}
interface AxisRangeFieldProps {
/** Prefix for the `-soft-min` / `-soft-max` test ids. */
testIdPrefix: string;
value: AxisBounds | undefined;
onChange: (bounds: AxisBounds) => void;
help?: ReactNode;
helpTestId?: string;
}
/** Soft min/max bounds, flagged when min is above max. */
function AxisRangeField({
testIdPrefix,
value,
onChange,
help = RANGE_HELP,
helpTestId,
}: AxisRangeFieldProps): JSX.Element {
return (
<ConfigInlineField
label="Range"
help={help}
helpTestId={helpTestId}
error={
isRangeInverted(value?.softMin, value?.softMax)
? RANGE_INVERTED_ERROR
: undefined
}
>
<ConfigRangeInput
minTestId={`${testIdPrefix}-soft-min`}
maxTestId={`${testIdPrefix}-soft-max`}
min={value?.softMin}
max={value?.softMax}
onChangeMin={(softMin): void => onChange({ softMin })}
onChangeMax={(softMax): void => onChange({ softMax })}
/>
</ConfigInlineField>
);
}
export default AxisRangeField;

View File

@@ -0,0 +1,37 @@
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles, {
type ConfigTileItem,
} from '../../controls/ConfigTiles/ConfigTiles';
interface AxisScaleFieldProps<T extends string> {
testId: string;
'aria-label': string;
value: T;
items: ConfigTileItem<T>[];
/** Help for each scale, shown for the selected one. */
help: Record<T, string>;
onChange: (next: T) => void;
}
function AxisScaleField<T extends string>({
testId,
'aria-label': ariaLabel,
value,
items,
help,
onChange,
}: AxisScaleFieldProps<T>): JSX.Element {
return (
<ConfigField label="Scale" help={help[value]}>
<ConfigTiles
testId={testId}
aria-label={ariaLabel}
value={value}
items={items}
onChange={onChange}
/>
</ConfigField>
);
}
export default AxisScaleField;

View File

@@ -90,25 +90,20 @@ describe('AxesSection', () => {
<AxesSection value={undefined} controls={{ y: true }} onChange={onChange} />,
);
const trigger = screen.getByTestId('panel-editor-v2-y-scale');
await user.click(
trigger.querySelector('.ant-select-selector') as HTMLElement,
['auto', 'linear', 'log', 'symlog'].forEach((scale) =>
expect(
screen.getByTestId(`panel-editor-v2-y-scale-${scale}`),
).toBeInTheDocument(),
);
await expect(
screen.findByRole('option', { name: /Auto/ }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: /Log/ })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /Linear/ })).toBeInTheDocument();
await user.click(screen.getByRole('option', { name: /Symmetric log/ }));
await user.click(screen.getByTestId('panel-editor-v2-y-scale-symlog'));
expect(onChange).toHaveBeenCalledWith({
y: { scale: DashboardtypesHeatmapYScaleDTO.symlog },
});
});
it('shows the scale the spec asks for', () => {
it('shows the help for the scale the spec asks for', () => {
render(
<AxesSection
value={{ y: { scale: DashboardtypesHeatmapYScaleDTO.symlog } }}
@@ -117,7 +112,7 @@ describe('AxesSection', () => {
/>,
);
expect(screen.getByText('Symmetric log')).toBeInTheDocument();
expect(screen.getByText(/Log on both sides of zero/)).toBeInTheDocument();
});
it('toggles the logarithmic scale through onChange', async () => {
@@ -131,8 +126,25 @@ describe('AxesSection', () => {
/>,
);
await user.click(screen.getByText('Log'));
await user.click(screen.getByTestId('panel-editor-v2-log-scale-log'));
expect(onChange).toHaveBeenCalledWith({ isLogScale: true });
});
it('flags a soft min above the soft max', () => {
render(
<AxesSection
value={{ softMin: 23, softMax: 12 }}
controls={{ minMax: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByText("Min can't be greater than Max."),
).toBeInTheDocument();
expect(
screen.queryByText(/The axis always shows at least this range/),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,15 @@
import { pickScaleOptions } from '../options';
describe('pickScaleOptions', () => {
it('returns the tiles for the given scales, in that order', () => {
expect(
pickScaleOptions(['symlog', 'linear'] as const).map(({ value, label }) => ({
value,
label,
})),
).toStrictEqual([
{ value: 'symlog', label: 'Symlog' },
{ value: 'linear', label: 'Linear' },
]);
});
});

View File

@@ -0,0 +1,69 @@
import { DashboardtypesHeatmapYScaleDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
/** Every axis scale a panel can offer; each section picks the ones its spec stores. */
export type AxisScale = 'auto' | 'linear' | 'log' | 'symlog';
const SCALE_TILES: Record<AxisScale, Omit<ConfigTileItem, 'value'>> = {
auto: { label: 'Auto', drawing: TILE_DRAWINGS.scaleAuto },
linear: { label: 'Linear', drawing: TILE_DRAWINGS.scaleLinear },
log: { label: 'Logarithmic', drawing: TILE_DRAWINGS.scaleLog },
symlog: { label: 'Symlog', drawing: TILE_DRAWINGS.scaleSymlog },
};
/** Tiles for `scales`, in the order given; `T` keeps the caller's own scale type. */
export function pickScaleOptions<T extends AxisScale>(
scales: readonly T[],
): ConfigTileItem<T>[] {
return scales.map((scale) => ({ ...SCALE_TILES[scale], value: scale }));
}
export const SCALE_HELP: Record<AxisScale, string> = {
auto:
'Logarithmic when all values are positive and span three or more orders of magnitude; otherwise linear.',
linear: 'Evenly spaced values.',
log: 'Spreads out values that span several orders of magnitude.',
symlog: 'Logarithmic, but also handles zero and negative values.',
};
export const LOG_SCALE_OPTIONS = pickScaleOptions(['linear', 'log'] as const);
/** Heatmap bucket-axis scales; their values match `DashboardtypesHeatmapYScaleDTO`. */
export const BUCKET_SCALE_OPTIONS: ConfigTileItem<DashboardtypesHeatmapYScaleDTO>[] =
[
{
value: DashboardtypesHeatmapYScaleDTO.auto,
label: 'Auto',
drawing: TILE_DRAWINGS.bucketsAuto,
},
{
value: DashboardtypesHeatmapYScaleDTO.linear,
label: 'Linear',
drawing: TILE_DRAWINGS.bucketsLinear,
},
{
value: DashboardtypesHeatmapYScaleDTO.log,
label: 'Log',
drawing: TILE_DRAWINGS.bucketsLog,
},
{
value: DashboardtypesHeatmapYScaleDTO.symlog,
label: 'Symlog',
drawing: TILE_DRAWINGS.bucketsSymlog,
},
];
export const BUCKET_SCALE_HELP: Record<DashboardtypesHeatmapYScaleDTO, string> =
{
auto: 'Log for wide-ranging buckets like latency, otherwise linear.',
linear: 'Rows evenly spaced by value.',
log: 'More room for small values.',
symlog: 'Log on both sides of zero, for buckets that cross it.',
};
export const RANGE_HELP =
'The axis always shows at least this range. Data outside it still stretches the axis.';
export const RANGE_INVERTED_ERROR = "Min can't be greater than Max.";

View File

@@ -1,5 +0,0 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -1,21 +1,14 @@
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 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'
>;
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import { createFieldResetter } from '../../utils/changes';
/**
* Edits the `histogramBuckets` slice of a Histogram panel spec: bucket count / width
@@ -24,55 +17,51 @@ type NumericBound = keyof Pick<
*/
function BucketsSection({
value,
savedValue,
controls,
onChange,
}: SectionEditorProps<SectionKind.Buckets>): JSX.Element {
// 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 });
};
const reset = createFieldResetter(value, savedValue, onChange);
return (
<>
{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.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.mergeQueries && (
<ConfigSwitch
testId="panel-editor-v2-merge-queries"
title="Merge active queries"
description="Bucket all active queries together into one distribution"
title="Combine queries"
description="Merges all active queries into one distribution. Hides the legend."
sketch={SWITCH_SKETCHES.combine}
changed={reset('mergeAllActiveQueries').changed}
value={value?.mergeAllActiveQueries ?? false}
onChange={(checked): void =>
onChange({ ...value, mergeAllActiveQueries: checked })
onChange={(mergeAllActiveQueries): void =>
onChange({ ...value, mergeAllActiveQueries })
}
/>
)}

View File

@@ -1,7 +1,27 @@
.field {
.inset {
display: flex;
flex-direction: column;
gap: 8px;
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;
}
.thresholdField {

View File

@@ -1,92 +1,32 @@
import { Typography } from '@signozhq/ui/typography';
import {
DashboardtypesFillModeDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { SegmentIcon } from '../../controls/segmentIcons';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import DisconnectValuesField from './DisconnectValuesField';
import HeatmapColorsField from './HeatmapColorsField';
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 panel spec: line style / interpolation, fill
* mode, fill opacity, point markers and the connect-null-gaps threshold for the
@@ -95,84 +35,90 @@ function formatOpacity(opacity: number): string {
*/
function ChartAppearanceSection({
value,
savedValue,
controls,
onChange,
stepInterval,
}: SectionEditorProps<SectionKind.ChartAppearance> &
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
const interpolation = value?.lineInterpolation;
return (
<>
{controls.lineStyle && (
<div className={styles.field}>
<Typography.Text>Line style</Typography.Text>
<ConfigSegmented
<ConfigField label="Line" {...reset('lineStyle')}>
<ConfigTiles
testId="panel-editor-v2-line-style"
aria-label="Line style"
value={value?.lineStyle}
items={LINE_STYLE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, lineStyle: next as DashboardtypesLineStyleDTO })
}
onChange={(lineStyle): void => onChange({ ...value, lineStyle })}
/>
</div>
</ConfigField>
)}
{controls.lineInterpolation && (
<div className={styles.field}>
<Typography.Text>Line interpolation</Typography.Text>
<ConfigSelect
<ConfigField
label="How points connect"
help={interpolation && LINE_INTERPOLATION_HELP[interpolation]}
{...reset('lineInterpolation')}
>
<ConfigTiles
testId="panel-editor-v2-line-interpolation"
placeholder="Select interpolation…"
value={value?.lineInterpolation}
aria-label="How points connect"
value={interpolation}
items={LINE_INTERPOLATION_OPTIONS}
onChange={(next): void =>
onChange({
...value,
lineInterpolation: next,
})
onChange={(lineInterpolation): void =>
onChange({ ...value, lineInterpolation })
}
/>
</div>
</ConfigField>
)}
{controls.fillMode && (
<div className={styles.field}>
<Typography.Text>Fill mode</Typography.Text>
<ConfigSegmented
<ConfigField
label="Area under the line"
{...reset('fillMode', 'fillOpacity')}
>
<ConfigTiles
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={(next): void =>
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
}
onChange={(fillMode): void => onChange({ ...value, fillMode })}
/>
</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.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>
)}
{controls.showPoints && (
<ConfigSwitch
testId="panel-editor-v2-show-points"
title="Show points"
description="Display individual data points on the chart"
description="Marks each sample the query returned."
sketch={SWITCH_SKETCHES.points}
changed={reset('showPoints').changed}
value={value?.showPoints ?? false}
onChange={(checked): void => onChange({ ...value, showPoints: checked })}
onChange={(showPoints): void => onChange({ ...value, showPoints })}
/>
)}
@@ -181,6 +127,7 @@ function ChartAppearanceSection({
testId="panel-editor-v2-span-gaps"
value={value?.spanGaps}
stepInterval={stepInterval}
{...reset('spanGaps')}
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
/>
)}
@@ -188,6 +135,7 @@ function ChartAppearanceSection({
{controls.colors && (
<HeatmapColorsField
value={value?.colors}
savedValue={savedValue?.colors}
onChange={(colors): void => onChange({ ...value, colors })}
/>
)}

View File

@@ -1,24 +1,16 @@
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 ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import type { FieldResetProps } from '../../utils/changes';
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
import { DISCONNECT_MODE_OPTIONS, DisconnectValuesMode } from './options';
import { defaultDisconnectDuration } from './utils';
import styles from './ChartAppearanceSection.module.scss';
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 {
interface DisconnectValuesFieldProps extends FieldResetProps {
testId: string;
value: DashboardtypesSpanGapsDTO | undefined;
/** Query step interval (seconds): seeds the default threshold and floors it. */
@@ -26,13 +18,6 @@ interface DisconnectValuesFieldProps {
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
@@ -43,6 +28,8 @@ function DisconnectValuesField({
testId,
value,
stepInterval,
changed,
onReset,
onChange,
}: DisconnectValuesFieldProps): JSX.Element {
const duration = value?.fillLessThan || undefined;
@@ -63,7 +50,7 @@ function DisconnectValuesField({
...value,
fillOnlyBelow: true,
// Seed from the live stepInterval (async — undefined until results load), not mount.
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
fillLessThan: lastDuration ?? defaultDisconnectDuration(stepInterval),
});
return;
}
@@ -72,21 +59,24 @@ function DisconnectValuesField({
};
return (
<>
<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>
<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}
/>
{isThreshold && duration && (
<div className={styles.field}>
<Typography.Text>Threshold value</Typography.Text>
<div className={styles.inset}>
<span className={styles.insetLabel}>Break when a gap is longer than</span>
<DisconnectValuesThresholdInput
testId={`${testId}-value`}
value={duration}
@@ -97,7 +87,7 @@ function DisconnectValuesField({
/>
</div>
)}
</>
</ConfigField>
);
}

View File

@@ -1,6 +1,4 @@
import { useState } from 'react';
import { Slider } from '@signozhq/ui/slider';
import { Typography } from '@signozhq/ui/typography';
import {
clampColorSteps,
DEFAULT_COLOR_STEPS,
@@ -8,11 +6,11 @@ import {
MIN_COLOR_STEPS,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { singleSliderValue } from './heatmapColorOptions';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import type { FieldResetProps } from '../../utils/changes';
import styles from './HeatmapColorsField.module.scss';
interface HeatmapColorStepsFieldProps {
interface HeatmapColorStepsFieldProps extends Partial<FieldResetProps> {
/** Unset until the slider moves; the chart's default stands in. */
value: number | undefined;
onChange: (steps: number) => void;
@@ -25,35 +23,31 @@ interface HeatmapColorStepsFieldProps {
function HeatmapColorStepsField({
value,
onChange,
changed,
onReset,
}: HeatmapColorStepsFieldProps): JSX.Element {
const [dragging, setDragging] = useState<number | null>(null);
const steps = dragging ?? value ?? DEFAULT_COLOR_STEPS;
return (
<div className={styles.field}>
<div className={styles.fieldHeader}>
<Typography.Text>Color steps</Typography.Text>
<span className={styles.mono}>{steps}</span>
</div>
<Slider
<ConfigField
label="Color steps"
help="Fewer steps make bands easier to tell apart."
changed={changed}
onReset={onReset}
>
<ConfigSlider
testId="panel-editor-v2-heatmap-color-steps"
className={styles.slider}
value={steps}
value={dragging ?? value ?? DEFAULT_COLOR_STEPS}
min={MIN_COLOR_STEPS}
max={MAX_COLOR_STEPS}
step={1}
aria-label="Color steps"
onChange={(next): void => setDragging(singleSliderValue(next))}
onAfterChange={(next): void => {
onChange={setDragging}
onChangeEnd={(next): void => {
setDragging(null);
onChange(clampColorSteps(singleSliderValue(next)));
onChange(clampColorSteps(next));
}}
/>
<Typography.Text className={styles.help}>
How many colours the ramp is split into.
</Typography.Text>
</div>
</ConfigField>
);
}

View File

@@ -1,138 +1,85 @@
.colors {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.field {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.fieldHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
}
// The track is inset by half a thumb at each end by default, which reads as an
// indent against the label and help text either side of it. Radix keeps the thumb
// in bounds on its own, so the track can run the full width of the field.
.slider {
--slider-track-margin-x: 0;
}
.help {
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.mono {
font-family: var(--periscope-font-family-mono);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.bounds {
display: flex;
gap: var(--spacing-4);
}
.fillGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--spacing-4);
}
.customFill {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-6);
border: 1px solid var(--l2-border);
border-radius: var(--radius-1);
background: var(--l2-background-60);
cursor: pointer;
&:hover {
border-color: var(--l2-foreground);
}
&.isSelected {
border-color: var(--accent-primary);
}
}
.customFillValue {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.customFillSwatch {
width: var(--spacing-12);
height: var(--spacing-8);
border-radius: var(--radius-1);
border: 1px solid var(--l2-border);
}
.preview {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.previewHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
gap: 6px;
}
.previewRamp {
height: var(--spacing-8);
border-radius: var(--radius-1);
border: 1px solid var(--l2-border);
height: 16px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px var(--l1-border);
}
.previewBounds {
display: flex;
justify-content: space-between;
gap: var(--spacing-4);
gap: 8px;
font-family: 'Geist Mono', monospace;
font-size: 12px;
color: var(--l2-foreground);
}
.paletteGrid {
// The grid can't query its own width, so a wrapper is the container.
.cards {
container-type: inline-size;
}
.cardGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-4);
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.paletteCard {
@container (min-width: 200px) {
.cardGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.card {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-1);
background: var(--l2-background-60);
cursor: pointer;
align-items: stretch;
gap: 8px;
min-width: 0;
padding: 10px 10px 8px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color 150ms;
&:hover {
border-color: var(--l2-foreground);
border-color: var(--l2-border);
}
&:focus-visible {
outline: 1px solid var(--bg-robin-500);
outline-offset: 1px;
}
&.isSelected {
border-color: var(--accent-primary);
border-color: var(--bg-robin-500);
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
}
}
.paletteRamp {
height: var(--spacing-6);
border-radius: var(--radius-1);
.cardRamp {
height: 10px;
border-radius: 2px;
}
.cardName {
overflow: hidden;
font-family: 'Geist Mono', monospace;
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--l2-foreground);
.isSelected & {
color: var(--l1-foreground);
}
}

View File

@@ -1,22 +1,29 @@
import { Typography } from '@signozhq/ui/typography';
import {
DashboardtypesHeatmapColorModeDTO,
DashboardtypesHeatmapColorScaleDTO,
type DashboardtypesHeatmapColorsDTO,
} from 'api/generated/services/sigNoz.schemas';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { createFieldResetter } from '../../utils/changes';
import HeatmapColorStepsField from './HeatmapColorStepsField';
import HeatmapCountRangeField from './HeatmapCountRangeField';
import HeatmapFillField from './HeatmapFillField';
import HeatmapPaletteGrid from './HeatmapPaletteGrid';
import HeatmapRampPreview from './HeatmapRampPreview';
import { COLOR_MODE_OPTIONS, COLOR_SCALE_OPTIONS } from './heatmapColorOptions';
import styles from './HeatmapColorsField.module.scss';
import {
COLOR_MODE_HELP,
COLOR_MODE_OPTIONS,
COLOR_SCALE_HELP,
COLOR_SCALE_OPTIONS,
DEFAULT_COLOR_MODE,
DEFAULT_COLOR_SCALE,
DEFAULT_PALETTE,
} from './heatmapColorOptions';
interface HeatmapColorsFieldProps {
value: DashboardtypesHeatmapColorsDTO | undefined;
savedValue?: DashboardtypesHeatmapColorsDTO;
onChange: (next: DashboardtypesHeatmapColorsDTO) => void;
}
@@ -27,73 +34,73 @@ interface HeatmapColorsFieldProps {
*/
function HeatmapColorsField({
value,
savedValue,
onChange,
}: HeatmapColorsFieldProps): JSX.Element {
const isOpacityMode =
value?.mode === DashboardtypesHeatmapColorModeDTO.opacity;
const reset = createFieldResetter(value, savedValue, onChange);
// Unset fields select what the chart draws for them.
const mode = value?.mode ?? DEFAULT_COLOR_MODE;
const scale = value?.scale ?? DEFAULT_COLOR_SCALE;
return (
<div className={styles.colors}>
<>
<HeatmapRampPreview colors={value} />
<div className={styles.field}>
<Typography.Text>Color mode</Typography.Text>
<ConfigSegmented
<ConfigField
label="Color mode"
help={COLOR_MODE_HELP[mode]}
{...reset('mode')}
>
<ConfigTiles
testId="panel-editor-v2-heatmap-color-mode"
value={value?.mode}
aria-label="Color mode"
value={mode}
items={COLOR_MODE_OPTIONS}
onChange={(next): void =>
onChange({
...value,
mode: next as DashboardtypesHeatmapColorModeDTO,
})
}
onChange={(next): void => onChange({ ...value, mode: next })}
/>
</div>
</ConfigField>
{isOpacityMode ? (
{mode === DashboardtypesHeatmapColorModeDTO.opacity ? (
<HeatmapFillField
value={value?.fill}
{...reset('fill')}
onChange={(fill): void => onChange({ ...value, fill })}
/>
) : (
<div className={styles.field}>
<Typography.Text>Palette</Typography.Text>
<ConfigField label="Palette" {...reset('palette')}>
<HeatmapPaletteGrid
value={value?.palette}
value={value?.palette ?? DEFAULT_PALETTE}
onChange={(palette): void => onChange({ ...value, palette })}
/>
</div>
</ConfigField>
)}
<div className={styles.field}>
<Typography.Text>Color scale</Typography.Text>
<ConfigSegmented
<ConfigField
label="Color scale"
help={COLOR_SCALE_HELP[scale]}
{...reset('scale')}
>
<ConfigTiles
testId="panel-editor-v2-heatmap-color-scale"
value={value?.scale}
aria-label="Color scale"
value={scale}
items={COLOR_SCALE_OPTIONS}
onChange={(next): void =>
onChange({
...value,
scale: next as DashboardtypesHeatmapColorScaleDTO,
})
}
onChange={(next): void => onChange({ ...value, scale: next })}
/>
<Typography.Text className={styles.help}>
How a count maps onto the ramp.
</Typography.Text>
</div>
</ConfigField>
<HeatmapColorStepsField
value={value?.steps || undefined}
{...reset('steps')}
onChange={(steps): void => onChange({ ...value, steps })}
/>
<HeatmapCountRangeField
value={{ minCount: value?.minCount, maxCount: value?.maxCount }}
{...reset('minCount', 'maxCount')}
onChange={(bounds): void => onChange({ ...value, ...bounds })}
/>
</div>
</>
);
}

View File

@@ -1,14 +1,15 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import { Callout } from '@signozhq/ui/callout';
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './HeatmapColorsField.module.scss';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigRangeInput from '../../controls/ConfigRangeInput/ConfigRangeInput';
import { isRangeInverted } from '../../controls/ConfigRangeInput/utils';
import type { FieldResetProps } from '../../utils/changes';
/** `null` on either bound derives it from the data. */
type CountBound = Pick<DashboardtypesHeatmapColorsDTO, 'minCount' | 'maxCount'>;
interface HeatmapCountRangeFieldProps {
interface HeatmapCountRangeFieldProps extends Partial<FieldResetProps> {
value: CountBound;
onChange: (next: CountBound) => void;
}
@@ -20,45 +21,35 @@ interface HeatmapCountRangeFieldProps {
function HeatmapCountRangeField({
value,
onChange,
changed,
onReset,
}: HeatmapCountRangeFieldProps): JSX.Element {
// An empty field derives the bound (null), as does transient non-numeric input
// (a lone "-"), which pinning would read as a bound the user never typed.
const handleBound =
(bound: keyof CountBound) =>
(event: ChangeEvent<HTMLInputElement>): void => {
const raw = event.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
const { minCount, maxCount } = value;
return (
<div className={styles.field}>
<div className={styles.bounds}>
<div className={styles.field}>
<Typography.Text>Min count</Typography.Text>
<Input
data-testid="panel-editor-v2-heatmap-min-count"
type="number"
placeholder="Auto"
value={value.minCount ?? ''}
onChange={handleBound('minCount')}
/>
</div>
<div className={styles.field}>
<Typography.Text>Max count</Typography.Text>
<Input
data-testid="panel-editor-v2-heatmap-max-count"
type="number"
placeholder="Auto"
value={value.maxCount ?? ''}
onChange={handleBound('maxCount')}
/>
</div>
</div>
<Typography.Text className={styles.help}>
The counts the ramp&apos;s ends mean; empty reads them off the data.
</Typography.Text>
</div>
<ConfigField
label="Count range"
changed={changed}
onReset={onReset}
help={
isRangeInverted(minCount, maxCount) ? (
<Callout type="error" size="small" showIcon>
Min count can&apos;t be greater than max count.
</Callout>
) : (
'Leave empty to read the range from the data.'
)
}
>
<ConfigRangeInput
minTestId="panel-editor-v2-heatmap-min-count"
maxTestId="panel-editor-v2-heatmap-max-count"
min={minCount}
max={maxCount}
onChangeMin={(next): void => onChange({ ...value, minCount: next })}
onChangeMax={(next): void => onChange({ ...value, maxCount: next })}
/>
</ConfigField>
);
}

View File

@@ -1,13 +1,11 @@
import cx from 'classnames';
import { ColorPicker } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DEFAULT_OPACITY_FILL } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { FILL_PRESETS, isSameColor } from './heatmapColorOptions';
import ColorSwatches from '../../controls/ColorSwatches/ColorSwatches';
import ConfigField from '../../controls/ConfigField/ConfigField';
import type { FieldResetProps } from '../../utils/changes';
import { FILL_OPTIONS, GROUP_FILL, isSameColor } from './heatmapColorOptions';
import styles from './HeatmapColorsField.module.scss';
interface HeatmapFillFieldProps {
interface HeatmapFillFieldProps extends Partial<FieldResetProps> {
/** Empty follows the selected group's legend colour. */
value: string | undefined;
onChange: (fill: string) => void;
@@ -20,65 +18,34 @@ interface HeatmapFillFieldProps {
function HeatmapFillField({
value,
onChange,
changed,
onReset,
}: HeatmapFillFieldProps): JSX.Element {
const isAuto = !value;
const isCustom =
!isAuto && !FILL_PRESETS.some((preset) => isSameColor(preset.color, value));
const selected = value
? FILL_OPTIONS.find((option) => isSameColor(option.value, value))?.value
: GROUP_FILL;
return (
<div className={styles.field}>
<Typography.Text>Base color</Typography.Text>
<div className={styles.fillGrid}>
{FILL_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
aria-pressed={isSameColor(preset.color, value)}
className={cx(styles.paletteCard, {
[styles.isSelected]: isSameColor(preset.color, value),
})}
data-testid={`panel-editor-v2-heatmap-fill-${preset.label}`}
onClick={(): void => onChange(preset.color)}
>
<span
className={styles.paletteRamp}
style={{ background: preset.color }}
/>
<span className={styles.mono}>{preset.label}</span>
</button>
))}
</div>
<ColorPicker
value={value || DEFAULT_OPACITY_FILL}
trigger="click"
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<button
type="button"
className={cx(styles.customFill, {
[styles.isSelected]: isCustom,
})}
data-testid="panel-editor-v2-heatmap-fill-custom"
>
<Typography.Text>Custom</Typography.Text>
<span className={styles.customFillValue}>
<span className={styles.mono}>
{isAuto ? 'group colour' : value.toUpperCase()}
</span>
<span
className={styles.customFillSwatch}
style={{ background: value || DEFAULT_OPACITY_FILL }}
/>
</span>
</button>
</ColorPicker>
<Typography.Text className={styles.help}>
The colour whose alpha ramps; unset uses the group&apos;s.
</Typography.Text>
</div>
<ConfigField
label="Base color"
help="Group color gives each group its own color."
changed={changed}
onReset={onReset}
>
<ColorSwatches
testId="panel-editor-v2-heatmap-fill"
label="Base color"
value={selected}
options={FILL_OPTIONS}
dividerAfter={0}
onChange={onChange}
custom={{
value: selected === undefined ? value : undefined,
initial: value || DEFAULT_OPACITY_FILL,
onChange,
}}
/>
</ConfigField>
);
}

View File

@@ -7,7 +7,7 @@ import { paletteGradient, PALETTE_OPTIONS } from './heatmapColorOptions';
import styles from './HeatmapColorsField.module.scss';
interface HeatmapPaletteGridProps {
value: DashboardtypesHeatmapPaletteDTO | undefined;
value: DashboardtypesHeatmapPaletteDTO;
onChange: (palette: DashboardtypesHeatmapPaletteDTO) => void;
}
@@ -19,25 +19,27 @@ function HeatmapPaletteGrid({
const isDarkMode = useIsDarkMode();
return (
<div className={styles.paletteGrid}>
{PALETTE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={option.value === value}
className={cx(styles.paletteCard, {
[styles.isSelected]: option.value === value,
})}
data-testid={`panel-editor-v2-heatmap-palette-${option.value}`}
onClick={(): void => onChange(option.value)}
>
<span
className={styles.paletteRamp}
style={{ background: paletteGradient(option.value, isDarkMode) }}
/>
<span className={styles.mono}>{option.label}</span>
</button>
))}
<div className={styles.cards}>
<div className={styles.cardGrid}>
{PALETTE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={option.value === value}
className={cx(styles.card, {
[styles.isSelected]: option.value === value,
})}
data-testid={`panel-editor-v2-heatmap-palette-${option.value}`}
onClick={(): void => onChange(option.value)}
>
<span
className={styles.cardRamp}
style={{ background: paletteGradient(option.value, isDarkMode) }}
/>
<span className={styles.cardName}>{option.label}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -1,15 +1,22 @@
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesHeatmapColorModeDTO,
type DashboardtypesHeatmapColorsDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
createHeatmapColorResolver,
DEFAULT_HEATMAP_COLORS,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { HeatmapColorMode } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { resolveHeatmapColors } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
import { rampGradient } from './heatmapColorOptions';
import ConfigField from '../../controls/ConfigField/ConfigField';
import {
DEFAULT_COLOR_MODE,
DEFAULT_COLOR_SCALE,
DEFAULT_PALETTE,
rampGradient,
} from './heatmapColorOptions';
import styles from './HeatmapColorsField.module.scss';
@@ -46,29 +53,30 @@ function HeatmapRampPreview({ colors }: HeatmapRampPreviewProps): JSX.Element {
);
const summary = [
options.mode === HeatmapColorMode.Opacity ? 'opacity' : options.palette,
options.scale,
(colors?.mode ?? DEFAULT_COLOR_MODE) ===
DashboardtypesHeatmapColorModeDTO.opacity
? DashboardtypesHeatmapColorModeDTO.opacity
: (colors?.palette ?? DEFAULT_PALETTE),
colors?.scale ?? DEFAULT_COLOR_SCALE,
`${options.steps} steps`,
].join(' · ');
return (
<div className={styles.preview} data-testid="panel-editor-v2-heatmap-preview">
<div className={styles.previewHeader}>
<Typography.Text>Preview</Typography.Text>
<span className={styles.mono}>{summary}</span>
</div>
<div
className={styles.previewRamp}
style={{ background: rampGradient(ramp) }}
/>
<div className={styles.previewBounds}>
<span className={styles.mono}>
{(colors?.minCount ?? 0).toLocaleString()}
</span>
<span className={styles.mono}>
{colors?.maxCount == null ? 'auto' : colors.maxCount.toLocaleString()}
</span>
</div>
<div data-testid="panel-editor-v2-heatmap-preview">
<ConfigField label="Preview" aside={summary}>
<div className={styles.preview}>
<div
className={styles.previewRamp}
style={{ background: rampGradient(ramp) }}
/>
<div className={styles.previewBounds}>
<span>{(colors?.minCount ?? 0).toLocaleString()}</span>
<span>
{colors?.maxCount == null ? 'auto' : colors.maxCount.toLocaleString()}
</span>
</div>
</div>
</ConfigField>
</div>
);
}

View File

@@ -30,16 +30,6 @@ 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,
@@ -139,7 +129,7 @@ describe('ChartAppearanceSection', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ fillOpacity: true }}
controls={{ fillMode: true, fillOpacity: true }}
onChange={jest.fn()}
/>,
);
@@ -156,7 +146,7 @@ describe('ChartAppearanceSection', () => {
render(
<ChartAppearanceSection
value={{ fillOpacity: 0.25 }}
controls={{ fillOpacity: true }}
controls={{ fillMode: true, fillOpacity: true }}
onChange={jest.fn()}
/>,
);
@@ -195,7 +185,7 @@ describe('ChartAppearanceSection', () => {
expect(screen.getByText('Gradient')).toBeInTheDocument();
});
it('writes the chosen line interpolation through the dropdown', async () => {
it('writes the chosen line interpolation through the tiles', async () => {
const onChange = jest.fn();
render(
<ChartAppearanceSection
@@ -205,7 +195,9 @@ describe('ChartAppearanceSection', () => {
/>,
);
await pickOption('panel-editor-v2-line-interpolation', 'Spline');
await userEvent
.setup()
.click(screen.getByTestId('panel-editor-v2-line-interpolation-spline'));
expect(onChange).toHaveBeenCalledWith({ lineInterpolation: 'spline' });
});
@@ -235,7 +227,9 @@ describe('ChartAppearanceSection', () => {
/>,
);
expect(screen.getByText('Never')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-span-gaps-never'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
).not.toBeInTheDocument();
@@ -252,7 +246,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
@@ -314,7 +308,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Never'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
@@ -390,7 +384,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
expect(onChange).toHaveBeenLastCalledWith({
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
@@ -418,7 +412,7 @@ describe('ChartAppearanceSection', () => {
/>,
);
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
// Regression: a value seeded at mount would still be the 1m fallback.
expect(onChange).toHaveBeenLastCalledWith({
@@ -472,7 +466,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.getByText('Never'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
expect(
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
@@ -483,8 +477,8 @@ describe('ChartAppearanceSection', () => {
const user = userEvent.setup();
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
await user.click(screen.getByText('Never'));
await user.click(screen.getByText('Threshold'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
'5m',

View File

@@ -124,10 +124,10 @@ describe('HeatmapColorsField', () => {
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
expect(
screen.getByText(/How a count maps onto the ramp/),
screen.getByText('Keeps sparse buckets visible next to busy ones.'),
).toBeInTheDocument();
await user.click(screen.getByText('Sqrt'));
await user.click(screen.getByText('Square root'));
expect(onChange).toHaveBeenCalledWith({
scale: DashboardtypesHeatmapColorScaleDTO.sqrt,
@@ -238,9 +238,7 @@ describe('HeatmapColorsField', () => {
/>,
);
expect(
screen.getByTestId('panel-editor-v2-heatmap-fill-forest'),
).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
});
it('shows a fill that is no preset as the custom one', () => {
@@ -254,10 +252,23 @@ describe('HeatmapColorsField', () => {
/>,
);
expect(screen.getByText('#0060E6')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
).toHaveAttribute('aria-pressed', 'false');
screen.getByTestId('panel-editor-v2-heatmap-fill-custom'),
).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('radio', { name: 'Robin' })).not.toBeChecked();
});
it('flags a min count above the max count', () => {
render(
<HeatmapColorsField
value={{ minCount: 500, maxCount: 10 }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByText("Min count can't be greater than max count."),
).toBeInTheDocument();
});
it('names the group colour as the fill until one is picked', () => {
@@ -268,9 +279,9 @@ describe('HeatmapColorsField', () => {
/>,
);
expect(screen.getByText('group colour')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Group color' })).toBeChecked();
expect(
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
screen.getByTestId('panel-editor-v2-heatmap-fill-custom'),
).toHaveAttribute('aria-pressed', 'false');
});
});

View File

@@ -4,46 +4,110 @@ import {
DashboardtypesHeatmapColorScaleDTO,
DashboardtypesHeatmapPaletteDTO,
} from 'api/generated/services/sigNoz.schemas';
import { HEATMAP_PALETTE_MAP } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
import {
HEATMAP_COLOR_MODE_MAP,
HEATMAP_COLOR_SCALE_MAP,
HEATMAP_PALETTE_MAP,
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { getPaletteStops } from 'lib/uPlotV2/plugins/HeatmapPlugin/palettes';
import type { ConfigSegmentedItem } from '../../controls/ConfigSegmented/ConfigSegmented';
import type { ColorSwatchOption } from '../../controls/ColorSwatches/ColorSwatches';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export const COLOR_MODE_OPTIONS: ConfigSegmentedItem[] = [
{ value: DashboardtypesHeatmapColorModeDTO.palette, label: 'Palette' },
{ value: DashboardtypesHeatmapColorModeDTO.opacity, label: 'Opacity' },
];
// Log first: bucket counts routinely span decades.
export const COLOR_SCALE_OPTIONS: ConfigSegmentedItem[] = [
{
value: DashboardtypesHeatmapColorScaleDTO.log,
label: 'Log',
icon: 'scale-log',
},
{
value: DashboardtypesHeatmapColorScaleDTO.sqrt,
label: 'Sqrt',
icon: 'scale-sqrt',
},
{
value: DashboardtypesHeatmapColorScaleDTO.linear,
label: 'Linear',
icon: 'scale-linear',
},
];
export interface HeatmapFillPreset {
label: string;
color: string;
function dtoFor<D extends string, V>(map: Record<D, V>, value: V): D {
return (Object.keys(map) as D[]).find((key) => map[key] === value) as D;
}
/** What the chart draws for an unset field, in spec terms. */
export const DEFAULT_COLOR_MODE = dtoFor(
HEATMAP_COLOR_MODE_MAP,
DEFAULT_HEATMAP_COLORS.mode,
);
export const DEFAULT_COLOR_SCALE = dtoFor(
HEATMAP_COLOR_SCALE_MAP,
DEFAULT_HEATMAP_COLORS.scale,
);
export const DEFAULT_PALETTE = dtoFor(
HEATMAP_PALETTE_MAP,
DEFAULT_HEATMAP_COLORS.palette,
);
export const COLOR_MODE_OPTIONS: ConfigTileItem<DashboardtypesHeatmapColorModeDTO>[] =
[
{
value: DashboardtypesHeatmapColorModeDTO.palette,
label: 'Palette',
drawing: TILE_DRAWINGS.colorPalette,
},
{
value: DashboardtypesHeatmapColorModeDTO.opacity,
label: 'Opacity',
drawing: TILE_DRAWINGS.colorOpacity,
},
];
export const COLOR_MODE_HELP: Record<
DashboardtypesHeatmapColorModeDTO,
string
> = {
palette: 'Each count maps to a color on a multi-color ramp.',
opacity: 'One color fades from clear to solid.',
};
// Log first: bucket counts routinely span decades.
export const COLOR_SCALE_OPTIONS: ConfigTileItem<DashboardtypesHeatmapColorScaleDTO>[] =
[
{
value: DashboardtypesHeatmapColorScaleDTO.log,
label: 'Log',
drawing: TILE_DRAWINGS.scaleLog,
},
{
value: DashboardtypesHeatmapColorScaleDTO.sqrt,
label: 'Square root',
drawing: TILE_DRAWINGS.scaleSqrt,
},
{
value: DashboardtypesHeatmapColorScaleDTO.linear,
label: 'Linear',
drawing: TILE_DRAWINGS.scaleLinear,
},
];
export const COLOR_SCALE_HELP: Record<
DashboardtypesHeatmapColorScaleDTO,
string
> = {
log: 'Keeps sparse buckets visible next to busy ones.',
sqrt: 'A middle ground between Log and Linear.',
linear: 'Color changes evenly with count.',
};
/** The unset fill: each group ramps its own legend colour. */
export const GROUP_FILL = '';
/** Opacity-mode base colours: the palette values that read on both themes. */
export const FILL_PRESETS: HeatmapFillPreset[] = [
{ label: 'robin', color: Color.BG_ROBIN_400 },
{ label: 'aqua', color: Color.BG_AQUA_400 },
{ label: 'forest', color: Color.BG_FOREST_400 },
{ label: 'sienna', color: Color.BG_SIENNA_400 },
export const FILL_OPTIONS: ColorSwatchOption<string>[] = [
{
value: GROUP_FILL,
id: 'group',
label: 'Group color',
tooltip: 'Each group’s own color',
fill: `conic-gradient(${Color.BG_ROBIN_400}, ${Color.BG_AQUA_400}, ${Color.BG_FOREST_400}, ${Color.BG_SIENNA_400}, ${Color.BG_ROBIN_400})`,
},
...[
{ id: 'robin', color: Color.BG_ROBIN_400 },
{ id: 'aqua', color: Color.BG_AQUA_400 },
{ id: 'forest', color: Color.BG_FOREST_400 },
{ id: 'sienna', color: Color.BG_SIENNA_400 },
].map(({ id, color }) => ({
value: color,
id,
label: id.charAt(0).toUpperCase() + id.slice(1),
fill: color,
})),
];
/** Hex comparison for a spec value that may differ only in case. */
@@ -95,8 +159,3 @@ export function rampGradient(ramp: string[]): string {
]);
return `linear-gradient(90deg, ${stops.join(', ')})`;
}
/** The Slider emits a pair in range mode; these are all single-thumb. */
export function singleSliderValue(value: number | number[]): number {
return Array.isArray(value) ? value[0] : value;
}

View File

@@ -0,0 +1,101 @@
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]:
'Shows each value from the previous point up to its own timestamp.',
[DashboardtypesLineInterpolationDTO.step_after]:
'Holds each value until the next point.',
};
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,
},
];

View File

@@ -0,0 +1,14 @@
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)}%`;
}

View File

@@ -1,7 +1,13 @@
.list {
display: flex;
flex-direction: column;
gap: 8px;
gap: 14px;
}
.help {
font-size: 13px;
line-height: 19px;
color: var(--l2-foreground);
}
/* --- Saved-link list item --- */
@@ -9,10 +15,15 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--l2-border);
border-radius: 6px;
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);
}
}
.listItemText {
@@ -32,12 +43,13 @@
.listItemLabel {
font-size: 13px;
color: var(--text-vanilla-100);
color: var(--l1-foreground);
}
.listItemUrl {
font-size: 11px;
color: var(--text-vanilla-400);
font-family: 'Geist Mono', monospace;
font-size: 12px;
color: var(--l2-foreground);
}
.listItemActions {

View File

@@ -61,6 +61,10 @@ 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.
@@ -81,7 +85,7 @@ function ContextLinksSection({
data-testid="panel-editor-v2-add-link"
onClick={openAddDialog}
>
Add Context Link
Add link
</Button>
<ContextLinkDialog

View File

@@ -31,7 +31,7 @@ function ColumnUnits({
if (columns.length === 0) {
return (
<Typography.Text className={styles.columnUnitsHint}>
Run the panel to set per-column units.
Run the query to set a unit for each value column.
</Typography.Text>
);
}
@@ -50,7 +50,7 @@ function ColumnUnits({
<div className={styles.columnUnits}>
{columns.map((column) => (
<div className={styles.columnField} key={column.key}>
<Typography.Text>{column.label}</Typography.Text>
<span className={styles.columnLabel}>{column.label}</span>
<YAxisUnitSelector
data-testid={`panel-editor-v2-column-unit-${column.key}`}
placeholder="Select unit"

View File

@@ -0,0 +1,53 @@
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;

View File

@@ -1,9 +1,3 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.unitSelector {
:global(.ant-select) {
width: 100%;
@@ -13,11 +7,31 @@
}
}
.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: 12px;
gap: 10px;
:global(.ant-select) {
width: 100%;
@@ -31,10 +45,15 @@
.columnField {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
flex-direction: column;
gap: 6px;
}
.columnLabel {
font-size: 13px;
color: var(--l1-foreground);
}
.columnUnitSelector {
flex: 1;
}

View File

@@ -1,5 +1,3 @@
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 {
@@ -7,28 +5,17 @@ import type {
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigField from '../../controls/ConfigField/ConfigField';
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
@@ -36,16 +23,22 @@ const DECIMAL_OPTIONS: {
*/
function FormattingSection({
value,
savedValue,
controls,
onChange,
tableColumns = [],
metricUnit,
}: FormattingSectionProps): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
return (
<>
{controls.unit && (
<div className={styles.field}>
<Typography.Text>Unit</Typography.Text>
<ConfigField
label="Unit"
help="Formats values, axis ticks and tooltips. With milliseconds, 1500 shows as 1.5 s."
{...reset('unit')}
>
<YAxisUnitSelector
containerClassName={styles.unitSelector}
data-testid="panel-editor-v2-unit"
@@ -54,37 +47,29 @@ function FormattingSection({
initialValue={metricUnit}
onChange={(unit): void => onChange({ ...value, unit })}
/>
</div>
</ConfigField>
)}
{controls.decimals && (
<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>
<DecimalsField
value={value?.decimalPrecision}
unit={value?.unit}
{...reset('decimalPrecision')}
onChange={(decimalPrecision): void =>
onChange({ ...value, decimalPrecision })
}
/>
)}
{controls.columnUnits && (
<div className={styles.field}>
<Typography.Text>Column units</Typography.Text>
<ConfigField label="Column units" {...reset('columnUnits')}>
<ColumnUnits
columns={tableColumns}
value={value?.columnUnits ?? {}}
metricUnit={metricUnit}
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
/>
</div>
</ConfigField>
)}
</>
);

View File

@@ -1,17 +1,15 @@
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.
// 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 }));
async function pickDecimal(value: string): Promise<void> {
await userEvent
.setup()
.click(screen.getByTestId(`panel-editor-v2-decimals-${value}`));
}
describe('FormattingSection', () => {
@@ -51,7 +49,7 @@ describe('FormattingSection', () => {
/>,
);
await pickDecimal('Full');
await pickDecimal('full');
expect(onChange).toHaveBeenCalledWith({ decimalPrecision: 'full' });
});
@@ -66,14 +64,61 @@ describe('FormattingSection', () => {
/>,
);
await pickDecimal('2 decimals');
await pickDecimal('3');
expect(onChange).toHaveBeenCalledWith({
unit: 'bytes',
decimalPrecision: '2',
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('reverts a changed precision to its saved value', async () => {
const onChange = jest.fn();
render(
<FormattingSection
value={{
unit: 'bytes',
decimalPrecision: DashboardtypesPrecisionOptionDTO.NUMBER_4,
}}
savedValue={{ unit: 'bytes' }}
controls={{ decimals: true }}
onChange={onChange}
/>,
);
await userEvent.setup().click(screen.getByTestId('config-field-reset'));
expect(onChange).toHaveBeenCalledWith({
unit: 'bytes',
decimalPrecision: undefined,
});
});
it('offers no reset for a unit that matches the saved one', () => {
render(
<FormattingSection
value={{ unit: 'ms' }}
savedValue={{ unit: 'ms' }}
controls={{ unit: true }}
onChange={jest.fn()}
/>,
);
expect(screen.queryByTestId('config-field-reset')).not.toBeInTheDocument();
});
it('warns when the selected unit mismatches the metric unit', () => {
// metric sent in seconds, but bytes is selected.
render(

View File

@@ -0,0 +1,19 @@
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' },
];

View File

@@ -1,5 +0,0 @@
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -1,32 +1,18 @@
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 ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import LegendColors from '../../controls/LegendColors/LegendColors';
import type { SectionEditorContext } from '../../sectionContext';
import styles from './LegendSection.module.scss';
import { createFieldResetter } from '../../utils/changes';
import { POSITION_OPTIONS } from './options';
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
@@ -34,38 +20,36 @@ const POSITION_OPTIONS = [
*/
function LegendSection({
value,
defaultValue,
savedValue,
controls,
onChange,
legendSeries,
}: LegendSectionProps): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
return (
<>
{controls.position && (
<div className={styles.field}>
<Typography.Text>Position</Typography.Text>
<ConfigSegmented
<ConfigField label="Position" {...reset('position')}>
<ConfigTiles
testId="panel-editor-v2-legend-position"
aria-label="Legend position"
items={POSITION_OPTIONS}
value={value?.position}
onChange={(next): void =>
onChange({
...value,
position: next as DashboardtypesLegendPositionDTO,
})
}
value={value?.position ?? defaultValue?.position}
onChange={(position): void => onChange({ ...value, position })}
/>
</div>
</ConfigField>
)}
{controls.colors && (
<div className={styles.field}>
<Typography.Text>Series colors</Typography.Text>
<ConfigField label="Series colors" {...reset('customColors')}>
<LegendColors
series={legendSeries ?? []}
value={value?.customColors}
onChange={(customColors): void => onChange({ ...value, customColors })}
/>
</div>
</ConfigField>
)}
</>
);

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
import LegendSection from '../LegendSection';
@@ -16,8 +17,8 @@ describe('LegendSection', () => {
expect(
screen.getByTestId('panel-editor-v2-legend-position'),
).toBeInTheDocument();
expect(screen.getByText('Bottom')).toBeInTheDocument();
expect(screen.getByText('Right')).toBeInTheDocument();
expect(screen.getByText('Below chart')).toBeInTheDocument();
expect(screen.getByText('Right of chart')).toBeInTheDocument();
});
it('renders nothing when position is not enabled', () => {
@@ -30,7 +31,8 @@ describe('LegendSection', () => {
).not.toBeInTheDocument();
});
it('writes the chosen position through onChange', () => {
it('writes the chosen position through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<LegendSection
@@ -40,12 +42,13 @@ describe('LegendSection', () => {
/>,
);
fireEvent.click(screen.getByText('Right'));
await user.click(screen.getByTestId('panel-editor-v2-legend-position-right'));
expect(onChange).toHaveBeenCalledWith({ position: 'right' });
});
it('preserves other legend fields when changing position', () => {
it('preserves other legend fields when changing position', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<LegendSection
@@ -58,7 +61,7 @@ describe('LegendSection', () => {
/>,
);
fireEvent.click(screen.getByText('Right'));
await user.click(screen.getByTestId('panel-editor-v2-legend-position-right'));
expect(onChange).toHaveBeenCalledWith({
position: 'right',

View File

@@ -0,0 +1,18 @@
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,
},
];

View File

@@ -4,19 +4,24 @@ 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 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 })}
title="Hide panel header"
description="Removes the title strip on the dashboard. The drag handle and actions appear on hover."
sketch={SWITCH_SKETCHES.hideHeader}
changed={hide}
value={hide}
onChange={(next): void => onChange({ ...value, hide: next })}
/>
);
}

View File

@@ -0,0 +1,12 @@
.contrast {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 4px 2px;
font-size: 12px;
color: var(--l2-foreground);
}
.warning {
color: var(--callout-warning-description);
}

View File

@@ -0,0 +1,39 @@
import { TriangleAlert } from '@signozhq/icons';
import cx from 'classnames';
import {
contrastRatio,
inkForSurface,
MIN_CONTRAST_RATIO,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
import styles from './BackgroundContrastNote.module.scss';
interface BackgroundContrastNoteProps {
testId: string;
color: string;
}
/** Warns below the contrast floor but never blocks the choice. */
function BackgroundContrastNote({
testId,
color,
}: BackgroundContrastNoteProps): JSX.Element {
const ratio = contrastRatio(inkForSurface(color), color);
const isLegible = ratio >= MIN_CONTRAST_RATIO;
return (
<div
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
data-testid={testId}
>
{!isLegible && <TriangleAlert size={12} />}
<span className="translate-safe">
{isLegible
? `Contrast ${ratio.toFixed(1)}:1`
: `Contrast ${ratio.toFixed(1)}:1 — below the ${MIN_CONTRAST_RATIO}:1 minimum`}
</span>
</div>
);
}
export default BackgroundContrastNote;

Some files were not shown because too many files have changed in this diff Show More