mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-07 18:50:41 +01:00
Compare commits
15 Commits
main
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27a635c40c | ||
|
|
6099faf30c | ||
|
|
050c295179 | ||
|
|
e5e0f169ff | ||
|
|
1cd716d3ad | ||
|
|
78ecfb7bc7 | ||
|
|
38cf950ce7 | ||
|
|
e5de83cc31 | ||
|
|
5b0a409751 | ||
|
|
bcd222f046 | ||
|
|
faa92bb38c | ||
|
|
0ae98b05c5 | ||
|
|
7c76ba8d9e | ||
|
|
104758f3cf | ||
|
|
504be0dfd7 |
@@ -9878,19 +9878,6 @@ components:
|
||||
- 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,61 +10192,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:
|
||||
@@ -16077,103 +16009,6 @@ paths:
|
||||
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
|
||||
|
||||
@@ -11426,86 +11426,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;
|
||||
};
|
||||
@@ -13190,40 +13110,6 @@ export type GetTraceSummary200 = {
|
||||
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
|
||||
|
||||
@@ -24,9 +24,6 @@ import type {
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetTraceThread200,
|
||||
GetTraceThreadParams,
|
||||
GetTraceThreadPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -260,121 +257,6 @@ export const invalidateGetTraceSummary = async (
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"> </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"> </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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
background-color: var(--l1-background);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
padding-bottom: 44px;
|
||||
|
||||
@include custom-scrollbar;
|
||||
@@ -43,12 +42,11 @@
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
padding: 18px 20px 12px;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 16px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.group {
|
||||
@@ -75,5 +73,9 @@
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
& > * {
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
@@ -8,7 +7,6 @@ import type {
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { buildPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
const OPTIONS = [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
{ kind: 'signoz/TextPanel', displayName: 'Text' },
|
||||
].map((option) => ({ ...option, icon: (): null => null }));
|
||||
|
||||
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
get PANEL_OPTIONS(): unknown {
|
||||
return OPTIONS;
|
||||
},
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
@@ -36,34 +28,14 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
|
||||
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
};
|
||||
|
||||
type User = ReturnType<typeof userEvent.setup>;
|
||||
|
||||
async function renderSwitcher(
|
||||
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
|
||||
): Promise<{ onChange: jest.Mock; user: User }> {
|
||||
// The open drawer sets `pointer-events: none` on the body.
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
await user.click(screen.getByTestId('panel-editor-v2-type-switcher'));
|
||||
return { onChange, user };
|
||||
function disabledLabels(): (string | null)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
}
|
||||
|
||||
function disabledKinds(): (string | undefined)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
|
||||
)
|
||||
.filter((el) => el.getAttribute('aria-disabled') === 'true')
|
||||
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
@@ -72,8 +44,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
...OPTIONS.find((option) => option.kind === kind),
|
||||
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
|
||||
mode: 'query',
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
@@ -86,94 +57,83 @@ describe('PanelTypeSwitcher', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('shows the current type and switches to the chosen one', async () => {
|
||||
const { onChange, user } = await renderSwitcher();
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
|
||||
'Time SeriesChange',
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('does not fire onChange when the current type is picked again', async () => {
|
||||
const { onChange, user } = await renderSwitcher();
|
||||
|
||||
await user.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current signal', async () => {
|
||||
const { onChange, user } = await renderSwitcher({
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
});
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not disable any type when the signal is unknown (builder, no signal)', async () => {
|
||||
await renderSwitcher();
|
||||
|
||||
expect(disabledKinds()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', async () => {
|
||||
await renderSwitcher({ queryType: EQueryType.PROM });
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'signoz/ListPanel',
|
||||
'signoz/TablePanel',
|
||||
'signoz/PieChartPanel',
|
||||
]),
|
||||
it('disables types whose supported signals exclude the current signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
|
||||
expect(disabledKinds()).not.toContain('signoz/TextPanel');
|
||||
|
||||
openDropdown();
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', async () => {
|
||||
await renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
});
|
||||
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('revert', () => {
|
||||
it('is hidden while the type is the original one', async () => {
|
||||
await renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.PROM}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-type-revert'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
openDropdown();
|
||||
// List/Table/Pie can't be authored in PromQL; Time Series can.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).toContain('Table');
|
||||
expect(disabledLabels()).toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
|
||||
it('switches back to the original type', async () => {
|
||||
const { onChange, user } = await renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
originalPanelKind: 'signoz/TimeSeriesPanel',
|
||||
});
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TablePanel"
|
||||
queryType={EQueryType.CLICKHOUSE}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const revert = screen.getByTestId('panel-editor-v2-type-revert');
|
||||
expect(revert).toHaveTextContent('Revert to Time Series');
|
||||
await user.click(revert);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
|
||||
});
|
||||
|
||||
it('is disabled when the original type no longer fits the query', async () => {
|
||||
await renderSwitcher({
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
originalPanelKind: 'signoz/ListPanel',
|
||||
queryType: EQueryType.PROM,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
|
||||
});
|
||||
openDropdown();
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Table');
|
||||
expect(disabledLabels()).not.toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,8 @@ interface UsePanelTypeSelectItemsArgs {
|
||||
/**
|
||||
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
|
||||
* tooltip) when the active query type or signal is incompatible — resolved through
|
||||
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
|
||||
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
|
||||
@@ -6,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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
flex: none;
|
||||
grid-template-columns: repeat(3, 28px);
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
&:has(:focus-visible) {
|
||||
outline: 1px solid var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 10px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: var(--bg-robin-500);
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 12%, transparent);
|
||||
|
||||
.mark {
|
||||
width: 14px;
|
||||
background: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useId } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { type Alignment, ALIGNMENT_CELLS, alignmentLabel } from './alignment';
|
||||
|
||||
import styles from './AlignmentGrid.module.scss';
|
||||
|
||||
interface AlignmentGridProps {
|
||||
testId: string;
|
||||
value: Alignment;
|
||||
description?: string;
|
||||
onChange: (next: Alignment) => void;
|
||||
}
|
||||
|
||||
function AlignmentGrid({
|
||||
testId,
|
||||
value,
|
||||
description,
|
||||
onChange,
|
||||
}: AlignmentGridProps): JSX.Element {
|
||||
const name = useId();
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Text position"
|
||||
data-testid={testId}
|
||||
className={styles.grid}
|
||||
>
|
||||
{ALIGNMENT_CELLS.map((cell) => {
|
||||
const label = alignmentLabel(cell);
|
||||
const selected =
|
||||
cell.textAlign === value.textAlign &&
|
||||
cell.verticalAlign === value.verticalAlign;
|
||||
return (
|
||||
<label
|
||||
key={label}
|
||||
title={label}
|
||||
data-testid={`${testId}-${cell.verticalAlign}-${cell.textAlign}`}
|
||||
className={cx(styles.cell, { [styles.selected]: selected })}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={styles.input}
|
||||
name={name}
|
||||
checked={selected}
|
||||
aria-label={label}
|
||||
onChange={(): void => onChange(cell)}
|
||||
/>
|
||||
<span className={styles.mark} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.text}>
|
||||
<span className={styles.label}>{alignmentLabel(value)}</span>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlignmentGrid;
|
||||
@@ -1,37 +0,0 @@
|
||||
import {
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const VERTICAL_LABELS: Record<DashboardtypesVerticalAlignDTO, string> = {
|
||||
[DashboardtypesVerticalAlignDTO.top]: 'Top',
|
||||
[DashboardtypesVerticalAlignDTO.center]: 'Middle',
|
||||
[DashboardtypesVerticalAlignDTO.bottom]: 'Bottom',
|
||||
};
|
||||
|
||||
const HORIZONTAL_LABELS: Record<DashboardtypesTextAlignDTO, string> = {
|
||||
[DashboardtypesTextAlignDTO.left]: 'left',
|
||||
[DashboardtypesTextAlignDTO.center]: 'center',
|
||||
[DashboardtypesTextAlignDTO.right]: 'right',
|
||||
};
|
||||
|
||||
export interface Alignment {
|
||||
textAlign: DashboardtypesTextAlignDTO;
|
||||
verticalAlign: DashboardtypesVerticalAlignDTO;
|
||||
}
|
||||
|
||||
export const ALIGNMENT_CELLS: Alignment[] = Object.values(
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
).flatMap((verticalAlign) =>
|
||||
Object.values(DashboardtypesTextAlignDTO).map((textAlign) => ({
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
})),
|
||||
);
|
||||
|
||||
export function alignmentLabel({
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
}: Alignment): string {
|
||||
return `${VERTICAL_LABELS[verticalAlign]} ${HORIZONTAL_LABELS[textAlign]}`;
|
||||
}
|
||||
@@ -1,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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-robin-500);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import styles from './ChangedDot.module.scss';
|
||||
|
||||
function ChangedDot({
|
||||
title = 'Unsaved changes',
|
||||
}: {
|
||||
title?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={styles.dot}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
data-testid="config-changed-dot"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangedDot;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import styles from './ConfigFieldRow.module.scss';
|
||||
|
||||
/** Side-by-side fields that wrap when the pane is narrow. */
|
||||
function ConfigFieldRow({ children }: { children: ReactNode }): JSX.Element {
|
||||
return <div className={styles.row}>{children}</div>;
|
||||
}
|
||||
|
||||
export default ConfigFieldRow;
|
||||
@@ -1,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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
export function isRangeInverted(
|
||||
min: number | null | undefined,
|
||||
max: number | null | undefined,
|
||||
): boolean {
|
||||
return min != null && max != null && min > max;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
|
||||
import styles from './ConfigSegmented.module.scss';
|
||||
|
||||
export interface ConfigSegmentedItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: SegmentIconName;
|
||||
}
|
||||
|
||||
interface ConfigSegmentedProps<T extends string = string> {
|
||||
testId: string;
|
||||
value: T | undefined;
|
||||
items: ConfigSegmentedItem[];
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline segmented control for short option sets in the config pane (line style, fill
|
||||
* mode, axis scale, legend position). Each segment carries an optional muted glyph that
|
||||
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
|
||||
* the Periscope ToggleGroup so it stays theme-faithful.
|
||||
*/
|
||||
function ConfigSegmented<T extends string = string>({
|
||||
testId,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
}: ConfigSegmentedProps<T>): JSX.Element {
|
||||
return (
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
testId={testId}
|
||||
className={styles.group}
|
||||
value={value}
|
||||
items={items.map((item) => ({
|
||||
value: item.value,
|
||||
'aria-label': item.label,
|
||||
label: (
|
||||
<span className={styles.segment}>
|
||||
{item.icon && <SegmentIcon name={item.icon} />}
|
||||
{item.label}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
|
||||
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
|
||||
onChange={(next: T): void => {
|
||||
if (next) {
|
||||
onChange(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSegmented;
|
||||
@@ -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}
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background-60);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
|
||||
import ChangedDot from '../ChangedDot/ChangedDot';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ConfigSwitch.module.scss';
|
||||
|
||||
interface ConfigSwitchProps {
|
||||
testId: string;
|
||||
/** Shown uppercased as the card title. */
|
||||
title: string;
|
||||
/** Optional helper line under the title. */
|
||||
description?: string;
|
||||
sketch?: ReactNode;
|
||||
changed?: boolean;
|
||||
value: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean toggle rendered as a bordered card: an uppercase title with an optional
|
||||
* description on the left and a Switch on the right. The standard presentation for
|
||||
* on/off panel-config controls (e.g. "Show points").
|
||||
*/
|
||||
function ConfigSwitch({
|
||||
testId,
|
||||
title,
|
||||
description,
|
||||
sketch,
|
||||
changed,
|
||||
value,
|
||||
onChange,
|
||||
}: ConfigSwitchProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
{sketch}
|
||||
<div className={styles.text}>
|
||||
<span className={styles.title}>
|
||||
{title}
|
||||
{changed && <ChangedDot />}
|
||||
</span>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
<span className={styles.title}>{title}</span>
|
||||
{description && (
|
||||
<Typography.Text className={styles.description}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<Switch testId={testId} value={value} onChange={onChange} />
|
||||
</div>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
.container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
height: 52px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
background 150ms;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
&:has(:focus-visible) {
|
||||
outline: 1px solid var(--bg-robin-500);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: var(--bg-robin-500);
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 12%, var(--l1-background));
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@mixin stacked-tiles($columns) {
|
||||
grid-template-columns: repeat($columns, minmax(0, 1fr));
|
||||
|
||||
.tile {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: 88px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 200px) {
|
||||
.grid[data-count='2'] {
|
||||
@include stacked-tiles(2);
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 216px) {
|
||||
.grid[data-count='3'] {
|
||||
@include stacked-tiles(3);
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 220px) {
|
||||
.grid[data-count='4'] {
|
||||
@include stacked-tiles(2);
|
||||
}
|
||||
}
|
||||
|
||||
@container (min-width: 420px) {
|
||||
.grid[data-count='4'] {
|
||||
@include stacked-tiles(4);
|
||||
}
|
||||
}
|
||||
|
||||
.grid.compact {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.tile {
|
||||
justify-content: center;
|
||||
height: 36px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
@@ -1,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;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ColorPicker } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './LegendColors.module.scss';
|
||||
|
||||
@@ -40,9 +41,9 @@ function LegendColorRow({
|
||||
data-testid={`legend-color-${label}`}
|
||||
>
|
||||
<span className={styles.swatch} style={{ backgroundColor: color }} />
|
||||
<span className={styles.label} title={label}>
|
||||
<Typography.Text className={styles.label} title={label}>
|
||||
{label}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
</button>
|
||||
</ColorPicker>
|
||||
{isOverridden && (
|
||||
|
||||
@@ -42,9 +42,7 @@
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -40,7 +40,7 @@ function LegendColors({
|
||||
if (series.length === 0) {
|
||||
return (
|
||||
<Typography.Text className={styles.empty}>
|
||||
Run the query to set a color for each series.
|
||||
Run the panel to customise series colors.
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
function LegendFrame({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={56}
|
||||
height={30}
|
||||
viewBox="0 0 56 30"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
style={{ flex: 'none' }}
|
||||
aria-hidden
|
||||
>
|
||||
<rect
|
||||
x={1}
|
||||
y={1}
|
||||
width={54}
|
||||
height={28}
|
||||
rx={2}
|
||||
stroke="var(--l3-border)"
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegendFrame;
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
function SketchSvg({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={40}
|
||||
height={22}
|
||||
viewBox="0 0 40 22"
|
||||
fill="none"
|
||||
stroke="var(--l3-foreground)"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ flex: 'none' }}
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SketchSvg;
|
||||
@@ -1,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;
|
||||
@@ -1,37 +0,0 @@
|
||||
import SketchSvg from './SketchSvg';
|
||||
|
||||
/** Muted sketches for on/off cards. */
|
||||
export const SWITCH_SKETCHES = {
|
||||
hideHeader: (
|
||||
<SketchSvg>
|
||||
<rect x={3} y={3} width={34} height={16} rx={2} />
|
||||
<path d="M3 8 H37" strokeDasharray="3 3" />
|
||||
</SketchSvg>
|
||||
),
|
||||
fillGaps: (
|
||||
<SketchSvg>
|
||||
<path d="M3 9 L11 7 L16 19 L26 19 L30 8 L37 6" />
|
||||
</SketchSvg>
|
||||
),
|
||||
points: (
|
||||
<SketchSvg>
|
||||
<path d="M4 17 L14 9 L24 13 L36 6" />
|
||||
{[
|
||||
[4, 17],
|
||||
[14, 9],
|
||||
[24, 13],
|
||||
[36, 6],
|
||||
].map(([cx, cy]) => (
|
||||
<circle key={cx} cx={cx} cy={cy} r={2} fill="var(--l3-foreground)" />
|
||||
))}
|
||||
</SketchSvg>
|
||||
),
|
||||
combine: (
|
||||
<SketchSvg>
|
||||
<rect x={4} y={10} width={6} height={10} />
|
||||
<rect x={13} y={4} width={6} height={16} />
|
||||
<rect x={22} y={8} width={6} height={12} />
|
||||
<rect x={31} y={13} width={6} height={7} />
|
||||
</SketchSvg>
|
||||
),
|
||||
} satisfies Record<string, JSX.Element>;
|
||||
@@ -1,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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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.";
|
||||
@@ -0,0 +1,5 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigFieldRow from '../../controls/ConfigFieldRow/ConfigFieldRow';
|
||||
import ConfigNumberInput from '../../controls/ConfigNumberInput/ConfigNumberInput';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
|
||||
import styles from './BucketsSection.module.scss';
|
||||
|
||||
// The two numeric bounds of the histogram-buckets spec (derived from the BE DTO).
|
||||
type NumericBound = keyof Pick<
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
'bucketCount' | 'bucketWidth'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Edits the `histogramBuckets` slice of a Histogram panel spec: bucket count / width
|
||||
@@ -17,51 +24,55 @@ import { createFieldResetter } from '../../utils/changes';
|
||||
*/
|
||||
function BucketsSection({
|
||||
value,
|
||||
savedValue,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.Buckets>): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
// Empty clears the bound to null (chart auto-sizes); otherwise parse to a number,
|
||||
// ignoring transient non-numeric input by leaving it unset.
|
||||
const handleNumber =
|
||||
(bound: NumericBound) =>
|
||||
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = e.target.value;
|
||||
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
|
||||
onChange({ ...value, [bound]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(controls.count || controls.width) && (
|
||||
<ConfigField
|
||||
label="Buckets"
|
||||
help="How values are grouped into bars. Leave on Auto to size buckets from the data."
|
||||
{...reset('bucketCount', 'bucketWidth')}
|
||||
>
|
||||
<ConfigFieldRow>
|
||||
{controls.count && (
|
||||
<ConfigNumberInput
|
||||
testId="panel-editor-v2-bucket-count"
|
||||
label="Count"
|
||||
value={value?.bucketCount}
|
||||
onChange={(bucketCount): void => onChange({ ...value, bucketCount })}
|
||||
/>
|
||||
)}
|
||||
{controls.width && (
|
||||
<ConfigNumberInput
|
||||
testId="panel-editor-v2-bucket-width"
|
||||
label="Width"
|
||||
value={value?.bucketWidth}
|
||||
onChange={(bucketWidth): void => onChange({ ...value, bucketWidth })}
|
||||
/>
|
||||
)}
|
||||
</ConfigFieldRow>
|
||||
</ConfigField>
|
||||
{controls.count && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Bucket count</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-bucket-count"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.bucketCount ?? ''}
|
||||
onChange={handleNumber('bucketCount')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.width && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Bucket width</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-bucket-width"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.bucketWidth ?? ''}
|
||||
onChange={handleNumber('bucketWidth')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.mergeQueries && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-merge-queries"
|
||||
title="Combine queries"
|
||||
description="Merges all active queries into one distribution. Hides the legend."
|
||||
sketch={SWITCH_SKETCHES.combine}
|
||||
changed={reset('mergeAllActiveQueries').changed}
|
||||
title="Merge active queries"
|
||||
description="Bucket all active queries together into one distribution"
|
||||
value={value?.mergeAllActiveQueries ?? false}
|
||||
onChange={(mergeAllActiveQueries): void =>
|
||||
onChange({ ...value, mergeAllActiveQueries })
|
||||
onChange={(checked): void =>
|
||||
onChange({ ...value, mergeAllActiveQueries: checked })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
.inset {
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.insetRow {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.insetLabel {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.opacitySlider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thresholdField {
|
||||
|
||||
@@ -1,32 +1,92 @@
|
||||
import cx from 'classnames';
|
||||
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
import { SegmentIcon } from '../../controls/segmentIcons';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import DisconnectValuesField from './DisconnectValuesField';
|
||||
import HeatmapColorsField from './HeatmapColorsField';
|
||||
import {
|
||||
FILL_MODE_OPTIONS,
|
||||
FILLED_FILL_MODE_OPTIONS,
|
||||
LINE_INTERPOLATION_HELP,
|
||||
LINE_INTERPOLATION_OPTIONS,
|
||||
LINE_STYLE_OPTIONS,
|
||||
} from './options';
|
||||
import { formatOpacity } from './utils';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
const LINE_STYLE_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.solid,
|
||||
label: 'Solid',
|
||||
icon: 'solid-line' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.dashed,
|
||||
label: 'Dashed',
|
||||
icon: 'dashed-line' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const LINE_INTERPOLATION_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.linear,
|
||||
label: 'Linear',
|
||||
icon: <SegmentIcon name="interp-linear" />,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.spline,
|
||||
label: 'Spline',
|
||||
icon: <SegmentIcon name="interp-spline" />,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_before,
|
||||
label: 'Step before',
|
||||
icon: <SegmentIcon name="interp-step-before" />,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_after,
|
||||
label: 'Step after',
|
||||
icon: <SegmentIcon name="interp-step-after" />,
|
||||
},
|
||||
];
|
||||
|
||||
const FILL_MODE_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.none,
|
||||
label: 'None',
|
||||
icon: 'fill-none' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.solid,
|
||||
label: 'Solid',
|
||||
icon: 'fill-solid' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.gradient,
|
||||
label: 'Gradient',
|
||||
icon: 'fill-gradient' as const,
|
||||
},
|
||||
];
|
||||
|
||||
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
|
||||
const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
|
||||
(option) => option.value !== DashboardtypesFillModeDTO.none,
|
||||
);
|
||||
|
||||
const FILL_OPACITY_STEP = 0.01;
|
||||
|
||||
function formatOpacity(opacity: number): string {
|
||||
return `${Math.round(opacity * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the `chartAppearance` slice of a panel spec: line style / interpolation, fill
|
||||
* mode, fill opacity, point markers and the connect-null-gaps threshold for the
|
||||
@@ -35,90 +95,84 @@ const FILL_OPACITY_STEP = 0.01;
|
||||
*/
|
||||
function ChartAppearanceSection({
|
||||
value,
|
||||
savedValue,
|
||||
controls,
|
||||
onChange,
|
||||
stepInterval,
|
||||
}: SectionEditorProps<SectionKind.ChartAppearance> &
|
||||
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
const interpolation = value?.lineInterpolation;
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.lineStyle && (
|
||||
<ConfigField label="Line" {...reset('lineStyle')}>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Line style</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-line-style"
|
||||
aria-label="Line style"
|
||||
value={value?.lineStyle}
|
||||
items={LINE_STYLE_OPTIONS}
|
||||
onChange={(lineStyle): void => onChange({ ...value, lineStyle })}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, lineStyle: next as DashboardtypesLineStyleDTO })
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.lineInterpolation && (
|
||||
<ConfigField
|
||||
label="How points connect"
|
||||
help={interpolation && LINE_INTERPOLATION_HELP[interpolation]}
|
||||
{...reset('lineInterpolation')}
|
||||
>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Line interpolation</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-line-interpolation"
|
||||
aria-label="How points connect"
|
||||
value={interpolation}
|
||||
placeholder="Select interpolation…"
|
||||
value={value?.lineInterpolation}
|
||||
items={LINE_INTERPOLATION_OPTIONS}
|
||||
onChange={(lineInterpolation): void =>
|
||||
onChange({ ...value, lineInterpolation })
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
lineInterpolation: next,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.fillMode && (
|
||||
<ConfigField
|
||||
label="Area under the line"
|
||||
{...reset('fillMode', 'fillOpacity')}
|
||||
>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Fill mode</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-fill-mode"
|
||||
aria-label="Area under the line"
|
||||
value={value?.fillMode}
|
||||
items={
|
||||
controls.fillOpacity ? FILLED_FILL_MODE_OPTIONS : FILL_MODE_OPTIONS
|
||||
}
|
||||
onChange={(fillMode): void => onChange({ ...value, fillMode })}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
|
||||
}
|
||||
/>
|
||||
{controls.fillOpacity && (
|
||||
<div className={cx(styles.inset, styles.insetRow)}>
|
||||
<span className={styles.insetLabel}>Opacity</span>
|
||||
<div className={styles.opacitySlider}>
|
||||
<ConfigSlider
|
||||
testId="panel-editor-v2-fill-opacity"
|
||||
// The chart's own default, so the thumb starts where an unset fill renders.
|
||||
value={resolveFillOpacity(value?.fillOpacity)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={FILL_OPACITY_STEP}
|
||||
formatValue={formatOpacity}
|
||||
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.fillOpacity && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Fill opacity</Typography.Text>
|
||||
<ConfigSlider
|
||||
testId="panel-editor-v2-fill-opacity"
|
||||
// The chart's own default, so the thumb starts where an unset fill renders.
|
||||
value={resolveFillOpacity(value?.fillOpacity)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={FILL_OPACITY_STEP}
|
||||
formatValue={formatOpacity}
|
||||
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.showPoints && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-show-points"
|
||||
title="Show points"
|
||||
description="Marks each sample the query returned."
|
||||
sketch={SWITCH_SKETCHES.points}
|
||||
changed={reset('showPoints').changed}
|
||||
description="Display individual data points on the chart"
|
||||
value={value?.showPoints ?? false}
|
||||
onChange={(showPoints): void => onChange({ ...value, showPoints })}
|
||||
onChange={(checked): void => onChange({ ...value, showPoints: checked })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -127,7 +181,6 @@ function ChartAppearanceSection({
|
||||
testId="panel-editor-v2-span-gaps"
|
||||
value={value?.spanGaps}
|
||||
stepInterval={stepInterval}
|
||||
{...reset('spanGaps')}
|
||||
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
|
||||
/>
|
||||
)}
|
||||
@@ -135,7 +188,6 @@ function ChartAppearanceSection({
|
||||
{controls.colors && (
|
||||
<HeatmapColorsField
|
||||
value={value?.colors}
|
||||
savedValue={savedValue?.colors}
|
||||
onChange={(colors): void => onChange({ ...value, colors })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesSpanGapsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import type { FieldResetProps } from '../../utils/changes';
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
|
||||
import { DISCONNECT_MODE_OPTIONS, DisconnectValuesMode } from './options';
|
||||
import { defaultDisconnectDuration } from './utils';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
interface DisconnectValuesFieldProps extends FieldResetProps {
|
||||
const DEFAULT_THRESHOLD = '1m';
|
||||
enum DisconnectValuesMode {
|
||||
NEVER = 'never',
|
||||
THRESHOLD = 'threshold',
|
||||
}
|
||||
const MODE_OPTIONS = [
|
||||
{ value: DisconnectValuesMode.NEVER, label: 'Never' },
|
||||
{ value: DisconnectValuesMode.THRESHOLD, label: 'Threshold' },
|
||||
];
|
||||
|
||||
interface DisconnectValuesFieldProps {
|
||||
testId: string;
|
||||
value: DashboardtypesSpanGapsDTO | undefined;
|
||||
/** Query step interval (seconds): seeds the default threshold and floors it. */
|
||||
@@ -18,6 +26,13 @@ interface DisconnectValuesFieldProps extends FieldResetProps {
|
||||
onChange: (next: DashboardtypesSpanGapsDTO | undefined) => void;
|
||||
}
|
||||
|
||||
/** Default threshold duration: the step interval (smallest meaningful), else 1m. */
|
||||
function defaultDuration(stepInterval?: number): string {
|
||||
return stepInterval && stepInterval > 0
|
||||
? rangeUtil.secondsToHms(stepInterval)
|
||||
: DEFAULT_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Disconnect values": Never (span every gap — the chart default) vs Threshold
|
||||
* (only bridge gaps shorter than a duration). The threshold persists as a
|
||||
@@ -28,8 +43,6 @@ function DisconnectValuesField({
|
||||
testId,
|
||||
value,
|
||||
stepInterval,
|
||||
changed,
|
||||
onReset,
|
||||
onChange,
|
||||
}: DisconnectValuesFieldProps): JSX.Element {
|
||||
const duration = value?.fillLessThan || undefined;
|
||||
@@ -50,7 +63,7 @@ function DisconnectValuesField({
|
||||
...value,
|
||||
fillOnlyBelow: true,
|
||||
// Seed from the live stepInterval (async — undefined until results load), not mount.
|
||||
fillLessThan: lastDuration ?? defaultDisconnectDuration(stepInterval),
|
||||
fillLessThan: lastDuration ?? defaultDuration(stepInterval),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -59,24 +72,21 @@ function DisconnectValuesField({
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigField
|
||||
label="When data is missing"
|
||||
help="Breaking the line makes outages and restarts visible."
|
||||
changed={changed}
|
||||
onReset={onReset}
|
||||
>
|
||||
<ConfigTiles
|
||||
testId={testId}
|
||||
aria-label="When data is missing"
|
||||
value={
|
||||
isThreshold ? DisconnectValuesMode.THRESHOLD : DisconnectValuesMode.NEVER
|
||||
}
|
||||
items={DISCONNECT_MODE_OPTIONS}
|
||||
onChange={handleMode}
|
||||
/>
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Disconnect values</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId={testId}
|
||||
value={
|
||||
isThreshold ? DisconnectValuesMode.THRESHOLD : DisconnectValuesMode.NEVER
|
||||
}
|
||||
items={MODE_OPTIONS}
|
||||
onChange={handleMode}
|
||||
/>
|
||||
</div>
|
||||
{isThreshold && duration && (
|
||||
<div className={styles.inset}>
|
||||
<span className={styles.insetLabel}>Break when a gap is longer than</span>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Threshold value</Typography.Text>
|
||||
<DisconnectValuesThresholdInput
|
||||
testId={`${testId}-value`}
|
||||
value={duration}
|
||||
@@ -87,7 +97,7 @@ function DisconnectValuesField({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ConfigField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Slider } from '@signozhq/ui/slider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
clampColorSteps,
|
||||
DEFAULT_COLOR_STEPS,
|
||||
@@ -6,11 +8,11 @@ import {
|
||||
MIN_COLOR_STEPS,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
|
||||
import type { FieldResetProps } from '../../utils/changes';
|
||||
import { singleSliderValue } from './heatmapColorOptions';
|
||||
|
||||
interface HeatmapColorStepsFieldProps extends Partial<FieldResetProps> {
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapColorStepsFieldProps {
|
||||
/** Unset until the slider moves; the chart's default stands in. */
|
||||
value: number | undefined;
|
||||
onChange: (steps: number) => void;
|
||||
@@ -23,31 +25,35 @@ interface HeatmapColorStepsFieldProps extends Partial<FieldResetProps> {
|
||||
function HeatmapColorStepsField({
|
||||
value,
|
||||
onChange,
|
||||
changed,
|
||||
onReset,
|
||||
}: HeatmapColorStepsFieldProps): JSX.Element {
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
|
||||
const steps = dragging ?? value ?? DEFAULT_COLOR_STEPS;
|
||||
|
||||
return (
|
||||
<ConfigField
|
||||
label="Color steps"
|
||||
help="Fewer steps make bands easier to tell apart."
|
||||
changed={changed}
|
||||
onReset={onReset}
|
||||
>
|
||||
<ConfigSlider
|
||||
<div className={styles.field}>
|
||||
<div className={styles.fieldHeader}>
|
||||
<Typography.Text>Color steps</Typography.Text>
|
||||
<span className={styles.mono}>{steps}</span>
|
||||
</div>
|
||||
<Slider
|
||||
testId="panel-editor-v2-heatmap-color-steps"
|
||||
value={dragging ?? value ?? DEFAULT_COLOR_STEPS}
|
||||
className={styles.slider}
|
||||
value={steps}
|
||||
min={MIN_COLOR_STEPS}
|
||||
max={MAX_COLOR_STEPS}
|
||||
step={1}
|
||||
onChange={setDragging}
|
||||
onChangeEnd={(next): void => {
|
||||
aria-label="Color steps"
|
||||
onChange={(next): void => setDragging(singleSliderValue(next))}
|
||||
onAfterChange={(next): void => {
|
||||
setDragging(null);
|
||||
onChange(clampColorSteps(next));
|
||||
onChange(clampColorSteps(singleSliderValue(next)));
|
||||
}}
|
||||
/>
|
||||
</ConfigField>
|
||||
<Typography.Text className={styles.help}>
|
||||
How many colours the ramp is split into.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +1,138 @@
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
// The track is inset by half a thumb at each end by default, which reads as an
|
||||
// indent against the label and help text either side of it. Radix keeps the thumb
|
||||
// in bounds on its own, so the track can run the full width of the field.
|
||||
.slider {
|
||||
--slider-track-margin-x: 0;
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.bounds {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.fillGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.customFill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--l2-background-60);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.customFillValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.customFillSwatch {
|
||||
width: var(--spacing-12);
|
||||
height: var(--spacing-8);
|
||||
border-radius: var(--radius-1);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.previewRamp {
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
box-shadow: inset 0 0 0 1px var(--l1-border);
|
||||
height: var(--spacing-8);
|
||||
border-radius: var(--radius-1);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.previewBounds {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
// The grid can't query its own width, so a wrapper is the container.
|
||||
.cards {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.cardGrid {
|
||||
.paletteGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
@container (min-width: 200px) {
|
||||
.cardGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
.paletteCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 10px 10px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--l2-background-60);
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--bg-robin-500);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
border-color: var(--bg-robin-500);
|
||||
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.cardRamp {
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
overflow: hidden;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.isSelected & {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
.paletteRamp {
|
||||
height: var(--spacing-6);
|
||||
border-radius: var(--radius-1);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
type DashboardtypesHeatmapColorsDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import HeatmapColorStepsField from './HeatmapColorStepsField';
|
||||
import HeatmapCountRangeField from './HeatmapCountRangeField';
|
||||
import HeatmapFillField from './HeatmapFillField';
|
||||
import HeatmapPaletteGrid from './HeatmapPaletteGrid';
|
||||
import HeatmapRampPreview from './HeatmapRampPreview';
|
||||
import {
|
||||
COLOR_MODE_HELP,
|
||||
COLOR_MODE_OPTIONS,
|
||||
COLOR_SCALE_HELP,
|
||||
COLOR_SCALE_OPTIONS,
|
||||
DEFAULT_COLOR_MODE,
|
||||
DEFAULT_COLOR_SCALE,
|
||||
DEFAULT_PALETTE,
|
||||
} from './heatmapColorOptions';
|
||||
import { COLOR_MODE_OPTIONS, COLOR_SCALE_OPTIONS } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapColorsFieldProps {
|
||||
value: DashboardtypesHeatmapColorsDTO | undefined;
|
||||
savedValue?: DashboardtypesHeatmapColorsDTO;
|
||||
onChange: (next: DashboardtypesHeatmapColorsDTO) => void;
|
||||
}
|
||||
|
||||
@@ -34,73 +27,73 @@ interface HeatmapColorsFieldProps {
|
||||
*/
|
||||
function HeatmapColorsField({
|
||||
value,
|
||||
savedValue,
|
||||
onChange,
|
||||
}: HeatmapColorsFieldProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
// Unset fields select what the chart draws for them.
|
||||
const mode = value?.mode ?? DEFAULT_COLOR_MODE;
|
||||
const scale = value?.scale ?? DEFAULT_COLOR_SCALE;
|
||||
const isOpacityMode =
|
||||
value?.mode === DashboardtypesHeatmapColorModeDTO.opacity;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.colors}>
|
||||
<HeatmapRampPreview colors={value} />
|
||||
|
||||
<ConfigField
|
||||
label="Color mode"
|
||||
help={COLOR_MODE_HELP[mode]}
|
||||
{...reset('mode')}
|
||||
>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Color mode</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-heatmap-color-mode"
|
||||
aria-label="Color mode"
|
||||
value={mode}
|
||||
value={value?.mode}
|
||||
items={COLOR_MODE_OPTIONS}
|
||||
onChange={(next): void => onChange({ ...value, mode: next })}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
mode: next as DashboardtypesHeatmapColorModeDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
|
||||
{mode === DashboardtypesHeatmapColorModeDTO.opacity ? (
|
||||
{isOpacityMode ? (
|
||||
<HeatmapFillField
|
||||
value={value?.fill}
|
||||
{...reset('fill')}
|
||||
onChange={(fill): void => onChange({ ...value, fill })}
|
||||
/>
|
||||
) : (
|
||||
<ConfigField label="Palette" {...reset('palette')}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Palette</Typography.Text>
|
||||
<HeatmapPaletteGrid
|
||||
value={value?.palette ?? DEFAULT_PALETTE}
|
||||
value={value?.palette}
|
||||
onChange={(palette): void => onChange({ ...value, palette })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfigField
|
||||
label="Color scale"
|
||||
help={COLOR_SCALE_HELP[scale]}
|
||||
{...reset('scale')}
|
||||
>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Color scale</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-heatmap-color-scale"
|
||||
aria-label="Color scale"
|
||||
value={scale}
|
||||
value={value?.scale}
|
||||
items={COLOR_SCALE_OPTIONS}
|
||||
onChange={(next): void => onChange({ ...value, scale: next })}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
scale: next as DashboardtypesHeatmapColorScaleDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
<Typography.Text className={styles.help}>
|
||||
How a count maps onto the ramp.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<HeatmapColorStepsField
|
||||
value={value?.steps || undefined}
|
||||
{...reset('steps')}
|
||||
onChange={(steps): void => onChange({ ...value, steps })}
|
||||
/>
|
||||
|
||||
<HeatmapCountRangeField
|
||||
value={{ minCount: value?.minCount, maxCount: value?.maxCount }}
|
||||
{...reset('minCount', 'maxCount')}
|
||||
onChange={(bounds): void => onChange({ ...value, ...bounds })}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigRangeInput from '../../controls/ConfigRangeInput/ConfigRangeInput';
|
||||
import { isRangeInverted } from '../../controls/ConfigRangeInput/utils';
|
||||
import type { FieldResetProps } from '../../utils/changes';
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
/** `null` on either bound derives it from the data. */
|
||||
type CountBound = Pick<DashboardtypesHeatmapColorsDTO, 'minCount' | 'maxCount'>;
|
||||
|
||||
interface HeatmapCountRangeFieldProps extends Partial<FieldResetProps> {
|
||||
interface HeatmapCountRangeFieldProps {
|
||||
value: CountBound;
|
||||
onChange: (next: CountBound) => void;
|
||||
}
|
||||
@@ -21,35 +20,45 @@ interface HeatmapCountRangeFieldProps extends Partial<FieldResetProps> {
|
||||
function HeatmapCountRangeField({
|
||||
value,
|
||||
onChange,
|
||||
changed,
|
||||
onReset,
|
||||
}: HeatmapCountRangeFieldProps): JSX.Element {
|
||||
const { minCount, maxCount } = value;
|
||||
// An empty field derives the bound (null), as does transient non-numeric input
|
||||
// (a lone "-"), which pinning would read as a bound the user never typed.
|
||||
const handleBound =
|
||||
(bound: keyof CountBound) =>
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = event.target.value;
|
||||
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
|
||||
onChange({ ...value, [bound]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigField
|
||||
label="Count range"
|
||||
changed={changed}
|
||||
onReset={onReset}
|
||||
help={
|
||||
isRangeInverted(minCount, maxCount) ? (
|
||||
<Callout type="error" size="small" showIcon>
|
||||
Min count can't be greater than max count.
|
||||
</Callout>
|
||||
) : (
|
||||
'Leave empty to read the range from the data.'
|
||||
)
|
||||
}
|
||||
>
|
||||
<ConfigRangeInput
|
||||
minTestId="panel-editor-v2-heatmap-min-count"
|
||||
maxTestId="panel-editor-v2-heatmap-max-count"
|
||||
min={minCount}
|
||||
max={maxCount}
|
||||
onChangeMin={(next): void => onChange({ ...value, minCount: next })}
|
||||
onChangeMax={(next): void => onChange({ ...value, maxCount: next })}
|
||||
/>
|
||||
</ConfigField>
|
||||
<div className={styles.field}>
|
||||
<div className={styles.bounds}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Min count</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-heatmap-min-count"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value.minCount ?? ''}
|
||||
onChange={handleBound('minCount')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Max count</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-heatmap-max-count"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value.maxCount ?? ''}
|
||||
onChange={handleBound('maxCount')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Typography.Text className={styles.help}>
|
||||
The counts the ramp's ends mean; empty reads them off the data.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import cx from 'classnames';
|
||||
import { ColorPicker } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DEFAULT_OPACITY_FILL } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
|
||||
import ColorSwatches from '../../controls/ColorSwatches/ColorSwatches';
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import type { FieldResetProps } from '../../utils/changes';
|
||||
import { FILL_OPTIONS, GROUP_FILL, isSameColor } from './heatmapColorOptions';
|
||||
import { FILL_PRESETS, isSameColor } from './heatmapColorOptions';
|
||||
|
||||
interface HeatmapFillFieldProps extends Partial<FieldResetProps> {
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapFillFieldProps {
|
||||
/** Empty follows the selected group's legend colour. */
|
||||
value: string | undefined;
|
||||
onChange: (fill: string) => void;
|
||||
@@ -18,34 +20,65 @@ interface HeatmapFillFieldProps extends Partial<FieldResetProps> {
|
||||
function HeatmapFillField({
|
||||
value,
|
||||
onChange,
|
||||
changed,
|
||||
onReset,
|
||||
}: HeatmapFillFieldProps): JSX.Element {
|
||||
const selected = value
|
||||
? FILL_OPTIONS.find((option) => isSameColor(option.value, value))?.value
|
||||
: GROUP_FILL;
|
||||
const isAuto = !value;
|
||||
const isCustom =
|
||||
!isAuto && !FILL_PRESETS.some((preset) => isSameColor(preset.color, value));
|
||||
|
||||
return (
|
||||
<ConfigField
|
||||
label="Base color"
|
||||
help="Group color gives each group its own color."
|
||||
changed={changed}
|
||||
onReset={onReset}
|
||||
>
|
||||
<ColorSwatches
|
||||
testId="panel-editor-v2-heatmap-fill"
|
||||
label="Base color"
|
||||
value={selected}
|
||||
options={FILL_OPTIONS}
|
||||
dividerAfter={0}
|
||||
onChange={onChange}
|
||||
custom={{
|
||||
value: selected === undefined ? value : undefined,
|
||||
initial: value || DEFAULT_OPACITY_FILL,
|
||||
onChange,
|
||||
}}
|
||||
/>
|
||||
</ConfigField>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Base color</Typography.Text>
|
||||
|
||||
<div className={styles.fillGrid}>
|
||||
{FILL_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
aria-pressed={isSameColor(preset.color, value)}
|
||||
className={cx(styles.paletteCard, {
|
||||
[styles.isSelected]: isSameColor(preset.color, value),
|
||||
})}
|
||||
data-testid={`panel-editor-v2-heatmap-fill-${preset.label}`}
|
||||
onClick={(): void => onChange(preset.color)}
|
||||
>
|
||||
<span
|
||||
className={styles.paletteRamp}
|
||||
style={{ background: preset.color }}
|
||||
/>
|
||||
<span className={styles.mono}>{preset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ColorPicker
|
||||
value={value || DEFAULT_OPACITY_FILL}
|
||||
trigger="click"
|
||||
onChangeComplete={(next): void => onChange(next.toHexString())}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.customFill, {
|
||||
[styles.isSelected]: isCustom,
|
||||
})}
|
||||
data-testid="panel-editor-v2-heatmap-fill-custom"
|
||||
>
|
||||
<Typography.Text>Custom</Typography.Text>
|
||||
<span className={styles.customFillValue}>
|
||||
<span className={styles.mono}>
|
||||
{isAuto ? 'group colour' : value.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className={styles.customFillSwatch}
|
||||
style={{ background: value || DEFAULT_OPACITY_FILL }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</ColorPicker>
|
||||
|
||||
<Typography.Text className={styles.help}>
|
||||
The colour whose alpha ramps; unset uses the group's.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { paletteGradient, PALETTE_OPTIONS } from './heatmapColorOptions';
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapPaletteGridProps {
|
||||
value: DashboardtypesHeatmapPaletteDTO;
|
||||
value: DashboardtypesHeatmapPaletteDTO | undefined;
|
||||
onChange: (palette: DashboardtypesHeatmapPaletteDTO) => void;
|
||||
}
|
||||
|
||||
@@ -19,27 +19,25 @@ function HeatmapPaletteGrid({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<div className={styles.cards}>
|
||||
<div className={styles.cardGrid}>
|
||||
{PALETTE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={option.value === value}
|
||||
className={cx(styles.card, {
|
||||
[styles.isSelected]: option.value === value,
|
||||
})}
|
||||
data-testid={`panel-editor-v2-heatmap-palette-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
>
|
||||
<span
|
||||
className={styles.cardRamp}
|
||||
style={{ background: paletteGradient(option.value, isDarkMode) }}
|
||||
/>
|
||||
<span className={styles.cardName}>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.paletteGrid}>
|
||||
{PALETTE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={option.value === value}
|
||||
className={cx(styles.paletteCard, {
|
||||
[styles.isSelected]: option.value === value,
|
||||
})}
|
||||
data-testid={`panel-editor-v2-heatmap-palette-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
>
|
||||
<span
|
||||
className={styles.paletteRamp}
|
||||
style={{ background: paletteGradient(option.value, isDarkMode) }}
|
||||
/>
|
||||
<span className={styles.mono}>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
type DashboardtypesHeatmapColorsDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { HeatmapColorMode } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { resolveHeatmapColors } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import {
|
||||
DEFAULT_COLOR_MODE,
|
||||
DEFAULT_COLOR_SCALE,
|
||||
DEFAULT_PALETTE,
|
||||
rampGradient,
|
||||
} from './heatmapColorOptions';
|
||||
import { rampGradient } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
@@ -53,30 +46,29 @@ function HeatmapRampPreview({ colors }: HeatmapRampPreviewProps): JSX.Element {
|
||||
);
|
||||
|
||||
const summary = [
|
||||
(colors?.mode ?? DEFAULT_COLOR_MODE) ===
|
||||
DashboardtypesHeatmapColorModeDTO.opacity
|
||||
? DashboardtypesHeatmapColorModeDTO.opacity
|
||||
: (colors?.palette ?? DEFAULT_PALETTE),
|
||||
colors?.scale ?? DEFAULT_COLOR_SCALE,
|
||||
options.mode === HeatmapColorMode.Opacity ? 'opacity' : options.palette,
|
||||
options.scale,
|
||||
`${options.steps} steps`,
|
||||
].join(' · ');
|
||||
|
||||
return (
|
||||
<div data-testid="panel-editor-v2-heatmap-preview">
|
||||
<ConfigField label="Preview" aside={summary}>
|
||||
<div className={styles.preview}>
|
||||
<div
|
||||
className={styles.previewRamp}
|
||||
style={{ background: rampGradient(ramp) }}
|
||||
/>
|
||||
<div className={styles.previewBounds}>
|
||||
<span>{(colors?.minCount ?? 0).toLocaleString()}</span>
|
||||
<span>
|
||||
{colors?.maxCount == null ? 'auto' : colors.maxCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ConfigField>
|
||||
<div className={styles.preview} data-testid="panel-editor-v2-heatmap-preview">
|
||||
<div className={styles.previewHeader}>
|
||||
<Typography.Text>Preview</Typography.Text>
|
||||
<span className={styles.mono}>{summary}</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.previewRamp}
|
||||
style={{ background: rampGradient(ramp) }}
|
||||
/>
|
||||
<div className={styles.previewBounds}>
|
||||
<span className={styles.mono}>
|
||||
{(colors?.minCount ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.mono}>
|
||||
{colors?.maxCount == null ? 'auto' : colors.maxCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,16 @@ function StatefulSpanGaps({
|
||||
);
|
||||
}
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label. The
|
||||
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
|
||||
// only used for the line-interpolation ConfigSelect.
|
||||
async function pickOption(triggerTestId: string, label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId(triggerTestId);
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
await user.click(await screen.findByRole('option', { name: label }));
|
||||
}
|
||||
|
||||
const ALL_CONTROLS = {
|
||||
lineStyle: true,
|
||||
lineInterpolation: true,
|
||||
@@ -129,7 +139,7 @@ describe('ChartAppearanceSection', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ fillMode: true, fillOpacity: true }}
|
||||
controls={{ fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -146,7 +156,7 @@ describe('ChartAppearanceSection', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ fillOpacity: 0.25 }}
|
||||
controls={{ fillMode: true, fillOpacity: true }}
|
||||
controls={{ fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -185,7 +195,7 @@ describe('ChartAppearanceSection', () => {
|
||||
expect(screen.getByText('Gradient')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen line interpolation through the tiles', async () => {
|
||||
it('writes the chosen line interpolation through the dropdown', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
@@ -195,9 +205,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByTestId('panel-editor-v2-line-interpolation-spline'));
|
||||
await pickOption('panel-editor-v2-line-interpolation', 'Spline');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ lineInterpolation: 'spline' });
|
||||
});
|
||||
@@ -227,9 +235,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-span-gaps-never'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -246,7 +252,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '1m' },
|
||||
@@ -308,7 +314,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
|
||||
await user.click(screen.getByText('Never'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillOnlyBelow: false, fillLessThan: undefined },
|
||||
@@ -384,7 +390,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillOnlyBelow: true, fillLessThan: '5m' },
|
||||
@@ -412,7 +418,7 @@ describe('ChartAppearanceSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
// Regression: a value seeded at mount would still be the 1m fallback.
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
@@ -466,7 +472,7 @@ describe('ChartAppearanceSection', () => {
|
||||
|
||||
// Focus the input first so clicking Never also fires its blur (the toggle race).
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-value'));
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
|
||||
await user.click(screen.getByText('Never'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-span-gaps-value'),
|
||||
@@ -477,8 +483,8 @@ describe('ChartAppearanceSection', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StatefulSpanGaps initial={{ spanGaps: { fillLessThan: '5m' } }} />);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-never'));
|
||||
await user.click(screen.getByTestId('panel-editor-v2-span-gaps-threshold'));
|
||||
await user.click(screen.getByText('Never'));
|
||||
await user.click(screen.getByText('Threshold'));
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-span-gaps-value')).toHaveValue(
|
||||
'5m',
|
||||
|
||||
@@ -124,10 +124,10 @@ describe('HeatmapColorsField', () => {
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Keeps sparse buckets visible next to busy ones.'),
|
||||
screen.getByText(/How a count maps onto the ramp/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Square root'));
|
||||
await user.click(screen.getByText('Sqrt'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
scale: DashboardtypesHeatmapColorScaleDTO.sqrt,
|
||||
@@ -238,7 +238,9 @@ describe('HeatmapColorsField', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-forest'),
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('shows a fill that is no preset as the custom one', () => {
|
||||
@@ -252,23 +254,10 @@ describe('HeatmapColorsField', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('#0060E6')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-custom'),
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('radio', { name: 'Robin' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('flags a min count above the max count', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ minCount: 500, maxCount: 10 }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Min count can't be greater than max count."),
|
||||
).toBeInTheDocument();
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('names the group colour as the fill until one is picked', () => {
|
||||
@@ -279,9 +268,9 @@ describe('HeatmapColorsField', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Group color' })).toBeChecked();
|
||||
expect(screen.getByText('group colour')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-custom'),
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,110 +4,46 @@ import {
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
HEATMAP_COLOR_MODE_MAP,
|
||||
HEATMAP_COLOR_SCALE_MAP,
|
||||
HEATMAP_PALETTE_MAP,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
|
||||
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { HEATMAP_PALETTE_MAP } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
|
||||
import { getPaletteStops } from 'lib/uPlotV2/plugins/HeatmapPlugin/palettes';
|
||||
|
||||
import type { ColorSwatchOption } from '../../controls/ColorSwatches/ColorSwatches';
|
||||
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
|
||||
import type { ConfigSegmentedItem } from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
|
||||
function dtoFor<D extends string, V>(map: Record<D, V>, value: V): D {
|
||||
return (Object.keys(map) as D[]).find((key) => map[key] === value) as D;
|
||||
}
|
||||
|
||||
/** What the chart draws for an unset field, in spec terms. */
|
||||
export const DEFAULT_COLOR_MODE = dtoFor(
|
||||
HEATMAP_COLOR_MODE_MAP,
|
||||
DEFAULT_HEATMAP_COLORS.mode,
|
||||
);
|
||||
export const DEFAULT_COLOR_SCALE = dtoFor(
|
||||
HEATMAP_COLOR_SCALE_MAP,
|
||||
DEFAULT_HEATMAP_COLORS.scale,
|
||||
);
|
||||
export const DEFAULT_PALETTE = dtoFor(
|
||||
HEATMAP_PALETTE_MAP,
|
||||
DEFAULT_HEATMAP_COLORS.palette,
|
||||
);
|
||||
|
||||
export const COLOR_MODE_OPTIONS: ConfigTileItem<DashboardtypesHeatmapColorModeDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesHeatmapColorModeDTO.palette,
|
||||
label: 'Palette',
|
||||
drawing: TILE_DRAWINGS.colorPalette,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
label: 'Opacity',
|
||||
drawing: TILE_DRAWINGS.colorOpacity,
|
||||
},
|
||||
];
|
||||
|
||||
export const COLOR_MODE_HELP: Record<
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
string
|
||||
> = {
|
||||
palette: 'Each count maps to a color on a multi-color ramp.',
|
||||
opacity: 'One color fades from clear to solid.',
|
||||
};
|
||||
export const COLOR_MODE_OPTIONS: ConfigSegmentedItem[] = [
|
||||
{ value: DashboardtypesHeatmapColorModeDTO.palette, label: 'Palette' },
|
||||
{ value: DashboardtypesHeatmapColorModeDTO.opacity, label: 'Opacity' },
|
||||
];
|
||||
|
||||
// Log first: bucket counts routinely span decades.
|
||||
export const COLOR_SCALE_OPTIONS: ConfigTileItem<DashboardtypesHeatmapColorScaleDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.log,
|
||||
label: 'Log',
|
||||
drawing: TILE_DRAWINGS.scaleLog,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.sqrt,
|
||||
label: 'Square root',
|
||||
drawing: TILE_DRAWINGS.scaleSqrt,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.linear,
|
||||
label: 'Linear',
|
||||
drawing: TILE_DRAWINGS.scaleLinear,
|
||||
},
|
||||
];
|
||||
export const COLOR_SCALE_OPTIONS: ConfigSegmentedItem[] = [
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.log,
|
||||
label: 'Log',
|
||||
icon: 'scale-log',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.sqrt,
|
||||
label: 'Sqrt',
|
||||
icon: 'scale-sqrt',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.linear,
|
||||
label: 'Linear',
|
||||
icon: 'scale-linear',
|
||||
},
|
||||
];
|
||||
|
||||
export const COLOR_SCALE_HELP: Record<
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
string
|
||||
> = {
|
||||
log: 'Keeps sparse buckets visible next to busy ones.',
|
||||
sqrt: 'A middle ground between Log and Linear.',
|
||||
linear: 'Color changes evenly with count.',
|
||||
};
|
||||
|
||||
/** The unset fill: each group ramps its own legend colour. */
|
||||
export const GROUP_FILL = '';
|
||||
export interface HeatmapFillPreset {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Opacity-mode base colours: the palette values that read on both themes. */
|
||||
export const FILL_OPTIONS: ColorSwatchOption<string>[] = [
|
||||
{
|
||||
value: GROUP_FILL,
|
||||
id: 'group',
|
||||
label: 'Group color',
|
||||
tooltip: 'Each group’s own color',
|
||||
fill: `conic-gradient(${Color.BG_ROBIN_400}, ${Color.BG_AQUA_400}, ${Color.BG_FOREST_400}, ${Color.BG_SIENNA_400}, ${Color.BG_ROBIN_400})`,
|
||||
},
|
||||
...[
|
||||
{ id: 'robin', color: Color.BG_ROBIN_400 },
|
||||
{ id: 'aqua', color: Color.BG_AQUA_400 },
|
||||
{ id: 'forest', color: Color.BG_FOREST_400 },
|
||||
{ id: 'sienna', color: Color.BG_SIENNA_400 },
|
||||
].map(({ id, color }) => ({
|
||||
value: color,
|
||||
id,
|
||||
label: id.charAt(0).toUpperCase() + id.slice(1),
|
||||
fill: color,
|
||||
})),
|
||||
export const FILL_PRESETS: HeatmapFillPreset[] = [
|
||||
{ label: 'robin', color: Color.BG_ROBIN_400 },
|
||||
{ label: 'aqua', color: Color.BG_AQUA_400 },
|
||||
{ label: 'forest', color: Color.BG_FOREST_400 },
|
||||
{ label: 'sienna', color: Color.BG_SIENNA_400 },
|
||||
];
|
||||
|
||||
/** Hex comparison for a spec value that may differ only in case. */
|
||||
@@ -159,3 +95,8 @@ export function rampGradient(ramp: string[]): string {
|
||||
]);
|
||||
return `linear-gradient(90deg, ${stops.join(', ')})`;
|
||||
}
|
||||
|
||||
/** The Slider emits a pair in range mode; these are all single-thumb. */
|
||||
export function singleSliderValue(value: number | number[]): number {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
|
||||
|
||||
export const LINE_STYLE_OPTIONS: ConfigTileItem<DashboardtypesLineStyleDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.solid,
|
||||
label: 'Solid',
|
||||
drawing: TILE_DRAWINGS.lineSolid,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.dashed,
|
||||
label: 'Dashed',
|
||||
drawing: TILE_DRAWINGS.lineDashed,
|
||||
},
|
||||
];
|
||||
|
||||
export const LINE_INTERPOLATION_OPTIONS: ConfigTileItem<DashboardtypesLineInterpolationDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.linear,
|
||||
label: 'Straight',
|
||||
drawing: TILE_DRAWINGS.interpLinear,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.spline,
|
||||
label: 'Smooth',
|
||||
drawing: TILE_DRAWINGS.interpSpline,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_before,
|
||||
label: 'Step before',
|
||||
drawing: TILE_DRAWINGS.interpStepBefore,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_after,
|
||||
label: 'Step after',
|
||||
drawing: TILE_DRAWINGS.interpStepAfter,
|
||||
},
|
||||
];
|
||||
|
||||
export const LINE_INTERPOLATION_HELP: Record<
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
string
|
||||
> = {
|
||||
[DashboardtypesLineInterpolationDTO.linear]:
|
||||
'Joins each point with a straight line.',
|
||||
[DashboardtypesLineInterpolationDTO.spline]:
|
||||
'Curves through each point. Can overshoot between points.',
|
||||
[DashboardtypesLineInterpolationDTO.step_before]:
|
||||
'Shows each value from the previous point up to its own timestamp.',
|
||||
[DashboardtypesLineInterpolationDTO.step_after]:
|
||||
'Holds each value until the next point.',
|
||||
};
|
||||
|
||||
export const FILL_MODE_OPTIONS: ConfigTileItem<DashboardtypesFillModeDTO>[] = [
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.none,
|
||||
label: 'None',
|
||||
drawing: TILE_DRAWINGS.fillNone,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.solid,
|
||||
label: 'Solid',
|
||||
drawing: TILE_DRAWINGS.fillSolid,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.gradient,
|
||||
label: 'Gradient',
|
||||
drawing: TILE_DRAWINGS.fillGradient,
|
||||
},
|
||||
];
|
||||
|
||||
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
|
||||
export const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
|
||||
(option) => option.value !== DashboardtypesFillModeDTO.none,
|
||||
);
|
||||
|
||||
export enum DisconnectValuesMode {
|
||||
NEVER = 'never',
|
||||
THRESHOLD = 'threshold',
|
||||
}
|
||||
|
||||
export const DISCONNECT_MODE_OPTIONS: ConfigTileItem<DisconnectValuesMode>[] = [
|
||||
{
|
||||
value: DisconnectValuesMode.NEVER,
|
||||
label: 'Connect the line',
|
||||
drawing: TILE_DRAWINGS.gapsConnect,
|
||||
},
|
||||
{
|
||||
value: DisconnectValuesMode.THRESHOLD,
|
||||
label: 'Break long gaps',
|
||||
drawing: TILE_DRAWINGS.gapsBreak,
|
||||
},
|
||||
];
|
||||
@@ -1,14 +0,0 @@
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
|
||||
const DEFAULT_DISCONNECT_DURATION = '1m';
|
||||
|
||||
/** The step interval (smallest meaningful gap), else 1m. */
|
||||
export function defaultDisconnectDuration(stepInterval?: number): string {
|
||||
return stepInterval && stepInterval > 0
|
||||
? rangeUtil.secondsToHms(stepInterval)
|
||||
: DEFAULT_DISCONNECT_DURATION;
|
||||
}
|
||||
|
||||
export function formatOpacity(opacity: number): string {
|
||||
return `${Math.round(opacity * 100)}%`;
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
color: var(--l2-foreground);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Saved-link list item --- */
|
||||
@@ -15,15 +9,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 8px 10px 8px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.listItemText {
|
||||
@@ -43,13 +32,12 @@
|
||||
|
||||
.listItemLabel {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.listItemUrl {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.listItemActions {
|
||||
|
||||
@@ -61,10 +61,6 @@ function ContextLinksSection({
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
<span className={styles.help}>
|
||||
Links appear in a menu when someone clicks a data point. Variables pass the
|
||||
clicked series and time range along.
|
||||
</span>
|
||||
{links.map((link, index) => (
|
||||
<ContextLinkListItem
|
||||
// Links have no stable id on the wire; index is the row identity here.
|
||||
@@ -85,7 +81,7 @@ function ContextLinksSection({
|
||||
data-testid="panel-editor-v2-add-link"
|
||||
onClick={openAddDialog}
|
||||
>
|
||||
Add link
|
||||
Add Context Link
|
||||
</Button>
|
||||
|
||||
<ContextLinkDialog
|
||||
|
||||
@@ -31,7 +31,7 @@ function ColumnUnits({
|
||||
if (columns.length === 0) {
|
||||
return (
|
||||
<Typography.Text className={styles.columnUnitsHint}>
|
||||
Run the query to set a unit for each value column.
|
||||
Run the panel to set per-column units.
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ function ColumnUnits({
|
||||
<div className={styles.columnUnits}>
|
||||
{columns.map((column) => (
|
||||
<div className={styles.columnField} key={column.key}>
|
||||
<span className={styles.columnLabel}>{column.label}</span>
|
||||
<Typography.Text>{column.label}</Typography.Text>
|
||||
<YAxisUnitSelector
|
||||
data-testid={`panel-editor-v2-column-unit-${column.key}`}
|
||||
placeholder="Select unit"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { resolveDecimalPrecision } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
|
||||
import { formatPanelValue } from 'pages/DashboardPage/DashboardContainer/Panels/utils/formatPanelValue';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import type { FieldResetProps } from '../../utils/changes';
|
||||
import {
|
||||
DECIMAL_OPTIONS,
|
||||
DECIMALS_PREVIEW_VALUE,
|
||||
DEFAULT_DECIMAL_PRECISION,
|
||||
} from './options';
|
||||
|
||||
import styles from './FormattingSection.module.scss';
|
||||
|
||||
interface DecimalsFieldProps extends FieldResetProps {
|
||||
value: DashboardtypesPrecisionOptionDTO | undefined;
|
||||
unit?: string;
|
||||
onChange: (next: DashboardtypesPrecisionOptionDTO) => void;
|
||||
}
|
||||
|
||||
function DecimalsField({
|
||||
value = DEFAULT_DECIMAL_PRECISION,
|
||||
unit,
|
||||
changed,
|
||||
onReset,
|
||||
onChange,
|
||||
}: DecimalsFieldProps): JSX.Element {
|
||||
return (
|
||||
<ConfigField label="Decimal places" changed={changed} onReset={onReset}>
|
||||
<ConfigTiles
|
||||
compact
|
||||
testId="panel-editor-v2-decimals"
|
||||
aria-label="Decimal places"
|
||||
value={value}
|
||||
items={DECIMAL_OPTIONS}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div className={styles.preview}>
|
||||
<span className={styles.previewLabel}>Shows as</span>
|
||||
<span className={styles.previewValue} data-testid="decimals-preview">
|
||||
{formatPanelValue(
|
||||
DECIMALS_PREVIEW_VALUE,
|
||||
unit,
|
||||
resolveDecimalPrecision(value),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</ConfigField>
|
||||
);
|
||||
}
|
||||
|
||||
export default DecimalsField;
|
||||
@@ -1,3 +1,9 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unitSelector {
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
@@ -7,31 +13,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 15px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Stacked per-column unit pickers; each column keeps the standard field layout.
|
||||
.columnUnits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
@@ -45,15 +31,10 @@
|
||||
|
||||
.columnField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.columnLabel {
|
||||
font-size: 13px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.columnUnitSelector {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import type {
|
||||
@@ -5,17 +7,28 @@ import type {
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import ColumnUnits from './ColumnUnits';
|
||||
import DecimalsField from './DecimalsField';
|
||||
|
||||
import styles from './FormattingSection.module.scss';
|
||||
|
||||
type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
|
||||
Pick<SectionEditorContext, 'tableColumns' | 'metricUnit'>;
|
||||
|
||||
// `full` means "show the raw value, no rounding"; the digits round to that many places.
|
||||
const DECIMAL_OPTIONS: {
|
||||
value: DashboardtypesPrecisionOptionDTO;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_0, label: '0 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_1, label: '1 decimal' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_2, label: '2 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_3, label: '3 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_4, label: '4 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.full, label: 'Full' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `formatting` slice of a panel spec (unit + decimal precision). Which
|
||||
* controls show is driven by the per-kind `controls` flags; the spec slice itself
|
||||
@@ -23,22 +36,16 @@ type FormattingSectionProps = SectionEditorProps<SectionKind.Formatting> &
|
||||
*/
|
||||
function FormattingSection({
|
||||
value,
|
||||
savedValue,
|
||||
controls,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
metricUnit,
|
||||
}: FormattingSectionProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.unit && (
|
||||
<ConfigField
|
||||
label="Unit"
|
||||
help="Formats values, axis ticks and tooltips. With milliseconds, 1500 shows as 1.5 s."
|
||||
{...reset('unit')}
|
||||
>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Unit</Typography.Text>
|
||||
<YAxisUnitSelector
|
||||
containerClassName={styles.unitSelector}
|
||||
data-testid="panel-editor-v2-unit"
|
||||
@@ -47,29 +54,37 @@ function FormattingSection({
|
||||
initialValue={metricUnit}
|
||||
onChange={(unit): void => onChange({ ...value, unit })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.decimals && (
|
||||
<DecimalsField
|
||||
value={value?.decimalPrecision}
|
||||
unit={value?.unit}
|
||||
{...reset('decimalPrecision')}
|
||||
onChange={(decimalPrecision): void =>
|
||||
onChange({ ...value, decimalPrecision })
|
||||
}
|
||||
/>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Decimals</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-decimals"
|
||||
placeholder="Select decimals…"
|
||||
value={value?.decimalPrecision}
|
||||
items={DECIMAL_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
decimalPrecision: next,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.columnUnits && (
|
||||
<ConfigField label="Column units" {...reset('columnUnits')}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Column units</Typography.Text>
|
||||
<ColumnUnits
|
||||
columns={tableColumns}
|
||||
value={value?.columnUnits ?? {}}
|
||||
metricUnit={metricUnit}
|
||||
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import FormattingSection from '../FormattingSection';
|
||||
|
||||
// Auto-seeding is covered by useSeedMetricUnit's tests; here `metricUnit` is just a prop.
|
||||
|
||||
async function pickDecimal(value: string): Promise<void> {
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByTestId(`panel-editor-v2-decimals-${value}`));
|
||||
// Open the Decimals select (clicking its antd selector) and pick the option with the
|
||||
// given visible label.
|
||||
async function pickDecimal(label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId('panel-editor-v2-decimals');
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
await user.click(await screen.findByRole('option', { name: label }));
|
||||
}
|
||||
|
||||
describe('FormattingSection', () => {
|
||||
@@ -49,7 +51,7 @@ describe('FormattingSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await pickDecimal('full');
|
||||
await pickDecimal('Full');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ decimalPrecision: 'full' });
|
||||
});
|
||||
@@ -64,61 +66,14 @@ describe('FormattingSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await pickDecimal('3');
|
||||
await pickDecimal('2 decimals');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
unit: 'bytes',
|
||||
decimalPrecision: '3',
|
||||
decimalPrecision: '2',
|
||||
});
|
||||
});
|
||||
|
||||
it('previews a sample value at the chosen precision', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ decimalPrecision: DashboardtypesPrecisionOptionDTO.NUMBER_1 }}
|
||||
controls={{ decimals: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('decimals-preview')).toHaveTextContent('1,234.5');
|
||||
});
|
||||
|
||||
it('reverts a changed precision to its saved value', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{
|
||||
unit: 'bytes',
|
||||
decimalPrecision: DashboardtypesPrecisionOptionDTO.NUMBER_4,
|
||||
}}
|
||||
savedValue={{ unit: 'bytes' }}
|
||||
controls={{ decimals: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.setup().click(screen.getByTestId('config-field-reset'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
unit: 'bytes',
|
||||
decimalPrecision: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers no reset for a unit that matches the saved one', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ unit: 'ms' }}
|
||||
savedValue={{ unit: 'ms' }}
|
||||
controls={{ unit: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('config-field-reset')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns when the selected unit mismatches the metric unit', () => {
|
||||
// metric sent in seconds, but bytes is selected.
|
||||
render(
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
|
||||
|
||||
/** What an unset precision renders with. */
|
||||
export const DEFAULT_DECIMAL_PRECISION =
|
||||
DashboardtypesPrecisionOptionDTO.NUMBER_2;
|
||||
|
||||
export const DECIMALS_PREVIEW_VALUE = 1234.56789;
|
||||
|
||||
export const DECIMAL_OPTIONS: ConfigTileItem<DashboardtypesPrecisionOptionDTO>[] =
|
||||
[
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_0, label: '0' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_1, label: '1' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_2, label: '2' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_3, label: '3' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_4, label: '4' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.full, label: 'Full' },
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,18 +1,32 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import LegendColors from '../../controls/LegendColors/LegendColors';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import { POSITION_OPTIONS } from './options';
|
||||
|
||||
import styles from './LegendSection.module.scss';
|
||||
|
||||
type LegendSectionProps = SectionEditorProps<SectionKind.Legend> &
|
||||
Pick<SectionEditorContext, 'legendSeries'>;
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.bottom,
|
||||
label: 'Bottom',
|
||||
icon: 'pos-bottom' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.right,
|
||||
label: 'Right',
|
||||
icon: 'pos-right' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `legend` slice of a panel spec: legend position and per-series color
|
||||
* overrides. The colors control reads the panel's resolved series from context (the
|
||||
@@ -20,36 +34,38 @@ type LegendSectionProps = SectionEditorProps<SectionKind.Legend> &
|
||||
*/
|
||||
function LegendSection({
|
||||
value,
|
||||
defaultValue,
|
||||
savedValue,
|
||||
controls,
|
||||
onChange,
|
||||
legendSeries,
|
||||
}: LegendSectionProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.position && (
|
||||
<ConfigField label="Position" {...reset('position')}>
|
||||
<ConfigTiles
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Position</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-legend-position"
|
||||
aria-label="Legend position"
|
||||
items={POSITION_OPTIONS}
|
||||
value={value?.position ?? defaultValue?.position}
|
||||
onChange={(position): void => onChange({ ...value, position })}
|
||||
value={value?.position}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
position: next as DashboardtypesLegendPositionDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.colors && (
|
||||
<ConfigField label="Series colors" {...reset('customColors')}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Series colors</Typography.Text>
|
||||
<LegendColors
|
||||
series={legendSeries ?? []}
|
||||
value={value?.customColors}
|
||||
onChange={(customColors): void => onChange({ ...value, customColors })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import LegendSection from '../LegendSection';
|
||||
@@ -17,8 +16,8 @@ describe('LegendSection', () => {
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-legend-position'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Below chart')).toBeInTheDocument();
|
||||
expect(screen.getByText('Right of chart')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bottom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Right')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when position is not enabled', () => {
|
||||
@@ -31,8 +30,7 @@ describe('LegendSection', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen position through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
it('writes the chosen position through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<LegendSection
|
||||
@@ -42,13 +40,12 @@ describe('LegendSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-legend-position-right'));
|
||||
fireEvent.click(screen.getByText('Right'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ position: 'right' });
|
||||
});
|
||||
|
||||
it('preserves other legend fields when changing position', async () => {
|
||||
const user = userEvent.setup();
|
||||
it('preserves other legend fields when changing position', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<LegendSection
|
||||
@@ -61,7 +58,7 @@ describe('LegendSection', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-legend-position-right'));
|
||||
fireEvent.click(screen.getByText('Right'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
position: 'right',
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
|
||||
|
||||
export const POSITION_OPTIONS: ConfigTileItem<DashboardtypesLegendPositionDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.bottom,
|
||||
label: 'Below chart',
|
||||
drawing: TILE_DRAWINGS.legendBottom,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.right,
|
||||
label: 'Right of chart',
|
||||
drawing: TILE_DRAWINGS.legendRight,
|
||||
},
|
||||
];
|
||||
@@ -4,24 +4,19 @@ import type {
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
|
||||
/** Edits the Text panel's `headerOptions` slice: the panel card's title strip. */
|
||||
function PanelHeaderSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.PanelHeader>): JSX.Element {
|
||||
const hide = value?.hide === true;
|
||||
|
||||
return (
|
||||
<ConfigSwitch
|
||||
testId="panel-header-hide"
|
||||
title="Hide panel header"
|
||||
description="Removes the title strip on the dashboard. The drag handle and actions appear on hover."
|
||||
sketch={SWITCH_SKETCHES.hideHeader}
|
||||
changed={hide}
|
||||
value={hide}
|
||||
onChange={(next): void => onChange({ ...value, hide: next })}
|
||||
title="Hide header"
|
||||
description="Drop the title strip on the dashboard; hovering the panel shows controls for drag and actions."
|
||||
value={value?.hide === true}
|
||||
onChange={(hide): void => onChange({ ...value, hide })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
.contrast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 4px 2px;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--callout-warning-description);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { TriangleAlert } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
contrastRatio,
|
||||
inkForSurface,
|
||||
MIN_CONTRAST_RATIO,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
|
||||
|
||||
import styles from './BackgroundContrastNote.module.scss';
|
||||
|
||||
interface BackgroundContrastNoteProps {
|
||||
testId: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Warns below the contrast floor but never blocks the choice. */
|
||||
function BackgroundContrastNote({
|
||||
testId,
|
||||
color,
|
||||
}: BackgroundContrastNoteProps): JSX.Element {
|
||||
const ratio = contrastRatio(inkForSurface(color), color);
|
||||
const isLegible = ratio >= MIN_CONTRAST_RATIO;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
|
||||
data-testid={testId}
|
||||
>
|
||||
{!isLegible && <TriangleAlert size={12} />}
|
||||
<span className="translate-safe">
|
||||
{isLegible
|
||||
? `Contrast ${ratio.toFixed(1)}:1`
|
||||
: `Contrast ${ratio.toFixed(1)}:1 — below the ${MIN_CONTRAST_RATIO}:1 minimum`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BackgroundContrastNote;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user