Compare commits

..

10 Commits

Author SHA1 Message Date
Vinícius Lourenço
179e607b6b test(e2e): stop Google Fonts from stalling page loads 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
8ad3410c34 test(e2e): wait for loaded alert rows before clicking 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
534c06d781 fix(tanstack-table): ignore clicks on skeleton rows 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
ee4dde1736 test(e2e): lift the per-user session cap on the e2e stack 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
ec4bb0dff9 test(e2e): compare against the served URL in base path mode 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
b19ee361df test(e2e): add a model pattern before saving a pricing rule 2026-10-08 00:19:28 -03:00
Vinícius Lourenço
490a2fccc7 test(router): add the E2E routing safety net 2026-10-08 00:19:28 -03:00
Vinícius Lourenço
de635f8baf test(e2e): add the driver kit and the routing driver 2026-10-08 00:19:28 -03:00
Vinícius Lourenço
f33b887426 test(e2e): add a base path mode 2026-10-08 00:19:07 -03:00
Vinícius Lourenço
5cafe15b17 test(e2e): expose testids for the time picker, NotFound and the discard dialog 2026-10-07 23:34:13 -03:00
278 changed files with 4572 additions and 10086 deletions

1
.gitattributes vendored
View File

@@ -1,3 +1,4 @@
*.css linguist-detectable=false
*.stories.mocks.tsx linguist-generated=true
**/__story_mockdata__/** linguist-generated=true
tests/e2e/drivers/** linguist-generated=true

View File

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

View File

@@ -9844,53 +9844,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
SpantypesGettableTraceThread:
properties:
nextCursor:
type: string
prevCursor:
type: string
spans:
items:
$ref: '#/components/schemas/SpantypesThreadSpan'
type: array
required:
- spans
type: object
SpantypesGettableWaterfallTrace:
properties:
endTimestampMillis:
@@ -10205,97 +10158,6 @@ components:
nullable: true
type: object
type: object
SpantypesThreadSpan:
properties:
attributes:
additionalProperties: {}
type: object
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/SpantypesEvent'
type: array
has_error:
type: boolean
kind_string:
type: string
name:
type: string
parent_span_id:
type: string
references:
items:
$ref: '#/components/schemas/SpantypesOtelSpanRef'
type: array
resource:
additionalProperties:
type: string
type: object
span_id:
type: string
status_code_string:
type: string
status_message:
type: string
time_unix:
minimum: 0
type: integer
trace_id:
type: string
required:
- span_id
- trace_id
- parent_span_id
- name
- kind_string
- time_unix
- duration_nano
- has_error
- status_code_string
- status_message
- resource
- attributes
- events
- references
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
totalInput:
minimum: 0
type: integer
required:
- input
- output
- cacheRead
- cacheWrite
- reasoning
type: object
SpantypesUpdatableSpanMapper:
properties:
config:
@@ -16020,163 +15882,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/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the spans carrying gen_ai input or output messages in timestamp
order. Pass nextCursor as after or prevCursor as before to page, or spanId
to open the page around a span.
operationId: GetTraceThread
parameters:
- description: Page size, at most 100. 0 means 20.
in: query
name: limit
schema:
description: Page size, at most 100. 0 means 20.
type: integer
- description: The nextCursor of a page; returns the spans after it. Set only
one of after, before and spanId.
in: query
name: after
schema:
description: The nextCursor of a page; returns the spans after it. Set only
one of after, before and spanId.
type: string
- description: The prevCursor of a page; returns the spans before it. Set only
one of after, before and spanId.
in: query
name: before
schema:
description: The prevCursor of a page; returns the spans before it. Set
only one of after, before and spanId.
type: string
- description: Returns the page around this span. Set only one of after, before
and spanId.
in: query
name: spanId
schema:
description: Returns the page around this span. Set only one of after, before
and spanId.
type: string
- in: path
name: traceID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/SpantypesGettableTraceThread'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"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 thread view for a trace
tags:
- tracedetail
x-signoz-stability: alpha
/api/v1/user/me:
get:
deprecated: true

View File

@@ -86,6 +86,9 @@ tests/
├── .env.local # generated by bootstrap/setup.py (gitignored)
├── bootstrap/
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
├── drivers/ # the driver kit and one driver per feature
│ ├── kit.ts
│ └── routing.ts
├── fixtures/ # Playwright test fixtures (test.extend) only
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
@@ -112,6 +115,24 @@ These two folders look similar but mean different things:
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
### Drivers
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
```ts
import { expect, PATH, test } from '../../drivers/routing';
test('TC-09 RouteTab switches the url', async ({ routing }) => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
});
```
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
- A driver reuses helpers by calling them, never by reaching for another fixture.
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
### Extended fixtures
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.

View File

@@ -417,7 +417,7 @@ for (const theme of themes.length ? themes : [null]) {
break;
}
height = Math.ceil(needed);
height = needed;
await page.setViewportSize({ width: Number(opts.width), height });
await page.waitForTimeout(Number(opts.settle));
}

View File

@@ -11335,87 +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;
/**
* @type integer
* @minimum 0
*/
totalInput?: 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
@@ -11431,86 +11350,6 @@ export interface SpantypesOtelSpanRefDTO {
traceId?: string;
}
export type SpantypesThreadSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesThreadSpanDTOResource = { [key: string]: string };
export interface SpantypesThreadSpanDTO {
/**
* @type object
*/
attributes: SpantypesThreadSpanDTOAttributes;
/**
* @type integer
* @minimum 0
*/
duration_nano: number;
/**
* @type array
*/
events: SpantypesEventDTO[];
/**
* @type boolean
*/
has_error: boolean;
/**
* @type string
*/
kind_string: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
parent_span_id: string;
/**
* @type array
*/
references: SpantypesOtelSpanRefDTO[];
/**
* @type object
*/
resource: SpantypesThreadSpanDTOResource;
/**
* @type string
*/
span_id: string;
/**
* @type string
*/
status_code_string: string;
/**
* @type string
*/
status_message: string;
/**
* @type integer
* @minimum 0
*/
time_unix: number;
/**
* @type string
*/
trace_id: string;
}
export interface SpantypesGettableTraceThreadDTO {
/**
* @type string
*/
nextCursor?: string;
/**
* @type string
*/
prevCursor?: string;
/**
* @type array
*/
spans: SpantypesThreadSpanDTO[];
}
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
[key: string]: unknown;
};
@@ -13184,51 +13023,6 @@ export type GetTraceAggregations200 = {
status: string;
};
export type GetTraceSummaryPathParameters = {
traceID: string;
};
export type GetTraceSummary200 = {
data: SpantypesGettableTraceSummaryDTO;
/**
* @type string
*/
status: string;
};
export type GetTraceThreadPathParameters = {
traceID: string;
};
export type GetTraceThreadParams = {
/**
* @type integer
* @description Page size, at most 100. 0 means 20.
*/
limit?: number;
/**
* @type string
* @description The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId.
*/
after?: string;
/**
* @type string
* @description The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId.
*/
before?: string;
/**
* @type string
* @description Returns the page around this span. Set only one of after, before and spanId.
*/
spanId?: string;
};
export type GetTraceThread200 = {
data: SpantypesGettableTraceThreadDTO;
/**
* @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,11 +16,6 @@ import type {
GetFlamegraphPathParameters,
GetTraceAggregations200,
GetTraceAggregationsPathParameters,
GetTraceSummary200,
GetTraceSummaryPathParameters,
GetTraceThread200,
GetTraceThreadParams,
GetTraceThreadPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
@@ -38,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
@@ -158,223 +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 spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetTraceThread200>({
url: `/api/v1/traces/${traceID}/thread`,
method: 'GET',
params,
signal,
});
};
export const getGetTraceThreadQueryKey = (
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
) => {
return [
`/api/v1/traces/${traceID}/thread`,
...(params ? [params] : []),
] as const;
};
export const getGetTraceThreadQueryOptions = <
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
signal,
}) => getTraceThread({ traceID }, params, signal);
return {
queryKey,
queryFn,
enabled: traceID !== null && traceID !== undefined,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetTraceThreadQueryResult = NonNullable<
Awaited<ReturnType<typeof getTraceThread>>
>;
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get thread view for a trace
*/
export function useGetTraceThread<
TData = Awaited<ReturnType<typeof getTraceThread>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getTraceThread>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetTraceThreadQueryOptions(
{ traceID },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Get thread view for a trace
*/
export const invalidateGetTraceThread = async (
queryClient: QueryClient,
{ traceID }: GetTraceThreadPathParameters,
params?: GetTraceThreadParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
options,
);
return queryClient;
};
/**
* Returns the flamegraph view of spans for a given trace ID.
* @summary Get flamegraph view for a trace

View File

@@ -72,6 +72,8 @@ interface CustomTimePickerProps {
maxTime: number;
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
isModalTimeSelection?: boolean;
/** Lands on the trigger input. Not spread — the component takes no rest props. */
'data-testid'?: string;
}
function CustomTimePicker({
@@ -95,6 +97,7 @@ function CustomTimePicker({
minTime,
maxTime,
isModalTimeSelection = false,
'data-testid': dataTestId,
}: CustomTimePickerProps): JSX.Element {
const [selectedTimePlaceholderValue, setSelectedTimePlaceholderValue] =
useState('Select / Enter Time Range');
@@ -596,6 +599,7 @@ function CustomTimePicker({
>
<Input
ref={inputRef}
data-testid={dataTestId}
autoComplete="off"
className={cx(
'timeSelection-input',
@@ -682,4 +686,5 @@ CustomTimePicker.defaultProps = {
onExitLiveLogs: noop,
showLiveLogs: false,
showRecentlyUsed: true,
'data-testid': undefined,
};

View File

@@ -180,6 +180,7 @@ function CustomTimePickerPopoverContent({
type="text"
className="time-btns"
key={option.label + option.value}
data-testid={`time-chip-${option.value}`}
onClick={(): void => {
handleExitLiveLogs();
onSelectHandler(option.label, option.value);
@@ -259,6 +260,7 @@ function CustomTimePickerPopoverContent({
<Button
type="text"
key={option.label + option.value}
data-testid={`time-option-${option.value}`}
onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
e.preventDefault();

View File

@@ -42,9 +42,6 @@
display: flex;
flex-direction: column;
padding: 0 16px;
// OverlayScrollbar owns the scroll. A native scrollbar here changes the
// body width when it toggles and shifts the header grid.
overflow: hidden;
}
.title {
@@ -72,7 +69,7 @@
}
.log-detail-drawer__content {
min-height: 100%;
height: 100%;
}
.log-detail-drawer__log {

View File

@@ -5,7 +5,6 @@ import { Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
@@ -289,150 +288,148 @@ function LogDetailInner({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
<OverlayScrollbar style={{ height: '100%' }}>
<div
className="log-detail-drawer__content"
data-log-detail-ignore="true"
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
mouseLeaveDelay={0}
>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div
className="log-detail-drawer__content"
data-log-detail-ignore="true"
data-testid="log-detail-drawer"
>
<div className="log-detail-drawer__log">
<LogStateIndicator
severityText={log.severity_text}
severityNumber={log.severity_number}
fontSize={options?.fontSize ?? FontSize.MEDIUM}
/>
<Tooltip
title={removeEscapeCharacters(logBody)}
placement="left"
mouseLeaveDelay={0}
>
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<LogHighlights log={log} />
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
items={[
{
value: VIEW_TYPES.OVERVIEW,
label: (
<div className="view-title">
<Table size={14} />
Overview
</div>
),
},
{
value: VIEW_TYPES.CONTEXT,
label: (
<div className="view-title">
<TextSelect size={14} />
Context
</div>
),
},
{
value: VIEW_TYPES.INFRAMETRICS,
label: (
<div className="view-title">
<Histogram size="md" />
Metrics
</div>
),
},
]}
/>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Filter size="lg" />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
/>
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
isListViewPanel={isListViewPanel}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
Use
</Typography.Text>
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log
</Typography.Text>
</div>
</div>
)}
<div className="log-overflow-shadow">&nbsp;</div>
</div>
</OverlayScrollbar>
<LogHighlights log={log} />
<div className="log-detail-drawer__section-divider" />
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
items={[
{
value: VIEW_TYPES.OVERVIEW,
label: (
<div className="view-title">
<Table size={14} />
Overview
</div>
),
},
{
value: VIEW_TYPES.CONTEXT,
label: (
<div className="view-title">
<TextSelect size={14} />
Context
</div>
),
},
{
value: VIEW_TYPES.INFRAMETRICS,
label: (
<div className="view-title">
<Histogram size="md" />
Metrics
</div>
),
},
]}
/>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
mouseLeaveDelay={0}
>
<Button
variant="link"
color="secondary"
size="sm"
prefix={<Filter size="lg" />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
/>
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
isListViewPanel={isListViewPanel}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
Use
</Typography.Text>
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
color="muted"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log
</Typography.Text>
</div>
</div>
)}
</div>
</Drawer>
);
}

View File

@@ -21,6 +21,7 @@ import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -29,7 +30,6 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
ALL_SELECTED_VALUE,
@@ -729,71 +729,65 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
};
return (
<TruncatedTooltip
<div
key={option.value || `option-${index}`}
title={String(option.label || '')}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<Checkbox
value={isSelected}
className="option-checkbox"
<Checkbox
value={isSelected}
className="option-checkbox"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
<div className="option-content">
<Typography.Text truncate={1} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</Typography.Text>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
Toggle
</Button>
</div>
</Checkbox>
Toggle
</Button>
</div>
)}
</TruncatedTooltip>
</Checkbox>
</div>
);
},
[

View File

@@ -16,7 +16,6 @@ import {
X,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Select } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
@@ -26,7 +25,6 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
@@ -209,46 +207,41 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const optionId = `option-${index}`;
return (
<TruncatedTooltip key={option.value} title={String(option.label || '')}>
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
)}
</TruncatedTooltip>
<div
key={option.value}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
);
},
[highlightMatchedText, searchText, onChange, activeOptionIndex],
@@ -709,67 +702,50 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
// ===== Final Processing =====
// Apply highlight to matched text in options
const optionsWithHighlight = useMemo(() => {
const matching =
options?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
) ?? [];
// antd shows a value with no matching option as a bare string, so keep the
// selected value listed to render it through the tooltip label below.
return (
isEmpty(value)
? matching
: prioritizeOrAddOptionForSingleSelect(matching, value)
).map((option) => ({
...option,
// Rendered as the selected value in the closed control.
label: (
<TruncatedTooltip title={String(option.label || '')} side="bottom">
{(textRef): JSX.Element => (
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
)}
</TruncatedTooltip>
),
}));
}, [options, searchText, value, highlightMatchedText]);
const optionsWithHighlight = useMemo(
() =>
options
?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
)
?.map((option) => ({
...option,
label: highlightMatchedText(String(option.label || ''), searchText),
})),
[options, searchText, highlightMatchedText],
);
// ===== Component Rendering =====
return (
// Self-provided so label tooltips work wherever this select is rendered.
<TooltipProvider>
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
</TooltipProvider>
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
);
};

View File

@@ -1,4 +0,0 @@
// Above the antd select popup it is shown from.
.tooltipContent {
--tooltip-z-index: 2100;
}

View File

@@ -1,41 +0,0 @@
import { ReactElement, RefObject, useRef, useState } from 'react';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import styles from './TruncatedTooltip.module.scss';
interface TruncatedTooltipProps {
title: string;
side?: TooltipSimpleProps['side'];
/** Renders the trigger; attach the ref to the element that may truncate. */
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
}
function TruncatedTooltip({
title,
side = 'right',
children,
}: TruncatedTooltipProps): JSX.Element {
const textRef = useRef<HTMLSpanElement>(null);
const [open, setOpen] = useState(false);
const handleOpenChange = (next: boolean): void => {
const el = textRef.current;
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
};
return (
<TooltipSimple
title={title}
side={side}
arrow
delayDuration={300}
open={open}
onOpenChange={handleOpenChange}
tooltipContentProps={{ className: styles.tooltipContent }}
>
{children(textRef)}
</TooltipSimple>
);
}
export default TruncatedTooltip;

View File

@@ -1,34 +0,0 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomSelect from '../CustomSelect';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
describe('CustomSelect selected value tooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(400);
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it.each([
['listed in the options', [{ label: LABEL, value: LABEL }]],
['missing from the options', [{ label: 'other', value: 'other' }]],
])('reveals a truncated selected value %s on hover', async (_, options) => {
render(<CustomSelect options={options} value={LABEL} />);
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(screen.getByText(LABEL));
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
});

View File

@@ -1,63 +0,0 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import TruncatedTooltip from '../TruncatedTooltip';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
function renderRow(scrollWidth: number, clientWidth: number): void {
jest
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
.mockReturnValue(scrollWidth);
jest
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
.mockReturnValue(clientWidth);
render(
<TooltipProvider>
<TruncatedTooltip title={LABEL}>
{(textRef): JSX.Element => (
<div data-testid="row">
<span ref={textRef}>{LABEL}</span>
</div>
)}
</TruncatedTooltip>
</TooltipProvider>,
);
}
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(element);
act(() => {
jest.advanceTimersByTime(500);
});
}
describe('TruncatedTooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('reveals truncated text when its row is hovered', async () => {
renderRow(400, 200);
await hover(screen.getByTestId('row'));
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
it('shows no tooltip when the text fits', async () => {
renderRow(200, 200);
await hover(screen.getByTestId('row'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});

View File

@@ -135,51 +135,6 @@ const LONG_LABEL_OPTION = {
value: 'checkout-payment-authorisation',
};
/** Truncation: hovering a cut-off option reveals its full label beside the row. */
export const TruncatedOption: Story = {
args: { options: [LONG_LABEL_OPTION, ...options] },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: hovering a cut-off selected value reveals it in full. */
export const TruncatedValue: Story = {
args: {
options: [LONG_LABEL_OPTION, ...options],
value: LONG_LABEL_OPTION.value,
},
play: async (): Promise<void> => {
await userEvent.hover(await screen.findByText(LONG_LABEL_OPTION.label));
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: a multi-select option reveals its full label the same way. */
export const MultiTruncatedOption: Story = {
render: (): JSX.Element => (
<CustomMultiSelect
aria-label="Services"
options={[LONG_LABEL_OPTION, ...options]}
/>
),
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Services' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/**
* Every tooltip the select renders, held open: the selected chip revealing the
* option label it was cut from. Nothing bounds that label, so the chip is given

View File

@@ -29,13 +29,6 @@ $custom-border-color: #2c3044;
background-color: var(--l2-background);
color: color-mix(in srgb, var(--border) 70%, transparent);
}
// The search input overlays the selected value; lift the value above it while
// closed so hovers reach its tooltip. Clicks still open the select and focus the input.
&:not(.ant-select-open) .ant-select-selection-item {
position: relative;
z-index: 1;
}
}
// Keep chip styles ONLY in the multi-select
@@ -230,33 +223,6 @@ $custom-border-color: #2c3044;
}
}
// Ellipsized option label; also the selected value in the closed single select.
.option-label-text {
display: block;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// overscroll-behavior only holds on a box that overflows; a 1px sentinel past
// the bottom makes even a short list overflow, so the wheel never scrolls the page.
.custom-select-dropdown,
.custom-multiselect-dropdown {
position: relative;
overscroll-behavior: contain;
&::after {
content: '';
position: absolute;
top: 100%;
left: 0;
width: 1px;
height: 1px;
}
}
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;

View File

@@ -98,6 +98,7 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
<div
class="c0"
data-testid="not-found"
>
<img
alt="not-found"

View File

@@ -6,7 +6,7 @@ import { Button, Container, Text, TextContainer } from './styles';
function NotFound({ text = defaultText }: Props): JSX.Element {
return (
<Container>
<Container data-testid="not-found">
<NotFoundImage />
<TextContainer>

View File

@@ -12,7 +12,6 @@
overflow: visible;
position: relative;
align-self: stretch;
clip-path: inset(0 -100vw 0 0);
}
}

View File

@@ -57,7 +57,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
showFilterCollapse = true,
showQueryName = true,
useFieldApis,
savedViewsHeader,
} = props;
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [params, setParams] = useApiMonitoringParams();
@@ -380,7 +379,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<div className="quick-filters-container">
<div className="quick-filters">
{savedViewsHeader}
{source !== QuickFiltersSource.INFRA_MONITORING && (
<section className="header">
{renderLeftActions()}

View File

@@ -8,7 +8,9 @@
overflow: hidden;
}
.sidebar {
// Positioned so overlays (settings drawer) paint above the content pane
// without changing this pane's layout width.
.filters {
width: 280px;
flex-shrink: 0;
display: flex;
@@ -19,44 +21,6 @@
z-index: 2;
}
.savedViews {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--l1-background);
border-right: 1px solid var(--l1-border);
}
.quickFilters {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--l1-background);
transition: transform 0.2s ease-in-out;
z-index: 1;
&.isOpen {
transform: translateX(100%);
}
}
// No quick filters: the header sits in flow and the list stays under it
.isStatic {
border-right: 1px solid var(--l1-border);
.quickFilters {
position: static;
flex-shrink: 0;
}
.savedViews {
border-right: 0;
}
}
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
// `height: 100%`), which owns the scrolling.
.content {
@@ -67,7 +31,3 @@
flex-direction: column;
overflow: hidden;
}
.contentInner {
height: 100%;
}

View File

@@ -1,9 +1,6 @@
import { ComponentProps, ReactNode, useState } from 'react';
import { ComponentProps, ReactNode } from 'react';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
@@ -15,15 +12,8 @@ type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
ComponentProps<typeof QuickFilters>
>;
// What the page configures; the layout adds the open / close wiring.
type SavedViewsElementProps = Omit<
ComponentProps<typeof SavedViewsPanel>,
'onClose'
>;
export interface QuickFiltersLayoutProps {
quickFilterProps?: QuickFiltersElementProps;
savedViewProps?: SavedViewsElementProps;
quickFilterProps: QuickFiltersElementProps;
showFilters: boolean;
className?: string;
contentClassName?: string;
@@ -33,60 +23,20 @@ export interface QuickFiltersLayoutProps {
function QuickFiltersLayout({
quickFilterProps,
savedViewProps,
showFilters,
className,
contentClassName,
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsOpen, setIsViewsOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
const hasSavedViews = !!savedViewProps && isSavedViewEnabled;
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
const isPanelPinned = hasSavedViews && !hasQuickFilters;
const isSliding = hasQuickFilters && isViewsOpen;
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
/>
) : undefined;
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{showSidebar && (
{showFilters && (
<aside
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
className={styles.filters}
data-testid="quick-filters-layout-filters"
>
<div
className={cx(styles.quickFilters, { [styles.isOpen]: isSliding })}
data-testid="quick-filters-layout-drawer"
>
{hasQuickFilters ? (
<QuickFilters
{...quickFilterProps}
savedViewsHeader={savedViewsHeader}
/>
) : (
savedViewsHeader
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
<SavedViewsPanel
{...savedViewProps}
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
/>
)}
</div>
<QuickFilters {...quickFilterProps} />
</aside>
)}
<section
@@ -94,7 +44,7 @@ function QuickFiltersLayout({
data-testid="quick-filters-layout-content"
>
<OverlayScrollbar>
<div className={styles.contentInner}>{children}</div>
<div>{children}</div>
</OverlayScrollbar>
</section>
</div>

View File

@@ -1,6 +1,4 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent } from 'tests/test-utils';
import { render, screen } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
@@ -9,33 +7,15 @@ jest.mock('../QuickFiltersLayout.module.scss', () => ({
__esModule: true,
default: {
layout: 'layout',
sidebar: 'sidebar',
isStatic: 'isStatic',
savedViews: 'savedViews',
quickFilters: 'quickFilters',
isOpen: 'isOpen',
filters: 'filters',
content: 'content',
contentInner: 'contentInner',
},
}));
jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({
source,
savedViewsHeader,
}: {
source: string;
savedViewsHeader?: React.ReactNode;
}): JSX.Element => (
<div data-testid="quick-filters">
{savedViewsHeader}
{source}
</div>
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="quick-filters">{source}</div>
),
}));
@@ -43,16 +23,8 @@ const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
const savedViewProps = { source: SavedviewtypesSourceDTO.traces };
const drawer = (): HTMLElement =>
screen.getByTestId('quick-filters-layout-drawer');
describe('QuickFiltersLayout', () => {
beforeEach(() => {
jest.mocked(useSavedViewEnabled).mockReturnValue(true);
});
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
@@ -68,16 +40,11 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
'content',
);
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
});
it('does not render the filters pane when showFilters is false', () => {
render(
<QuickFiltersLayout
showFilters={false}
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
<div>content</div>
</QuickFiltersLayout>,
);
@@ -89,138 +56,24 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByText('content')).toBeInTheDocument();
});
it('renders no sidebar at all without quick filters or saved views', () => {
render(
<QuickFiltersLayout showFilters>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
});
it('merges classNames onto the root and content panes', () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
className="root-extra"
contentClassName="content-extra"
testId="layout"
className="page-root"
contentClassName="page-content"
testId="page"
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('layout')).toHaveClass('layout', 'root-extra');
const root = screen.getByTestId('page');
expect(root).toHaveClass('layout', 'page-root');
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
'content',
'content-extra',
'page-content',
);
});
describe('saved views', () => {
it('ignores savedViewProps while the flag is off', () => {
jest.mocked(useSavedViewEnabled).mockReturnValue(false);
const { unmount } = render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('quick-filters')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-header')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
// Saved views only, so nothing is left to show.
unmount();
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
const user = userEvent.setup();
const quickFilters = screen.getByTestId('quick-filters');
expect(quickFilters).toContainElement(
screen.getByTestId('saved-views-header'),
);
expect(drawer()).toContainElement(quickFilters);
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await user.click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
await user.click(screen.getByTestId('saved-views-close'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
});
it('hands the source to the header and the panel', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
expect(screen.getByTestId('saved-views-header')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
});
it('without quick filters: static sidebar, header on top, list always on screen with no open or close controls', () => {
render(
<QuickFiltersLayout showFilters savedViewProps={savedViewProps}>
<div>content</div>
</QuickFiltersLayout>,
);
const sidebar = screen.getByTestId('quick-filters-layout-filters');
expect(sidebar).toHaveClass('isStatic');
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-close')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
});
});

View File

@@ -8,20 +8,12 @@
width: 342px;
height: 100%;
background: var(--l1-background);
border-right: 1px solid var(--l1-border);
transition: width 0.05s ease-in-out;
overflow: hidden;
color: var(--l1-foreground);
transition:
transform 0.2s ease-in-out,
visibility 0s;
&.hidden {
transform: translateX(-100%);
visibility: hidden;
pointer-events: none;
transition:
transform 0.2s ease-in-out,
visibility 0s 0.2s;
width: 0;
}
.qf-header {
display: flex;

View File

@@ -1,4 +1,3 @@
import { ReactNode } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -62,7 +61,6 @@ export interface IQuickFiltersProps {
showFilterCollapse?: boolean;
showQueryName?: boolean;
useFieldApis?: QuickFilterCheckboxUseFieldApis;
savedViewsHeader?: ReactNode;
}
export enum QuickFiltersSource {

View File

@@ -35,7 +35,8 @@ function TanStackRowCellInner<TData>({
<td
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
data-single-column={hasSingleColumn || undefined}
onClick={onClick}
// While loading, rowData may be a placeholder ({ id: 'skeleton-N' }).
onClick={showSkeleton ? undefined : onClick}
>
{showSkeleton ? (
<Skeleton.Input

View File

@@ -83,6 +83,18 @@ describe('TanStackTableView Integration', () => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('does not call onRowClick for skeleton rows', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
renderTanStackTable({
props: { data: [], isLoading: true, onRowClick },
});
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).not.toHaveBeenCalled();
});
it('shows loading spinner for infinite scroll when loading', () => {
renderTanStackTable({
props: { isLoading: true, onEndReached: jest.fn() },

View File

@@ -10,8 +10,6 @@ import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
@@ -349,62 +347,57 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<QuickFiltersLayout
showFilters
savedViewProps={{ source: SavedviewtypesSourceDTO.metrics }}
>
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
/>
</Tooltip>
</div>
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
<div className="metrics-explorer-explore-container">
<div className="explore-header">
<div className="explore-header-left-actions">
<span>1 chart/query</span>
<Tooltip
open={disableOneChartPerQuery ? undefined : false}
title={oneChartPerQueryDisabledTooltip}
>
<Switch
value={showOneChartPerQuery}
onChange={handleToggleShowOneChartPerQuery}
disabled={disableOneChartPerQuery || splitedQueries.length <= 1}
/>
</div>
</Tooltip>
</div>
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
<div className="explore-header-right-actions">
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
<DateTimeSelector showAutoRefresh />
<RightToolbarActions
onStageRunQuery={(): void => handleRunQuery()}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
</div>
</div>
</QuickFiltersLayout>
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
showFunctions={false}
version="v3"
/>
<div className="explore-content">
<TimeSeries
onFetchingStateChange={setIsLoadingQueries}
showOneChartPerQuery={showOneChartPerQuery}
setWarning={setWarning}
areAllMetricUnitsSame={areAllMetricUnitsSame}
isMetricUnitsLoading={isMetricUnitsLoading}
isMetricUnitsError={isMetricUnitsError}
metricUnits={units}
metricNames={metricNames}
metrics={metrics}
handleOpenMetricDetails={handleOpenMetricDetails}
yAxisUnit={yAxisUnit}
setYAxisUnit={setYAxisUnit}
showYAxisUnitSelector={showYAxisUnitSelector}
isCancelled={isCancelled}
exportDefaultQuery={exportDefaultQuery}
/>
</div>
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}

View File

@@ -308,7 +308,7 @@ function CreateEditRolePageContent(): JSX.Element {
cancelText="Keep editing"
onConfirm={confirmNavigation}
onCancel={cancelNavigation}
data-testid="discard-changes-dialog"
testId="discard-changes-dialog"
>
<Typography>
{isCreateMode

View File

@@ -1,19 +0,0 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
color: var(--l1-foreground);
flex-shrink: 0;
}
.name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -1,43 +0,0 @@
import { Menu } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './SavedViewsHeader.module.scss';
// Temp Placeholder data till main functionality is plugged in
const PLACEHOLDER_VIEW_NAME = 'My view';
function SavedViewsHeader({
source,
onOpenViews,
}: {
source: SavedviewtypesSourceDTO;
// Absent when the list is always on screen.
onOpenViews?: () => void;
}): JSX.Element {
return (
<div
className={styles.header}
data-testid="saved-views-header"
data-source={source}
>
<Typography.Text className={styles.name}>
{PLACEHOLDER_VIEW_NAME}
</Typography.Text>
{onOpenViews && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="All views"
prefix={<Menu size={14} />}
onClick={onOpenViews}
data-testid="saved-views-open"
/>
)}
</div>
);
}
export default SavedViewsHeader;

View File

@@ -1,21 +0,0 @@
.panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
color: var(--l1-foreground);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-2) var(--spacing-3);
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
}
.title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}

View File

@@ -1,40 +0,0 @@
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './SavedViewsPanel.module.scss';
function SavedViewsPanel({
source,
onClose,
}: {
source: SavedviewtypesSourceDTO;
// Absent when the list is always on screen.
onClose?: () => void;
}): JSX.Element {
return (
<div
className={styles.panel}
data-testid="saved-views-panel"
data-source={source}
>
<div className={styles.header}>
<Typography.Text className={styles.title}>All views</Typography.Text>
{onClose && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Close"
prefix={<X size={14} />}
onClick={onClose}
data-testid="saved-views-close"
/>
)}
</div>
</div>
);
}
export default SavedViewsPanel;

View File

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

View File

@@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type {
@@ -8,7 +7,6 @@ import type {
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { buildPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
@@ -24,12 +22,9 @@ 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
@@ -60,10 +55,8 @@ interface ConfigPaneProps {
*/
function ConfigPane({
spec,
savedSpec,
onChangeSpec,
onChangePanelKind,
originalPanelKind,
queryType,
legendSeries,
tableColumns,
@@ -83,11 +76,6 @@ function ConfigPane({
(config) => config.kind !== SectionKind.PanelHeader,
);
const defaults = useMemo(
() => buildPluginSpec(definition.sections),
[definition],
);
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
// Title/description are just a slice of the spec — edit them through the same
@@ -131,15 +119,12 @@ 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}
@@ -158,15 +143,12 @@ 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,54 +1,6 @@
@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 {
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
.field {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
@include custom-scrollbar;
gap: 8px;
}

View File

@@ -1,17 +1,12 @@
import { useCallback, useState } from 'react';
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import ConfigField from '../controls/ConfigField/ConfigField';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
import { getPanelTypeDisabledReason } from './utils';
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
@@ -20,97 +15,33 @@ interface PanelTypeSwitcherProps {
queryType: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
/** Kind the panel was opened with; a revert button appears once it differs. */
originalPanelKind?: PanelKind;
onChange: (kind: PanelKind) => void;
}
/**
* Visualization-type selector (rendered inside the Visualization section): opens the
* panel type browser in a drawer. A type is disabled when the active query type or
* signal is incompatible with it — resolved through the capabilities guard.
* Visualization-type selector (rendered inside the Visualization section). A type is
* disabled when the active query type or signal is incompatible with it — resolved
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
* those query types still disable kinds that only support Query Builder (e.g. List).
*/
function PanelTypeSwitcher({
panelKind,
queryType,
signal,
originalPanelKind,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const { displayName, icon: Icon } = getPanelDefinition(panelKind);
const getDisabledReason = useCallback(
(kind: PanelKind): string | undefined =>
getPanelTypeDisabledReason({
kind,
queryType,
signal,
label: getPanelDefinition(kind).displayName,
}),
[queryType, signal],
);
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
const revertBlockedReason = canRevert
? getDisabledReason(originalPanelKind)
: undefined;
const handleSelect = (kind: PanelKind): void => {
setIsOpen(false);
if (kind !== panelKind) {
onChange(kind);
}
};
const items = usePanelTypeSelectItems({ queryType, signal });
return (
<ConfigField label="Panel type">
<Button
variant="outlined"
color="secondary"
className={styles.trigger}
onClick={(): void => setIsOpen(true)}
<div className={styles.field}>
<Typography.Text>Panel Type</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-type-switcher"
>
<Icon size={14} className={styles.triggerIcon} />
<span className={styles.triggerName}>{displayName}</span>
<span className={styles.triggerAction}>
<ArrowRightLeft size={14} />
Change
</span>
</Button>
{canRevert && (
<Button
variant="link"
color="primary"
size="sm"
prefix={<Undo2 />}
className={styles.revert}
disabled={!!revertBlockedReason}
title={revertBlockedReason}
onClick={(): void => onChange(originalPanelKind)}
testId="panel-editor-v2-type-revert"
>
Revert to {getPanelDefinition(originalPanelKind).displayName}
</Button>
)}
<DrawerWrapper
open={isOpen}
onOpenChange={setIsOpen}
title="Change panel type"
subTitle="Pick a visualization for this panel."
direction="right"
width="wide"
testId="panel-type-switcher-drawer"
drawerDescriptionProps={{ className: styles.drawerBody }}
>
<PanelTypeBrowser
selectedKind={panelKind}
onSelect={handleSelect}
getDisabledReason={getDisabledReason}
/>
</DrawerWrapper>
</ConfigField>
value={panelKind}
items={items}
onChange={(value): void => onChange(value)}
/>
</div>
);
}

View File

@@ -1,30 +1,22 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { fireEvent, render, screen } from '@testing-library/react';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
const OPTIONS = [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
{ kind: 'signoz/TextPanel', displayName: 'Text' },
].map((option) => ({ ...option, icon: (): null => null }));
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
get PANEL_OPTIONS(): unknown {
return OPTIONS;
},
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
].map((option) => ({ ...option, icon: (): null => null })),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
@@ -36,34 +28,14 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
};
type User = ReturnType<typeof userEvent.setup>;
async function renderSwitcher(
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
): Promise<{ onChange: jest.Mock; user: User }> {
// The open drawer sets `pointer-events: none` on the body.
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<TooltipProvider>
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
await user.click(screen.getByTestId('panel-editor-v2-type-switcher'));
return { onChange, user };
function disabledLabels(): (string | null)[] {
return Array.from(
document.querySelectorAll('.ant-select-item-option-disabled'),
).map((el) => el.textContent);
}
function disabledKinds(): (string | undefined)[] {
return Array.from(
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
)
.filter((el) => el.getAttribute('aria-disabled') === 'true')
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
function openDropdown(): void {
fireEvent.mouseDown(screen.getByRole('combobox'));
}
describe('PanelTypeSwitcher', () => {
@@ -72,8 +44,7 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
mode: 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
@@ -86,94 +57,83 @@ describe('PanelTypeSwitcher', () => {
}));
});
it('shows the current type and switches to the chosen one', async () => {
const { onChange, user } = await renderSwitcher();
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
'Time SeriesChange',
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
openDropdown();
fireEvent.click(screen.getByText('List'));
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
});
it('does not fire onChange when the current type is picked again', async () => {
const { onChange, user } = await renderSwitcher();
await user.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('disables types whose supported signals exclude the current signal', async () => {
const { onChange, user } = await renderSwitcher({
signal: TelemetrytypesSignalDTO.metrics,
});
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
expect(onChange).not.toHaveBeenCalled();
});
it('does not disable any type when the signal is unknown (builder, no signal)', async () => {
await renderSwitcher();
expect(disabledKinds()).toHaveLength(0);
});
it('disables Query-Builder-only kinds under PromQL even without a signal', async () => {
await renderSwitcher({ queryType: EQueryType.PROM });
expect(disabledKinds()).toStrictEqual(
expect.arrayContaining([
'signoz/ListPanel',
'signoz/TablePanel',
'signoz/PieChartPanel',
]),
it('disables types whose supported signals exclude the current signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
);
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
expect(disabledKinds()).not.toContain('signoz/TextPanel');
openDropdown();
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Time Series');
});
it('disables List under ClickHouse while Table/Pie stay enabled', async () => {
await renderSwitcher({
panelKind: 'signoz/TablePanel',
queryType: EQueryType.CLICKHOUSE,
});
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
openDropdown();
expect(
document.querySelectorAll('.ant-select-item-option-disabled'),
).toHaveLength(0);
});
describe('revert', () => {
it('is hidden while the type is the original one', async () => {
await renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-type-revert'),
).not.toBeInTheDocument();
});
openDropdown();
// List/Table/Pie can't be authored in PromQL; Time Series can.
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).toContain('Table');
expect(disabledLabels()).toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
});
it('switches back to the original type', async () => {
const { onChange, user } = await renderSwitcher({
panelKind: 'signoz/TablePanel',
originalPanelKind: 'signoz/TimeSeriesPanel',
});
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);
const revert = screen.getByTestId('panel-editor-v2-type-revert');
expect(revert).toHaveTextContent('Revert to Time Series');
await user.click(revert);
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
});
it('is disabled when the original type no longer fits the query', async () => {
await renderSwitcher({
panelKind: 'signoz/TimeSeriesPanel',
originalPanelKind: 'signoz/ListPanel',
queryType: EQueryType.PROM,
});
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
});
openDropdown();
expect(disabledLabels()).toContain('List');
expect(disabledLabels()).not.toContain('Table');
expect(disabledLabels()).not.toContain('Pie Chart');
expect(disabledLabels()).not.toContain('Time Series');
});
});

View File

@@ -18,7 +18,8 @@ interface UsePanelTypeSelectItemsArgs {
/**
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
* tooltip) when the active query type or signal is incompatible — resolved through
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
* modal's header so the two selectors apply the same rule and can't drift.
*/
export function usePanelTypeSelectItems({
queryType,

View File

@@ -6,22 +6,16 @@ import {
type SectionConfig,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
import type { SectionEditorContext } from '../sectionContext';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import { resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { 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'>;
@@ -57,16 +51,13 @@ const SECTION_HEADER_SLOT: Partial<
function SectionSlot({
config,
spec,
savedSpec,
onChangeSpec,
defaults,
bare,
legendSeries,
tableColumns,
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
@@ -112,11 +103,8 @@ function SectionSlot({
return null;
}
const { title } = SECTION_METADATA[config.kind];
const { title, icon: Icon } = 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).
@@ -125,27 +113,20 @@ function SectionSlot({
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
const context: SectionEditorContext = {
legendSeries,
yAxisUnit,
tableColumns,
signal,
panelKind,
onChangePanelKind,
originalPanelKind,
queryType,
stepInterval,
metricUnit,
};
const editorElement = (
<Component
value={value}
defaultValue={defaultValue}
savedValue={savedValue}
value={get(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
{...context}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}
/>
);
@@ -157,7 +138,7 @@ function SectionSlot({
return (
<SettingsSection
title={title}
changed={isDifferent(value, savedValue)}
icon={<Icon size={15} />}
open={open}
onOpenChange={setOpen}
headerSlot={headerSlot}

View File

@@ -24,16 +24,11 @@ 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 [savedSpec] = useState(() => makeSpec(initial));
const [spec, setSpec] = useState<DashboardtypesPanelSpecDTO>(savedSpec);
const [spec, setSpec] = useState<DashboardtypesPanelSpecDTO>(
makeSpec(initial),
);
return (
<SectionSlot
config={THRESHOLDS_CONFIG}
spec={spec}
savedSpec={savedSpec}
defaults={{}}
onChangeSpec={setSpec}
/>
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
);
}
@@ -75,49 +70,3 @@ 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,42 +1,51 @@
.section {
border-top: 1px solid var(--l1-border);
}
.header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 20px 0 0;
&:hover {
background: var(--l2-background);
}
width: 100%;
height: 44px;
}
// Disclosure control (icon tile + title); fills the row so the action slot and chevron sit right.
.toggle {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
gap: 11px;
min-width: 0;
padding: 18px 0 18px 20px;
padding: 0 !important;
border: none;
background: transparent;
font: inherit;
cursor: pointer;
color: var(--text-vanilla-100);
border-radius: 4px;
}
.iconTile {
display: grid;
place-items: center;
width: 27px;
height: 27px;
flex: none;
border-radius: 3px;
background: var(--l3-background);
color: var(--l3-foreground);
transition: all 0.15s ease;
}
.iconTileOpen {
background: color-mix(in srgb, var(--bg-robin-400) 14%, transparent);
color: var(--bg-robin-400);
}
.title {
font-size: 13px;
font-weight: 400;
white-space: nowrap;
color: var(--l1-foreground);
flex: 1;
text-align: left;
font-weight: 600;
color: var(--l2-foreground);
}
.chevron {
flex: none;
color: var(--l3-foreground);
cursor: pointer;
transition: transform 0.15s ease;
&.open {
@@ -47,6 +56,6 @@
.body {
display: flex;
flex-direction: column;
gap: 26px;
padding: 4px 20px 26px;
gap: 16px;
padding: 2px 0 18px;
}

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,17 +51,30 @@ function SettingsSection({
type="button"
className={styles.toggle}
aria-expanded={isOpen}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
data-testid={`config-section-${serializedTitle}`}
onClick={toggle}
>
<span className={styles.title}>{title}</span>
{changed && <ChangedDot title="Unsaved changes" />}
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
</button>
{headerSlot}
<ChevronDown
size={14}
className={cx(styles.chevron, { [styles.open]: isOpen })}
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
prefix={
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
tabIndex={-1}
onClick={toggle}
/>
</div>

View File

@@ -47,7 +47,6 @@ 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,
@@ -109,7 +108,7 @@ describe('ConfigPane', () => {
const toggle = screen.getByTestId('panel-header-hide');
expect(toggle).toBeInTheDocument();
expect(screen.getByText('Hide panel header')).toBeInTheDocument();
expect(screen.getByText('Hide header')).toBeInTheDocument();
// No collapsible wrapper of its own.
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
});

View File

@@ -1,80 +0,0 @@
.row {
display: flex;
align-items: center;
gap: 16px;
}
.grid {
display: grid;
flex: none;
grid-template-columns: repeat(3, 28px);
gap: 4px;
padding: 6px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
}
.cell {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 22px;
padding: 0;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
cursor: pointer;
&:hover {
background: var(--l3-background);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.mark {
width: 10px;
height: 3px;
border-radius: 2px;
background: var(--l3-foreground);
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, transparent);
.mark {
width: 14px;
background: var(--bg-robin-400);
}
}
.text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.label {
font-size: 14px;
color: var(--l1-foreground);
}
.description {
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
}

View File

@@ -1,64 +0,0 @@
import { useId } from 'react';
import cx from 'classnames';
import { type Alignment, ALIGNMENT_CELLS, alignmentLabel } from './alignment';
import styles from './AlignmentGrid.module.scss';
interface AlignmentGridProps {
testId: string;
value: Alignment;
description?: string;
onChange: (next: Alignment) => void;
}
function AlignmentGrid({
testId,
value,
description,
onChange,
}: AlignmentGridProps): JSX.Element {
const name = useId();
return (
<div className={styles.row}>
<div
role="radiogroup"
aria-label="Text position"
data-testid={testId}
className={styles.grid}
>
{ALIGNMENT_CELLS.map((cell) => {
const label = alignmentLabel(cell);
const selected =
cell.textAlign === value.textAlign &&
cell.verticalAlign === value.verticalAlign;
return (
<label
key={label}
title={label}
data-testid={`${testId}-${cell.verticalAlign}-${cell.textAlign}`}
className={cx(styles.cell, { [styles.selected]: selected })}
>
<input
type="radio"
className={styles.input}
name={name}
checked={selected}
aria-label={label}
onChange={(): void => onChange(cell)}
/>
<span className={styles.mark} />
</label>
);
})}
</div>
<div className={styles.text}>
<span className={styles.label}>{alignmentLabel(value)}</span>
{description && <span className={styles.description}>{description}</span>}
</div>
</div>
);
}
export default AlignmentGrid;

View File

@@ -1,37 +0,0 @@
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
const VERTICAL_LABELS: Record<DashboardtypesVerticalAlignDTO, string> = {
[DashboardtypesVerticalAlignDTO.top]: 'Top',
[DashboardtypesVerticalAlignDTO.center]: 'Middle',
[DashboardtypesVerticalAlignDTO.bottom]: 'Bottom',
};
const HORIZONTAL_LABELS: Record<DashboardtypesTextAlignDTO, string> = {
[DashboardtypesTextAlignDTO.left]: 'left',
[DashboardtypesTextAlignDTO.center]: 'center',
[DashboardtypesTextAlignDTO.right]: 'right',
};
export interface Alignment {
textAlign: DashboardtypesTextAlignDTO;
verticalAlign: DashboardtypesVerticalAlignDTO;
}
export const ALIGNMENT_CELLS: Alignment[] = Object.values(
DashboardtypesVerticalAlignDTO,
).flatMap((verticalAlign) =>
Object.values(DashboardtypesTextAlignDTO).map((textAlign) => ({
textAlign,
verticalAlign,
})),
);
export function alignmentLabel({
textAlign,
verticalAlign,
}: Alignment): string {
return `${VERTICAL_LABELS[verticalAlign]} ${HORIZONTAL_LABELS[textAlign]}`;
}

View File

@@ -1,34 +1,32 @@
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.group {
display: contents;
gap: 6px;
}
.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;
&:has(.input:focus-visible),
&:focus-visible {
// The input carries focus, so the ring is drawn on the swatch around it.
&:has(.input:focus-visible) {
outline: 2px solid var(--bg-robin-400);
outline-offset: 2px;
outline-offset: 1px;
}
}
// Clicks and focus land on the radio; the swatch is what the user sees.
// 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.
.input {
position: absolute;
inset: 0;
@@ -37,13 +35,12 @@
cursor: pointer;
}
.selected,
.selected:has(.input:focus-visible) {
outline: 2px solid var(--l1-foreground);
outline-offset: 2px;
.selected {
box-shadow: 0 0 0 2px var(--bg-robin-500);
}
.transparent {
// Transparency has no colour to show, so it reads as the conventional checkerboard.
.checkerboard {
background-color: var(--l2-background);
background-image:
linear-gradient(
@@ -66,29 +63,14 @@
4px 4px;
}
.surface {
.defaultSurface {
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,119 @@
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

@@ -0,0 +1,67 @@
.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

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

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

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

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

View File

@@ -1,18 +0,0 @@
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,80 +0,0 @@
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

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

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

@@ -1,60 +0,0 @@
.field {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
}
.label {
font-family: 'Geist Mono', monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
color: var(--l2-foreground);
}
.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

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

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

View File

@@ -1,10 +0,0 @@
import type { ReactNode } from 'react';
import styles from './ConfigFieldRow.module.scss';
/** Side-by-side fields that wrap when the pane is narrow. */
function ConfigFieldRow({ children }: { children: ReactNode }): JSX.Element {
return <div className={styles.row}>{children}</div>;
}
export default ConfigFieldRow;

View File

@@ -1,24 +0,0 @@
.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

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,59 @@
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
import styles from './ConfigSegmented.module.scss';
export interface ConfigSegmentedItem {
value: string;
label: string;
icon?: SegmentIconName;
}
interface ConfigSegmentedProps<T extends string = string> {
testId: string;
value: T | undefined;
items: ConfigSegmentedItem[];
onChange: (value: T) => void;
}
/**
* Inline segmented control for short option sets in the config pane (line style, fill
* mode, axis scale, legend position). Each segment carries an optional muted glyph that
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
* the Periscope ToggleGroup so it stays theme-faithful.
*/
function ConfigSegmented<T extends string = string>({
testId,
value,
items,
onChange,
}: ConfigSegmentedProps<T>): JSX.Element {
return (
<ToggleGroupSimple
type="single"
testId={testId}
className={styles.group}
value={value}
items={items.map((item) => ({
value: item.value,
'aria-label': item.label,
label: (
<span className={styles.segment}>
{item.icon && <SegmentIcon name={item.icon} />}
{item.label}
</span>
),
}))}
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
onChange={(next: T): void => {
if (next) {
onChange(next);
}
}}
/>
);
}
export default ConfigSegmented;

View File

@@ -11,8 +11,6 @@ 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;
}
/**
@@ -27,7 +25,6 @@ function ConfigSlider({
step,
formatValue,
onChange,
onChangeEnd,
}: ConfigSliderProps): JSX.Element {
return (
<div className={styles.row}>
@@ -39,10 +36,6 @@ 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,31 +1,30 @@
.card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l2-background-60);
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 3px;
gap: 2px;
min-width: 0;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l2-foreground);
}
.description {
font-size: 12px;
line-height: 17px;
color: var(--l2-foreground);
color: var(--l3-foreground);
}

View File

@@ -1,38 +1,39 @@
import type { ReactNode } from 'react';
import { Switch } from '@signozhq/ui/switch';
import ChangedDot from '../ChangedDot/ChangedDot';
import { Typography } from '@signozhq/ui/typography';
import styles from './ConfigSwitch.module.scss';
interface ConfigSwitchProps {
testId: string;
/** Shown uppercased as the card title. */
title: string;
/** Optional helper line under the title. */
description?: string;
sketch?: ReactNode;
changed?: boolean;
value: boolean;
onChange: (checked: boolean) => void;
}
/**
* Boolean toggle rendered as a bordered card: an uppercase title with an optional
* description on the left and a Switch on the right. The standard presentation for
* on/off panel-config controls (e.g. "Show points").
*/
function ConfigSwitch({
testId,
title,
description,
sketch,
changed,
value,
onChange,
}: ConfigSwitchProps): JSX.Element {
return (
<div className={styles.card}>
{sketch}
<div className={styles.text}>
<span className={styles.title}>
{title}
{changed && <ChangedDot />}
</span>
{description && <span className={styles.description}>{description}</span>}
<span className={styles.title}>{title}</span>
{description && (
<Typography.Text className={styles.description}>
{description}
</Typography.Text>
)}
</div>
<Switch testId={testId} value={value} onChange={onChange} />
</div>

View File

@@ -1,110 +0,0 @@
.container {
container-type: inline-size;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.tile {
position: relative;
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
height: 52px;
padding: 0 14px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l2-background);
color: var(--l2-foreground);
font: inherit;
font-size: 13px;
cursor: pointer;
transition:
border-color 150ms,
background 150ms;
&:hover {
border-color: var(--l2-border);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
outline-offset: 1px;
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, var(--l1-background));
color: var(--l1-foreground);
&:hover {
border-color: var(--bg-robin-500);
}
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@mixin stacked-tiles($columns) {
grid-template-columns: repeat($columns, minmax(0, 1fr));
.tile {
flex-direction: column;
justify-content: center;
gap: 10px;
height: 88px;
padding: 0 4px;
}
}
@container (min-width: 200px) {
.grid[data-count='2'] {
@include stacked-tiles(2);
}
}
@container (min-width: 216px) {
.grid[data-count='3'] {
@include stacked-tiles(3);
}
}
@container (min-width: 220px) {
.grid[data-count='4'] {
@include stacked-tiles(2);
}
}
@container (min-width: 420px) {
.grid[data-count='4'] {
@include stacked-tiles(4);
}
}
.grid.compact {
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 6px;
.tile {
justify-content: center;
height: 36px;
padding: 0 4px;
}
}

View File

@@ -1,74 +0,0 @@
import { type ReactNode, useId } from 'react';
import cx from 'classnames';
import styles from './ConfigTiles.module.scss';
export interface ConfigTileItem<T extends string = string> {
value: T;
label: string;
/** 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,5 +1,6 @@
import { ColorPicker } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './LegendColors.module.scss';
@@ -40,9 +41,9 @@ function LegendColorRow({
data-testid={`legend-color-${label}`}
>
<span className={styles.swatch} style={{ backgroundColor: color }} />
<span className={styles.label} title={label}>
<Typography.Text className={styles.label} title={label}>
{label}
</span>
</Typography.Text>
</button>
</ColorPicker>
{isOverridden && (

View File

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

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
import type { ReactNode } from 'react';
function LegendFrame({ children }: { children: ReactNode }): JSX.Element {
return (
<svg
width={56}
height={30}
viewBox="0 0 56 30"
fill="none"
strokeLinecap="round"
style={{ flex: 'none' }}
aria-hidden
>
<rect
x={1}
y={1}
width={54}
height={28}
rx={2}
stroke="var(--l3-border)"
strokeWidth={1.4}
/>
{children}
</svg>
);
}
export default LegendFrame;

View File

@@ -1,22 +0,0 @@
import type { ReactNode } from 'react';
function SketchSvg({ children }: { children: ReactNode }): JSX.Element {
return (
<svg
width={40}
height={22}
viewBox="0 0 40 22"
fill="none"
stroke="var(--l3-foreground)"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
style={{ flex: 'none' }}
aria-hidden
>
{children}
</svg>
);
}
export default SketchSvg;

View File

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

@@ -1,37 +0,0 @@
import SketchSvg from './SketchSvg';
/** Muted sketches for on/off cards. */
export const SWITCH_SKETCHES = {
hideHeader: (
<SketchSvg>
<rect x={3} y={3} width={34} height={16} rx={2} />
<path d="M3 8 H37" strokeDasharray="3 3" />
</SketchSvg>
),
fillGaps: (
<SketchSvg>
<path d="M3 9 L11 7 L16 19 L26 19 L30 8 L37 6" />
</SketchSvg>
),
points: (
<SketchSvg>
<path d="M4 17 L14 9 L24 13 L36 6" />
{[
[4, 17],
[14, 9],
[24, 13],
[36, 6],
].map(([cx, cy]) => (
<circle key={cx} cx={cx} cy={cy} r={2} fill="var(--l3-foreground)" />
))}
</SketchSvg>
),
combine: (
<SketchSvg>
<rect x={4} y={10} width={6} height={10} />
<rect x={13} y={4} width={6} height={16} />
<rect x={22} y={8} width={6} height={12} />
<rect x={31} y={13} width={6} height={7} />
</SketchSvg>
),
} satisfies Record<string, JSX.Element>;

View File

@@ -1,240 +0,0 @@
import LegendFrame from './LegendFrame';
import TileSvg from './TileSvg';
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
const LINE = 'M4 17 L18 9 L30 14 L44 5 L60 11';
const POINTS = [
[4, 18],
[22, 8],
[40, 14],
[58, 6],
];
function interpDrawing(path: string): JSX.Element {
return (
<TileSvg>
<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

@@ -0,0 +1,152 @@
/**
* 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,8 +16,6 @@ export interface SectionEditorContext {
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
/** Kind the panel was opened with, offered as a revert target. */
originalPanelKind?: PanelKind;
yAxisUnit?: string;
queryType?: EQueryType;
stepInterval?: number;

View File

@@ -15,7 +15,6 @@ 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';
@@ -160,8 +159,6 @@ export interface ErasedSectionDescriptor {
Component: ComponentType<
{
value: unknown;
defaultValue?: unknown;
savedValue?: unknown;
controls?: unknown;
onChange: (next: unknown) => void;
} & SectionEditorContext
@@ -177,16 +174,3 @@ export function resolveSectionEditor(
| ErasedSectionDescriptor
| undefined;
}
/** A slice's default, read through the same lens from the kind's seeded plugin spec. */
export function getSectionDefault(
editor: ErasedSectionDescriptor,
spec: PanelSpec,
defaults: SeededPluginSpec,
): unknown {
return editor.get({
...spec,
links: [],
plugin: { ...spec.plugin, spec: defaults },
} as PanelSpec);
}

View File

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

View File

@@ -1,81 +1,127 @@
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 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';
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',
},
];
/**
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds, the
* 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.
* linear/logarithmic scale toggle, and the heatmap's bucket-axis distribution.
* Each control is gated by its `controls` flag.
*/
function AxesSection({
value,
savedValue,
controls,
onChange,
}: SectionEditorProps<SectionKind.Axes>): JSX.Element {
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>
);
}
// An empty field clears the bound (null); otherwise parse to a number, ignoring
// transient non-numeric input (e.g. a lone "-") by leaving the bound unset.
const handleBound =
(bound: SoftBound) =>
(e: ChangeEvent<HTMLInputElement>): void => {
const raw = e.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
return (
<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.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>
)}
{controls.logScale && (
<AxisScaleField
{controls.logScale && (
<div className={styles.field}>
<Typography.Text>Y-axis scale</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-log-scale"
aria-label="Y-axis scale"
value={value?.isLogScale ? 'log' : 'linear'}
items={LOG_SCALE_OPTIONS}
help={SCALE_HELP}
items={SCALE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, isLogScale: next === 'log' })
}
/>
)}
</AxisFields>
</ConfigField>
</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>
)}
</>
);
}

View File

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

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

@@ -1,37 +0,0 @@
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,20 +90,25 @@ describe('AxesSection', () => {
<AxesSection value={undefined} controls={{ y: true }} onChange={onChange} />,
);
['auto', 'linear', 'log', 'symlog'].forEach((scale) =>
expect(
screen.getByTestId(`panel-editor-v2-y-scale-${scale}`),
).toBeInTheDocument(),
const trigger = screen.getByTestId('panel-editor-v2-y-scale');
await user.click(
trigger.querySelector('.ant-select-selector') as HTMLElement,
);
await user.click(screen.getByTestId('panel-editor-v2-y-scale-symlog'));
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/ }));
expect(onChange).toHaveBeenCalledWith({
y: { scale: DashboardtypesHeatmapYScaleDTO.symlog },
});
});
it('shows the help for the scale the spec asks for', () => {
it('shows the scale the spec asks for', () => {
render(
<AxesSection
value={{ y: { scale: DashboardtypesHeatmapYScaleDTO.symlog } }}
@@ -112,7 +117,7 @@ describe('AxesSection', () => {
/>,
);
expect(screen.getByText(/Log on both sides of zero/)).toBeInTheDocument();
expect(screen.getByText('Symmetric log')).toBeInTheDocument();
});
it('toggles the logarithmic scale through onChange', async () => {
@@ -126,25 +131,8 @@ describe('AxesSection', () => {
/>,
);
await user.click(screen.getByTestId('panel-editor-v2-log-scale-log'));
await user.click(screen.getByText('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

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

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

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

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