Compare commits

..

2 Commits

Author SHA1 Message Date
Jugal Kishore
53f7c3ae5d Merge branch 'main' into chore/openapi-deprecated-replacements 2026-11-12 13:24:35 +05:30
Jugal Kishore
34fe120a1f docs(openapi): name replacements for deprecated operations 2026-11-09 22:34:21 +05:30
174 changed files with 2949 additions and 7082 deletions

View File

@@ -553,23 +553,23 @@ components:
- apiKey
type: object
AlertmanagertypesChannelJiraAuth:
oneOf:
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraCloudAuth'
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraSelfHostedAuth'
type: object
AlertmanagertypesChannelJiraCloudAuth:
properties:
apiToken:
description: The Atlassian API token paired with email. Required when deployment
is cloud. A secret; returned by create, update and fetch by ID, never
by list.
description: The Atlassian API token paired with email. A secret; returned
by create, update and fetch by ID, never by list.
format: password
type: string
email:
description: The Atlassian account email used for basic auth. Required when
deployment is cloud.
type: string
pat:
description: The Jira personal access token, sent as a Bearer header. Required
when deployment is selfhosted. A secret; returned by create, update and
fetch by ID, never by list.
format: password
description: The Atlassian account email used for basic auth.
type: string
required:
- email
- apiToken
type: object
AlertmanagertypesChannelJiraConfig:
properties:
@@ -642,6 +642,16 @@ components:
- issueType
- auth
type: object
AlertmanagertypesChannelJiraSelfHostedAuth:
properties:
pat:
description: The Jira personal access token, sent as a Bearer header. A
secret; returned by create, update and fetch by ID, never by list.
format: password
type: string
required:
- pat
type: object
AlertmanagertypesChannelKind:
enum:
- slack
@@ -4517,99 +4527,6 @@ components:
required:
- name
type: object
DashboardtypesDistributionAxes:
properties:
x:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionXAxis'
description: The bucket axis.
"y":
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionYAxis'
description: The count axis.
type: object
DashboardtypesDistributionChartAppearance:
properties:
percentiles:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionPercentiles'
description: Percentile markers drawn across the bucket axis.
stack:
allOf:
- $ref: '#/components/schemas/DashboardtypesStackMode'
description: 'How groups combine in a bucket: normal (default) stacks them,
none draws them side by side, percent stacks them as shares of the bucket.'
required:
- stack
type: object
DashboardtypesDistributionPanelSpec:
properties:
axes:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionAxes'
description: The X (bucket) axis range and Y (count) axis scale.
chartAppearance:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionChartAppearance'
description: Stacking and percentile markers.
formatting:
allOf:
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
description: Unit and decimal precision of the bucket bounds; counts take
no unit.
legend:
allOf:
- $ref: '#/components/schemas/DashboardtypesLegend'
description: Legend placement and series colors.
visualization:
allOf:
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
description: The time range the bucket counts are summed over.
type: object
DashboardtypesDistributionPercentiles:
properties:
enabled:
description: Whether percentile markers are drawn. Defaults to true.
type: boolean
values:
description: The percentiles to mark, each in (0, 100), at most 5, no duplicates.
Defaults to [50, 90, 99].
items:
format: double
type: number
nullable: true
type: array
required:
- enabled
- values
type: object
DashboardtypesDistributionXAxis:
properties:
max:
description: Buckets wholly above this value are hidden. Null shows them
all.
nullable: true
type: number
min:
description: Buckets wholly below this value are hidden. Null shows them
all.
nullable: true
type: number
type: object
DashboardtypesDistributionYAxis:
properties:
scale:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionYScale'
description: 'How counts are spaced: linear (default) or log.'
required:
- scale
type: object
DashboardtypesDistributionYScale:
enum:
- linear
- log
type: string
DashboardtypesDynamicVariableSignal:
enum:
- traces
@@ -5380,7 +5297,6 @@ components:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/DistributionPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -5403,7 +5319,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -5418,7 +5333,6 @@ components:
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
- signoz/DistributionPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -5450,21 +5364,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec:
properties:
kind:
description: The panel kind.
enum:
- signoz/DistributionPanel
type: string
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesDistributionPanelSpec'
description: The options for this panel kind. {} accepts every default.
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
@@ -5618,9 +5517,9 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPlugin'
description: 'The visualization: kind is one of signoz/TimeSeriesPanel,
signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel,
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel,
signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind''s
options ({} accepts every default).'
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel
or signoz/HeatmapPanel, and spec holds that kind''s options ({} accepts
every default).'
queries:
description: The queries the panel runs. Use exactly one entry for a data
panel and [] for signoz/TextPanel. To run several named queries or formulas,
@@ -5756,8 +5655,8 @@ components:
- $ref: '#/components/schemas/Querybuildertypesv5RequestType'
description: 'The result shape the panel requests: time_series for time
series, bar, area and histogram panels, scalar for number, table and pie
panels, raw for list panels, heatmap for heatmap panels, distribution
for distribution panels, trace for trace panels.'
panels, raw for list panels, heatmap for heatmap panels, trace for trace
panels.'
spec:
allOf:
- $ref: '#/components/schemas/DashboardtypesQuerySpec'
@@ -5914,9 +5813,9 @@ components:
(builder query over AI observability traces), signoz/CompositeQuery (several
named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL
or signoz/TraceOperator, and spec holds that kind''s query. Pie and table
panels reject signoz/PromQLQuery; heatmap and distribution panels reject
signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only
signoz/BuilderQuery and signoz/AIBuilderQuery.'
panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery
and signoz/TraceOperator; list panels accept only signoz/BuilderQuery
and signoz/AIBuilderQuery.'
required:
- plugin
type: object
@@ -11874,6 +11773,15 @@ components:
enum:
- v2alpha1
type: string
source:
deprecated: true
description: The URL of the page the rule was created from. Informational;
it is not used for evaluation.
type: string
version:
deprecated: true
description: The query payload version. Nothing reads it.
type: string
required:
- alert
- alertType
@@ -11992,6 +11900,11 @@ components:
enum:
- v2alpha1
type: string
source:
deprecated: true
description: The URL of the page the rule was created from. Informational;
it is not used for evaluation.
type: string
state:
allOf:
- $ref: '#/components/schemas/RuletypesAlertState'
@@ -12002,6 +11915,10 @@ components:
type: string
updatedBy:
type: string
version:
deprecated: true
description: The query payload version. Nothing reads it.
type: string
required:
- id
- state
@@ -14306,8 +14223,9 @@ paths:
/api/v1/cloud-integrations/{cloud_provider}/agent-check-in:
post:
deprecated: true
description: '[Deprecated] This endpoint is called by the deployed agent to
check in'
description: 'This endpoint is called by the deployed agent to check in. Deprecated:
use POST /api/v1/cloud_integrations/{cloud_provider}/accounts/check_in instead.
Send cloudIntegrationId and providerAccountId instead of account_id and cloud_account_id.'
operationId: AgentCheckInDeprecated
parameters:
- in: path
@@ -18977,7 +18895,7 @@ paths:
/api/v1/testChannel:
post:
deprecated: true
description: 'Deprecated: use /api/v1/channels/test instead'
description: 'Deprecated: use POST /api/v1/channels/test instead.'
operationId: TestChannelDeprecated
requestBody:
content:
@@ -21464,7 +21382,8 @@ paths:
x-signoz-stability: alpha
post:
deprecated: true
description: This endpoint creates an ingestion key limit.
description: 'This endpoint creates an ingestion key limit. Deprecated: use
POST /api/v2/gateway/ingestion_limits instead. Send keyId in the request body.'
operationId: CreateIngestionKeyLimit
parameters:
- in: path
@@ -21524,7 +21443,8 @@ paths:
/api/v2/gateway/ingestion_keys/limits/{limitId}:
delete:
deprecated: true
description: This endpoint deletes an ingestion key limit
description: 'This endpoint deletes an ingestion key limit. Deprecated: use
DELETE /api/v2/gateway/ingestion_limits/{limitId} instead.'
operationId: DeleteIngestionKeyLimit
parameters:
- in: path
@@ -21566,7 +21486,8 @@ paths:
x-signoz-stability: alpha
patch:
deprecated: true
description: This endpoint updates an ingestion key limit.
description: 'This endpoint updates an ingestion key limit. Deprecated: use
PATCH /api/v2/gateway/ingestion_limits/{limitId} instead.'
operationId: UpdateIngestionKeyLimit
parameters:
- in: path
@@ -25642,6 +25563,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
logs_threshold:
description: Counts matching log records (ERROR severity + body contains)
over a rolling window. Fires at least once per evaluation when the
@@ -25708,6 +25630,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
@@ -25780,6 +25703,7 @@ paths:
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment"). Useful
@@ -25835,6 +25759,7 @@ paths:
interval: 1h
ruleType: promql_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_formula:
description: Computes disk utilization as (1 - available/capacity)
* 100 by combining two disabled base queries with a builder_formula.
@@ -25926,6 +25851,7 @@ paths:
interval: 2h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_single:
description: Fires when a pod consumes more than 80% of its requested
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
@@ -25994,6 +25920,7 @@ paths:
interval: 4h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
notification_settings:
description: 'Demonstrates the full notificationSettings surface:
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
@@ -26092,6 +26019,7 @@ paths:
usePolicy: false
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
tiered_thresholds:
description: Two tiers (warning and critical) in a single rule, each
with its own target, op, matchType, and channels so warnings and
@@ -26191,6 +26119,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_error_rate_formula:
description: Two disabled trace count queries (A = error spans where
hasError=true, B = total spans) combined via a builder_formula into
@@ -26280,6 +26209,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_threshold_latency:
description: Builder query against the traces signal with p99(duration_nano).
The series unit is ns (compositeQuery.unit), the target is in seconds
@@ -26348,6 +26278,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
schema:
$ref: '#/components/schemas/RuletypesPostableRule'
responses:
@@ -26598,6 +26529,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
logs_threshold:
description: Counts matching log records (ERROR severity + body contains)
over a rolling window. Fires at least once per evaluation when the
@@ -26664,6 +26596,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
@@ -26736,6 +26669,7 @@ paths:
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment"). Useful
@@ -26791,6 +26725,7 @@ paths:
interval: 1h
ruleType: promql_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_formula:
description: Computes disk utilization as (1 - available/capacity)
* 100 by combining two disabled base queries with a builder_formula.
@@ -26882,6 +26817,7 @@ paths:
interval: 2h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_single:
description: Fires when a pod consumes more than 80% of its requested
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
@@ -26950,6 +26886,7 @@ paths:
interval: 4h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
notification_settings:
description: 'Demonstrates the full notificationSettings surface:
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
@@ -27048,6 +26985,7 @@ paths:
usePolicy: false
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
tiered_thresholds:
description: Two tiers (warning and critical) in a single rule, each
with its own target, op, matchType, and channels so warnings and
@@ -27147,6 +27085,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_error_rate_formula:
description: Two disabled trace count queries (A = error spans where
hasError=true, B = total spans) combined via a builder_formula into
@@ -27236,6 +27175,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_threshold_latency:
description: Builder query against the traces signal with p99(duration_nano).
The series unit is ns (compositeQuery.unit), the target is in seconds
@@ -27304,6 +27244,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
schema:
$ref: '#/components/schemas/RuletypesPostableRule'
responses:
@@ -27455,6 +27396,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
logs_threshold:
description: Counts matching log records (ERROR severity + body contains)
over a rolling window. Fires at least once per evaluation when the
@@ -27521,6 +27463,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
@@ -27593,6 +27536,7 @@ paths:
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment"). Useful
@@ -27648,6 +27592,7 @@ paths:
interval: 1h
ruleType: promql_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_formula:
description: Computes disk utilization as (1 - available/capacity)
* 100 by combining two disabled base queries with a builder_formula.
@@ -27739,6 +27684,7 @@ paths:
interval: 2h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_single:
description: Fires when a pod consumes more than 80% of its requested
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
@@ -27807,6 +27753,7 @@ paths:
interval: 4h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
notification_settings:
description: 'Demonstrates the full notificationSettings surface:
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
@@ -27905,6 +27852,7 @@ paths:
usePolicy: false
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
tiered_thresholds:
description: Two tiers (warning and critical) in a single rule, each
with its own target, op, matchType, and channels so warnings and
@@ -28004,6 +27952,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_error_rate_formula:
description: Two disabled trace count queries (A = error spans where
hasError=true, B = total spans) combined via a builder_formula into
@@ -28093,6 +28042,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_threshold_latency:
description: Builder query against the traces signal with p99(duration_nano).
The series unit is ns (compositeQuery.unit), the target is in seconds
@@ -28161,6 +28111,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
schema:
$ref: '#/components/schemas/RuletypesPostableRule'
responses:
@@ -29074,6 +29025,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
logs_threshold:
description: Counts matching log records (ERROR severity + body contains)
over a rolling window. Fires at least once per evaluation when the
@@ -29140,6 +29092,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_anomaly:
description: Wraps a builder query in the `anomaly` function with
daily seasonality, so SigNoz compares each point against the forecast
@@ -29212,6 +29165,7 @@ paths:
interval: 4h
ruleType: anomaly_rule
schemaVersion: v2alpha1
version: v5
metric_promql:
description: PromQL expression instead of the builder. Dotted OTEL
resource attributes are quoted ("deployment.environment"). Useful
@@ -29267,6 +29221,7 @@ paths:
interval: 1h
ruleType: promql_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_formula:
description: Computes disk utilization as (1 - available/capacity)
* 100 by combining two disabled base queries with a builder_formula.
@@ -29358,6 +29313,7 @@ paths:
interval: 2h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
metric_threshold_single:
description: Fires when a pod consumes more than 80% of its requested
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
@@ -29426,6 +29382,7 @@ paths:
interval: 4h
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
notification_settings:
description: 'Demonstrates the full notificationSettings surface:
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
@@ -29524,6 +29481,7 @@ paths:
usePolicy: false
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
tiered_thresholds:
description: Two tiers (warning and critical) in a single rule, each
with its own target, op, matchType, and channels so warnings and
@@ -29623,6 +29581,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_error_rate_formula:
description: Two disabled trace count queries (A = error spans where
hasError=true, B = total spans) combined via a builder_formula into
@@ -29712,6 +29671,7 @@ paths:
interval: 15m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
traces_threshold_latency:
description: Builder query against the traces signal with p99(duration_nano).
The series unit is ns (compositeQuery.unit), the target is in seconds
@@ -29780,6 +29740,7 @@ paths:
interval: 30m
ruleType: threshold_rule
schemaVersion: v2alpha1
version: v5
schema:
$ref: '#/components/schemas/RuletypesPostableRule'
responses:
@@ -31644,8 +31605,9 @@ paths:
/api/v3/licenses:
post:
deprecated: true
description: This endpoint validates the license key with the upstream server
and activates the license for the organization.
description: 'This endpoint validates the license key with the upstream server
and activates the license for the organization. Deprecated: use POST /api/v4/licenses
instead. The v4 endpoint returns 201 with the license id.'
operationId: ActivateLicenseDeprecated
requestBody:
content:
@@ -31702,8 +31664,9 @@ paths:
x-signoz-stability: alpha
put:
deprecated: true
description: This endpoint refreshes the active license of the organization
from the upstream server.
description: 'This endpoint refreshes the active license of the organization
from the upstream server. Deprecated: use PUT /api/v4/licenses/{id} instead.
Pass the active license id in the path.'
operationId: RefreshLicenseDeprecated
responses:
"204":
@@ -32242,8 +32205,8 @@ paths:
x-signoz-stability: alpha
put:
deprecated: false
description: This endpoint refreshes the active license of the organization
from the upstream server.
description: This endpoint refreshes the license with the given id from the
upstream server. Only the active license can be refreshed.
operationId: RefreshLicense
parameters:
- in: path

View File

@@ -11,7 +11,7 @@ const config: Config.InitialOptions = {
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
modulePathIgnorePatterns: ['dist'],
moduleNameMapper: {
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
'<rootDir>/__mocks__/fileMock.ts',

View File

@@ -597,7 +597,7 @@ export const useTestChannel = <
return useMutation(getTestChannelMutationOptions(options));
};
/**
* Deprecated: use /api/v1/channels/test instead
* Deprecated: use POST /api/v1/channels/test instead.
* @deprecated
* @summary Test notification channel (deprecated)
*/

View File

@@ -72,7 +72,7 @@ const withQueryKey = <T extends object, K>(
};
/**
* [Deprecated] This endpoint is called by the deployed agent to check in
* This endpoint is called by the deployed agent to check in. Deprecated: use POST /api/v1/cloud_integrations/{cloud_provider}/accounts/check_in instead. Send cloudIntegrationId and providerAccountId instead of account_id and cloud_account_id.
* @deprecated
* @summary Agent check-in
*/

View File

@@ -634,7 +634,7 @@ export const invalidateGetIngestionKeyLimits = async (
};
/**
* This endpoint creates an ingestion key limit.
* This endpoint creates an ingestion key limit. Deprecated: use POST /api/v2/gateway/ingestion_limits instead. Send keyId in the request body.
* @deprecated
* @summary Create limit for the ingestion key
*/
@@ -736,7 +736,7 @@ export const useCreateIngestionKeyLimit = <
return useMutation(getCreateIngestionKeyLimitMutationOptions(options));
};
/**
* This endpoint deletes an ingestion key limit
* This endpoint deletes an ingestion key limit. Deprecated: use DELETE /api/v2/gateway/ingestion_limits/{limitId} instead.
* @deprecated
* @summary Delete limit for the ingestion key
*/
@@ -818,7 +818,7 @@ export const useDeleteIngestionKeyLimit = <
return useMutation(getDeleteIngestionKeyLimitMutationOptions(options));
};
/**
* This endpoint updates an ingestion key limit.
* This endpoint updates an ingestion key limit. Deprecated: use PATCH /api/v2/gateway/ingestion_limits/{limitId} instead.
* @deprecated
* @summary Update limit for the ingestion key
*/

View File

@@ -53,7 +53,7 @@ const withQueryKey = <T extends object, K>(
};
/**
* This endpoint validates the license key with the upstream server and activates the license for the organization.
* This endpoint validates the license key with the upstream server and activates the license for the organization. Deprecated: use POST /api/v4/licenses instead. The v4 endpoint returns 201 with the license id.
* @deprecated
* @summary Activate a license.
*/
@@ -139,7 +139,7 @@ export const useActivateLicenseDeprecated = <
return useMutation(getActivateLicenseDeprecatedMutationOptions(options));
};
/**
* This endpoint refreshes the active license of the organization from the upstream server.
* This endpoint refreshes the active license of the organization from the upstream server. Deprecated: use PUT /api/v4/licenses/{id} instead. Pass the active license id in the path.
* @deprecated
* @summary Refresh a license.
*/
@@ -559,7 +559,7 @@ export const invalidateGetLicense = async (
};
/**
* This endpoint refreshes the active license of the organization from the upstream server.
* This endpoint refreshes the license with the given id from the upstream server. Only the active license can be refreshed.
* @summary Refresh a license.
*/
export const refreshLicense = (

View File

@@ -829,26 +829,33 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
jira = 'jira',
}
export interface AlertmanagertypesChannelJiraAuthDTO {
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
/**
* @type string
* @format password
* @description The Atlassian API token paired with email. Required when deployment is cloud. A secret; returned by create, update and fetch by ID, never by list.
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
*/
apiToken?: string;
apiToken: string;
/**
* @type string
* @description The Atlassian account email used for basic auth. Required when deployment is cloud.
* @description The Atlassian account email used for basic auth.
*/
email?: string;
/**
* @type string
* @format password
* @description The Jira personal access token, sent as a Bearer header. Required when deployment is selfhosted. A secret; returned by create, update and fetch by ID, never by list.
*/
pat?: string;
email: string;
}
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
/**
* @type string
* @format password
* @description The Jira personal access token, sent as a Bearer header. A secret; returned by create, update and fetch by ID, never by list.
*/
pat: string;
}
export type AlertmanagertypesChannelJiraAuthDTO =
| AlertmanagertypesChannelJiraCloudAuthDTO
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
export enum AlertmanagertypesJiraDeploymentDTO {
cloud = 'cloud',
selfhosted = 'selfhosted',
@@ -6943,107 +6950,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
/**
* The panel kind.
*/
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind {
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
}
export interface DashboardtypesDistributionXAxisDTO {
/**
* @type number,null
* @description Buckets wholly above this value are hidden. Null shows them all.
*/
max?: number | null;
/**
* @type number,null
* @description Buckets wholly below this value are hidden. Null shows them all.
*/
min?: number | null;
}
export enum DashboardtypesDistributionYScaleDTO {
linear = 'linear',
log = 'log',
}
export interface DashboardtypesDistributionYAxisDTO {
/**
* @description How counts are spaced: linear (default) or log.
*/
scale: DashboardtypesDistributionYScaleDTO;
}
export interface DashboardtypesDistributionAxesDTO {
/**
* @description The bucket axis.
*/
x?: DashboardtypesDistributionXAxisDTO;
/**
* @description The count axis.
*/
y?: DashboardtypesDistributionYAxisDTO;
}
export interface DashboardtypesDistributionPercentilesDTO {
/**
* @type boolean
* @description Whether percentile markers are drawn. Defaults to true.
*/
enabled: boolean;
/**
* @type array,null
* @description The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99].
*/
values: number[] | null;
}
export interface DashboardtypesDistributionChartAppearanceDTO {
/**
* @description Percentile markers drawn across the bucket axis.
*/
percentiles?: DashboardtypesDistributionPercentilesDTO;
/**
* @description How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket.
*/
stack: DashboardtypesStackModeDTO;
}
export interface DashboardtypesDistributionPanelSpecDTO {
/**
* @description The X (bucket) axis range and Y (count) axis scale.
*/
axes?: DashboardtypesDistributionAxesDTO;
/**
* @description Stacking and percentile markers.
*/
chartAppearance?: DashboardtypesDistributionChartAppearanceDTO;
/**
* @description Unit and decimal precision of the bucket bounds; counts take no unit.
*/
formatting?: DashboardtypesPanelFormattingDTO;
/**
* @description Legend placement and series colors.
*/
legend?: DashboardtypesLegendDTO;
/**
* @description The time range the bucket counts are summed over.
*/
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO {
/**
* @enum signoz/DistributionPanel
* @type string
* @description The panel kind.
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind;
/**
* @description The options for this panel kind. {} accepts every default.
*/
spec: DashboardtypesDistributionPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -7055,8 +6961,7 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -7545,14 +7450,14 @@ export interface DashboardtypesQuerySpecDTO {
*/
name?: string;
/**
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
*/
plugin: DashboardtypesQueryPluginDTO;
}
export interface DashboardtypesQueryDTO {
/**
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels.
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels.
*/
kind: Querybuildertypesv5RequestTypeDTO;
/**
@@ -7572,7 +7477,7 @@ export interface DashboardtypesPanelSpecDTO {
*/
links?: DashboardtypesLinkDTO[] | null;
/**
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default).
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default).
*/
plugin: DashboardtypesPanelPluginDTO;
/**
@@ -8343,7 +8248,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
}
/**
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
@@ -13144,6 +13048,16 @@ export interface RuletypesPostableRuleDTO {
* @description The rule payload schema.
*/
schemaVersion: RuletypesPostableRuleDTOSchemaVersion;
/**
* @type string
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
*/
source?: string;
/**
* @type string
* @description The query payload version. Nothing reads it.
*/
version?: string;
}
export interface RuletypesPostableRuleViewDTO {
@@ -13236,6 +13150,11 @@ export interface RuletypesRuleDTO {
* @description The rule payload schema.
*/
schemaVersion: RuletypesRuleDTOSchemaVersion;
/**
* @type string
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
*/
source?: string;
/**
* @description The current evaluation state: inactive, pending, recovering, firing, nodata or disabled.
*/
@@ -13249,6 +13168,11 @@ export interface RuletypesRuleDTO {
* @type string
*/
updatedBy?: string;
/**
* @type string
* @description The query payload version. Nothing reads it.
*/
version?: string;
}
export enum RuletypesThresholdKindDTO {

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="1.25" y="1.25" width="21.5" height="21.5" rx="3.5" fill="none" stroke="#7A8291" stroke-width="1.5"/><g fill="#7A8291"><circle cx="7" cy="4.5" r="1.05"/><circle cx="9.5" cy="4.5" r="1.05"/><circle cx="12" cy="4.5" r="1.05"/><circle cx="14.5" cy="4.5" r="1.05"/><circle cx="7" cy="7" r="1.05"/><circle cx="17" cy="7" r="1.05"/><circle cx="7" cy="9.5" r="1.05"/><circle cx="17" cy="9.5" r="1.05"/><circle cx="7" cy="12" r="1.05"/><circle cx="9.5" cy="12" r="1.05"/><circle cx="12" cy="12" r="1.05"/><circle cx="14.5" cy="12" r="1.05"/><circle cx="7" cy="14.5" r="1.05"/><circle cx="7" cy="17" r="1.05"/><circle cx="7" cy="19.5" r="1.05"/></g></svg>

Before

Width:  |  Height:  |  Size: 726 B

View File

@@ -48,59 +48,22 @@ export function QuerySearchV2Provider({
}, [initialExpression, store]);
const isInitialized = useRef(false);
const lastCommittedRef = useRef(committedExpression);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
return;
}
isInitialized.current = true;
store
.getState()
.initializeFromUrl(
getUserExpressionFromCombined(initialExpression, urlExpression),
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, store]);
// Back and Forward. Reads the browser's URL: after a write, nuqs can briefly
// report the value it replaced.
useEffect(() => {
const onPopState = (): void => {
isInitialized.current = true;
const urlValue = new URLSearchParams(window.location.search).get(
queryParamKey,
);
const expression = urlValue
? getUserExpressionFromCombined(initialExpression, urlValue)
: '';
// Another param changed; keep any text typed but not run.
if (expression === store.getState().committedExpression) {
return;
}
// Already in the URL, so the write below must not run for it.
lastCommittedRef.current = expression;
if (expression) {
store.getState().initializeFromUrl(expression);
} else {
store.getState().resetExpression();
}
};
window.addEventListener('popstate', onPopState);
return (): void => window.removeEventListener('popstate', onPopState);
}, [queryParamKey, initialExpression, store]);
// Write only when the committed filter changes, never in reply to a URL change.
useEffect(() => {
if (committedExpression === lastCommittedRef.current) {
return;
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
lastCommittedRef.current = committedExpression;
isInitialized.current = true;
const next = committedExpression || null;
if (next === urlExpression) {
return;
}
void setUrlExpression(next);
}, [committedExpression, urlExpression, setUrlExpression]);
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {

View File

@@ -1,10 +1,9 @@
import { ReactNode } from 'react';
import { act, renderHook } from 'tests/test-utils';
import { act, renderHook } from '@testing-library/react';
import {
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
@@ -35,22 +34,10 @@ function createWrapper(
};
}
// Back or Forward. nuqs has not caught up yet, so its value is still the old one.
function goTo(expression: string | null): void {
act(() => {
const search = expression
? `?${new URLSearchParams({ testExpression: expression })}`
: '';
window.history.pushState(null, '', `/${search}`);
window.dispatchEvent(new PopStateEvent('popstate'));
});
}
function useTestHooks(): {
expression: string;
userExpression: string;
initialExpression: string;
inputExpression: string;
querySearchInitialExpressionProp: string | undefined;
onChange: (expr: string) => void;
onRun: (expr: string) => void;
@@ -59,7 +46,6 @@ function useTestHooks(): {
expression: useExpression(),
userExpression: useUserExpression(),
initialExpression: useInitialExpression(),
inputExpression: useInputExpression(),
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
onChange: useQuerySearchOnChange(),
onRun: useQuerySearchOnRun(),
@@ -70,7 +56,6 @@ describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
window.history.replaceState(null, '', '/');
});
it('should provide initial context values', () => {
@@ -125,96 +110,6 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.expression).toBe('status = 500');
});
it('follows Back and Forward from the browser URL', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
goTo('status = 404');
expect(result.current.userExpression).toBe('status = 404');
expect(mockSetQueryState).not.toHaveBeenCalled();
});
it('clears the filter on Back to a URL without the param', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
goTo(null);
expect(result.current.userExpression).toBe('');
expect(mockSetQueryState).not.toHaveBeenCalled();
});
it('keeps text typed but not run when Back changes another param', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onChange('status = 404');
});
goTo('status = 500');
expect(result.current.inputExpression).toBe('status = 404');
});
it('writes a run filter to the URL once and ignores it coming back', () => {
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 500');
});
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
expect(mockSetQueryState).toHaveBeenLastCalledWith('status = 500');
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 500');
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
});
it('keeps a run filter when the URL briefly reports the value it replaced', () => {
mockUrlValue = 'status = 500';
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 404');
});
mockUrlValue = 'status = 404';
rerender();
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 404');
});
it('keeps a newer run when a late URL value arrives after an empty mount', () => {
const { result, rerender } = renderHook(() => useTestHooks(), {
wrapper: createWrapper(),
});
act(() => {
result.current.onRun('status = 500');
});
act(() => {
result.current.onRun('status = 404');
});
mockUrlValue = 'status = 500';
rerender();
expect(result.current.userExpression).toBe('status = 404');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useExpression());

View File

@@ -15,41 +15,11 @@ $max-recents-shown: 5;
Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', sans-serif;
.query-where-clause-row {
display: flex;
align-items: flex-start;
gap: 8px;
// single-line editor: 36px .cm-line + 1px .cm-content border on each side
--button-height: 38px;
.query-where-clause-editor-container {
flex: 1;
min-width: 0;
}
}
.query-where-clause-editor-container {
position: relative;
display: flex;
flex-direction: row;
// The field draws one outline around the scope chip and the editor, so
// the editor inside draws none
&.hasScopeChip {
align-items: center;
gap: var(--spacing-3);
padding-left: var(--spacing-3);
border: 1px solid var(--l2-border);
border-radius: 2px;
background: var(--l1-background);
--query-builder-v2-border-color: transparent;
--query-builder-v2-background-color: transparent;
.query-where-clause-editor {
min-width: 0;
}
}
.query-search-initial-scope-label {
position: absolute;
left: 8px;

View File

@@ -1,12 +1,6 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CircleCheck,
Info,
TriangleAlert,
Filter,
Play,
} from '@signozhq/icons';
import { CircleCheck, Info, TriangleAlert, Filter } from '@signozhq/icons';
import {
autocompletion,
closeCompletion,
@@ -22,7 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Button as SignozButton } from '@signozhq/ui/button';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
@@ -66,7 +59,6 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import { ScopeChip } from './ScopeChip/ScopeChip';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -113,14 +105,9 @@ interface QuerySearchProps {
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showRunButton?: boolean;
/** Shows the Run button busy while the page's queries fetch. */
isLoadingQueries?: boolean;
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
/** Shows `initialExpression` as a locked chip in the field instead of the scope icon. */
showScopeChip?: boolean;
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
valueSuggestionsOverride?: (
key: string,
@@ -138,13 +125,10 @@ function QuerySearch({
queryData,
dataSource,
onRun,
showRunButton,
isLoadingQueries,
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
showScopeChip: showScopeChipProp,
metricNamespace,
valueSuggestionsOverride,
}: QuerySearchProps): JSX.Element {
@@ -164,7 +148,6 @@ function QuerySearch({
const editorRef = useRef<EditorView | null>(null);
const isScopedFilter = initialExpression !== undefined;
const showScopeChip = !!showScopeChipProp && !!initialExpression;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
@@ -190,18 +173,6 @@ function QuerySearch({
[],
);
const handleRun = useCallback((): void => {
if (!onRun) {
return;
}
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}, [onRun, getCurrentExpression, isScopedFilter, initialExpression]);
const updateEditorValue = useCallback(
(value: string, options: { skipOnChange?: boolean } = {}): void => {
const view = editorRef.current;
@@ -1586,182 +1557,169 @@ function QuerySearch({
</div>
)}
<div className="query-where-clause-row">
<div
className={cx('query-where-clause-editor-container', {
hasScopeChip: showScopeChip,
})}
>
{showScopeChip && <ScopeChip expression={initialExpression ?? ''} />}
{isScopedFilter && !showScopeChip ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
>
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</a>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
>
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</a>
</Tooltip>
<CodeMirror
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
onCreateEditor={handleEditorCreate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
<CodeMirror
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
onCreateEditor={handleEditorCreate}
data-testid="query-where-clause-editor"
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter,
})}
extensions={[
autocompletion({
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,
stopEventsExtension,
Prec.highest(
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
{
key: 'Enter',
preventDefault: true,
// Prevent default behavior of Enter to add new line
// and instead run a custom action
run: (): boolean => true,
},
{
key: 'Mod-Enter',
preventDefault: true,
// Prevent default behavior of Mod-Enter to add new line
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}
return true;
},
},
{
key: 'Shift-Enter',
preventDefault: true,
// Prevent default behavior of Shift-Enter to add new line
run: (): boolean => true,
},
]),
),
]}
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
}}
onBlur={handleBlur}
/>
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter && !showScopeChip,
})}
extensions={[
autocompletion({
override: [combinedSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,
stopEventsExtension,
Prec.highest(
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
{
key: 'Enter',
preventDefault: true,
// Prevent default behavior of Enter to add new line
// and instead run a custom action
run: (): boolean => true,
},
{
key: 'Mod-Enter',
preventDefault: true,
// Prevent default behavior of Mod-Enter to add new line
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
handleRun();
return true;
},
},
{
key: 'Shift-Enter',
preventDefault: true,
// Prevent default behavior of Shift-Enter to add new line
run: (): boolean => true,
},
]),
),
]}
placeholder={placeholder}
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
}}
onBlur={handleBlur}
/>
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,
})}
data-testid="query-validation-status"
>
<Popover
placement="bottomRight"
showArrow={false}
content={
<div className="query-status-content">
<div className="query-status-content-header">
<div className="query-validation">
<div className="query-validation-errors">
{validation.errors.map((error) => (
<div key={error.message} className="query-validation-error">
<div className="query-validation-error">
{error.line}:{error.column} - {error.message}
</div>
data-testid="query-validation-status"
>
<Popover
placement="bottomRight"
showArrow={false}
content={
<div className="query-status-content">
<div className="query-status-content-header">
<div className="query-validation">
<div className="query-validation-errors">
{validation.errors.map((error) => (
<div key={error.message} className="query-validation-error">
<div className="query-validation-error">
{error.line}:{error.column} - {error.message}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
}
overlayClassName="query-status-popover"
>
{validation.isValid ? (
<Button
type="text"
icon={<CircleCheck size="md" />}
className="periscope-btn ghost"
/>
) : (
<Button
type="text"
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
className="periscope-btn ghost"
/>
)}
</Popover>
</div>
)}
</div>
{showRunButton && (
<SignozButton
variant="solid"
color="secondary"
size="md"
prefix={<Play size={14} />}
loading={isLoadingQueries}
onClick={handleRun}
testId="query-search-run-button"
>
Run
</SignozButton>
</div>
}
overlayClassName="query-status-popover"
>
{validation.isValid ? (
<Button
type="text"
icon={<CircleCheck size="md" />}
className="periscope-btn ghost"
/>
) : (
<Button
type="text"
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
className="periscope-btn ghost"
/>
)}
</Popover>
</div>
)}
</div>
@@ -1855,15 +1813,12 @@ function QuerySearch({
QuerySearch.defaultProps = {
onRun: undefined,
showRunButton: false,
isLoadingQueries: false,
signalSource: '',
hardcodedAttributeKeys: undefined,
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
showScopeChip: false,
};
export default QuerySearch;

View File

@@ -1,57 +0,0 @@
.scopeChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
flex: 0 1 auto;
min-width: 0;
max-width: 420px;
padding: var(--spacing-1) var(--spacing-3);
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
cursor: default;
}
.scopeIcon {
flex: none;
}
.scopeMore {
flex: none;
color: var(--l3-foreground);
}
// The editor beside it is CodeMirror on the copilot theme in dark and
// githubLight in light; the chip borrows those token colours so one filter
// does not read as two
.scopeKey {
color: #939da5;
}
.scopeOperator {
color: #939da5;
}
.scopeValue {
color: #5bec95;
}
:global(.lightMode) {
.scopeKey,
.scopeOperator {
color: #005cc5;
}
.scopeValue {
color: #032f62;
}
}
.scopeExpression {
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,41 +0,0 @@
import { useMemo } from 'react';
import { Lock } from '@signozhq/icons';
import { Tooltip } from '@signozhq/ui/tooltip';
import { extractQueryPairs } from 'utils/queryContextUtils';
import styles from './ScopeChip.module.scss';
interface ScopeChipProps {
expression: string;
}
/**
* The locked half of the filter. It reads like the editor beside it: the last
* clause carries the relation, the ones scoping it are counted instead.
*/
export function ScopeChip({ expression }: ScopeChipProps): JSX.Element {
const clauses = useMemo(() => extractQueryPairs(expression), [expression]);
const relation = clauses[clauses.length - 1];
return (
<Tooltip title={expression} side="top">
<span className={styles.scopeChip} data-testid="query-search-scope-chip">
<Lock size={12} className={styles.scopeIcon} />
<span className={styles.scopeExpression}>
{relation ? (
<>
<span className={styles.scopeKey}>{relation.key}</span>
<span className={styles.scopeOperator}>{` ${relation.operator} `}</span>
<span className={styles.scopeValue}>{relation.value}</span>
</>
) : (
expression
)}
</span>
{clauses.length > 1 && (
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
)}
</span>
</Tooltip>
);
}

View File

@@ -224,134 +224,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
});
it('does not render the run button by default', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={jest.fn()}
/>,
);
expect(
screen.queryByTestId('query-search-run-button'),
).not.toBeInTheDocument();
});
it('calls onRun with the editor text when the run button is clicked', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={{
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: SAMPLE_STATUS_QUERY },
}}
dataSource={DataSource.LOGS}
onRun={onRun}
showRunButton
/>,
);
await waitFor(() =>
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
SAMPLE_STATUS_QUERY,
),
);
await userEvent.click(screen.getByTestId('query-search-run-button'));
expect(onRun).toHaveBeenCalledWith(SAMPLE_STATUS_QUERY);
});
it('shows the locked scope as a chip in place of the scope icon', () => {
const scope = "kind_string = 'Client' AND http_host = 'api.example.com'";
const { container } = render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.traces.builder.queryData[0]}
dataSource={DataSource.TRACES}
initialExpression={scope}
showScopeChip
/>,
);
const chip = screen.getByTestId('query-search-scope-chip');
expect(chip).toHaveTextContent("http_host = 'api.example.com'");
expect(chip).toHaveTextContent('+1');
expect(chip).not.toHaveTextContent('kind_string');
expect(
container.querySelector('.query-search-initial-scope-label'),
).not.toBeInTheDocument();
});
it('keeps the scope icon unless the chip is asked for', () => {
const { container } = render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.traces.builder.queryData[0]}
dataSource={DataSource.TRACES}
initialExpression="kind_string = 'Client'"
/>,
);
expect(
screen.queryByTestId('query-search-scope-chip'),
).not.toBeInTheDocument();
expect(
container.querySelector('.query-search-initial-scope-label'),
).toBeInTheDocument();
});
it('shows the run button busy while the queries load', () => {
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
onRun={jest.fn()}
showRunButton
isLoadingQueries
/>,
);
expect(screen.getByTestId('query-search-run-button')).toHaveAttribute(
'aria-busy',
'true',
);
});
it('runs the locked scope combined with the editor text', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
const scope = "kind_string = 'Client'";
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={{
...initialQueriesMap.traces.builder.queryData[0],
filter: { expression: SAMPLE_STATUS_QUERY },
}}
dataSource={DataSource.TRACES}
onRun={onRun}
initialExpression={scope}
showRunButton
/>,
);
await waitFor(() =>
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
SAMPLE_STATUS_QUERY,
),
);
await userEvent.click(screen.getByTestId('query-search-run-button'));
expect(onRun).toHaveBeenCalledWith(`${scope} AND (${SAMPLE_STATUS_QUERY})`);
});
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
const testExpression =
"http.status_code >= 500 AND service.name = 'frontend'";

View File

@@ -69,6 +69,8 @@ export const REACT_QUERY_KEY = {
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
GET_ENDPOINT_METRICS_DATA: 'GET_ENDPOINT_METRICS_DATA',
GET_ENDPOINT_STATUS_CODE_DATA: 'GET_ENDPOINT_STATUS_CODE_DATA',
GET_ENDPOINT_RATE_OVER_TIME_DATA: 'GET_ENDPOINT_RATE_OVER_TIME_DATA',
GET_ENDPOINT_LATENCY_OVER_TIME_DATA: 'GET_ENDPOINT_LATENCY_OVER_TIME_DATA',
GET_ENDPOINT_DROPDOWN_DATA: 'GET_ENDPOINT_DROPDOWN_DATA',
GET_ENDPOINT_DEPENDENT_SERVICES_DATA: 'GET_ENDPOINT_DEPENDENT_SERVICES_DATA',
GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA:

View File

@@ -1,24 +1,20 @@
import { useCallback, useEffect, useMemo } from 'react';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from 'antd';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
getAllEndpointsWidgetData,
getGroupByExpressionFromRowData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
import GridCard from 'container/WidgetCard/Card';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { isEqual } from 'lodash-es';
import { parseAsString, useQueryState } from 'nuqs';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { ENDPOINT_STATS_EXPRESSION_KEY } from '../../../filterParamKeys';
import { useApiMonitoringParams } from '../../../queryParams';
import { SPAN_ATTRIBUTES, VIEWS } from './constants';
import { useFilterExpression } from './hooks/useFilterExpression';
import { useWidgetQueries } from './hooks/useWidgetQueries';
function AllEndPoints({
domainName,
@@ -27,6 +23,8 @@ function AllEndPoints({
groupBy,
setGroupBy,
timeRange,
initialFilters,
setInitialFiltersEndPointStats,
}: {
domainName: string;
setSelectedEndPointName: (name: string) => void;
@@ -37,50 +35,113 @@ function AllEndPoints({
startTime: number;
endTime: number;
};
initialFilters: IBuilderQuery['filters'];
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [, setEndPointStatsExpression] = useQueryState(
ENDPOINT_STATS_EXPRESSION_KEY,
parseAsString,
);
const [groupBySearchValue, setGroupBySearchValue] = useState<string>('');
const [allAvailableGroupByOptions, setAllAvailableGroupByOptions] = useState<{
[key: string]: any;
}>({});
const { data: groupByFiltersData, isLoading: isLoadingGroupByFilters } =
useGetAggregateKeys({
dataSource: DataSource.TRACES,
aggregateAttribute: '',
aggregateOperator: 'noop',
searchText: groupBySearchValue,
tagType: '',
});
const [groupByOptions, setGroupByOptions] = useState<
{ value: string; label: string }[]
>([]);
// --- FILTERS STATE SYNC ---
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression({
groupBy,
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
if (params.allEndpointsLocalFilters) {
return params.allEndpointsLocalFilters;
}
// Initialize filters based on the initial endPointName prop
const initialItems = [...(initialFilters?.items || [])];
return { op: 'AND', items: initialItems };
});
// Sync params to local filters state on param change
useEffect(() => {
if (
params.allEndpointsLocalFilters &&
!isEqual(params.allEndpointsLocalFilters, filters)
) {
setFilters(params.allEndpointsLocalFilters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.allEndpointsLocalFilters]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
setFilters(newFilters);
setParams({ allEndpointsLocalFilters: newFilters });
},
[setParams],
);
const currentQuery = initialQueriesMap[DataSource.TRACES];
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters, // Use the local filters state
},
],
},
}),
[filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const allEndpointsWidgetData = useMemo(
() => getAllEndpointsWidgetData(groupBy, domainName, filterExpression),
[groupBy, domainName, filterExpression],
);
const tableWidgets = useMemo(
() => [allEndpointsWidgetData],
[allEndpointsWidgetData],
);
const { isFetching: isFetchingTable, refetch: refetchTable } =
useWidgetQueries(tableWidgets);
const handleRun = useCallback(
(value: string): void => onRun(value, refetchTable),
[onRun, refetchTable],
() => getAllEndpointsWidgetData(groupBy, domainName, filters),
[groupBy, domainName, filters],
);
// --- GROUP BY STATE SYNC (existing) ---
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
setGroupBy(value);
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = value[index] as unknown as string;
// Check if the key exists in our cached options first
if (allAvailableGroupByOptions[element]) {
newGroupBy.push(allAvailableGroupByOptions[element]);
} else {
// If not found in cache, check the current filtered results
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
}
setGroupBy(newGroupBy);
setGroupBySearchValue('');
// Update params
setParams({ groupBy: value.map((g) => g.key) });
setParams({ groupBy: newGroupBy.map((g) => g.key) });
},
[setGroupBy, setParams],
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
);
// Sync params to local groupBy state on param change
@@ -93,31 +154,77 @@ function AllEndPoints({
groupBy.map((g) => g.key),
)
) {
setGroupBy(
params.groupBy.map((key) => chooseAutocompleteFromCustomValue([], key)),
);
// Only update if different
const newGroupBy = params.groupBy
.map((key) => allAvailableGroupByOptions[key])
.filter(Boolean);
if (newGroupBy.length === params.groupBy.length) {
setGroupBy(newGroupBy);
}
}
}, [params.groupBy, groupBy, setGroupBy]);
}, [params.groupBy, allAvailableGroupByOptions, groupBy, setGroupBy]);
useEffect(() => {
if (groupByFiltersData?.payload) {
// Update dropdown options
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
// Cache all available options to preserve selected values using functional update
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupByFiltersData?.payload?.attributeKeys?.forEach((filter) => {
newOptions[filter.key] = filter;
});
return newOptions;
});
}
}, [groupByFiltersData]); // Only depends on groupByFiltersData now
// Cache existing selected options on component mount
useEffect(() => {
if (groupBy && groupBy.length > 0) {
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupBy.forEach((option) => {
newOptions[option.key] = option;
});
return newOptions;
});
}
}, [groupBy]);
const onRowClick = useCallback(
(props: any): void => {
setSelectedEndPointName(props[SPAN_ATTRIBUTES.HTTP_URL] as string);
setSelectedView(VIEWS.ENDPOINT_STATS);
const statsExpression = combineInitialAndUserExpression(
getGroupByExpressionFromRowData(props),
filterExpression,
);
void setEndPointStatsExpression(statsExpression || null);
const initialItems = [
...(filters?.items || []),
...(getGroupByFiltersFromGroupByValues(props, groupBy)?.items || []),
];
setInitialFiltersEndPointStats({
items: initialItems,
op: 'AND',
});
setParams({
selectedEndPointName: props[SPAN_ATTRIBUTES.HTTP_URL] as string,
selectedView: VIEWS.ENDPOINT_STATS,
endPointDetailsLocalFilters: {
items: initialItems,
op: 'AND',
},
});
},
[
filterExpression,
setEndPointStatsExpression,
filters,
setInitialFiltersEndPointStats,
setSelectedEndPointName,
setSelectedView,
groupBy,
setParams,
],
);
@@ -125,28 +232,28 @@ function AllEndPoints({
return (
<div className="all-endpoints-container">
<div className="all-endpoints-header">
<div className="filter-container" onBlur={handleFilterBlur}>
<QuerySearch
queryData={queryData}
onChange={onDraftChange}
onRun={handleRun}
showRunButton
isLoadingQueries={isFetchingTable}
initialExpression={scopeExpression}
showScopeChip
dataSource={DataSource.TRACES}
<div className="filter-container">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
placeholder="Search for filters..."
/>
</div>
</div>
<div className="group-by-container">
<div className="group-by-label"> Group by </div>
<GroupByFilter
query={queryData}
onChange={handleGroupByChange}
disabled={false}
<Select
className="group-by-select"
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
/>
options={groupByOptions}
onChange={handleGroupByChange}
onSearch={(value: string): void => setGroupBySearchValue(value)}
/>{' '}
</div>
<div className="endpoints-table-container">
<GridCard

View File

@@ -195,6 +195,16 @@
line-height: 18px;
letter-spacing: -0.07px;
}
.group-by-select {
width: 100%;
.ant-select-selector {
font-size: 14px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
}
// Add border-bottom to table cells when pagination is not present
.ant-spin-container:not(:has(.ant-pagination)) .ant-table-cell {
@@ -355,9 +365,16 @@
.top-errors-dropdown-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
align-items: center;
.endpoint-details-filters-container-dropdown {
width: 100%;
}
.endpoint-details-filters-container-search {
flex: 1;
}
}
.endpoint-details-container {
@@ -399,8 +416,29 @@
.endpoint-details-filters-container {
display: flex;
flex-direction: column;
gap: 8px;
flex-direction: row;
align-items: center;
border: 1px solid var(--l1-border);
height: 36px;
box-sizing: content-box;
.ant-select-selector {
border: none !important;
}
.endpoint-details-filters-container-dropdown {
width: 120px;
border-right: 1px solid var(--l1-border);
height: 36px;
display: flex;
align-items: center;
.ant-select-single {
height: 32px;
}
}
.endpoint-details-filters-container-search {
flex: 1;
}
}
.ant-select-item,

View File

@@ -6,7 +6,6 @@ import { Button, Drawer } from 'antd';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
@@ -18,12 +17,7 @@ import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../../filterParamKeys';
import { useApiMonitoringParams } from '../../../queryParams';
import { getDomainScopeExpression } from '../../../utils';
import AllEndPoints from './AllEndPoints';
import DomainMetrics from './components/DomainMetrics';
import { VIEW_TYPES, VIEWS } from './constants';
@@ -40,14 +34,14 @@ function DomainDetails({
selectedDomainIndex,
setSelectedDomainIndex,
domainListLength,
domainListFilterExpression,
domainListFilters,
}: {
domainData: any;
handleClose: () => void;
selectedDomainIndex: number;
setSelectedDomainIndex: (index: number) => void;
domainListLength: number;
domainListFilterExpression: string;
domainListFilters: IBuilderQuery['filters'];
}): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const [selectedView, setSelectedView] = useState<VIEWS>(
@@ -59,16 +53,8 @@ function DomainDetails({
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
IBuilderQuery['groupBy']
>([]);
const domainName = domainData?.domainName ?? '';
const allEndpointsScope = useMemo(
() => getDomainScopeExpression(domainName),
[domainName],
);
const endPointStatsScope = useMemo(
() => getDomainScopeExpression(domainName, selectedEndPointName),
[domainName, selectedEndPointName],
);
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] =
useState<IBuilderQuery['filters']>(domainListFilters);
const handleTabChange = (value: string): void => {
setSelectedView(value as VIEWS);
@@ -236,7 +222,7 @@ function DomainDetails({
<>
<DomainMetrics
domainName={domainData.domainName}
domainListFilterExpression={domainListFilterExpression}
domainListFilters={domainListFilters}
timeRange={modalTimeRange}
/>
<div className="views-tabs-container">
@@ -267,43 +253,34 @@ function DomainDetails({
/>
</div>
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
<QuerySearchV2Provider
queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}
initialExpression={allEndpointsScope}
persistOnUnmount
>
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
/>
</QuerySearchV2Provider>
<AllEndPoints
domainName={domainData.domainName}
setSelectedEndPointName={handleEndPointChange}
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
initialFilters={domainListFilters}
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
/>
)}
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
<QuerySearchV2Provider
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
initialExpression={endPointStatsScope}
persistOnUnmount
>
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
</QuerySearchV2Provider>
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={handleEndPointChange}
initialFilters={initialFiltersEndPointStats}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
)}
{selectedView === VIEW_TYPES.TOP_ERRORS && (
<TopErrors
domainName={domainData.domainName}
timeRange={modalTimeRange}
initialFilterExpression={domainListFilterExpression}
initialFilters={domainListFilters}
/>
)}
</>

View File

@@ -1,17 +1,17 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
getEndPointDetailsQueryPayload,
getHttpUrlExpression,
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import {
CustomTimeType,
Time,
@@ -19,28 +19,36 @@ import {
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import DependentServices from './components/DependentServices';
import EndPointMetrics from './components/EndPointMetrics';
import EndPointPicker from './components/EndPointPicker';
import EndPointsDropDown from './components/EndPointsDropDown';
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
import StatusCodeTable from './components/StatusCodeTable';
import { useFilterExpression } from './hooks/useFilterExpression';
import { useWidgetQueries } from './hooks/useWidgetQueries';
import { SPAN_ATTRIBUTES } from './constants';
const httpUrlKey = {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.HTTP_URL,
type: 'tag',
};
function EndPointDetails({
domainName,
endPointName,
setSelectedEndPointName,
initialFilters,
timeRange,
handleTimeChange,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
initialFilters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
@@ -51,66 +59,159 @@ function EndPointDetails({
) => void;
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
const [params, setParams] = useApiMonitoringParams();
// Search filters; the endpoint filter is combined in at query time
const {
expression: filterExpression,
initialExpression: scopeExpression,
queryData,
onDraftChange,
onRun,
onBlur: handleFilterBlur,
} = useFilterExpression();
const currentQuery = initialQueriesMap[DataSource.TRACES];
const httpUrlExpression = useMemo(
() => getHttpUrlExpression(endPointName),
[endPointName],
// Local state for filters, combining endpoint filter and search filters
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
// Initialize filters based on the initial endPointName prop
const initialItems = params.endPointDetailsLocalFilters
? [...(params.endPointDetailsLocalFilters?.items || [])]
: [...(initialFilters?.items || [])];
if (endPointName) {
initialItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: initialItems };
});
// Effect to synchronize local filters when the endPointName prop changes (e.g., from dropdown)
useEffect(() => {
setFilters((currentFilters) => {
const existingHttpUrlFilter = currentFilters?.items?.find(
(item) => item.key?.key === httpUrlKey.key,
);
const existingHttpUrlValue = (existingHttpUrlFilter?.value as string) || '';
// Only update filters if the prop value is different from what's already in filters
if (endPointName === existingHttpUrlValue) {
return currentFilters; // No change needed, prevents loop
}
// Rebuild filters: Keep non-http_url filters and add/update http_url filter based on prop
const otherFilters = currentFilters?.items?.filter(
(item) => item.key?.key !== httpUrlKey.key,
);
const newItems = [...(otherFilters || [])];
if (endPointName) {
newItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: newItems };
});
}, [endPointName]);
// Separate effect to update params when filters change
useEffect(() => {
const filtersWithoutHttpUrl = {
op: 'AND',
items:
filters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) || [],
};
setParams({ endPointDetailsLocalFilters: filtersWithoutHttpUrl });
}, [filters, setParams]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
// 1. Update local filters state immediately
setFilters(newFilters);
// Filter out http_url filter before saving to params
const filteredNewFilters = {
op: 'AND',
items:
newFilters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) ||
[],
};
setParams({ endPointDetailsLocalFilters: filteredNewFilters });
// 2. Derive the endpoint name from the *new* filters state
const httpUrlFilter = newFilters?.items?.find(
(item) => item.key?.key === httpUrlKey.key,
);
const derivedEndPointName = (httpUrlFilter?.value as string) || '';
// 3. If the derived endpoint name is different from the current prop,
// it means the search change modified the effective endpoint.
// Notify the parent component.
if (derivedEndPointName !== endPointName) {
setSelectedEndPointName(derivedEndPointName);
}
},
[endPointName, setSelectedEndPointName, setParams], // Dependencies for the callback
);
const scopedExpression = useMemo(
() => combineInitialAndUserExpression(httpUrlExpression, filterExpression),
[httpUrlExpression, filterExpression],
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters, // Use the local filters state
},
],
},
}),
[filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const isServicesFilterApplied = useMemo(
() =>
extractQueryPairs(filterExpression).some(
(pair) => pair.key === 'service.name',
),
[filterExpression],
() => filters?.items?.some((item) => item.key?.key === 'service.name'),
[filters],
);
const endPointDetailsQueryPayload = useMemo(
() =>
getEndPointDetailsQueryPayload(
domainName,
minTime,
maxTime,
scopedExpression,
),
[domainName, scopedExpression, minTime, maxTime],
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
[domainName, filters, minTime, maxTime],
);
const V5_QUERIES = [
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
] as const;
const endPointDetailsDataQueries = useQueries(
endPointDetailsQueryPayload.map((payload, index) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
ENTITY_VERSION_V5,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
// Keeps the table's rows, and so its height, while a new filter loads
keepPreviousData:
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index] ===
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
})),
endPointDetailsQueryPayload.map((payload, index) => {
const queryKey = END_POINT_DETAILS_QUERY_KEYS_ARRAY[index];
const version = (V5_QUERIES as readonly string[]).includes(queryKey)
? ENTITY_VERSION_V5
: ENTITY_VERSION_V4;
return {
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching
version,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, version),
enabled: !!payload,
};
}),
);
const [
endPointMetricsDataQuery,
endPointStatusCodeDataQuery,
endPointDropDownDataQuery,
endPointDependentServicesDataQuery,
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
@@ -121,6 +222,7 @@ function EndPointDetails({
endPointDetailsDataQueries[2],
endPointDetailsDataQueries[3],
endPointDetailsDataQueries[4],
endPointDetailsDataQueries[5],
],
[endPointDetailsDataQueries],
);
@@ -132,32 +234,10 @@ function EndPointDetails({
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
() => [
getRateOverTimeWidgetData(domainName, endPointName, scopedExpression),
getLatencyOverTimeWidgetData(domainName, endPointName, scopedExpression),
getRateOverTimeWidgetData(domainName, endPointName, filters),
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
],
[domainName, endPointName, scopedExpression],
);
const graphWidgets = useMemo(
() => [rateOverTimeWidget, latencyOverTimeWidget],
[rateOverTimeWidget, latencyOverTimeWidget],
);
const { isFetching: isFetchingGraphs, refetch: refetchGraphs } =
useWidgetQueries(graphWidgets);
const isFetchingQueries =
isFetchingGraphs ||
endPointDetailsDataQueries.some((query) => query.isFetching);
const refetchQueries = useCallback((): void => {
refetchGraphs();
endPointDetailsDataQueries.forEach((query) => {
void query.refetch();
});
}, [refetchGraphs, endPointDetailsDataQueries]);
const handleRun = useCallback(
(value: string): void => onRun(value, refetchQueries),
[onRun, refetchQueries],
[domainName, endPointName, filters], // Use combinedFilters
);
// // [TODO] Fix this later
@@ -177,23 +257,19 @@ function EndPointDetails({
return (
<div className="endpoint-details-container">
<div className="endpoint-details-filters-container">
<EndPointPicker
domainName={domainName}
timeRange={timeRange}
filterExpression={filterExpression}
value={endPointName}
onChange={setSelectedEndPointName}
/>
<div onBlur={handleFilterBlur}>
<QuerySearch
queryData={queryData}
onChange={onDraftChange}
onRun={handleRun}
showRunButton
isLoadingQueries={isFetchingQueries}
initialExpression={scopeExpression}
showScopeChip
dataSource={DataSource.TRACES}
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
dropdownStyle={{ width: 'calc(100% - 36px)' }}
/>
</div>
<div className="endpoint-details-filters-container-search">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
placeholder="Search for filters..."
/>
</div>
@@ -223,7 +299,7 @@ function EndPointDetails({
endPointStatusCodeLatencyBarChartsDataQuery
}
domainName={domainName}
filterExpression={scopedExpression}
filters={filters}
timeRange={timeRange}
onDragSelect={onDragSelect}
/>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { QueryFunctionContext, useQuery } from 'react-query';
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
import { Spin, Table, Tooltip } from 'antd';
import { Info, Loader } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
@@ -7,36 +7,41 @@ import { Typography } from '@signozhq/ui/typography';
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
formatTopErrorsDataForTable,
getHttpUrlExpression,
getEndPointDetailsQueryPayload,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { SuccessResponseV2 } from 'types/api';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse, SuccessResponseV2 } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import EndPointPicker from './components/EndPointPicker';
import EndPointsDropDown from './components/EndPointsDropDown';
import ErrorState from './components/ErrorState';
import { SPAN_ATTRIBUTES } from './constants';
function TopErrors({
domainName,
timeRange,
initialFilterExpression,
initialFilters,
}: {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
initialFilterExpression: string;
initialFilters: IBuilderQuery['filters'];
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -50,10 +55,27 @@ function TopErrors({
domainName,
minTime,
maxTime,
combineInitialAndUserExpression(
getHttpUrlExpression(endPointName),
initialFilterExpression,
),
{
items: endPointName
? [
// Remove any existing http_url filters from initialFilters to avoid duplicates
...(initialFilters?.items?.filter(
(item) => item.key?.key !== SPAN_ATTRIBUTES.HTTP_URL,
) || []),
{
id: '92b8a1c1',
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.HTTP_URL,
type: 'tag',
},
op: '=',
value: endPointName,
},
]
: [...(initialFilters?.items || [])],
op: 'AND',
},
showStatusCodeErrors,
),
[
@@ -61,7 +83,7 @@ function TopErrors({
endPointName,
minTime,
maxTime,
initialFilterExpression,
initialFilters,
showStatusCodeErrors,
],
);
@@ -98,6 +120,35 @@ function TopErrors({
[topErrorsData],
);
const endPointDropDownQueryPayload = useMemo(
() => [
getEndPointDetailsQueryPayload(domainName, minTime, maxTime, {
items: [],
op: 'AND',
})[2],
],
[domainName, minTime, maxTime],
);
const endPointDropDownDataQueries = useQueries(
endPointDropDownQueryPayload.map((payload) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[2],
payload,
ENTITY_VERSION_V5,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
enabled: !!payload,
staleTime: 60 * 1000,
})),
);
const [endPointDropDownDataQuery] = useMemo(
() => [endPointDropDownDataQueries[0]],
[endPointDropDownDataQueries],
);
const navigateToExplorer = useNavigateToExplorer();
if (isError) {
@@ -111,13 +162,14 @@ function TopErrors({
return (
<div className="all-endpoints-container">
<div className="top-errors-dropdown-container">
<EndPointPicker
domainName={domainName}
timeRange={timeRange}
filterExpression=""
value={endPointName}
onChange={setSelectedEndPointName}
/>
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Switch
color="primary"

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { Table, TablePaginationConfig } from 'antd';
import { Skeleton, Table, TablePaginationConfig } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { QueryParams } from 'constants/query';
import {
@@ -54,6 +54,10 @@ function DependentServices({
[isExpanded, dependentServicesData.length],
);
if (isLoading || isRefetching) {
return <Skeleton />;
}
if (isError) {
return <ErrorState refetch={refetch} />;
}

View File

@@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { TraceAggregation } from 'api/v5/v5';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../constants';
import DomainMetrics from './DomainMetrics';
@@ -33,7 +34,10 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
startTime: 1758259531000,
endTime: 1758261331000,
},
domainListFilterExpression: '',
domainListFilters: {
items: [],
op: 'AND' as const,
} as IBuilderQuery['filters'],
};
const mockSuccessResponse = {
@@ -170,12 +174,35 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
it('includes custom filters in filter expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilterExpression =
"service.name = 'my-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'my-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND' as const,
};
renderComponent({
...mockProps,
domainListFilterExpression: customFilterExpression,
domainListFilters: customFilters,
});
await waitFor(() => {

View File

@@ -14,17 +14,18 @@ import {
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';
function DomainMetrics({
domainName,
timeRange,
domainListFilterExpression,
domainListFilters,
}: {
domainName: string;
timeRange: { startTime: number; endTime: number };
domainListFilterExpression: string;
domainListFilters: IBuilderQuery['filters'];
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
@@ -34,9 +35,9 @@ function DomainMetrics({
domainName,
minTime,
maxTime,
domainListFilterExpression,
domainListFilters,
),
[domainName, minTime, maxTime, domainListFilterExpression],
[domainName, minTime, maxTime, domainListFilters],
);
// Since only one query here

View File

@@ -114,7 +114,10 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
const domainName = 'api.example.com';
const startTime = 1758259531000;
const endTime = 1758261331000;
const filters = '';
const filters = {
items: [],
op: 'AND' as const,
};
// Get the actual payload that would be generated
const payloads = getEndPointDetailsQueryPayload(
@@ -234,8 +237,31 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
it('includes custom domainListFilters in all query expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilters =
"service.name = 'payment-service' AND deployment.environment = 'staging'";
const customFilters = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'payment-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'staging',
},
],
op: 'AND' as const,
};
const payloads = getEndPointDetailsQueryPayload(
'api.internal.com',

View File

@@ -1,18 +0,0 @@
.picker {
width: 460px;
max-width: 100%;
// Popover fits its longest option by default; match the trigger so long URLs truncate.
[data-slot='combobox-content'] {
--popover-width: var(--radix-popover-trigger-width);
}
}
.option {
display: block;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,92 +0,0 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
getEndPointDropDownQueryPayload,
getFormattedEndPointDropDownData,
} from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import styles from './EndPointPicker.module.scss';
const VIRTUALIZE_AFTER = 100;
const ALL_ENDPOINTS = '__all_endpoints__';
interface EndPointPickerProps {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
filterExpression: string;
value: string;
onChange: (endPointName: string) => void;
}
function EndPointPicker({
domainName,
timeRange,
filterExpression,
value,
onChange,
}: EndPointPickerProps): JSX.Element {
const payload = useMemo(
() =>
getEndPointDropDownQueryPayload(
domainName,
timeRange.startTime,
timeRange.endTime,
filterExpression,
),
[domainName, timeRange.startTime, timeRange.endTime, filterExpression],
);
const { data, isLoading, isFetching } = useQuery({
queryKey: [
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
payload,
ENTITY_VERSION_V5,
],
queryFn: () => GetMetricQueryRange(payload, ENTITY_VERSION_V5),
staleTime: 60 * 1000,
});
const items = useMemo(
(): ComboboxSimpleItem[] => [
{ value: ALL_ENDPOINTS, label: 'All endpoints' },
...getFormattedEndPointDropDownData(
data?.payload?.data?.result?.[0]?.table?.rows ?? [],
).map(({ value: url }) => ({
value: url,
label: (
<span className={styles.option} title={url}>
{url}
</span>
),
})),
],
[data],
);
return (
<div className={styles.picker}>
<ComboboxSimple
items={items}
value={value || ALL_ENDPOINTS}
onChange={(next): void =>
onChange(!next || next === ALL_ENDPOINTS ? '' : (next as string))
}
inputPlaceholder="Search endpoints"
emptyPlaceholder="No endpoints found"
loading={isLoading || isFetching}
virtualized={items.length > VIRTUALIZE_AFTER}
withPortal={false}
testId="endpoint-picker"
/>
</div>
);
}
export default EndPointPicker;

View File

@@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { Select } from 'antd';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
interface EndPointsDropDownProps {
selectedEndPointName?: string;
setSelectedEndPointName: (value: string) => void;
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
parentContainerDiv?: string;
dropdownStyle?: React.CSSProperties;
}
const defaultProps = {
selectedEndPointName: '',
parentContainerDiv: '',
dropdownStyle: {},
};
function EndPointsDropDown({
selectedEndPointName,
setSelectedEndPointName,
endPointDropDownDataQuery,
parentContainerDiv,
dropdownStyle,
}: EndPointsDropDownProps): JSX.Element {
const { data, isLoading, isFetching } = endPointDropDownDataQuery;
const handleChange = (value: string): void => {
setSelectedEndPointName(value);
};
const formattedData = useMemo(
() =>
getFormattedEndPointDropDownData(data?.payload.data.result[0].table.rows),
[data?.payload.data.result],
);
return (
<Select
value={selectedEndPointName || undefined}
placeholder="Select endpoint"
loading={isLoading || isFetching}
style={{ width: '100%' }}
onChange={handleChange}
options={formattedData}
getPopupContainer={
parentContainerDiv
? (): HTMLElement =>
document.querySelector(parentContainerDiv) as HTMLElement
: (triggerNode): HTMLElement => triggerNode.parentNode as HTMLElement
}
dropdownStyle={dropdownStyle}
allowClear
onClear={(): void => {
setSelectedEndPointName('');
}}
/>
);
}
EndPointsDropDown.defaultProps = defaultProps;
export default EndPointsDropDown;

View File

@@ -24,6 +24,7 @@ import { LegendPosition } from 'lib/uPlotV2/components/types';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/widgets/widget';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';
import {
@@ -36,7 +37,7 @@ function StatusCodeBarCharts({
endPointStatusCodeBarChartsDataQuery,
endPointStatusCodeLatencyBarChartsDataQuery,
domainName,
filterExpression,
filters,
timeRange,
onDragSelect,
}: {
@@ -49,7 +50,7 @@ function StatusCodeBarCharts({
unknown
>;
domainName: string;
filterExpression: string;
filters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
@@ -129,8 +130,12 @@ function StatusCodeBarCharts({
);
const widget = useMemo<Widgets>(
() => getStatusCodeBarChartWidgetData(domainName, filterExpression),
[domainName, filterExpression],
() =>
getStatusCodeBarChartWidgetData(domainName, {
items: [...(filters?.items || [])],
op: filters?.op || 'AND',
}),
[domainName, filters],
);
const activeApiResponse = useMemo(

View File

@@ -1,157 +0,0 @@
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { FilterProviders } from 'container/ApiMonitoring/__tests__/FilterProviders';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import {
act,
render,
renderHook,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { useFilterExpression } from '../useFilterExpression';
const PARAM_KEY = 'testExpression';
const linkExpression = "service.name = 'frontend'";
const linkSearch = `${PARAM_KEY}=${encodeURIComponent(linkExpression)}`;
function renderFilterHook(
searchParams = '',
): ReturnType<
typeof renderHook<ReturnType<typeof useFilterExpression>, unknown>
> {
return renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders queryParamKey={PARAM_KEY} searchParams={searchParams}>
{children}
</FilterProviders>
),
});
}
function FilterBox(): JSX.Element {
const { expression, onDraftChange, onBlur } = useFilterExpression();
return (
<div onBlur={onBlur}>
<span data-testid="applied">{expression}</span>
<input
data-testid="filter-input"
onChange={(e): void => onDraftChange(e.target.value)}
/>
<button type="button" data-testid="inside-button">
Run
</button>
</div>
);
}
describe('useFilterExpression', () => {
beforeEach(() => {
resetNuqsState();
});
it('reads the filter from the link', async () => {
const { result } = renderFilterHook(linkSearch);
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
expect(result.current.queryData.dataSource).toBe('traces');
expect(result.current.queryData.filter?.expression).toBe(linkExpression);
});
it('applies a valid expression on run and ignores an invalid one', async () => {
const { result } = renderFilterHook(linkSearch);
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
act(() => {
result.current.onRun('service.name =');
});
expect(result.current.expression).toBe(linkExpression);
act(() => {
result.current.onRun('http.status_code = 500');
});
expect(result.current.expression).toBe('http.status_code = 500');
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
'http.status_code = 500',
),
);
});
it('runs again instead of applying when the filter is already applied', async () => {
const scope = "http_host = 'api.example.com'";
const { result } = renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders
queryParamKey={PARAM_KEY}
initialExpression={scope}
searchParams={linkSearch}
>
{children}
</FilterProviders>
),
});
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
const onUnchanged = jest.fn() as jest.MockedFunction<() => void>;
act(() => {
result.current.onRun(
combineInitialAndUserExpression(scope, linkExpression),
onUnchanged,
);
});
expect(onUnchanged).toHaveBeenCalledTimes(1);
act(() => {
result.current.onRun(
combineInitialAndUserExpression(scope, 'http.status_code = 500'),
onUnchanged,
);
});
expect(onUnchanged).toHaveBeenCalledTimes(1);
expect(result.current.expression).toBe('http.status_code = 500');
});
it('applies the draft on blur only when focus leaves the container', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<FilterProviders queryParamKey={PARAM_KEY}>
<FilterBox />
</FilterProviders>,
);
await user.type(screen.getByTestId('filter-input'), "service.name = 'cart'");
await user.click(screen.getByTestId('inside-button'));
expect(screen.getByTestId('applied')).toHaveTextContent('');
await user.click(document.body);
expect(screen.getByTestId('applied')).toHaveTextContent(
"service.name = 'cart'",
);
});
it('keeps only the user text when the scope is set', async () => {
const scope = "http_host = 'api.example.com'";
const { result } = renderHook(() => useFilterExpression(), {
wrapper: ({ children }) => (
<FilterProviders queryParamKey={PARAM_KEY} initialExpression={scope}>
{children}
</FilterProviders>
),
});
await waitFor(() => expect(result.current.initialExpression).toBe(scope));
act(() => {
result.current.onRun(`${scope} AND (service.name = 'cart')`);
});
expect(result.current.expression).toBe("service.name = 'cart'");
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
"service.name = 'cart'",
),
);
});
});

View File

@@ -1,82 +0,0 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider, QueryObserver } from 'react-query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { act, renderHook, waitFor } from 'tests/test-utils';
import { Widgets } from 'types/api/widgets/widget';
import { useWidgetQueries } from '../useWidgetQueries';
const widgetWithQueryId = (id: string): Widgets =>
getWidgetQueryBuilder({
query: { ...initialQueriesMap.traces, id },
panelTypes: PANEL_TYPES.TABLE,
});
const gridCardKey = (queryId: string): unknown[] => [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
0,
0,
{ id: queryId },
];
function startGridCardFetch(client: QueryClient, queryId: string): void {
void client.prefetchQuery(gridCardKey(queryId), () => new Promise(() => {}));
}
function renderWidgetQueries(
client: QueryClient,
widgets: Widgets[],
): ReturnType<typeof renderHook<ReturnType<typeof useWidgetQueries>, unknown>> {
return renderHook(() => useWidgetQueries(widgets), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
});
}
describe('useWidgetQueries', () => {
it('counts only the given widgets’ grid card queries', async () => {
const client = new QueryClient();
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
await act(async () => {
startGridCardFetch(client, 'other-tab-chart');
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(client.isFetching()).toBe(1);
expect(result.current.isFetching).toBe(false);
act(() => startGridCardFetch(client, 'table'));
await waitFor(() => expect(result.current.isFetching).toBe(true));
});
it('refetches only the given widgets’ grid card queries', async () => {
const client = new QueryClient();
const fetchTable = jest.fn(() =>
Promise.resolve('table'),
) as jest.MockedFunction<() => Promise<string>>;
const fetchOther = jest.fn(() =>
Promise.resolve('other'),
) as jest.MockedFunction<() => Promise<string>>;
const unsubscribers = [
new QueryObserver(client, {
queryKey: gridCardKey('table'),
queryFn: fetchTable,
}).subscribe(() => {}),
new QueryObserver(client, {
queryKey: gridCardKey('other-tab-chart'),
queryFn: fetchOther,
}).subscribe(() => {}),
];
await waitFor(() => expect(client.isFetching()).toBe(0));
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
act(() => result.current.refetch());
await waitFor(() => expect(fetchTable).toHaveBeenCalledTimes(2));
expect(fetchOther).toHaveBeenCalledTimes(1);
unsubscribers.forEach((unsubscribe) => unsubscribe());
});
});

View File

@@ -1,93 +0,0 @@
import { FocusEvent, useCallback, useEffect, useMemo, useRef } from 'react';
import {
useInitialExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { initialQueriesMap } from 'constants/queryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
interface UseFilterExpressionParams {
groupBy?: IBuilderQuery['groupBy'];
}
interface UseFilterExpressionReturn {
expression: string;
/** The provider's locked scope, for `QuerySearch`'s `initialExpression` */
initialExpression: string | undefined;
queryData: IBuilderQuery;
onDraftChange: (value: string) => void;
/** `onUnchanged` runs instead when the filter is already applied. */
onRun: (value: string, onUnchanged?: () => void) => void;
onBlur: (event: FocusEvent<HTMLDivElement>) => void;
}
/** Must be used inside a `QuerySearchV2Provider`. */
export function useFilterExpression({
groupBy,
}: UseFilterExpressionParams = {}): UseFilterExpressionReturn {
const expression = useUserExpression();
const scope = useInitialExpression();
const initialExpression = useQuerySearchInitialExpressionProp();
const querySearchOnRun = useQuerySearchOnRun();
const queryData = useMemo(
(): IBuilderQuery => ({
...initialQueriesMap[DataSource.TRACES].builder.queryData[0],
dataSource: DataSource.TRACES,
filter: { expression },
...(groupBy ? { groupBy } : {}),
}),
[expression, groupBy],
);
const onRun = useCallback(
(value: string, onUnchanged?: () => void): void => {
if (!validateQuery(value).isValid) {
return;
}
if (getUserExpressionFromCombined(scope, value) === expression) {
onUnchanged?.();
return;
}
querySearchOnRun(value);
},
[querySearchOnRun, scope, expression],
);
// A ref, not the provider's draft, so typing does not re-render the page.
const draftRef = useRef(expression);
useEffect(() => {
draftRef.current = expression;
}, [expression]);
const onDraftChange = useCallback((value: string): void => {
draftRef.current = value;
}, []);
const onBlur = useCallback(
(event: FocusEvent<HTMLDivElement>): void => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
return;
}
onRun(combineInitialAndUserExpression(scope, draftRef.current));
},
[onRun, scope],
);
return {
expression,
initialExpression,
queryData,
onDraftChange,
onRun,
onBlur,
};
}

View File

@@ -1,45 +0,0 @@
import { useCallback, useMemo } from 'react';
import {
Query as ReactQuery,
useIsFetching,
useQueryClient,
} from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { Widgets } from 'types/api/widgets/widget';
interface UseWidgetQueriesReturn {
isFetching: boolean;
refetch: () => void;
}
/**
* These widgets' `GridCard` queries. Matches on the widget's query id, so
* another tab's cards are neither counted nor refetched.
*/
export function useWidgetQueries(widgets: Widgets[]): UseWidgetQueriesReturn {
const queryClient = useQueryClient();
const predicate = useMemo(() => {
const queryIds = new Set(
widgets
.map((widget) => widget.query?.id)
.filter((id): id is string => Boolean(id)),
);
return ({ queryKey }: ReactQuery): boolean =>
Array.isArray(queryKey) &&
queryKey[0] === REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE &&
queryKey.some((part) => {
const id = (part as Query | undefined)?.id;
return !!id && queryIds.has(id);
});
}, [widgets]);
const isFetching = useIsFetching({ predicate }) > 0;
const refetch = useCallback((): void => {
void queryClient.refetchQueries({ predicate, active: true });
}, [queryClient, predicate]);
return { isFetching, refetch };
}

View File

@@ -19,7 +19,6 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
import { get } from 'lodash-es';
import { parseAsString, useQueryStates } from 'nuqs';
import { AppState } from 'store/reducers';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
@@ -28,26 +27,16 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../filterParamKeys';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
import { columnsConfig, formatDataForTable } from '../../utils';
import DomainDetails from './DomainDetails/DomainDetails';
import { useConvertLegacyFilterLinks } from './hooks/useConvertLegacyFilterLinks';
import '../Explorer.styles.scss';
function DomainList(): JSX.Element {
const [params, setParams] = useApiMonitoringParams();
const { showIP, selectedDomain } = params;
const [, setDrawerFilters] = useQueryStates({
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
});
const [selectedDomainIndex, setSelectedDomainIndex] = useState<number>(-1);
useConvertLegacyFilterLinks();
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -66,8 +55,6 @@ function DomainList(): JSX.Element {
});
const compositeData = useGetCompositeQueryParam();
const listFilterExpression =
compositeData?.builder?.queryData[0]?.filter?.expression ?? '';
const [isCancelled, setIsCancelled] = useState(false);
@@ -168,7 +155,6 @@ function DomainList(): JSX.Element {
dataSource={DataSource.TRACES}
queryData={query}
onChange={handleSearchChange}
onRun={handleStageAndRunQuery}
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
@@ -239,11 +225,6 @@ function DomainList(): JSX.Element {
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
// The drawer tabs start from the list's filter
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: listFilterExpression || null,
[ENDPOINT_STATS_EXPRESSION_KEY]: listFilterExpression || null,
});
logEvent('API Monitoring: Domain name row clicked', {});
}
},
@@ -264,12 +245,8 @@ function DomainList(): JSX.Element {
handleClose={(): void => {
setSelectedDomainIndex(-1);
setParams(DEFAULT_PARAMS);
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: null,
[ENDPOINT_STATS_EXPRESSION_KEY]: null,
});
}}
domainListFilterExpression={listFilterExpression}
domainListFilters={query?.filters}
/>
)}
</section>

View File

@@ -1,122 +0,0 @@
import { ReactNode, useEffect, useState } from 'react';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from 'container/ApiMonitoring/filterParamKeys';
import { getApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
getCurrentNuqsSearchParams,
onNuqsUrlUpdate,
resetNuqsState,
} from 'tests/nuqs-helpers';
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
import { renderHook, waitFor } from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { useConvertLegacyFilterLinks } from '../useConvertLegacyFilterLinks';
const legacyFilter = (key: string, value: string): TagFilter => ({
op: 'AND',
items: [
{
id: key,
key: { key, dataType: DataTypes.String, type: 'tag' },
op: '=',
value,
},
],
});
// NuqsTestingAdapter resets its update queue on every render, not only on mount,
// dropping writes queued just before it re-renders.
function UrlAdapter({
searchParams,
children,
}: {
searchParams: string;
children: ReactNode;
}): JSX.Element {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
resetUrlUpdateQueueOnMount={!mounted}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
}
function renderWithLink(linkParams: object): jest.SpyInstance {
const search = `?apiMonitoringParams=${encodeURIComponent(
encodeURIComponent(JSON.stringify(linkParams)),
)}`;
__setSearchParamsGetterForTest(() => new URLSearchParams(search));
mockAppLocation({ search });
const navigateSpy = mockNavigate();
renderHook(() => useConvertLegacyFilterLinks(), {
wrapper: ({ children }: { children: ReactNode }) => (
<UrlAdapter searchParams={search}>{children}</UrlAdapter>
),
});
return navigateSpy;
}
describe('useConvertLegacyFilterLinks', () => {
beforeEach(() => {
resetNuqsState();
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('moves an older link’s filters into the drawer params once', async () => {
const navigateSpy = renderWithLink({
selectedDomain: 'api.example.com',
allEndpointsLocalFilters: legacyFilter('service.name', 'cart'),
endPointDetailsLocalFilters: legacyFilter('http.status_code', '500'),
});
await waitFor(() => {
const params = getCurrentNuqsSearchParams();
expect(params.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
"service.name = 'cart'",
);
expect(params.get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"http.status_code = '500'",
);
});
const [to, options] = navigateSpy.mock.calls[0];
const rewritten = getApiMonitoringParams(
typeof to === 'string' ? to : (to.search ?? ''),
);
expect(rewritten.selectedDomain).toBe('api.example.com');
expect(rewritten).not.toHaveProperty('allEndpointsLocalFilters');
expect(options).toStrictEqual({ replace: true });
});
it('leaves a link without legacy filters alone', () => {
const navigateSpy = renderWithLink({ selectedDomain: 'api.example.com' });
expect(navigateSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ search: expect.any(String) }),
expect.anything(),
);
expect(getCurrentNuqsSearchParams().has(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
false,
);
});
});

View File

@@ -1,42 +0,0 @@
import { useEffect } from 'react';
import { parseAsString, useQueryStates } from 'nuqs';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from '../../../filterParamKeys';
import {
getLegacyFilterExpressions,
useApiMonitoringParams,
} from '../../../queryParams';
/**
* Moves the filters an older link keeps inside `apiMonitoringParams` into the
* drawer's own params, then rewrites `apiMonitoringParams` without them, so it
* happens once.
*/
export function useConvertLegacyFilterLinks(): void {
const [, setParams] = useApiMonitoringParams();
const [, setDrawerFilters] = useQueryStates({
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
});
useEffect(() => {
const legacy = getLegacyFilterExpressions(
`?${getUnstableCurrentSearchParams().toString()}`,
);
if (
legacy.allEndpoints === undefined &&
legacy.endPointDetails === undefined
) {
return;
}
void setDrawerFilters({
[ALL_ENDPOINTS_EXPRESSION_KEY]: legacy.allEndpoints || null,
[ENDPOINT_STATS_EXPRESSION_KEY]: legacy.endPointDetails || null,
});
setParams({}, true);
}, [setParams, setDrawerFilters]);
}

View File

@@ -1,4 +1,6 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import {
@@ -9,14 +11,20 @@ import {
getFormattedEndPointDropDownData,
getFormattedEndPointStatusCodeChartData,
getFormattedEndPointStatusCodeData,
getGroupByExpressionFromRowData,
getHttpUrlExpression,
getGroupByFiltersFromGroupByValues,
getStatusCodeBarChartWidgetData,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
} from '../utils';
import { APIMonitoringColumnsMock } from './mock';
// Mock or define DataTypes since it seems to be missing from imports
const DataTypes = {
String: 'string',
Float64: 'float64',
bool: 'bool',
};
// Mock the external utils dependencies that are used within our tested functions
jest.mock('../utils', () => {
// Import the actual module to partial mock
@@ -82,39 +90,140 @@ describe('API Monitoring Utils', () => {
expect(result[0].lastUsed).toBe('-');
});
});
describe('getGroupByExpressionFromRowData', () => {
it('builds an AND expression from the row values, skipping the url and metric columns', () => {
expect(
getGroupByExpressionFromRowData({
http_url: '/api/test',
key: '3f2a-row-uuid',
A: 10,
B: 20,
C: 1,
F1: 5,
'service.name': 'api-service',
'http.status_code': 200,
}),
).toBe("service.name = 'api-service' AND http.status_code = 200");
});
describe('getGroupByFiltersFromGroupByValues', () => {
it('should convert row data to filters correctly', () => {
// Arrange
const rowData = {
'http.method': 'GET',
'http.status_code': '200',
'service.name': 'api-service',
// Fields that should be filtered out
data: 'someValue',
key: 'someKey',
};
it('escapes quotes in values', () => {
expect(getGroupByExpressionFromRowData({ 'user.name': "o'brien" })).toBe(
"user.name = 'o\\'brien'",
const groupBy = [
{
id: 'group-by-1',
key: 'http.method',
dataType: DataTypes.String,
type: '',
},
{
id: 'group-by-2',
key: 'http.status_code',
dataType: DataTypes.String,
type: '',
},
{
id: 'group-by-3',
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
];
// Act
const result = getGroupByFiltersFromGroupByValues(
rowData,
groupBy as BaseAutocompleteData[],
);
});
it('matches spans without the attribute when the group has no value', () => {
// Assert
expect(result).toBeDefined();
expect(result?.op).toBe('AND');
// The implementation includes all keys from rowData, not just those in groupBy
expect(result?.items?.length).toBeGreaterThanOrEqual(3);
// Verify each filter matches the corresponding groupBy
expect(
getGroupByExpressionFromRowData({
'service.name': 'cart',
'ai.embeddings': null,
}),
).toBe("service.name = 'cart' AND ai.embeddings NOT EXISTS");
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'http.method' &&
item.value === 'GET' &&
item.op === '=',
),
).toBe(true);
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'http.status_code' &&
item.value === '200' &&
item.op === '=',
),
).toBe(true);
expect(
result?.items?.some(
(item) =>
item.key &&
item.key.key === 'service.name' &&
item.value === 'api-service' &&
item.op === '=',
),
).toBe(true);
});
it('returns an empty expression for a row without group columns', () => {
expect(getGroupByExpressionFromRowData({ http_url: '/x', A: 1 })).toBe('');
it('should handle fields not in groupBy', () => {
// Arrange
const rowData = {
'http.method': 'GET',
'unknown.field': 'someValue',
};
const groupBy = [
{
id: 'group-by-1',
key: 'http.method',
dataType: DataTypes.String,
type: '',
},
];
// Act
const result = getGroupByFiltersFromGroupByValues(
rowData,
groupBy as BaseAutocompleteData[],
);
// Assert
expect(result).toBeDefined();
// The implementation includes all keys from rowData, not just those in groupBy
expect(result?.items?.length).toBeGreaterThanOrEqual(1);
// Should include the known field with the proper dataType from groupBy
const knownField = result?.items?.find(
(item) => item.key && item.key.key === 'http.method',
);
expect(knownField).toBeDefined();
if (knownField && knownField.key) {
expect(knownField.key.dataType).toBe(DataTypes.String);
}
// Should include the unknown field
const unknownField = result?.items?.find(
(item) => item.key && item.key.key === 'unknown.field',
);
expect(unknownField).toBeDefined();
if (unknownField && unknownField.key) {
expect(unknownField.key.dataType).toBe(DataTypes.String); // Default
}
});
it('should handle empty input', () => {
// Arrange
const rowData = {};
const groupBy: any[] = [];
// Act
const result = getGroupByFiltersFromGroupByValues(rowData, groupBy);
// Assert
expect(result).toBeDefined();
expect(result?.op).toBe('AND');
expect(result?.items).toHaveLength(0);
});
});
@@ -594,9 +703,13 @@ describe('API Monitoring Utils', () => {
it('should generate widget configuration for status code bar chart', () => {
// Arrange
const domainName = 'test-domain';
const filters = { items: [], op: 'AND' };
// Act
const result = getStatusCodeBarChartWidgetData(domainName, '');
const result = getStatusCodeBarChartWidgetData(
domainName,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
@@ -610,29 +723,47 @@ describe('API Monitoring Utils', () => {
const queryData = result.query.builder.queryData[0];
// Should have domain filter
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}'`,
const domainFilter = queryData.filters?.items?.find(
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
);
expect(domainFilter).toBeDefined();
if (domainFilter) {
expect(domainFilter.value).toBe(domainName);
}
});
it('should include custom filters in the widget configuration', () => {
// Arrange
const domainName = 'test-domain';
const customFilterExpression = "custom.key = 'custom-value'";
const customFilter = {
id: 'custom-filter',
key: {
dataType: 'string',
key: 'custom.key',
type: '',
},
op: '=',
value: 'custom-value',
};
const filters = { items: [customFilter], op: 'AND' };
// Act
const result = getStatusCodeBarChartWidgetData(
domainName,
customFilterExpression,
filters as IBuilderQuery['filters'],
);
// Assert
const queryData = result.query.builder.queryData[0];
// Should include our custom filter after the domain filter
expect(queryData.filter?.expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}' AND (${customFilterExpression})`,
// Should include our custom filter
const includedFilter = queryData.filters?.items?.find(
(item) => item.id === 'custom-filter',
);
expect(includedFilter).toBeDefined();
if (includedFilter) {
expect(includedFilter.value).toBe('custom-value');
}
});
});
@@ -796,17 +927,4 @@ describe('API Monitoring Utils', () => {
expect(latencyColumn?.title).toBeDefined();
});
});
describe('getHttpUrlExpression', () => {
it('returns no clause when no endpoint is picked', () => {
expect(getHttpUrlExpression('')).toBe('');
});
it('quotes the endpoint and escapes apostrophes', () => {
expect(getHttpUrlExpression('/x')).toBe("http_url = '/x'");
expect(getHttpUrlExpression("/users/o'brien")).toBe(
"http_url = '/users/o\\'brien'",
);
});
});
});

View File

@@ -1,17 +1,11 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import {
getAllEndpointsWidgetData,
getGroupByExpressionFromRowData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
import {
ALL_ENDPOINTS_EXPRESSION_KEY,
ENDPOINT_STATS_EXPRESSION_KEY,
} from 'container/ApiMonitoring/filterParamKeys';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import { cleanup, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
import { FilterProviders } from './FilterProviders';
import {
SPAN_ATTRIBUTES,
VIEWS,
@@ -20,7 +14,7 @@ import {
// Mock the dependencies
jest.mock('container/ApiMonitoring/utils', () => ({
getAllEndpointsWidgetData: jest.fn(),
getGroupByExpressionFromRowData: jest.fn(),
getGroupByFiltersFromGroupByValues: jest.fn(),
}));
jest.mock('container/WidgetCard/Card', () => ({
@@ -40,47 +34,53 @@ jest.mock('container/WidgetCard/Card', () => ({
)),
}));
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onRun }) => (
<div data-testid="query-builder-mock">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void => onRun("test = 'test'")}
>
Change Filter
</button>
</div>
)),
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="query-builder-mock">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void =>
onChange({
items: [{ id: 'test', key: 'test', op: '=', value: 'test' }],
op: 'AND',
})
}
>
Change Filter
</button>
</div>
)),
}),
);
jest.mock('hooks/queryBuilder/useGetAggregateKeys', () => ({
useGetAggregateKeys: jest.fn(),
}));
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
GroupByFilter: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="select-mock">
<button
data-testid="select-change-button"
type="button"
onClick={(): void =>
onChange([
{
id: 'http.status_code--string--attribute',
key: 'http.status_code',
dataType: 'string',
type: 'attribute',
},
])
}
>
Change GroupBy
</button>
</div>
)),
}));
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Select: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="select-mock">
<button
data-testid="select-change-button"
type="button"
onClick={(): void => onChange(['http.status_code'])}
>
Change GroupBy
</button>
</div>
)),
};
});
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
...jest.requireActual('container/ApiMonitoring/queryParams'),
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
@@ -88,6 +88,8 @@ jest.mock('container/ApiMonitoring/queryParams', () => ({
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
@@ -106,21 +108,29 @@ describe('AllEndPoints', () => {
startTime: 1609459200000,
endTime: 1609545600000,
},
};
const renderAllEndPoints = (): void => {
render(
<FilterProviders queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}>
<AllEndPoints {...mockProps} />
</FilterProviders>,
);
initialFilters: { op: 'AND', items: [] },
setInitialFiltersEndPointStats: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
resetNuqsState();
// Setup mock implementations
(useGetAggregateKeys as jest.Mock).mockReturnValue({
data: {
payload: {
attributeKeys: [
{
key: 'http.status_code',
dataType: 'string',
type: '',
},
],
},
},
isLoading: false,
});
(getAllEndpointsWidgetData as jest.Mock).mockReturnValue({
id: 'test-widget',
title: 'Endpoint Overview',
@@ -129,9 +139,10 @@ describe('AllEndPoints', () => {
queryData: [],
});
(getGroupByExpressionFromRowData as jest.Mock).mockReturnValue(
"status = '200'",
);
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
op: 'AND',
});
});
// Add cleanup after each test
@@ -140,7 +151,7 @@ describe('AllEndPoints', () => {
});
it('renders component correctly', () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Verify basic component rendering
expect(screen.getByText('Group by')).toBeInTheDocument();
@@ -150,7 +161,7 @@ describe('AllEndPoints', () => {
});
it('handles filter changes', () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
@@ -159,24 +170,25 @@ describe('AllEndPoints', () => {
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
expect.anything(),
'test-domain',
"test = 'test'",
expect.objectContaining({
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
op: 'AND',
}),
);
});
it('handles group by changes', () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Trigger group by change
fireEvent.click(screen.getByTestId('select-change-button'));
// The picked keys are stored as given and written to the URL as bare names
expect(mockProps.setGroupBy).toHaveBeenCalledWith([
expect.objectContaining({ key: 'http.status_code', type: 'attribute' }),
]);
// Check if setGroupBy was called with updated group by value
expect(mockProps.setGroupBy).toHaveBeenCalled();
});
it('handles row click in grid card', async () => {
renderAllEndPoints();
render(<AllEndPoints {...mockProps} />);
// Trigger row click
fireEvent.click(screen.getByTestId('row-click-button'));
@@ -184,11 +196,7 @@ describe('AllEndPoints', () => {
// Check if proper functions were called
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
expect(getGroupByExpressionFromRowData).toHaveBeenCalled();
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"status = '200'",
),
);
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
});
});

View File

@@ -14,12 +14,16 @@ import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
describe('AllEndpointsWidget - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyGroupBy: BaseAutocompleteData[] = [];
describe('1. V5 Format Migration - All Four Queries', () => {

View File

@@ -121,7 +121,8 @@ describe('DependentServices', () => {
);
});
it('keeps the table in place while loading', () => {
it('renders loading state correctly', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: true,
isRefetching: false,
@@ -130,15 +131,16 @@ describe('DependentServices', () => {
refetch: refetchFn,
};
render(
// Act
const { container } = render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
expect(screen.getByTestId('dependent-services-table')).toBeInTheDocument();
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('renders error state correctly', () => {
@@ -223,7 +225,8 @@ describe('DependentServices', () => {
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
});
it('shows the rows under the spinner while refetching', () => {
it('handles refetching state correctly', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: true,
@@ -232,16 +235,16 @@ describe('DependentServices', () => {
refetch: refetchFn,
};
render(
// Act
const { container } = render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
expect(screen.getByTestId('row-count')).toHaveTextContent('2');
expect(screen.getByTestId('table-row-0')).toHaveTextContent('auth-service');
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('handles row click correctly', () => {

View File

@@ -1,60 +0,0 @@
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { render, screen } from 'tests/test-utils';
import { VIEWS } from '../Explorer/Domains/DomainDetails/constants';
import DomainDetails from '../Explorer/Domains/DomainDetails/DomainDetails';
jest.mock('container/ApiMonitoring/queryParams', () => ({
...jest.requireActual('container/ApiMonitoring/queryParams'),
useApiMonitoringParams: jest.fn(),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/components/DomainMetrics', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/EndPointDetails', () => {
const { useInitialExpression } = jest.requireActual(
'components/QueryBuilderV2',
);
return {
__esModule: true,
default: function MockEndPointDetails(): JSX.Element {
return (
<span data-testid="endpoint-stats-scope">{useInitialExpression()}</span>
);
},
};
});
describe('DomainDetails', () => {
it('scopes Endpoint Stats to the domain and the picked endpoint', () => {
jest
.mocked(useApiMonitoringParams)
.mockReturnValue([
{ selectedView: VIEWS.ENDPOINT_STATS, selectedEndPointName: '/v1/charges' },
jest.fn(),
]);
render(
<DomainDetails
domainData={{ domainName: 'api.example.com' }}
handleClose={jest.fn()}
selectedDomainIndex={0}
setSelectedDomainIndex={jest.fn()}
domainListLength={1}
domainListFilterExpression=""
/>,
);
expect(screen.getByTestId('endpoint-stats-scope')).toHaveTextContent(
"kind_string = 'Client' AND http_host = 'api.example.com' AND http_url = '/v1/charges'",
);
});
});

View File

@@ -1,84 +0,0 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import DomainList from '../Explorer/Domains/DomainList';
const FILTER = "http_host = 'api.example.com'";
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(
({
onChange,
onRun,
}: {
onChange: (query: string) => void;
onRun?: (query: string) => void;
}) => (
<>
<button
type="button"
data-testid="query-search-type"
onClick={(): void => onChange("http_host = 'api.example.com'")}
>
Type
</button>
<button
type="button"
data-testid="query-search-run"
onClick={(): void => onRun?.("http_host = 'api.example.com'")}
>
Run
</button>
</>
),
),
}));
jest.mock('container/Toolbar/Toolbar', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('../Explorer/Domains/DomainDetails/DomainDetails', () => ({
__esModule: true,
default: (): null => null,
}));
// Under the test router it keeps rewriting the URL, which re-renders the page
// without end.
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
useShareBuilderUrl: jest.fn(),
}));
describe('DomainList', () => {
it('runs the typed filter from the filter box', async () => {
let listRequests = 0;
server.use(
rest.post('*/third-party-apis/overview/list', (_req, res, ctx) => {
listRequests += 1;
return res(ctx.status(200), ctx.json({ data: { data: { results: [] } } }));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<DomainList />, undefined, {
initialRoute: '/api-monitoring/explorer',
});
await waitFor(() => expect(listRequests).toBe(1));
await user.click(screen.getByTestId('query-search-type'));
await user.click(screen.getByTestId('query-search-run'));
await waitFor(() => expect(listRequests).toBe(2));
const [url] = safeNavigateMock.mock.calls[0];
const compositeQuery = new URLSearchParams(String(url).split('?')[1]).get(
'compositeQuery',
);
expect(decodeURIComponent(compositeQuery ?? '')).toContain(
`"expression":"${FILTER}"`,
);
});
});

View File

@@ -1,7 +1,5 @@
import { useQueries } from 'react-query';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import { fireEvent, render, screen } from '@testing-library/react';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
@@ -13,13 +11,14 @@ import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
import EndPointDetails from '../Explorer/Domains/DomainDetails/EndPointDetails';
import { FilterProviders } from './FilterProviders';
// Mock dependencies
jest.mock('react-query', () => ({
@@ -27,32 +26,72 @@ jest.mock('react-query', () => ({
useQueries: jest.fn(),
}));
// Mock useApiMonitoringParams hook
jest.mock('container/ApiMonitoring/queryParams', () => ({
useApiMonitoringParams: jest.fn().mockReturnValue([
{
showIP: true,
selectedDomain: '',
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
},
jest.fn(),
]),
}));
jest.mock('container/ApiMonitoring/utils', () => ({
END_POINT_DETAILS_QUERY_KEYS_ARRAY: jest.requireActual(
'container/ApiMonitoring/utils',
).END_POINT_DETAILS_QUERY_KEYS_ARRAY,
END_POINT_DETAILS_QUERY_KEYS_ARRAY: [
'endPointMetricsData',
'endPointStatusCodeData',
'endPointDropDownData',
'endPointDependentServicesData',
'endPointStatusCodeBarChartsData',
'endPointStatusCodeLatencyBarChartsData',
],
extractPortAndEndpoint: jest.fn(),
getHttpUrlExpression: jest.requireActual('container/ApiMonitoring/utils')
.getHttpUrlExpression,
getEndPointDetailsQueryPayload: jest.fn(),
getLatencyOverTimeWidgetData: jest.fn(),
getRateOverTimeWidgetData: jest.fn(),
}));
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onRun }) => (
<div data-testid="query-builder-search">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void => onRun("test.key = 'test-value'")}
>
Change Filter
</button>
</div>
)),
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="query-builder-search">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void =>
onChange({
items: [
{
id: 'test-filter',
key: {
key: 'test.key',
dataType: DataTypes.String,
type: 'tag',
},
op: '=',
value: 'test-value',
},
],
op: 'AND',
})
}
>
Change Filter
</button>
</div>
)),
}),
);
// Mock all child components to simplify testing
jest.mock(
@@ -68,15 +107,15 @@ jest.mock(
);
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
default: jest.fn().mockImplementation(({ setSelectedEndPointName }) => (
<div data-testid="endpoints-dropdown">
<button
type="button"
data-testid="select-endpoint-button"
onClick={(): void => onChange('/api/new-endpoint')}
onClick={(): void => setSelectedEndPointName('/api/new-endpoint')}
>
Select Endpoint
</button>
@@ -133,23 +172,8 @@ jest.mock(
}),
);
function renderStats(ui: JSX.Element, linkFilter = ''): void {
render(
<FilterProviders
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
searchParams={
linkFilter
? `${ENDPOINT_STATS_EXPRESSION_KEY}=${encodeURIComponent(linkFilter)}`
: ''
}
>
{ui}
</FilterProviders>,
);
}
describe('EndPointDetails Component', () => {
const mockQueryResults = Array(5).fill({
const mockQueryResults = Array(6).fill({
data: { data: [] },
isLoading: false,
isError: false,
@@ -160,6 +184,7 @@ describe('EndPointDetails Component', () => {
domainName: 'test-domain',
endPointName: '/api/test',
setSelectedEndPointName: jest.fn(),
initialFilters: { items: [], op: 'AND' } as TagFilter,
timeRange: {
startTime: 1609459200000,
endTime: 1609545600000,
@@ -172,7 +197,6 @@ describe('EndPointDetails Component', () => {
beforeEach(() => {
jest.clearAllMocks();
resetNuqsState();
(extractPortAndEndpoint as jest.Mock).mockReturnValue({
port: '8080',
@@ -185,6 +209,7 @@ describe('EndPointDetails Component', () => {
{ id: 'query3', label: 'Query 3' },
{ id: 'query4', label: 'Query 4' },
{ id: 'query5', label: 'Query 5' },
{ id: 'query6', label: 'Query 6' },
]);
(getRateOverTimeWidgetData as jest.Mock).mockReturnValue({
@@ -201,7 +226,7 @@ describe('EndPointDetails Component', () => {
});
it('renders the component correctly', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
// Check all major components are rendered
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
@@ -221,29 +246,44 @@ describe('EndPointDetails Component', () => {
});
it('calls getEndPointDetailsQueryPayload with correct parameters', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
op: 'AND',
}),
);
});
it('adds endpoint filter to initial filters', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
);
});
it('updates filters when the search expression is run', async () => {
renderStats(<EndPointDetails {...mockProps} />);
it('updates filters when QueryBuilderSearch changes', () => {
render(<EndPointDetails {...mockProps} />);
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
@@ -254,37 +294,19 @@ describe('EndPointDetails Component', () => {
expect.anything(),
expect.anything(),
expect.anything(),
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test' AND (test.key = 'test-value')`,
);
await waitFor(() =>
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
"test.key = 'test-value'",
),
);
});
it('keeps the endpoint out of the editor and the picker options', () => {
renderStats(<EndPointDetails {...mockProps} />, "service.name = 'checkout'");
expect(QuerySearch).toHaveBeenLastCalledWith(
expect.objectContaining({
queryData: expect.objectContaining({
filter: { expression: "service.name = 'checkout'" },
}),
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'test.key' }),
value: 'test-value',
}),
]),
}),
expect.anything(),
);
expect(EndPointPicker).toHaveBeenLastCalledWith(
expect.objectContaining({
filterExpression: "service.name = 'checkout'",
value: '/api/test',
}),
expect.anything(),
);
});
it('handles endpoint dropdown selection', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
// Trigger endpoint selection
fireEvent.click(screen.getByTestId('select-endpoint-button'));
@@ -296,50 +318,63 @@ describe('EndPointDetails Component', () => {
});
it('does not display dependent services when service filter is applied', () => {
renderStats(
<EndPointDetails {...mockProps} />,
"service.name = 'test-service'",
);
const propsWithServiceFilter = {
...mockProps,
initialFilters: {
items: [
{
id: 'service-filter',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
},
op: '=',
value: 'test-service',
},
] as TagFilterItem[],
op: 'AND',
} as TagFilter,
};
render(<EndPointDetails {...propsWithServiceFilter} />);
// Dependent services should not be displayed
expect(screen.queryByTestId('dependent-services')).not.toBeInTheDocument();
});
it('passes the correct parameters to widget data generators', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
expect(getRateOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/api/test',
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
);
expect(getLatencyOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/api/test',
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
);
});
it('keeps the previous dependent services while a new filter loads', () => {
renderStats(<EndPointDetails {...mockProps} />);
const queries = jest.mocked(useQueries).mock.calls[0][0];
expect(
queries.map(({ queryKey, keepPreviousData }) => [
(queryKey as unknown[])[0],
keepPreviousData,
]),
).toStrictEqual(
END_POINT_DETAILS_QUERY_KEYS_ARRAY.map((key) => [
key,
key === REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
]),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
value: '/api/test',
}),
]),
}),
);
});
it('generates correct query parameters for useQueries', () => {
renderStats(<EndPointDetails {...mockProps} />);
render(<EndPointDetails {...mockProps} />);
// Check if useQueries was called with correct parameters
expect(useQueries).toHaveBeenCalledWith(

View File

@@ -1,115 +0,0 @@
import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
const mockGetMetricQueryRange = jest.mocked(GetMetricQueryRange);
const rowsResponse = (urls: string[]): MetricQueryRangeSuccessResponse => ({
statusCode: 200,
message: 'success',
error: null,
payload: {
data: {
resultType: '',
newResult: { data: { result: [], resultType: '' } },
result: [
{
metric: {},
queryName: 'A',
values: [],
table: {
columns: [],
rows: urls.map((url) => ({
data: { [SPAN_ATTRIBUTES.HTTP_URL]: url, A: 1 },
})),
},
},
],
},
},
});
const baseProps = {
domainName: 'api.example.com',
timeRange: { startTime: 1000, endTime: 2000 },
filterExpression: "service.name = 'checkout'",
value: '',
};
describe('EndPointPicker', () => {
beforeEach(() => {
mockGetMetricQueryRange.mockResolvedValue(
rowsResponse(['/v1/charges', '/v1/refunds']),
);
});
it('loads options for the domain scoped by the user filter', async () => {
render(<EndPointPicker {...baseProps} onChange={jest.fn()} />);
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
const payload: GetQueryResultsProps =
mockGetMetricQueryRange.mock.calls[0][0];
expect(payload.query.builder.queryData[0].filter?.expression).toBe(
`http_host = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'checkout')`,
);
});
it('reports the picked endpoint', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const onChange = jest.fn() as jest.MockedFunction<
(endPointName: string) => void
>;
render(<EndPointPicker {...baseProps} onChange={onChange} />);
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
await user.click(screen.getByTestId('endpoint-picker'));
await user.click(await screen.findByText('/v1/refunds'));
expect(onChange).toHaveBeenCalledWith('/v1/refunds');
});
it('shows All endpoints when none is picked and clears through it', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const onChange = jest.fn() as jest.MockedFunction<
(endPointName: string) => void
>;
const { rerender } = render(
<EndPointPicker {...baseProps} onChange={onChange} />,
);
expect(screen.getByTestId('endpoint-picker')).toHaveTextContent(
'All endpoints',
);
rerender(
<EndPointPicker {...baseProps} value="/v1/charges" onChange={onChange} />,
);
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
await user.click(screen.getByTestId('endpoint-picker'));
await user.click(
await screen.findByRole('option', { name: 'All endpoints' }),
);
expect(onChange).toHaveBeenCalledWith('');
});
it('shows the selected endpoint', async () => {
render(
<EndPointPicker {...baseProps} value="/v1/charges" onChange={jest.fn()} />,
);
await waitFor(() =>
expect(screen.getByText('/v1/charges')).toBeInTheDocument(),
);
});
});

View File

@@ -0,0 +1,210 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import EndPointsDropDown from '../Explorer/Domains/DomainDetails/components/EndPointsDropDown';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
// Mock the Select component from antd
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Select: jest
.fn()
.mockImplementation(({ value, loading, onChange, options, onClear }) => (
<div data-testid="mock-select">
<div data-testid="select-value">{value}</div>
<div data-testid="select-loading">
{loading ? 'loading' : 'not-loading'}
</div>
<select
data-testid="select-element"
value={value || ''}
onChange={(e): void => onChange(e.target.value)}
>
<option value="">Select...</option>
{options?.map((option: { value: string; label: string; key: string }) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button data-testid="select-clear-button" type="button" onClick={onClear}>
Clear
</button>
</div>
)),
};
});
// Mock the utilities
jest.mock('container/ApiMonitoring/utils', () => ({
getFormattedEndPointDropDownData: jest.fn(),
}));
describe('EndPointsDropDown Component', () => {
const mockEndPoints = [
{ key: '1', value: '/api/endpoint1', label: '/api/endpoint1' },
{ key: '2', value: '/api/endpoint2', label: '/api/endpoint2' },
];
const mockSetSelectedEndPointName = jest.fn();
// Create a mock that satisfies the UseQueryResult interface
const createMockQueryResult = (overrides: any = {}): any => ({
data: {
payload: {
data: {
result: [
{
table: {
rows: [],
},
},
],
},
},
},
dataUpdatedAt: 0,
error: null,
errorUpdatedAt: 0,
failureCount: 0,
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isIdle: false,
isLoading: false,
isLoadingError: false,
isPlaceholderData: false,
isPreviousData: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isSuccess: true,
refetch: jest.fn(),
remove: jest.fn(),
status: 'success',
...overrides,
});
const defaultProps = {
selectedEndPointName: '',
setSelectedEndPointName: mockSetSelectedEndPointName,
endPointDropDownDataQuery: createMockQueryResult(),
};
beforeEach(() => {
jest.clearAllMocks();
(getFormattedEndPointDropDownData as jest.Mock).mockReturnValue(
mockEndPoints,
);
});
it('renders the component correctly', () => {
render(<EndPointsDropDown {...defaultProps} />);
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
expect(screen.getByTestId('select-loading')).toHaveTextContent('not-loading');
});
it('shows loading state when data is loading', () => {
const loadingProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
isLoading: true,
}),
};
render(<EndPointsDropDown {...loadingProps} />);
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
});
it('shows loading state when data is fetching', () => {
const fetchingProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
isFetching: true,
}),
};
render(<EndPointsDropDown {...fetchingProps} />);
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
});
it('displays the selected endpoint', () => {
const selectedProps = {
...defaultProps,
selectedEndPointName: '/api/endpoint1',
};
render(<EndPointsDropDown {...selectedProps} />);
expect(screen.getByTestId('select-value')).toHaveTextContent(
'/api/endpoint1',
);
});
it('calls setSelectedEndPointName when an option is selected', () => {
render(<EndPointsDropDown {...defaultProps} />);
// Get the select element and change its value
const selectElement = screen.getByTestId('select-element');
fireEvent.change(selectElement, { target: { value: '/api/endpoint2' } });
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('/api/endpoint2');
});
it('calls setSelectedEndPointName with empty string when cleared', () => {
render(<EndPointsDropDown {...defaultProps} />);
// Click the clear button
const clearButton = screen.getByTestId('select-clear-button');
fireEvent.click(clearButton);
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('');
});
it('passes dropdown style prop correctly', () => {
const styleProps = {
...defaultProps,
dropdownStyle: { maxHeight: '200px' },
};
render(<EndPointsDropDown {...styleProps} />);
// We can't easily test style props in our mock, but at least ensure the component rendered
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
});
it('formats data using the utility function', () => {
const mockRows = [
{ data: { [SPAN_ATTRIBUTES.HTTP_URL]: '/api/test', A: 10 } },
];
const dataProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
data: {
payload: {
data: {
result: [
{
table: {
rows: mockRows,
},
},
],
},
},
},
}),
};
render(<EndPointsDropDown {...dataProps} />);
expect(getFormattedEndPointDropDownData).toHaveBeenCalledWith(mockRows);
});
});

View File

@@ -1,8 +1,8 @@
/**
* V5 Migration Tests for Endpoint Dropdown Query
*
* These tests validate the migration from V4 to V5 format for
* getEndPointDropDownQueryPayload (endpoint picker options):
* These tests validate the migration from V4 to V5 format for the third payload
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
* - Filter format change: filters.items[] → filter.expression
* - Domain handling: http_host = '${domainName}'
* - Kind filter: kind_string = 'Client'
@@ -10,8 +10,9 @@
* - Aggregation: count() expression
* - GroupBy: http_url with type 'attribute'
*/
import { getEndPointDropDownQueryPayload } from 'container/ApiMonitoring/utils';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
@@ -19,16 +20,22 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. V5 Format Migration - Structure and Base Filters', () => {
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
const dropdownQuery = getEndPointDropDownQueryPayload(
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
emptyFilters,
);
// Third payload is the endpoint dropdown query (index 2)
const dropdownQuery = payload[2];
const queryA = dropdownQuery.query.builder.queryData[0];
// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
@@ -70,21 +77,46 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
describe('2. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const dropdownQuery = getEndPointDropDownQueryPayload(
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
customFilters,
);
const dropdownQuery = payload[2];
const expression =
dropdownQuery.query.builder.queryData[0].filter?.expression;
// Exact filter expression with custom filters merged
expect(expression).toBe(
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'user-service' AND deployment.environment = 'production')`,
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS service.name = 'user-service' AND deployment.environment = 'production'`,
);
});
});

View File

@@ -1,34 +0,0 @@
import { ReactNode } from 'react';
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { onNuqsUrlUpdate } from 'tests/nuqs-helpers';
/** Mounts a drawer tab's filter provider over an in-memory URL. */
export function FilterProviders({
queryParamKey,
initialExpression,
searchParams = '',
children,
}: {
queryParamKey: string;
initialExpression?: string;
searchParams?: string;
children: ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
hasMemory
>
<QuerySearchV2Provider
queryParamKey={queryParamKey}
initialExpression={initialExpression}
persistOnUnmount
>
{children}
</QuerySearchV2Provider>
</NuqsTestingAdapter>
);
}

View File

@@ -2,11 +2,16 @@ import {
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('MetricOverTime - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockEndpointName = '/api/users';
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. Rate Over Time - V5 Payload Structure', () => {
it('generates V5 filter expression format (not V3 filters.items)', () => {
@@ -46,8 +51,31 @@ describe('MetricOverTime - V5 Migration Validation', () => {
});
it('merges custom filters into filter expression', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const widget = getRateOverTimeWidgetData(
mockDomainName,
@@ -109,8 +137,21 @@ describe('MetricOverTime - V5 Migration Validation', () => {
});
it('merges custom filters into filter expression', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'user-service',
},
],
op: 'AND',
};
const widget = getLatencyOverTimeWidgetData(
mockDomainName,
@@ -122,7 +163,7 @@ describe('MetricOverTime - V5 Migration Validation', () => {
// Verify domain filter is present
expect(queryData?.filter?.expression).toContain(
`http_host = '${mockDomainName}' AND (service.name = 'user-service'`,
`http_host = '${mockDomainName}' service.name = 'user-service'`,
);
});
});

View File

@@ -5,6 +5,7 @@ import {
getStatusCodeBarChartWidgetData,
} from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
import StatusCodeBarCharts from '../Explorer/Domains/DomainDetails/components/StatusCodeBarCharts';
@@ -155,6 +156,7 @@ jest.mock('antd', () => {
describe('StatusCodeBarCharts', () => {
// Default props for tests
const mockFilters: IBuilderQuery['filters'] = { items: [], op: 'AND' };
const mockTimeRange = {
startTime: 1609459200000,
endTime: 1609545600000,
@@ -249,7 +251,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -284,7 +286,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -326,7 +328,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -370,7 +372,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -417,7 +419,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression=""
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -431,13 +433,26 @@ describe('StatusCodeBarCharts', () => {
// but we've confirmed the function is mocked and ready to be tested
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
mockDomainName,
'',
expect.objectContaining({
items: [],
op: 'AND',
}),
);
});
it('handles widget generation with current filters', () => {
// Arrange
const mockCustomFilterExpression = "test-key = 'test-value'";
const mockCustomFilters = {
items: [
{
id: 'custom-filter',
key: { key: 'test-key' },
op: '=',
value: 'test-value',
},
],
op: 'AND',
};
const mockData = {
payload: mockFormattedData,
@@ -465,7 +480,7 @@ describe('StatusCodeBarCharts', () => {
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
filterExpression={mockCustomFilterExpression}
filters={mockCustomFilters as IBuilderQuery['filters']}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
@@ -474,7 +489,12 @@ describe('StatusCodeBarCharts', () => {
// Assert widget creation was called with the correct parameters
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
mockDomainName,
mockCustomFilterExpression,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ id: 'custom-filter' }),
]),
op: 'AND',
}),
);
});
});

View File

@@ -2,7 +2,7 @@
* V5 Migration Tests for Status Code Bar Chart Queries
*
* These tests validate the migration to V5 format for the bar chart payloads
* in getEndPointDetailsQueryPayload (4th and 5th payloads):
* in getEndPointDetailsQueryPayload (5th and 6th payloads):
* - Number of Calls Chart (count aggregation)
* - Latency Chart (p99 aggregation)
*
@@ -15,12 +15,16 @@
*/
import { TraceAggregation } from 'api/v5/v5';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('StatusCodeBarCharts - V5 Migration Validation', () => {
const mockDomainName = '0.0.0.0';
const mockStartTime = 1762573673000;
const mockEndTime = 1762832873000;
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
it('generates correct V5 payload for count aggregation grouped by status code', () => {
@@ -31,8 +35,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
emptyFilters,
);
// 4th payload (index 3) is the number of calls bar chart
const callsChartQuery = payload[3];
// 5th payload (index 4) is the number of calls bar chart
const callsChartQuery = payload[4];
const queryA = callsChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
@@ -80,8 +84,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
emptyFilters,
);
// 5th payload (index 4) is the latency bar chart
const latencyChartQuery = payload[4];
// 6th payload (index 5) is the latency bar chart
const latencyChartQuery = payload[5];
const queryA = latencyChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
@@ -126,8 +130,31 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression for both charts', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
@@ -136,8 +163,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
customFilters,
);
const callsChartQuery = payload[3];
const latencyChartQuery = payload[4];
const callsChartQuery = payload[4];
const latencyChartQuery = payload[5];
const callsExpression =
callsChartQuery.query.builder.queryData[0].filter?.expression;

View File

@@ -13,12 +13,16 @@
*/
import { TraceAggregation } from 'api/v5/v5';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('StatusCodeTable - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters = '';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. V5 Format Migration with Base Filters', () => {
it('migrates to V5 format with correct filter expression structure and base filters', () => {
@@ -105,8 +109,31 @@ describe('StatusCodeTable - V5 Migration Validation', () => {
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters =
"service.name = 'user-service' AND deployment.environment = 'production'";
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,

View File

@@ -1,40 +1,31 @@
import { BuilderQuery } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
import { getTopErrorsQueryPayload } from '../utils';
interface QueryRangeRequestBody {
compositeQuery: { queries: { spec: { filter: { expression: string } } }[] };
}
// Mock the EndPointPicker component to avoid issues
// Mock the EndPointsDropDown component to avoid issues
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }): JSX.Element => (
<div data-testid="endpoints-dropdown-mock">
<select
data-testid="endpoints-select"
onChange={(e): void => onChange(e.target.value)}
>
<option value="/api/test">/api/test</option>
<option value="/api/new-endpoint">/api/new-endpoint</option>
</select>
</div>
)),
default: jest
.fn()
.mockImplementation(({ setSelectedEndPointName }): JSX.Element => (
<div data-testid="endpoints-dropdown-mock">
<select
data-testid="endpoints-select"
onChange={(e): void => setSelectedEndPointName(e.target.value)}
>
<option value="/api/test">/api/test</option>
<option value="/api/new-endpoint">/api/new-endpoint</option>
</select>
</div>
)),
}),
);
@@ -52,7 +43,10 @@ describe('TopErrors', () => {
startTime: 1000000000,
endTime: 1000010000,
},
initialFilterExpression: '',
initialFilters: {
items: [],
op: 'AND',
},
};
// Helper function to wait for table data to load
@@ -233,30 +227,22 @@ describe('TopErrors', () => {
});
});
it('scopes the query to the endpoint picked in the picker', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const expressions: string[] = [];
server.use(
rest.post(V5_QUERY_RANGE_API_PATH, async (req) => {
const body: QueryRangeRequestBody = await req.json();
expressions.push(body.compositeQuery.queries[0].spec.filter.expression);
}),
);
it('updates endpoint filter when dropdown value changes', async () => {
render(<TopErrors {...mockProps} />);
await waitFor(() => expect(expressions).toHaveLength(1));
expect(expressions[0]).not.toContain(`${SPAN_ATTRIBUTES.HTTP_URL} =`);
await user.selectOptions(
screen.getByTestId('endpoints-select'),
'/api/new-endpoint',
);
// Wait for initial load
await waitFor(() => {
expect(expressions[expressions.length - 1]).toContain(
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/new-endpoint'`,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// Find the dropdown
const dropdown = screen.getByRole('combobox');
// Mock the change
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
// Component should re-render with new filter
expect(dropdown).toBeInTheDocument();
});
it('handles status message toggle correctly', async () => {
@@ -320,7 +306,7 @@ describe('TopErrors', () => {
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
'',
{ items: [], op: 'AND' },
false,
);

View File

@@ -2,7 +2,6 @@ import {
ApiMonitoringParams,
DEFAULT_PARAMS,
getApiMonitoringParams,
getLegacyFilterExpressions,
setApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { mockNavigate } from 'tests/router-mocks';
@@ -53,50 +52,6 @@ describe('API Monitoring Query Params', () => {
expect(getApiMonitoringParams(search)).toStrictEqual(DEFAULT_PARAMS);
});
it('converts filter items saved by older links into an expression', () => {
const legacyParams = {
selectedDomain: 'test-domain',
allEndpointsLocalFilters: {
op: 'AND',
items: [
{
id: 'a',
key: { key: 'service.name', dataType: 'string', type: 'resource' },
op: '=',
value: 'frontend',
},
],
},
endPointDetailsLocalFilters: {
op: 'AND',
items: [
{
id: 'b',
key: { key: 'http.status_code', dataType: 'string', type: '' },
op: '=',
value: '500',
},
],
},
};
const urlParams = new URLSearchParams();
urlParams.set(
'apiMonitoringParams',
encodeURIComponent(JSON.stringify(legacyParams)),
);
const search = `?${urlParams.toString()}`;
expect(getLegacyFilterExpressions(search)).toStrictEqual({
allEndpoints: "service.name = 'frontend'",
endPointDetails: "http.status_code = '500'",
});
const result = getApiMonitoringParams(search);
expect(result.selectedDomain).toBe('test-domain');
expect(result).not.toHaveProperty('allEndpointsLocalFilters');
expect(result).not.toHaveProperty('endPointDetailsLocalFilters');
});
});
describe('setApiMonitoringParams', () => {

View File

@@ -1,65 +0,0 @@
import { ALL_ENDPOINTS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import {
getApiMonitoringParams,
useApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
import { act, renderHook } from 'tests/test-utils';
// The stub writes to the browser URL, which the hook reads back; the router's
// own location (`useAppLocation`) stays at its first value.
function renderParamsHook(): ReturnType<
typeof renderHook<ReturnType<typeof useApiMonitoringParams>, unknown>
> {
mockNavigate().mockImplementation((to) => {
const url = typeof to === 'string' ? to : `/${to.search ?? ''}`;
window.history.pushState(null, '', url);
});
mockAppLocation();
return renderHook(() => useApiMonitoringParams());
}
describe('useApiMonitoringParams', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/');
});
it('keeps every write made before the next render', () => {
const { result } = renderParamsHook();
act(() => {
const [, setParams] = result.current;
setParams({ selectedView: 'endpoint_stats' });
setParams({ selectedInterval: '15m' });
});
expect(getApiMonitoringParams(window.location.search)).toMatchObject({
selectedView: 'endpoint_stats',
selectedInterval: '15m',
});
});
it('keeps a drawer filter written outside the router', () => {
window.history.replaceState(
null,
'',
`/?${ALL_ENDPOINTS_EXPRESSION_KEY}=${encodeURIComponent(
"service.name = 'cart'",
)}`,
);
const { result } = renderParamsHook();
act(() => {
const [, setParams] = result.current;
setParams({ selectedView: 'endpoint_stats' });
});
const search = new URLSearchParams(window.location.search);
expect(search.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
"service.name = 'cart'",
);
expect(getApiMonitoringParams(window.location.search).selectedView).toBe(
'endpoint_stats',
);
});
});

View File

@@ -1,5 +0,0 @@
// The drawer filters live in their own URL params, owned by `QuerySearchV2Provider`.
export const ALL_ENDPOINTS_EXPRESSION_KEY =
'apiMonitoringAllEndpointsExpression';
export const ENDPOINT_STATS_EXPRESSION_KEY =
'apiMonitoringEndpointStatsExpression';

View File

@@ -1,10 +1,6 @@
import { useCallback } from 'react';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { navigate } from 'lib/router/navigation';
import { useAppLocation } from 'lib/router/useAppLocation';
import { omit } from 'lodash-es';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { navigate } from 'lib/router/navigation';
// --- Types for all API Monitoring query params ---
export interface ApiMonitoringParams {
@@ -13,6 +9,8 @@ export interface ApiMonitoringParams {
selectedView?: string;
selectedEndPointName?: string;
groupBy?: string[];
allEndpointsLocalFilters?: any;
endPointDetailsLocalFilters?: any;
modalTimeRange?: { startTime: number; endTime: number };
selectedInterval?: string;
// Add more params as needed
@@ -24,6 +22,8 @@ export const DEFAULT_PARAMS: ApiMonitoringParams = {
selectedView: 'all_endpoints',
selectedEndPointName: '',
groupBy: [],
allEndpointsLocalFilters: undefined,
endPointDetailsLocalFilters: undefined,
modalTimeRange: undefined,
selectedInterval: undefined,
};
@@ -35,50 +35,15 @@ function encodeParams(params: ApiMonitoringParams): string {
return encodeURIComponent(JSON.stringify(params));
}
// Older shared links carry the filters as TagFilter items.
type LegacyApiMonitoringParams = ApiMonitoringParams & {
allEndpointsLocalFilters?: TagFilter;
endPointDetailsLocalFilters?: TagFilter;
};
function parseParams(value: string | null): LegacyApiMonitoringParams | null {
function decodeParams(value: string | null): ApiMonitoringParams {
if (!value) {
return null;
return DEFAULT_PARAMS;
}
try {
return JSON.parse(decodeURIComponent(value));
} catch {
return null;
}
}
// Drops the legacy filters, so the next write leaves them out of the URL.
function decodeParams(value: string | null): ApiMonitoringParams {
const parsed = parseParams(value);
if (!parsed) {
return DEFAULT_PARAMS;
}
return omit(parsed, [
'allEndpointsLocalFilters',
'endPointDetailsLocalFilters',
]);
}
export interface LegacyFilterExpressions {
allEndpoints?: string;
endPointDetails?: string;
}
export function getLegacyFilterExpressions(
search: string,
): LegacyFilterExpressions {
const parsed = parseParams(new URLSearchParams(search).get(PARAM_KEY));
const toExpression = (filters?: TagFilter): string | undefined =>
filters ? convertFiltersToExpression(filters).expression : undefined;
return {
allEndpoints: toExpression(parsed?.allEndpointsLocalFilters),
endPointDetails: toExpression(parsed?.endPointDetailsLocalFilters),
};
}
// --- Read query params from URL ---
@@ -109,18 +74,11 @@ export function useApiMonitoringParams(): [
const location = useAppLocation();
const params = getApiMonitoringParams(location.search);
// Read the browser's URL, not the router's: several writes can land before
// the next render, and the drawer filters are written by nuqs, which the
// router's location does not see.
const setParams = useCallback(
(newParams: Partial<ApiMonitoringParams>, replace = false) => {
setApiMonitoringParams(
newParams,
`?${getUnstableCurrentSearchParams().toString()}`,
replace,
);
setApiMonitoringParams(newParams, location.search, replace);
},
[],
[location.search],
);
return [params, setParams];

View File

@@ -3,8 +3,7 @@ import { Color } from '@signozhq/design-tokens';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Progress } from '@signozhq/ui/progress';
import { Badge } from '@signozhq/ui/badge';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
import {
FiltersType,
IQuickFiltersConfig,
@@ -57,28 +56,30 @@ export const getDisplayValue = (value: unknown): string =>
isEmptyFilterValue(value) ? '-' : String(value);
export const getDomainNameFilterExpression = (domainName: string): string =>
`${SPAN_ATTRIBUTES.SERVER_NAME} = ${formatValueForExpression(domainName)}`;
`http_host = '${domainName}'`;
export const clientKindExpression = "kind_string = 'Client'";
export const clientKindExpression = `kind_string = 'Client'`;
export const getHttpUrlExpression = (endPointName: string): string =>
endPointName
? `${SPAN_ATTRIBUTES.HTTP_URL} = ${formatValueForExpression(endPointName, '=')}`
: '';
/**
* Converts filters to expression
* @param filters Filters to convert
* @param baseExpression Base expression to combine with filters
* @returns Filter expression string
*/
export const convertFiltersWithUrlHandling = (
filters: IBuilderQuery['filters'],
baseExpression: string,
): string => {
if (!filters) {
return baseExpression;
}
// The domain (and endpoint) the drawer is locked to, built from the same
// expressions as the requests' filters so the chip shows what runs.
export const getDomainScopeExpression = (
domainName: string,
endPointName = '',
): string =>
[
clientKindExpression,
getDomainNameFilterExpression(domainName),
getHttpUrlExpression(endPointName),
]
.filter(Boolean)
.join(' AND ');
const { filter } = convertFiltersToExpressionWithExistingQuery(
filters,
baseExpression,
);
return filter.expression;
};
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
{
@@ -346,7 +347,7 @@ export const getDomainMetricsQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
@@ -367,9 +368,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)} AND ${urlExpression}`,
filterExpression,
),
},
expression: 'A',
@@ -395,9 +396,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)}`,
filterExpression,
),
},
expression: 'B',
@@ -423,9 +424,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)} AND has_error = true`,
filterExpression,
),
},
expression: 'C',
@@ -451,9 +452,9 @@ export const getDomainMetricsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(domainName)}`,
filterExpression,
),
},
expression: 'D',
@@ -823,7 +824,7 @@ export const getEndPointsQueryPayload = (
function buildFilterExpression(
domainName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
showStatusCodeErrors: boolean,
): string {
const baseFilterParts = [
@@ -835,22 +836,21 @@ function buildFilterExpression(
if (showStatusCodeErrors) {
baseFilterParts.push('status_message EXISTS');
}
return combineInitialAndUserExpression(
baseFilterParts.join(' AND '),
filterExpression,
);
const filterExpression = baseFilterParts.join(' AND ');
return convertFiltersWithUrlHandling(filters, filterExpression);
}
export const getTopErrorsQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
showStatusCodeErrors = true,
): QueryRangePayloadV5 => {
const combinedExpression = buildFilterExpression(
const filterExpression = buildFilterExpression(
domainName,
filterExpression,
filters,
showStatusCodeErrors,
);
@@ -869,7 +869,7 @@ export const getTopErrorsQueryPayload = (
stepInterval: 60,
disabled: false,
aggregations: [{ expression: 'count()' }],
filter: { expression: combinedExpression },
filter: { expression: filterExpression },
groupBy: [
{
name: SPAN_ATTRIBUTES.HTTP_URL,
@@ -1367,14 +1367,15 @@ export const createFiltersForSelectedRowData = (
// First query payload for endpoint metrics
// Second query payload for endpoint status code table
// Third query payload for endpoint dependant services
// Fourth query payload for endpoint response status count bar chart
// Fifth query payload for endpoint response status code latency bar chart
// Third query payload for endpoint dropdown selection
// Fourth query payload for endpoint dependant services
// Fifth query payload for endpoint response status count bar chart
// Sixth query payload for endpoint response status code latency bar chart
export const getEndPointDetailsQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
@@ -1393,11 +1394,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1423,11 +1424,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1453,11 +1454,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'C',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true`,
filterExpression,
),
},
functions: [],
@@ -1483,11 +1484,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'D',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1513,11 +1514,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'E',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1585,11 +1586,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
functions: [],
@@ -1621,11 +1622,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
functions: [],
@@ -1659,11 +1660,11 @@ export const getEndPointDetailsQueryPayload = (
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
filterExpression,
),
},
expression: 'C',
@@ -1712,6 +1713,77 @@ export const getEndPointDetailsQueryPayload = (
end,
step: 60,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregations: [
{
expression: 'count()',
},
],
timeAggregation: 'count',
spaceAggregation: 'sum',
functions: [],
filter: {
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
),
},
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
legend: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
dataType: DataTypes.String,
type: 'attribute',
},
],
reduceTo: ReduceOperators.AVG,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: true,
start,
end,
step: 60,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
@@ -1729,11 +1801,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1766,11 +1838,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1803,11 +1875,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'C',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1840,11 +1912,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: true,
expression: 'D',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true`,
filterExpression,
),
},
functions: [],
@@ -1919,11 +1991,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -1990,11 +2062,11 @@ export const getEndPointDetailsQueryPayload = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression}`,
filterExpression,
),
},
functions: [],
@@ -2046,82 +2118,100 @@ export const getEndPointDetailsQueryPayload = (
},
];
export const getEndPointDropDownQueryPayload = (
export const getEndPointZeroStateQueryPayload = (
domainName: string,
start: number,
end: number,
filterExpression: string,
): GetQueryResultsProps => ({
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregations: [
{
expression: 'count()',
},
],
timeAggregation: 'count',
spaceAggregation: 'sum',
functions: [],
filter: {
expression: combineInitialAndUserExpression(
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
filterExpression,
),
},
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
legend: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
): GetQueryResultsProps[] => [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TABLE,
query: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: {
dataType: DataTypes.String,
type: 'attribute',
key: '',
type: '',
},
],
reduceTo: ReduceOperators.AVG,
timeAggregation: 'count',
spaceAggregation: 'sum',
functions: [],
filters: {
items: [
{
id: '3db61dd6',
key: {
key: SPAN_ATTRIBUTES.SERVER_NAME,
dataType: DataTypes.String,
type: '',
},
op: '=',
value: domainName,
},
{
id: '212678b9',
key: {
key: 'kind_string',
dataType: DataTypes.String,
type: '',
},
op: '=',
value: 'Client',
},
],
op: 'AND',
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
legend: '',
limit: null,
orderBy: [],
groupBy: [
{
key: SPAN_ATTRIBUTES.HTTP_URL,
dataType: DataTypes.String,
type: 'tag',
},
],
reduceTo: ReduceOperators.AVG,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryFormulas: [],
queryTraceOperator: [],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
variables: {},
formatForWeb: true,
start,
end,
step: 60,
},
variables: {},
formatForWeb: true,
start,
end,
step: 60,
});
];
interface EndPointMetricsResponseRow {
data: {
@@ -2279,8 +2369,8 @@ export const statusCodeWidgetInfo = [
interface EndPointDropDownResponseRow {
data: {
[SPAN_ATTRIBUTES.HTTP_URL]?: string;
A?: number;
[SPAN_ATTRIBUTES.HTTP_URL]: string;
A: number;
};
}
@@ -2622,7 +2712,7 @@ export const groupStatusCodes = (
export const getStatusCodeBarChartWidgetData = (
domainName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
): Widgets => ({
query: {
builder: {
@@ -2638,13 +2728,22 @@ export const getStatusCodeBarChartWidgetData = (
dataSource: DataSource.TRACES,
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
getDomainNameFilterExpression(domainName),
filterExpression,
),
filters: {
items: [
{
id: 'c6724407',
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: '',
},
op: '=',
value: domainName,
},
...(filters?.items || []),
],
op: 'AND',
},
filters: { items: [], op: 'AND' },
functions: [],
groupBy: [],
having: [],
@@ -2725,15 +2824,18 @@ export const getFormattedEndPointStatusCodeChartData = (
export const END_POINT_DETAILS_QUERY_KEYS_ARRAY = [
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_RATE_OVER_TIME_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_LATENCY_OVER_TIME_DATA,
];
export const getAllEndpointsWidgetData = (
groupBy: BaseAutocompleteData[],
domainName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
// eslint-disable-next-line sonarjs/cognitive-complexity
): Widgets => {
const isGroupedByAttribute = groupBy.length > 0;
@@ -2755,11 +2857,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2787,11 +2889,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'B',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2819,11 +2921,11 @@ export const getAllEndpointsWidgetData = (
disabled: false,
expression: 'C',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2851,11 +2953,11 @@ export const getAllEndpointsWidgetData = (
disabled: true,
expression: 'D',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters,
`${getDomainNameFilterExpression(
domainName,
)} AND ${clientKindExpression} AND has_error = true AND http_url EXISTS`,
filterExpression,
),
},
functions: [],
@@ -2973,26 +3075,38 @@ export const getAllEndpointsWidgetData = (
return widget;
};
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'key', 'A', 'B', 'C', 'F1'];
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'A', 'B', 'C', 'F1'];
// A group without a value for a key is the set of spans missing that attribute.
export const getGroupByExpressionFromRowData = (
rowData: Record<string, string | number | boolean | null | undefined>,
): string =>
Object.keys(rowData)
export const getGroupByFiltersFromGroupByValues = (
rowData: any,
groupBy: BaseAutocompleteData[],
): IBuilderQuery['filters'] => {
const items = Object.keys(rowData)
.filter((key) => !keysToRemove.includes(key))
.map((key) => {
const value = rowData[key];
return value === null || value === undefined
? `${key} NOT EXISTS`
: `${key} = ${formatValueForExpression(value)}`;
})
.join(' AND ');
const groupByAttribute = groupBy.find((gb) => gb.key === key);
return {
id: groupByAttribute?.id || v4(),
key: {
dataType: groupByAttribute?.dataType || DataTypes.String,
key: groupByAttribute?.key || key,
type: groupByAttribute?.type || '',
},
op: '=', // operator for every attribute -> discuss
value: rowData[key],
};
});
return {
items,
op: 'AND',
};
};
export const getRateOverTimeWidgetData = (
domainName: string,
endPointName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
): Widgets => {
let legend = domainName;
if (endPointName) {
@@ -3016,9 +3130,9 @@ export const getRateOverTimeWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`http_host = '${domainName}'`,
filterExpression,
),
},
functions: [],
@@ -3042,7 +3156,7 @@ export const getRateOverTimeWidgetData = (
export const getLatencyOverTimeWidgetData = (
domainName: string,
endPointName: string,
filterExpression: string,
filters: IBuilderQuery['filters'],
): Widgets => {
let legend = domainName;
if (endPointName) {
@@ -3066,9 +3180,9 @@ export const getLatencyOverTimeWidgetData = (
disabled: false,
expression: 'A',
filter: {
expression: combineInitialAndUserExpression(
expression: convertFiltersWithUrlHandling(
filters || { items: [], op: 'AND' },
`http_host = '${domainName}'`,
filterExpression,
),
},
functions: [],

View File

@@ -174,6 +174,7 @@ function QuerySection(): JSX.Element {
runQuery={runQueryHandler}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
alertDef={alertDef}
panelType={PANEL_TYPES.TIME_SERIES}
isAnomalyDetection={isAnomalyDetection}
key={currentQuery.queryType}

View File

@@ -23,11 +23,20 @@ export type JiraDeployment = AlertmanagertypesJiraDeploymentDTO;
export type KeysOfUnion<T> = T extends unknown ? keyof T : never;
/** Every key of every member, optional, so a form can hold any member's fields. */
export type FlattenUnion<T> = {
[K in KeysOfUnion<T>]?: T extends unknown
? K extends keyof T
? T[K]
: never
: never;
};
export type JiraFormSpec = Omit<
AlertmanagertypesChannelJiraConfigDTO,
'auth'
> & {
auth?: AlertmanagertypesChannelJiraAuthDTO;
auth?: FlattenUnion<AlertmanagertypesChannelJiraAuthDTO>;
};
/** Merged so switching kind keeps the fields the kinds share. */

View File

@@ -1,4 +1,5 @@
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
@@ -27,6 +28,7 @@ const defaultAnnotations = {
export const alertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -60,6 +62,7 @@ export const alertDefaults: AlertDef = {
export const anamolyAlertDefaults: AlertDef = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
condition: {
compositeQuery: {
@@ -104,6 +107,7 @@ export const anamolyAlertDefaults: AlertDef = {
export const logAlertDefaults: AlertDef = {
alertType: AlertTypes.LOGS_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -135,6 +139,7 @@ export const logAlertDefaults: AlertDef = {
export const traceAlertDefaults: AlertDef = {
alertType: AlertTypes.TRACES_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {
@@ -166,6 +171,7 @@ export const traceAlertDefaults: AlertDef = {
export const exceptionAlertDefaults: AlertDef = {
alertType: AlertTypes.EXCEPTIONS_BASED_ALERT,
version: ENTITY_VERSION_V5,
condition: {
compositeQuery: {
builderQueries: {

View File

@@ -176,6 +176,7 @@ function ChartPreview({
},
originalGraphType: graphType,
},
// alertDef?.version || DEFAULT_ENTITY_VERSION,
ENTITY_VERSION_V5,
{
queryKey: [

View File

@@ -6,7 +6,7 @@ import logEvent from 'api/common/logEvent';
import PromQLIcon from 'assets/Dashboard/PromQl';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
@@ -15,6 +15,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { isEmpty } from 'lodash-es';
import { Atom, Terminal } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
import { EQueryType } from 'types/common/dashboard';
import ChQuerySection from './ChQuerySection';
@@ -36,6 +37,7 @@ function QuerySection({
runQuery,
isLoadingQueries,
handleCancelQuery,
alertDef,
panelType,
ruleId,
isAnomalyDetection,
@@ -69,8 +71,12 @@ function QuerySection({
signalSource: signalSource === 'meter' ? 'meter' : '',
}}
showTraceOperator={alertType === AlertTypes.TRACES_BASED_ALERT}
showFunctions={alertType === AlertTypes.LOGS_BASED_ALERT}
version={ENTITY_VERSION_V5}
showFunctions={
(alertType === AlertTypes.METRICS_BASED_ALERT &&
alertDef.version === ENTITY_VERSION_V4) ||
alertType === AlertTypes.LOGS_BASED_ALERT
}
version={alertDef.version || 'v3'}
onSignalSourceChange={handleSignalSourceChange}
signalSourceChangeEnabled
/>
@@ -295,6 +301,7 @@ interface QuerySectionProps {
runQuery: VoidFunction;
isLoadingQueries: boolean;
handleCancelQuery: () => void;
alertDef: AlertDef;
panelType: PANEL_TYPES;
ruleId: string;
isAnomalyDetection?: boolean;

View File

@@ -35,11 +35,11 @@
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l1-background);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-active-bg: var(--l1-background-active);
--tanstack-table-resize-handle-bg: transparent;
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
@@ -48,6 +48,16 @@
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;
--tanstack-table-row-odd-bg: color-mix(
in srgb,
var(--l1-foreground) 2%,
transparent
);
--tanstack-table-row-even-bg: color-mix(
in srgb,
var(--l1-foreground) 1%,
transparent
);
--badge-cursor: pointer;
}

View File

@@ -240,6 +240,7 @@ function ListAlertRules(): JSX.Element {
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={2}
/>
)}

View File

@@ -107,7 +107,9 @@ export function getAlertRuleColumns(
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return <LabelColumn labels={tagKeys} value={labels} color="secondary" />;
return (
<LabelColumn labels={tagKeys} value={labels} color="highlight-danger" />
);
},
},
{

View File

@@ -1,9 +1,7 @@
import React, { Dispatch, SetStateAction, useMemo, useRef } from 'react';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -17,7 +15,11 @@ import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderQuery,
Query,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { Tags } from 'hooks/useResourceAttribute/types';
import { isModifierKeyPressed } from 'utils/app';
@@ -124,7 +126,7 @@ const generateAPIMonitoringPath = (
domainName: string,
startTime: number,
endTime: number,
filterExpression: string,
filters: IBuilderQuery['filters'],
): string => {
const basePath = ROUTES.API_MONITORING;
return `${basePath}?${createQueryParams({
@@ -136,8 +138,8 @@ const generateAPIMonitoringPath = (
endTime,
},
selectedInterval: 'custom',
endPointDetailsLocalFilters: filters,
}),
[ENDPOINT_STATS_EXPRESSION_KEY]: filterExpression,
})}`;
};
export function onViewAPIMonitoringPopupClick({
@@ -151,15 +153,41 @@ export function onViewAPIMonitoringPopupClick({
return (e?: React.MouseEvent): void => {
const endTime = timestamp + (stepInterval || 60);
const startTime = timestamp - (stepInterval || 60);
const filterExpression = [
...(isError ? ["hasError IN ['true']"] : []),
`service.name = ${formatValueForExpression(servicename)}`,
].join(' AND ');
const filters = {
items: [
...(isError
? [
{
id: uuid().slice(0, 8),
key: {
key: 'hasError',
dataType: DataTypes.bool,
type: 'tag',
id: 'hasError--bool--tag--true',
},
op: 'in',
value: ['true'],
},
]
: []),
{
id: uuid().slice(0, 8),
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: servicename,
},
],
op: 'AND',
};
const newPath = generateAPIMonitoringPath(
domainName,
startTime,
endTime,
filterExpression,
filters,
);
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });

View File

@@ -153,7 +153,6 @@ import opentelemetryUrl from '@/assets/Logos/opentelemetry.svg';
import phpUrl from '@/assets/Logos/php.svg';
import pinoUrl from '@/assets/Logos/pino.svg';
import pipecatUrl from '@/assets/Logos/pipecat.svg';
import pixeltableUrl from '@/assets/Logos/pixeltable.svg';
import planetscaleUrl from '@/assets/Logos/planetscale.svg';
import pm2Url from '@/assets/Logos/pm2.svg';
import podmanUrl from '@/assets/Logos/podman.svg';
@@ -2580,7 +2579,7 @@ const onboardingConfigWithLinks = [
key: 'aws-ec2-metrics-one-click',
label: 'One Click AWS',
imgUrl: ec2Url,
link: '/integrations/aws?service=ec2',
link: '/integrations?integration=aws-integration&service=ec2',
internalRedirect: true,
},
{
@@ -2638,7 +2637,7 @@ const onboardingConfigWithLinks = [
key: 'aws-ecs-ec2-one-click',
label: 'One Click AWS',
imgUrl: ecsUrl,
link: '/integrations/aws?service=ecs',
link: '/integrations?integration=aws-integration&service=ecs',
internalRedirect: true,
},
{
@@ -2700,7 +2699,7 @@ const onboardingConfigWithLinks = [
key: 'aws-eks-ec2-one-click',
label: 'One Click AWS',
imgUrl: eksUrl,
link: '/integrations/aws?service=eks',
link: '/integrations?integration=aws-integration&service=eks',
internalRedirect: true,
},
{
@@ -2824,21 +2823,21 @@ const onboardingConfigWithLinks = [
key: 'aws-rds-one-click',
label: 'One Click AWS',
imgUrl: rdsUrl,
link: '/integrations/aws?service=rds',
link: '/integrations?integration=aws-integration&service=rds',
internalRedirect: true,
},
{
key: 'aws-rds-mysql-manual',
label: 'MySQL Manual',
imgUrl: rdsUrl,
link: '/integrations/builtin-aws_rds_mysql',
link: '/integrations?integration=builtin-aws_rds_mysql',
internalRedirect: true,
},
{
key: 'aws-rds-postgresql-manual',
label: 'PostgreSQL Manual',
imgUrl: rdsUrl,
link: '/integrations/builtin-aws_rds_postgresql',
link: '/integrations?integration=builtin-aws_rds_postgresql',
internalRedirect: true,
},
],
@@ -2897,7 +2896,7 @@ const onboardingConfigWithLinks = [
key: 'aws-lambda-logs-one-click',
label: 'One Click AWS',
imgUrl: lambdaUrl,
link: '/integrations/aws?service=lambda',
link: '/integrations?integration=aws-integration&service=lambda',
internalRedirect: true,
},
{
@@ -2955,7 +2954,7 @@ const onboardingConfigWithLinks = [
key: 'aws-lambda-metrics-one-click',
label: 'One Click AWS',
imgUrl: lambdaUrl,
link: '/integrations/aws?service=lambda',
link: '/integrations?integration=aws-integration&service=lambda',
internalRedirect: true,
},
{
@@ -4711,7 +4710,6 @@ const onboardingConfigWithLinks = [
'messaging',
'messaging queues',
'messaging-queues-celery',
'python celery',
'queues',
'task queue tracing',
],
@@ -4932,7 +4930,7 @@ const onboardingConfigWithLinks = [
'redis metrics',
],
imgUrl: redisUrl,
link: '/integrations/builtin-redis',
link: '/integrations?integration=builtin-redis',
internalRedirect: true,
},
{
@@ -4948,7 +4946,7 @@ const onboardingConfigWithLinks = [
'postgresql metrics',
],
imgUrl: postgresqlUrl,
link: '/integrations/builtin-postgres',
link: '/integrations?integration=builtin-postgres',
internalRedirect: true,
},
{
@@ -4964,7 +4962,7 @@ const onboardingConfigWithLinks = [
'proxy',
],
imgUrl: nginxUrl,
link: '/integrations/builtin-nginx',
link: '/integrations?integration=builtin-nginx',
internalRedirect: true,
},
{
@@ -4985,7 +4983,7 @@ const onboardingConfigWithLinks = [
'self',
],
imgUrl: mongoUrl,
link: '/integrations/builtin-mongo',
link: '/integrations?integration=builtin-mongo',
internalRedirect: true,
},
{
@@ -5003,7 +5001,7 @@ const onboardingConfigWithLinks = [
'olap database',
],
imgUrl: clickhouseUrl,
link: '/integrations/builtin-clickhouse',
link: '/integrations?integration=builtin-clickhouse',
internalRedirect: true,
},
{
@@ -5058,7 +5056,7 @@ const onboardingConfigWithLinks = [
key: 'aws-alb-one-click',
label: 'One Click AWS',
imgUrl: awsAlbUrl,
link: '/integrations/aws?service=alb',
link: '/integrations?integration=aws-integration&service=alb',
internalRedirect: true,
},
{
@@ -5099,7 +5097,7 @@ const onboardingConfigWithLinks = [
key: 'aws-api-gateway-one-click',
label: 'One Click AWS',
imgUrl: apiGatewayUrl,
link: '/integrations/aws?service=api-gateway',
link: '/integrations?integration=aws-integration&service=api-gateway',
internalRedirect: true,
},
{
@@ -5144,7 +5142,7 @@ const onboardingConfigWithLinks = [
key: 'aws-dynamodb-one-click',
label: 'One Click AWS',
imgUrl: dynamodbUrl,
link: '/integrations/aws?service=dynamodb',
link: '/integrations?integration=aws-integration&service=dynamodb',
internalRedirect: true,
},
{
@@ -5191,14 +5189,14 @@ const onboardingConfigWithLinks = [
key: 'aws-elasticache-one-click',
label: 'One Click AWS',
imgUrl: elasticacheUrl,
link: '/integrations/aws?service=elasticache',
link: '/integrations?integration=aws-integration&service=elasticache',
internalRedirect: true,
},
{
key: 'aws-elasticache-redis-manual',
label: 'Redis Manual',
imgUrl: elasticacheUrl,
link: '/integrations/builtin-aws_elasticache_redis',
link: '/integrations?integration=builtin-aws_elasticache_redis',
internalRedirect: true,
},
],
@@ -5237,7 +5235,7 @@ const onboardingConfigWithLinks = [
key: 'aws-msk-one-click',
label: 'One Click AWS',
imgUrl: amazonMskUrl,
link: '/integrations/aws?service=msk',
link: '/integrations?integration=aws-integration&service=msk',
internalRedirect: true,
},
{
@@ -5280,7 +5278,7 @@ const onboardingConfigWithLinks = [
key: 'aws-s3-one-click',
label: 'One Click AWS',
imgUrl: s3Url,
link: '/integrations/aws?service=s3sync',
link: '/integrations?integration=aws-integration&service=s3sync',
internalRedirect: true,
},
{
@@ -5322,7 +5320,7 @@ const onboardingConfigWithLinks = [
key: 'aws-sns-one-click',
label: 'One Click AWS',
imgUrl: snsUrl,
link: '/integrations/aws?service=sns',
link: '/integrations?integration=aws-integration&service=sns',
internalRedirect: true,
},
{
@@ -5364,7 +5362,7 @@ const onboardingConfigWithLinks = [
key: 'aws-sqs-one-click',
label: 'One Click AWS',
imgUrl: sqsUrl,
link: '/integrations/aws?service=sqs',
link: '/integrations?integration=aws-integration&service=sqs',
internalRedirect: true,
},
{
@@ -7251,14 +7249,12 @@ const onboardingConfigWithLinks = [
'cloud monitoring metrics',
'connect gcp account',
'gcp',
'gcp agent',
'gcp integration',
'gcp logs',
'gcp manual setup',
'gcp metrics',
'gcp monitoring',
'gcp observability',
'gcp one click',
'gcp service account',
'google cloud',
'google cloud integration',
@@ -7267,8 +7263,6 @@ const onboardingConfigWithLinks = [
'metrics',
'monitoring',
'observability',
'one click',
'one click gcp integration',
'opentelemetry collector gcp',
'pub/sub',
],
@@ -8161,50 +8155,29 @@ const onboardingConfigWithLinks = [
link: '/docs/instrumentation/javascript/opentelemetry-pm2/',
},
{
dataSource: 'pixeltable',
label: 'Pixeltable',
imgUrl: pixeltableUrl,
tags: ['LLM Monitoring'],
module: 'apm',
dataSource: 'gcp-one-click-integration',
label: 'GCP One-Click Integration',
imgUrl: gcpUrl,
tags: ['GCP', 'metrics', 'logs'],
module: 'integrations',
relatedSearchKeywords: [
'apm',
'llm',
'llm monitoring',
'multimodal ai',
'observability',
'opentelemetry pixeltable',
'pixeltable',
'pixeltable monitoring',
'pixeltable observability',
'pixeltable traces',
'traces',
'udf',
],
id: 'pixeltable',
link: '/docs/pixeltable-observability/',
},
{
dataSource: 'claude-cowork',
label: 'Claude Cowork',
imgUrl: claudeCodeUrl,
tags: ['LLM Monitoring', 'logs'],
module: 'logs',
relatedSearchKeywords: [
'claude',
'claude cowork',
'claude cowork monitoring',
'claude cowork observability',
'cowork',
'llm',
'llm monitoring',
'gcp',
'gcp agent',
'gcp integration',
'gcp logs',
'gcp metrics',
'gcp one click',
'google cloud',
'google cloud integration',
'logs',
'metrics',
'monitoring',
'observability',
'otel claude cowork',
'telemetry',
'one click',
'one click gcp integration',
],
id: 'claude-cowork',
link: '/docs/claude-cowork-monitoring/',
id: 'gcp-one-click-integration',
link: '/docs/integrations/gcp/one-click-gcp-integrations/',
},
];
export default onboardingConfigWithLinks;

View File

@@ -1,139 +0,0 @@
import type uPlot from 'uplot';
import { createVariableWidthBarsPathBuilder } from '../variableWidthBars';
class FakePath2D {
rects: number[][] = [];
rect(x: number, y: number, width: number, height: number): void {
this.rects.push([x, y, width, height]);
}
}
/** 10 px per unit on both axes; y grows downward from 100. */
const fakePlot: Partial<uPlot> = {
valToPos: (value: number, scaleKey: string): number =>
scaleKey === 'x' ? value * 10 : 100 - value * 10,
ctx: {} as CanvasRenderingContext2D,
};
const plot = fakePlot as uPlot;
type Fills = Map<CanvasRenderingContext2D['fillStyle'], Path2D>;
function fillsOf(paths: ReturnType<uPlot.Series.PathBuilder>): Fills {
const fill = paths?.fill;
if (!(fill instanceof Map)) {
throw new Error('expected a fill per style');
}
return fill;
}
function rectsOf(path: Path2D | undefined): number[][] {
if (!(path instanceof FakePath2D)) {
throw new Error('expected a FakePath2D');
}
return path.rects;
}
describe('createVariableWidthBarsPathBuilder', () => {
const originalPath2D = globalThis.Path2D;
beforeAll(() => {
Object.defineProperty(globalThis, 'Path2D', {
value: FakePath2D,
configurable: true,
});
});
afterAll(() => {
Object.defineProperty(globalThis, 'Path2D', {
value: originalPath2D,
configurable: true,
});
});
function build(
pattern: CanvasPattern | null = null,
): ReturnType<uPlot.Series.PathBuilder> {
return createVariableWidthBarsPathBuilder({
getBars: () => [
{ x0: 0, x1: 1, y0: 0, y1: 2, hatched: false },
{ x0: 1, x1: 4, y0: 0, y1: 5, hatched: false },
{ x0: 4, x1: 6, y0: 1, y1: 3, hatched: true },
],
fill: '#ff0000',
hatchedFill: '#ff000066',
getHatchPattern: () => pattern,
gapPx: 2,
})(plot, 1, 0, 0);
}
it('draws each bar at its own width, inset by half the gap', () => {
const fills = fillsOf(build());
expect(rectsOf(fills.get('#ff0000'))).toStrictEqual([
[1, 80, 8, 20],
[11, 50, 28, 50],
]);
});
it('draws hatched bars in their own fill, with the pattern on top', () => {
const pattern = {} as CanvasPattern;
const fills = fillsOf(build(pattern));
expect([...fills.keys()]).toStrictEqual(['#ff0000', '#ff000066', pattern]);
expect(rectsOf(fills.get(pattern))).toStrictEqual([[41, 70, 18, 20]]);
});
it('reads a fill callback on every draw', () => {
let fill = '#ff0000';
const builder = createVariableWidthBarsPathBuilder({
getBars: () => [{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false }],
fill: () => fill,
hatchedFill: '#00ff00',
getHatchPattern: () => null,
gapPx: 0,
});
const first = fillsOf(builder(plot, 1, 0, 0));
fill = '#0000ff';
const second = fillsOf(builder(plot, 1, 0, 0));
expect([...first.keys()]).toStrictEqual(['#ff0000']);
expect([...second.keys()]).toStrictEqual(['#0000ff']);
});
it('keeps solid bars when the hatched fill is the same colour', () => {
const fills = fillsOf(
createVariableWidthBarsPathBuilder({
getBars: () => [
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
],
fill: 'red',
hatchedFill: 'red',
getHatchPattern: () => null,
gapPx: 0,
})(plot, 1, 0, 0),
);
expect(rectsOf(fills.get('red'))).toHaveLength(2);
});
it('hatches only overflow bars when both fills are the same colour', () => {
const pattern = {} as CanvasPattern;
const fills = fillsOf(
createVariableWidthBarsPathBuilder({
getBars: () => [
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
],
fill: 'red',
hatchedFill: 'red',
getHatchPattern: () => pattern,
gapPx: 0,
})(plot, 1, 0, 0),
);
expect(rectsOf(fills.get(pattern))).toStrictEqual([[10, 90, 10, 10]]);
});
});

View File

@@ -1,88 +0,0 @@
import uPlot from 'uplot';
/** A bar in scale space. Unlike uPlot's own bars, each one carries its own width. */
export interface VariableWidthBar {
x0: number;
x1: number;
y0: number;
y1: number;
/** Drawn in `hatchedFill` with the hatch pattern on top. */
hatched: boolean;
}
/** A fill, or one read on every draw (e.g. to dim a series on hover). */
type BarFill = string | (() => string);
/** Bars narrower than this, in CSS pixels, keep no gap between them. */
const MIN_WIDTH_FOR_GAP_PX = 3;
/**
* Draws `[x0, x1] × [y0, y1]` rects on the `x` and `y` scales. uPlot's bar builder
* assumes one width for every bar, which a bucket axis with uneven bounds breaks.
*
* `getBars` runs on every redraw, so a caller can derive the bars from the plot's
* current state (e.g. which series are shown).
*/
export function createVariableWidthBarsPathBuilder({
getBars,
fill,
hatchedFill,
getHatchPattern,
gapPx,
}: {
getBars: (u: uPlot, seriesIdx: number) => VariableWidthBar[];
fill: BarFill;
hatchedFill: BarFill;
getHatchPattern: (ctx: CanvasRenderingContext2D) => CanvasPattern | null;
/** Space between neighbouring bars, in CSS pixels. */
gapPx: number;
}): uPlot.Series.PathBuilder {
return (u, seriesIdx): uPlot.Series.Paths => {
const solidFill = resolveFill(fill);
const overflowFill = resolveFill(hatchedFill);
const solidPath = new Path2D();
// The hatch overlays only overflow bars, so they keep a path of their own even
// when one colour fill covers both (a Map keyed by fill holds one path a fill).
const hatchedPath = new Path2D();
const isSharedFill = overflowFill === solidFill;
const inset = (gapPx * uPlot.pxRatio) / 2;
const minWidthForGap = MIN_WIDTH_FOR_GAP_PX * uPlot.pxRatio;
let hasHatched = false;
getBars(u, seriesIdx).forEach((bar) => {
const left = u.valToPos(bar.x0, 'x', true);
const right = u.valToPos(bar.x1, 'x', true);
const top = u.valToPos(bar.y1, 'y', true);
const bottom = u.valToPos(bar.y0, 'y', true);
const width = right - left;
const gap = width > minWidthForGap ? inset : 0;
const rect = [left + gap, top, width - 2 * gap, bottom - top] as const;
if (!bar.hatched || isSharedFill) {
solidPath.rect(...rect);
}
if (bar.hatched) {
hatchedPath.rect(...rect);
hasHatched = true;
}
});
const fills = new Map<CanvasRenderingContext2D['fillStyle'], Path2D>([
[solidFill, solidPath],
]);
if (hasHatched) {
if (!isSharedFill) {
fills.set(overflowFill, hatchedPath);
}
const pattern = getHatchPattern(u.ctx);
if (pattern) {
fills.set(pattern, hatchedPath);
}
}
return { stroke: null, fill: fills, clip: null, band: null, flags: 0 };
};
}
function resolveFill(fill: BarFill): string {
return typeof fill === 'function' ? fill() : fill;
}

View File

@@ -1,5 +0,0 @@
.notice {
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-xs);
color: var(--muted-foreground);
}

View File

@@ -1,279 +0,0 @@
import { useCallback, useMemo } from 'react';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
LegendPosition,
type TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { StackMode } from 'lib/uPlotV2/config/types';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import { resolveBoundaryPrecision } from 'lib/visualization/charts/Heatmap/utils';
import { DistributionChartProps } from 'lib/visualization/charts/types';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
import { resolveOtherColor, resolveSeriesColor } from './colors';
import DistributionTooltip from './components/DistributionTooltip/DistributionTooltip';
import { buildDistributionConfig } from './config';
import {
clampDistributionXAxis,
cropDistributionXAxis,
resolveDistributionBars,
resolveDistributionXAxis,
sumVisibleCounts,
} from './layout';
import { computeDistributionPercentiles } from './percentiles';
import { groupIntoOther, resolveMaxDrawnGroups } from './topGroups';
import { DistributionYScale } from './types';
import { useDistributionBarClick } from './useDistributionBarClick';
import styles from './Distribution.module.scss';
import { createBoundFormatter, prepareDistributionChartData } from './utils';
/** Rough share of the panel width the plot gets, for fitting side-by-side bars. */
const PLOT_SHARE = 0.9;
const PLOT_SHARE_WITH_RIGHT_LEGEND = 0.65;
/** Vertical space the Other notice takes out of the container. */
const NOTICE_HEIGHT = 20;
const NO_PERCENTILES: readonly number[] = Object.freeze([]);
export default function Distribution({
id,
buckets,
series,
width,
height,
isDarkMode,
xMin,
xMax,
yScale = DistributionYScale.Linear,
stack = StackMode.Normal,
xAxisUnit,
decimalPrecision,
customColors,
percentiles = NO_PERCENTILES,
onBarClick,
showLegend = true,
legendPosition = LegendPosition.BOTTOM,
showTooltip = true,
canPinTooltip = false,
renderTooltipFooter,
tooltipPortalRoot,
layoutChildren,
'data-testid': testId,
}: DistributionChartProps): JSX.Element {
const labels = useMemo(() => series.map((entry) => entry.label), [series]);
const counts = useMemo(() => series.map((entry) => entry.counts), [series]);
const { visibleKeys, focusedSeriesIndex, onLegendAction } =
useLegendVisibility({ keys: labels, indexOffset: 1, id });
const visible = useMemo(
() => labels.map((label) => visibleKeys.includes(label)),
[labels, visibleKeys],
);
const colors = useMemo(
() =>
labels.map((label) => resolveSeriesColor(label, isDarkMode, customColors)),
[labels, isDarkMode, customColors],
);
const { xAxis, totals } = useMemo(() => {
const full = resolveDistributionXAxis(buckets);
const visibleTotals = sumVisibleCounts(counts, visible, full.rows.length);
// An explicit max may reach past the last occupied bucket; only auto-crop without one.
const fitted =
xMax === undefined ? cropDistributionXAxis(full, visibleTotals) : full;
return {
xAxis: clampDistributionXAxis(fitted, xMin, xMax),
totals: visibleTotals,
};
}, [buckets, xMin, xMax, counts, visible]);
// Over the visible groups combined, so a legend toggle moves them without a refetch.
const markers = useMemo(
() => computeDistributionPercentiles({ xAxis, totals, percentiles }),
[xAxis, totals, percentiles],
);
const isLogY =
yScale === DistributionYScale.Log && stack !== StackMode.Percent;
// The legend lists every group; the plot draws the largest and sums the rest into
// Other, so a toggle re-ranks without a refetch.
const drawn = useMemo(
() =>
groupIntoOther({
labels,
counts,
colors,
visible,
maxGroups: resolveMaxDrawnGroups({
stack,
xAxis,
plotWidth:
width *
(legendPosition === LegendPosition.RIGHT
? PLOT_SHARE_WITH_RIGHT_LEGEND
: PLOT_SHARE),
}),
otherColor: resolveOtherColor(isDarkMode),
}),
[
labels,
counts,
colors,
visible,
stack,
xAxis,
width,
legendPosition,
isDarkMode,
],
);
const layout = useMemo(
() =>
resolveDistributionBars({
xAxis,
counts: drawn.counts,
stack,
isLogY,
}),
[xAxis, drawn, stack, isLogY],
);
const boundaryPrecision = useMemo(
() =>
resolveBoundaryPrecision({
yAxis: xAxis,
yAxisUnit: xAxisUnit,
decimalPrecision,
}),
[xAxis, xAxisUnit, decimalPrecision],
);
const formatBound = useMemo(
() => createBoundFormatter(xAxisUnit, boundaryPrecision),
[xAxisUnit, boundaryPrecision],
);
const config = useMemo(
() =>
buildDistributionConfig({
id,
xAxis,
layout,
labels: drawn.labels,
colors: drawn.colors,
stack,
isLogY,
isDarkMode,
markers,
xAxisUnit,
decimalPrecision: boundaryPrecision,
}),
[
id,
xAxis,
layout,
drawn,
stack,
isLogY,
isDarkMode,
markers,
xAxisUnit,
boundaryPrecision,
],
);
const renderTooltip = useCallback(
(args: TooltipRenderArgs): React.ReactNode => (
<DistributionTooltip
uPlotInstance={args.uPlotInstance}
isPinned={args.isPinned}
dismiss={args.dismiss}
xAxis={xAxis}
layout={layout}
labels={drawn.labels}
colors={drawn.colors}
counts={drawn.counts}
formatBound={formatBound}
renderTooltipFooter={renderTooltipFooter}
/>
),
[xAxis, layout, drawn, formatBound, renderTooltipFooter],
);
const handleClick = useDistributionBarClick({
xAxis,
layout,
drawn,
series,
onBarClick,
});
const data = useMemo(
() => prepareDistributionChartData(xAxis, drawn.counts),
[xAxis, drawn],
);
const legendItems = useMemo<LegendItem[]>(
() =>
labels.map((label, index) => ({
seriesIndex: index + 1,
label,
color: colors[index],
show: visible[index],
})),
[labels, colors, visible],
);
const groupLegend = useCallback(
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
<Legend
items={legendItems}
position={legendPosition}
averageLegendWidth={averageLegendWidth}
showSearch={showSearch}
itemLabel="groups"
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>
),
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
);
return (
<ChartWrapper
config={config}
data={data}
width={width}
height={
drawn.otherGroupCount > 0 ? Math.max(0, height - NOTICE_HEIGHT) : height
}
legendConfig={{ position: legendPosition }}
showLegend={showLegend}
customLegend={groupLegend}
legendLabels={labels}
showTooltip={showTooltip}
canPinTooltip={canPinTooltip}
decimalPrecision={decimalPrecision}
customTooltip={renderTooltip}
onClick={handleClick}
contentFooter={
drawn.otherGroupCount > 0 && (
<div className={styles.notice} data-testid="distribution-other-notice">
Showing top {drawn.labels.length - 1} of {drawn.visibleGroupCount} groups;
the rest are summed into Other.
</div>
)
}
renderTooltipFooter={renderTooltipFooter}
tooltipPortalRoot={tooltipPortalRoot}
layoutChildren={layoutChildren}
data-testid={testId}
/>
);
}

View File

@@ -1,115 +0,0 @@
import type React from 'react';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { render, screen, userEvent } from 'tests/test-utils';
import Distribution from '../Distribution';
import type { DistributionSeries } from '../types';
// The shared Legend virtualises its items; render them all so they are queryable.
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div>
{data.map((item, index) => (
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
))}
</div>
),
}));
const BUCKETS = [5, 10, 100, 1000];
const SERIES: DistributionSeries[] = [
{ label: 'service.name=cart', counts: [3, 40, 12, 1, 0] },
{ label: 'service.name=checkout', counts: [1, 10, 30, 6, 2] },
];
function renderDistribution(
props: Partial<React.ComponentProps<typeof Distribution>> = {},
): ReturnType<typeof render> {
return render(
<Distribution
id="panel-1"
buckets={BUCKETS}
series={SERIES}
width={800}
height={400}
isDarkMode
data-testid="distribution"
{...props}
/>,
);
}
describe('Distribution', () => {
// Legend items count from 1, as uPlot's data series do.
const CART = 1;
const CHECKOUT = 2;
// The selection persists under the panel id, which every case here shares.
beforeEach(() => localStorage.clear());
function legendItem(seriesIndex: number): HTMLElement {
return screen.getByTestId(`legend-item-${seriesIndex}`);
}
it('renders the plot container', () => {
renderDistribution();
expect(screen.getByTestId('distribution')).toBeInTheDocument();
});
it('lists every group in the legend, all shown', () => {
renderDistribution();
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
});
it('isolates a group when its row is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDistribution();
await user.click(legendItem(CART));
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
});
it('hides only that group when its marker is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDistribution();
await user.click(screen.getByTestId(`legend-marker-${CHECKOUT}`));
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
});
it('sums groups past the stacked cap into Other, with a notice', () => {
renderDistribution({
series: Array.from({ length: 12 }, (_, index) => ({
label: `group-${index}`,
counts: [index, 1, 1, 0, 0],
})),
});
expect(screen.getByTestId('distribution-other-notice')).toHaveTextContent(
'Showing top 9 of 12 groups; the rest are summed into Other.',
);
// The legend still lists every group, so any can be isolated.
expect(legendItem(1)).toHaveAttribute('aria-label', 'group-0');
});
it('shows no notice when every group fits', () => {
renderDistribution();
expect(
screen.queryByTestId('distribution-other-notice'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,126 +0,0 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import type uPlot from 'uplot';
import { resolveDistributionBars, resolveDistributionXAxis } from '../layout';
import { buildDistributionConfig } from '../config';
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4]. Only (10, 20] is occupied.
const xAxis = resolveDistributionXAxis([10, 20, 40]);
const counts = [
[0, 4, 0, 0],
[0, 6, 0, 0],
];
function setCursorHook(drawnCounts = counts): (u: uPlot) => void {
const config = buildDistributionConfig({
id: 'panel-1',
xAxis,
layout: resolveDistributionBars({
xAxis,
counts: drawnCounts,
stack: StackMode.Normal,
isLogY: false,
}),
labels: drawnCounts.map((_, index) => `group-${index}`),
colors: drawnCounts.map(() => '#ff0000'),
stack: StackMode.Normal,
isLogY: false,
isDarkMode: true,
markers: [],
}).getConfig();
const [hook] = config.hooks?.setCursor ?? [];
return hook as (u: uPlot) => void;
}
/** Pixels are values, so the cursor sits at `(left, top)` in scale space. */
function plotAt(
left: number,
top: number,
): { u: uPlot; setSeries: jest.Mock; redraw: jest.Mock } {
const setSeries = jest.fn();
const redraw = jest.fn();
const plot: Partial<uPlot> = {
cursor: { left, top },
posToVal: (position: number) => position,
setSeries,
redraw,
};
return { u: plot as uPlot, setSeries, redraw };
}
describe('buildDistributionConfig cursor', () => {
it('turns off drag-to-zoom', () => {
const config = buildDistributionConfig({
id: 'panel-1',
xAxis,
layout: resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Normal,
isLogY: false,
}),
labels: ['group-0', 'group-1'],
colors: ['#ff0000', '#ff0000'],
stack: StackMode.Normal,
isLogY: false,
isDarkMode: true,
markers: [],
}).getConfig();
expect(config.cursor?.drag).toMatchObject({ x: false, y: false });
});
});
describe('buildDistributionConfig setCursor', () => {
// TooltipPlugin only shows a tooltip for a focused series.
it('focuses the group under the cursor, 1-based as uPlot counts series', () => {
const { u, setSeries, redraw } = plotAt(1.5, 8);
setCursorHook()(u);
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
expect(redraw).toHaveBeenCalledWith(true, false);
});
it('focuses the nearest group above the stack, dimming the rest', () => {
const { u, setSeries, redraw } = plotAt(1.5, 50);
setCursorHook()(u);
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
expect(redraw).toHaveBeenCalledWith(true, false);
});
it('keeps a tooltip over an empty bucket without dimming any group', () => {
const hook = setCursorHook();
const { u, setSeries, redraw } = plotAt(1.5, 8);
hook(u);
redraw.mockClear();
u.cursor.left = 2.5;
u.cursor.top = 1;
hook(u);
expect(setSeries).toHaveBeenLastCalledWith(1, { focus: true });
expect(redraw).toHaveBeenCalledWith(true, false);
});
it('focuses nothing off the plot', () => {
const hook = setCursorHook();
const { u, setSeries } = plotAt(1.5, 8);
hook(u);
u.cursor.left = -1;
hook(u);
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
});
it('focuses nothing over an empty bucket when every group is hidden', () => {
const { u, setSeries } = plotAt(2.5, 1);
setCursorHook([])(u);
expect(setSeries).not.toHaveBeenCalled();
});
});

View File

@@ -1,58 +0,0 @@
import {
layoutMarkerLabels,
resolveHoveredMarker,
} from '../drawPercentileMarkers';
describe('resolveHoveredMarker', () => {
const areas = [
{
x: 100,
badges: [
{ left: 92, right: 108, top: 0, bottom: 16 },
{ left: 92, right: 108, top: 184, bottom: 200 },
],
},
{
x: 120,
badges: [{ left: 112, right: 128, top: 18, bottom: 34 }],
},
];
it('picks the line nearest x within the tolerance', () => {
expect(resolveHoveredMarker(areas, 103, 100, 4)).toBe(0);
expect(resolveHoveredMarker(areas, 117, 100, 4)).toBe(1);
expect(resolveHoveredMarker(areas, 110, 100, 4)).toBeNull();
});
it('picks the marker whose badge is under the cursor', () => {
expect(resolveHoveredMarker(areas, 107, 190, 4)).toBe(0);
expect(resolveHoveredMarker(areas, 126, 20, 4)).toBe(1);
});
});
describe('layoutMarkerLabels', () => {
it('keeps labels that fit on the first row', () => {
expect(
layoutMarkerLabels(
[
{ x: 0, width: 20 },
{ x: 40, width: 20 },
],
4,
),
).toStrictEqual([0, 0]);
});
it('drops a label that would overlap to the next free row', () => {
expect(
layoutMarkerLabels(
[
{ x: 30, width: 20 },
{ x: 0, width: 20 },
{ x: 10, width: 20 },
],
4,
),
).toStrictEqual([0, 0, 1]);
});
});

View File

@@ -1,286 +0,0 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import {
clampDistributionXAxis,
cropDistributionXAxis,
LOG_Y_BASE,
resolveDistributionBars,
resolveDistributionXAxis,
resolveNearestSeries,
sumVisibleCounts,
thinLabelledSplits,
} from '../layout';
function widths(edges: number[]): number[] {
return edges.slice(1).map((edge, index) => edge - edges[index]);
}
describe('resolveDistributionXAxis', () => {
it('lays N bounds out as N + 1 buckets, overflow last', () => {
const axis = resolveDistributionXAxis([10, 20, 30]);
expect(axis.rows).toHaveLength(4);
expect(axis.rows[0].isUnderflow).toBe(true);
expect(axis.rows[3]).toMatchObject({ lower: 30, isOverflow: true });
expect(axis.edges).toHaveLength(5);
});
it('gives every bucket the same width, ticks on the bounds', () => {
const axis = resolveDistributionXAxis([0, 5, 10, 25, 1000]);
expect(widths(axis.edges)).toStrictEqual([1, 1, 1, 1, 1, 1]);
expect(axis.splits).toStrictEqual([1, 2, 3, 4, 5]);
expect(axis.overflowSplit).toBe(6);
expect(axis.toBucketValue(3.5)).toBe(17.5);
});
});
describe('cropDistributionXAxis', () => {
it('stops one bucket past the last occupied one', () => {
const axis = resolveDistributionXAxis([1, 2, 3, 4]);
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
expect(cropped.max).toBe(3);
expect(cropped.overflowSplit).toBeNull();
});
});
describe('clampDistributionXAxis', () => {
// Buckets ≤ 10, (10, 20], (20, 40], (40, 80], > 80 in slots [0, 1] … [4, 5].
const axis = resolveDistributionXAxis([10, 20, 40, 80]);
it('keeps every bucket without a range', () => {
expect(clampDistributionXAxis(axis)).toStrictEqual(axis);
});
it('hides buckets wholly outside the range, keeping straddling ones whole', () => {
const clamped = clampDistributionXAxis(axis, 20, 50);
expect([clamped.min, clamped.max]).toStrictEqual([2, 4]);
expect(clamped.overflowSplit).toBeNull();
});
it('keeps the overflow tick while the overflow bucket shows', () => {
const clamped = clampDistributionXAxis(axis, 15);
expect([clamped.min, clamped.max]).toStrictEqual([1, 5]);
expect(clamped.overflowSplit).toBe(5);
});
it('keeps one bucket when the range misses them all', () => {
const clamped = clampDistributionXAxis(axis, 1000);
expect([clamped.min, clamped.max]).toStrictEqual([4, 5]);
});
it('narrows an already cropped axis, never widening it', () => {
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
expect(clampDistributionXAxis(cropped, 15).max).toBe(cropped.max);
});
});
describe('sumVisibleCounts', () => {
it('adds only the visible series', () => {
expect(
sumVisibleCounts(
[
[1, 2],
[10, 20],
[100, 200],
],
[true, false, true],
2,
),
).toStrictEqual([101, 202]);
});
});
describe('resolveDistributionBars', () => {
const xAxis = resolveDistributionXAxis([1, 2]);
const counts = [
[4, 6, 0],
[1, 3, 2],
];
it('sizes the count axis by the buckets left in the window', () => {
// Buckets ≤ 1, (1, 2], > 2; a max of 1 keeps only the first.
const { yMax } = resolveDistributionBars({
xAxis: clampDistributionXAxis(xAxis, undefined, 1),
counts: [[4, 1000, 0]],
stack: StackMode.Normal,
isLogY: false,
});
expect(yMax).toBe(4);
});
it('stacks series in each bucket under normal', () => {
const { rects, yMax } = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Normal,
isLogY: false,
});
expect(rects[0].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
[0, 4],
[0, 6],
]);
expect(rects[1].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
[4, 5],
[6, 9],
[0, 2],
]);
expect(yMax).toBe(9);
});
it('marks the overflow bucket hatched', () => {
const { rects } = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Normal,
isLogY: false,
});
expect(rects[1].map(({ hatched }) => hatched)).toStrictEqual([
false,
false,
true,
]);
});
it('stacks shares of each bucket under percent', () => {
const { rects, yMax } = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Percent,
isLogY: false,
});
expect(rects[0][0]).toMatchObject({ y0: 0, y1: 80 });
expect(rects[1][0]).toMatchObject({ y0: 80, y1: 100 });
expect(yMax).toBe(100);
});
it('splits the bucket width between series under none', () => {
const { rects, yMax } = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.None,
isLogY: false,
});
expect(rects[0][0]).toMatchObject({ x0: 0, x1: 0.5, y0: 0, y1: 4 });
expect(rects[1][0]).toMatchObject({ x0: 0.5, x1: 1, y0: 0, y1: 1 });
expect(yMax).toBe(6);
});
it('starts bars at the log base and skips zero counts', () => {
const { rects } = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Normal,
isLogY: true,
});
expect(rects[0].map(({ y0 }) => y0)).toStrictEqual([LOG_Y_BASE, LOG_Y_BASE]);
expect(rects[0].map(({ bucketIndex }) => bucketIndex)).toStrictEqual([0, 1]);
});
it('skips bars whose top stays under the log base, never inverting one', () => {
const fractional = [
[0.5, 3, 0],
[0.25, 0.5, 2],
];
const stacked = resolveDistributionBars({
xAxis,
counts: fractional,
stack: StackMode.Normal,
isLogY: true,
});
const sideBySide = resolveDistributionBars({
xAxis,
counts: fractional,
stack: StackMode.None,
isLogY: true,
});
expect(
stacked.rects.map((rects) => rects.map(({ y0, y1 }) => [y0, y1])),
).toStrictEqual([
[[LOG_Y_BASE, 3]],
[
[3, 3.5],
[LOG_Y_BASE, 2],
],
]);
expect(
sideBySide.rects.map((rects) => rects.map(({ bucketIndex }) => bucketIndex)),
).toStrictEqual([[1], [2]]);
});
});
describe('thinLabelledSplits', () => {
it('keeps labels that clear each other by the gap', () => {
expect(
thinLabelledSplits({
positions: [0, 50, 100],
widths: [20, 20, 20],
gap: 10,
}),
).toStrictEqual([0, 1, 2]);
});
it('drops a wide label that would touch its neighbour, keeping the rightmost', () => {
// 16.67 mins-wide labels 60 px apart: each needs 70 px of centre distance.
expect(
thinLabelledSplits({
positions: [0, 60, 120, 180],
widths: [60, 60, 60, 60],
gap: 10,
}),
).toStrictEqual([1, 3]);
});
});
describe('resolveNearestSeries', () => {
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
const xAxis = resolveDistributionXAxis([10, 20, 40]);
const counts = [
[0, 4, 0, 0],
[0, 6, 0, 0],
];
const stacked = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Normal,
isLogY: false,
});
it('names the group whose bar is under the point', () => {
expect(resolveNearestSeries(stacked, 1, 1.5, 2)).toBe(0);
expect(resolveNearestSeries(stacked, 1, 1.5, 8)).toBe(1);
});
it('takes the vertically nearest group off the bars, as a bar chart does', () => {
expect(resolveNearestSeries(stacked, 1, 1.5, 50)).toBe(1);
});
it('is null over an empty bucket', () => {
expect(resolveNearestSeries(stacked, 2, 2.5, 1)).toBeNull();
});
it('picks the sub-bar under the cursor side by side', () => {
const sideBySide = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.None,
isLogY: false,
});
expect(resolveNearestSeries(sideBySide, 1, 1.25, 50)).toBe(0);
expect(resolveNearestSeries(sideBySide, 1, 1.75, 1)).toBe(1);
});
});

View File

@@ -1,97 +0,0 @@
import { resolveDistributionXAxis } from '../layout';
import {
computeDistributionPercentiles,
formatPercentileBadge,
formatPercentileName,
formatPercentileValue,
groupPercentileMarkers,
} from '../percentiles';
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
const xAxis = resolveDistributionXAxis([10, 20, 40]);
describe('computeDistributionPercentiles', () => {
it('interpolates inside the bucket holding the rank, as histogram_quantile does', () => {
const markers = computeDistributionPercentiles({
xAxis,
totals: [10, 20, 10, 0],
percentiles: [50, 90],
});
expect(markers.map(({ value }) => value)).toStrictEqual([15, 32]);
expect(markers.every(({ isOverflow }) => !isOverflow)).toBe(true);
});
it('places the marker the same fraction across the bucket on the axis', () => {
const [p50] = computeDistributionPercentiles({
xAxis,
totals: [10, 20, 10, 0],
percentiles: [50],
});
expect(p50.axisValue).toBe(1.5);
});
it('starts the lowest bucket at zero', () => {
const [p10] = computeDistributionPercentiles({
xAxis,
totals: [10, 20, 10, 0],
percentiles: [10],
});
expect(p10.value).toBe(4);
});
it('reports a percentile in overflow as above the last bound, at its left edge', () => {
const [p99] = computeDistributionPercentiles({
xAxis,
totals: [0, 0, 10, 10],
percentiles: [99],
});
expect(p99).toMatchObject({ value: 40, axisValue: 3, isOverflow: true });
});
it('marks nothing when every count is zero', () => {
expect(
computeDistributionPercentiles({
xAxis,
totals: [0, 0, 0, 0],
percentiles: [50],
}),
).toStrictEqual([]);
});
});
describe('formatPercentileName', () => {
it('keeps fractional percentile names short', () => {
expect(formatPercentileName(99.9)).toBe('p99.9');
expect(formatPercentileName(50)).toBe('p50');
});
});
describe('percentile marker groups', () => {
const formatBound = (value: number): string => `${value} ms`;
const p50 = { percentile: 50, value: 15, axisValue: 1.5, isOverflow: false };
const p99 = { percentile: 99, value: 40, axisValue: 3, isOverflow: true };
const p90 = { percentile: 90, value: 40, axisValue: 3, isOverflow: true };
it('groups markers on the same spot, lowest percentile first', () => {
expect(groupPercentileMarkers([p50, p99, p90])).toStrictEqual([
[p50],
[p90, p99],
]);
});
it('names a group by its lowest percentile', () => {
expect(formatPercentileBadge([p50])).toBe('p50');
expect(formatPercentileBadge([p90, p99])).toBe('p90+');
});
it('shows the estimate, or the overflow bound once for the group', () => {
expect(formatPercentileValue([p50], formatBound)).toBe('p50 ≈ 15 ms');
expect(formatPercentileValue([p90, p99], formatBound)).toBe(
'p90, p99 > 40 ms',
);
});
});

View File

@@ -1,126 +0,0 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import { resolveDistributionXAxis } from '../layout';
import {
groupIntoOther,
resolveClickedGroup,
MAX_STACKED_GROUPS,
OTHER_GROUP_LABEL,
resolveMaxDrawnGroups,
} from '../topGroups';
describe('resolveMaxDrawnGroups', () => {
// Four equal buckets plus underflow and overflow: six slots of one unit each.
const xAxis = resolveDistributionXAxis([1, 2, 3, 4, 5]);
it('caps stacked and percent at the fixed limit', () => {
expect(
resolveMaxDrawnGroups({ stack: StackMode.Normal, xAxis, plotWidth: 60 }),
).toBe(MAX_STACKED_GROUPS);
expect(
resolveMaxDrawnGroups({ stack: StackMode.Percent, xAxis, plotWidth: 60 }),
).toBe(MAX_STACKED_GROUPS);
});
it('fits side by side so the narrowest bar stays 3 px wide', () => {
// 120 px over six slots: 20 px a bucket, six 3 px bars.
expect(
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 120 }),
).toBe(6);
expect(
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 6 }),
).toBe(1);
});
});
describe('groupIntoOther', () => {
const args = {
labels: ['a', 'b', 'c', 'd'],
counts: [
[1, 1],
[5, 5],
[2, 0],
[3, 3],
],
colors: ['#a', '#b', '#c', '#d'],
visible: [true, true, true, true],
otherColor: '#999',
};
it('draws every visible group when they fit', () => {
const drawn = groupIntoOther({ ...args, maxGroups: 4 });
expect(drawn.labels).toStrictEqual(['a', 'b', 'c', 'd']);
expect(drawn.otherGroupCount).toBe(0);
});
it('keeps the largest, in legend order, and sums the rest exactly', () => {
const drawn = groupIntoOther({ ...args, maxGroups: 3 });
expect(drawn.labels).toStrictEqual(['b', 'd', OTHER_GROUP_LABEL]);
expect(drawn.sourceIndexes).toStrictEqual([1, 3, null]);
expect(drawn.counts[2]).toStrictEqual([3, 1]);
expect(drawn.colors[2]).toBe('#999');
expect(drawn).toMatchObject({ otherGroupCount: 2, visibleGroupCount: 4 });
});
it('re-ranks without hidden groups, which never reach Other', () => {
const drawn = groupIntoOther({
...args,
visible: [true, false, true, true],
maxGroups: 2,
});
expect(drawn.labels).toStrictEqual(['d', OTHER_GROUP_LABEL]);
expect(drawn.sourceIndexes).toStrictEqual([3, null]);
expect(drawn.counts[1]).toStrictEqual([3, 1]);
expect(drawn.visibleGroupCount).toBe(3);
});
});
describe('resolveClickedGroup', () => {
const series = ['a', 'b', 'c'].map((label) => ({
label,
counts: [label === 'a' ? 5 : 1],
}));
const drawn = groupIntoOther({
labels: ['a', 'b', 'c'],
counts: series.map(({ counts }) => counts),
colors: ['#a', '#b', '#c'],
visible: [true, true, true],
maxGroups: 2,
otherColor: '#999',
});
it('maps a drawn bar back to its source group and colour', () => {
expect(resolveClickedGroup(drawn, 0, series)).toStrictEqual({
series: series[0],
color: '#a',
});
});
it('refuses Other and empty space', () => {
expect(resolveClickedGroup(drawn, 1, series)).toBeNull();
expect(resolveClickedGroup(drawn, null, series)).toBeNull();
});
it('maps by source index, not label, when groups share a legend', () => {
const twins = [
{ label: 'Other', groupLabels: { pod: 'a' }, counts: [1] },
{ label: 'Other', groupLabels: { pod: 'b' }, counts: [2] },
];
const drawnTwins = groupIntoOther({
labels: twins.map(({ label }) => label),
counts: twins.map(({ counts }) => counts),
colors: ['#a', '#b'],
visible: [true, true],
maxGroups: 2,
otherColor: '#999',
});
expect(resolveClickedGroup(drawnTwins, 1, twins)).toStrictEqual({
series: twins[1],
color: '#b',
});
});
});

View File

@@ -1,29 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { toAlphaHex } from 'lib/uPlotV2/utils/fillOpacity';
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
export function withOpacity(color: string, opacity: number): string {
return HEX_COLOR.test(color) ? `${color}${toAlphaHex(opacity)}` : color;
}
/** Neutral, so the summed remainder never reads as one more group. */
export function resolveOtherColor(isDarkMode: boolean): string {
return isDarkMode ? Color.BG_VANILLA_400 : Color.BG_SLATE_50;
}
export function resolveSeriesColor(
label: string,
isDarkMode: boolean,
customColors?: Record<string, string>,
): string {
return (
customColors?.[label] ??
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
)
);
}

View File

@@ -1,116 +0,0 @@
@use '../../../../../../styles/scrollbar' as *;
// Same surface as the shared and heatmap tooltips.
.container {
--gutter: 14px;
--row-inset: 6px;
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
min-width: 240px;
&.pinned {
border-color: var(--ring);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
// Same as the heatmap tooltip's bucket row.
.title {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-6) var(--gutter);
}
.titleBucket,
.titleCount {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: -0.01em;
color: var(--popover-foreground);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.titleSeparator {
flex: 1;
border-width: 0.5px;
border-style: dashed;
border-color: currentColor;
min-width: 24px;
opacity: 0.5;
}
// One grid for the heading and every row, so their columns line up.
.rows {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) repeat(3, auto);
column-gap: var(--spacing-6);
row-gap: var(--spacing-1);
align-content: start;
padding: var(--spacing-4) var(--spacing-4) var(--spacing-6);
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.columnHeader,
.rows .row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
// The shared item's own `gap` would otherwise shift the subgrid off the parent.
column-gap: var(--spacing-6);
align-items: center;
}
.columnHeader {
padding: 0 6px var(--spacing-2);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
> span:nth-child(n + 2) {
text-align: right;
}
}
.columnLabel {
grid-column: 1 / 3;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rows .row {
border-radius: var(--radius);
font-variant-numeric: tabular-nums;
&[data-is-active='true'] {
background: var(--l3-background);
}
> :nth-child(n + 3) {
text-align: right;
}
}

View File

@@ -1,154 +0,0 @@
import { useMemo } from 'react';
import cx from 'classnames';
import {
formatRowLabel,
resolveRowIndex,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import TooltipPinnedBadge from 'lib/uPlotV2/components/Tooltip/components/TooltipPinnedBadge/TooltipPinnedBadge';
import ItemStyles from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem.module.scss';
import { resolveNearestSeries } from '../../layout';
import {
buildDistributionTooltipRows,
formatPercent,
resolveGroupColumn,
} from './distributionTooltipContent';
import { DistributionTooltipProps } from './types';
import Styles from './DistributionTooltip.module.scss';
/**
* The bucket comes from the live cursor, not uPlot's `cursor.idx`: that snaps to the
* nearest bucket centre, which on uneven widths is often the neighbour.
*/
export default function DistributionTooltip({
uPlotInstance,
xAxis,
layout,
labels,
colors,
counts,
formatBound,
isPinned,
dismiss,
renderTooltipFooter,
}: DistributionTooltipProps): JSX.Element | null {
// Read outside the memo: uPlot mutates the same instance on every move.
const { left = -10, top = -10 } = uPlotInstance.cursor;
const hover = useMemo(() => {
if (left < 0 || top < 0) {
return null;
}
const x = uPlotInstance.posToVal(left, 'x');
const bucket = resolveRowIndex(xAxis.edges, x);
if (bucket === null) {
return null;
}
return {
bucket,
series: resolveNearestSeries(
layout,
bucket,
x,
uPlotInstance.posToVal(top, 'y'),
),
};
}, [left, top, uPlotInstance, xAxis, layout]);
const groupColumn = useMemo(() => resolveGroupColumn(labels), [labels]);
const rows = useMemo(
() =>
hover
? buildDistributionTooltipRows({
labels,
colors,
counts,
bucket: hover.bucket,
hoveredSeries: hover.series,
formatLabel: groupColumn.format,
})
: [],
[hover, labels, colors, counts, groupColumn],
);
if (!hover) {
return null;
}
const bucket = xAxis.rows[hover.bucket];
const bucketTotal = rows.reduce((sum, row) => sum + row.count, 0);
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="distribution-tooltip"
>
<div className={Styles.title}>
<span
className={Styles.titleBucket}
data-testid="distribution-tooltip-range"
>
{formatRowLabel(bucket, formatBound)}
</span>
<span className={Styles.titleSeparator} />
<span
className={Styles.titleCount}
data-testid="distribution-tooltip-total"
>
{bucketTotal.toLocaleString()}
</span>
{isPinned && <TooltipPinnedBadge />}
</div>
<span className={Styles.divider} />
<div className={Styles.rows}>
<div className={Styles.columnHeader}>
<span
className={Styles.columnLabel}
title={groupColumn.heading}
data-testid="distribution-tooltip-group-heading"
>
{groupColumn.heading}
</span>
<span>Count</span>
<span>% Total</span>
<span data-testid="distribution-tooltip-cumulative-heading">
{bucket.isOverflow ? 'All' : `≤ ${formatBound(bucket.upper)}`}
</span>
</div>
{rows.map((row) => (
<div
key={row.seriesIndex}
className={cx(ItemStyles.uplotTooltipItem, Styles.row)}
data-is-active={row.isHovered}
data-testid="distribution-tooltip-row"
data-hovered={row.isHovered}
>
<span
className={ItemStyles.uplotTooltipItemMarker}
style={{ borderColor: row.color, backgroundColor: row.color }}
/>
<span className={ItemStyles.uplotTooltipItemLabel} title={row.label}>
{row.label}
</span>
<span className={ItemStyles.uplotTooltipItemValue}>
{row.count.toLocaleString()}
</span>
<span className={ItemStyles.uplotTooltipItemValue}>
{formatPercent(row.share)}
</span>
<span className={ItemStyles.uplotTooltipItemValue}>
{formatPercent(row.cumulativeShare)}
</span>
</div>
))}
</div>
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -1,88 +0,0 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import {
resolveDistributionBars,
resolveDistributionXAxis,
} from 'lib/visualization/charts/Distribution/layout';
import { render, screen } from 'tests/test-utils';
import type uPlot from 'uplot';
import DistributionTooltip from '../DistributionTooltip';
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
const xAxis = resolveDistributionXAxis([10, 20, 40]);
const counts = [
[1, 4, 2, 0],
[0, 6, 1, 1],
];
const layout = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Normal,
isLogY: false,
});
/** Pixels are values: the cursor at (1.5, 2) is over the first group in (10, 20]. */
function plotAt(left: number, top: number): uPlot {
const plot: Partial<uPlot> = {
cursor: { left, top },
posToVal: (position: number) => position,
};
return plot as uPlot;
}
function renderTooltip(uPlotInstance: uPlot): void {
render(
<DistributionTooltip
uPlotInstance={uPlotInstance}
isPinned={false}
dismiss={jest.fn()}
xAxis={xAxis}
layout={layout}
labels={['{service.name="cart"}', '{service.name="checkout"}']}
colors={['#f00', '#0f0']}
counts={counts}
formatBound={(value): string => `${value} ms`}
/>,
);
}
describe('DistributionTooltip', () => {
it('names the bucket under the cursor and its total', () => {
renderTooltip(plotAt(1.5, 2));
expect(screen.getByTestId('distribution-tooltip-range')).toHaveTextContent(
'10 ms – 20 ms',
);
expect(screen.getByTestId('distribution-tooltip-total')).toHaveTextContent(
'10',
);
});
it('heads the columns with the group-by key and the bucket bound', () => {
renderTooltip(plotAt(1.5, 2));
expect(
screen.getByTestId('distribution-tooltip-group-heading'),
).toHaveTextContent('service.name');
expect(
screen.getByTestId('distribution-tooltip-cumulative-heading'),
).toHaveTextContent('≤ 20 ms');
});
it('lists every group in legend order, the hovered one active', () => {
renderTooltip(plotAt(1.5, 2));
const rows = screen.getAllByTestId('distribution-tooltip-row');
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent('cart457%71%');
expect(rows[0]).toHaveAttribute('data-is-active', 'true');
expect(rows[1]).toHaveTextContent('checkout675%75%');
expect(rows[1]).toHaveAttribute('data-is-active', 'false');
});
it('renders nothing off the plot', () => {
renderTooltip(plotAt(-1, -1));
expect(screen.queryByTestId('distribution-tooltip')).not.toBeInTheDocument();
});
});

View File

@@ -1,73 +0,0 @@
import {
buildDistributionTooltipRows,
formatPercent,
resolveGroupColumn,
} from '../distributionTooltipContent';
describe('formatPercent', () => {
it('rounds, and never shows a real share as 0%', () => {
expect(formatPercent(0.256)).toBe('26%');
expect(formatPercent(0.004)).toBe('<1%');
expect(formatPercent(0)).toBe('0%');
});
});
describe('resolveGroupColumn', () => {
it('names the column after a shared single key and shows its values', () => {
const column = resolveGroupColumn([
'{service.name="cart"}',
'{service.name="checkout"}',
]);
expect(column.heading).toBe('service.name');
expect(column.format('{service.name="cart"}')).toBe('cart');
});
it('keeps full labels for several keys or mixed labels', () => {
const several = resolveGroupColumn(['{env="prod", service.name="cart"}']);
const mixed = resolveGroupColumn(['{service.name="cart"}', 'Other']);
expect(several.heading).toBe('Group');
expect(several.format('{env="prod", service.name="cart"}')).toBe(
'{env="prod", service.name="cart"}',
);
expect(mixed.heading).toBe('Group');
});
});
describe('buildDistributionTooltipRows', () => {
it('gives each drawn group, in legend order, its count, share and cumulative share', () => {
const rows = buildDistributionTooltipRows({
labels: ['cart', 'checkout'],
colors: ['#f00', '#0f0'],
counts: [
[2, 6, 2],
[5, 0, 5],
],
bucket: 1,
hoveredSeries: 0,
formatLabel: (label) => label.toUpperCase(),
});
expect(rows).toStrictEqual([
{
seriesIndex: 0,
label: 'CART',
color: '#f00',
count: 6,
share: 0.6,
cumulativeShare: 0.8,
isHovered: true,
},
{
seriesIndex: 1,
label: 'CHECKOUT',
color: '#0f0',
count: 0,
share: 0,
cumulativeShare: 0.5,
isHovered: false,
},
]);
});
});

View File

@@ -1,70 +0,0 @@
import { DistributionGroupColumn, DistributionTooltipRow } from './types';
const SINGLE_LABEL = /^\{([^=,]+)="([^"]*)"\}$/;
/** `<1%` rather than `0%`, so a share that exists never reads as none. */
export function formatPercent(share: number): string {
if (share > 0 && share < 0.01) {
return '<1%';
}
return `${Math.round(share * 100)}%`;
}
/**
* Names the group column after the one group-by key every label shares, showing just
* its values; anything else (several keys, Other) keeps the full labels.
*/
export function resolveGroupColumn(labels: string[]): DistributionGroupColumn {
const keys = new Set<string>();
labels.forEach((label) => {
keys.add(SINGLE_LABEL.exec(label)?.[1] ?? '');
});
const [key] = [...keys];
if (keys.size !== 1 || !key) {
return { heading: 'Group', format: (label) => label };
}
return {
heading: key,
format: (label) => SINGLE_LABEL.exec(label)?.[2] ?? label,
};
}
/** One row per drawn group, in legend order, with its count in `bucket`. */
export function buildDistributionTooltipRows({
labels,
colors,
counts,
bucket,
hoveredSeries,
formatLabel,
}: {
labels: string[];
colors: string[];
/** `counts[series][bucket]`. */
counts: number[][];
bucket: number;
hoveredSeries: number | null;
formatLabel: (label: string) => string;
}): DistributionTooltipRow[] {
return labels.map((label, series) => {
const seriesCounts = counts[series] ?? [];
let total = 0;
let atOrBelow = 0;
seriesCounts.forEach((count, index) => {
total += count;
if (index <= bucket) {
atOrBelow += count;
}
});
const count = seriesCounts[bucket] ?? 0;
return {
seriesIndex: series,
label: formatLabel(label),
color: colors[series],
count,
share: total > 0 ? count / total : 0,
cumulativeShare: total > 0 ? atOrBelow / total : 0,
isHovered: series === hoveredSeries,
};
});
}

View File

@@ -1,39 +0,0 @@
import type {
IRenderTooltipFooterArgs,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import type { DistributionBarsLayout } from '../../layout';
import type { DistributionXAxis } from '../../types';
export interface DistributionTooltipRow {
/** Index among the drawn series. */
seriesIndex: number;
label: string;
color: string;
count: number;
/** Of the group's own total over the range. */
share: number;
/** Of the group's total, at or below this bucket. */
cumulativeShare: number;
isHovered: boolean;
}
export interface DistributionGroupColumn {
heading: string;
format: (label: string) => string;
}
export interface DistributionTooltipProps extends Pick<
TooltipRenderArgs,
'uPlotInstance' | 'isPinned' | 'dismiss'
> {
xAxis: DistributionXAxis;
layout: DistributionBarsLayout;
labels: string[];
colors: string[];
/** `counts[series][bucket]`. */
counts: number[][];
formatBound: (value: number) => string;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
}

View File

@@ -1,288 +0,0 @@
import { PrecisionOption } from 'components/Graph/types';
import {
DistributionType,
DrawStyle,
StackMode,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { createVariableWidthBarsPathBuilder } from 'lib/uPlotV2/paths/variableWidthBars';
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { createHatchPattern } from 'lib/uPlotV2/plugins/HeatmapPlugin/paint';
import uPlot from 'uplot';
import { withOpacity } from './colors';
import {
drawPercentileMarkers,
MARKER_HIT_PX,
PercentileMarkerHitArea,
resolveHoveredMarker,
} from './drawPercentileMarkers';
import {
DistributionBarsLayout,
LOG_Y_BASE,
resolveNearestSeries,
thinLabelledSplits,
} from './layout';
import { DistributionPercentile, groupPercentileMarkers } from './percentiles';
import { DistributionXAxis } from './types';
import { createBoundFormatter } from './utils';
/** Minimum space between two x tick labels, in CSS pixels. */
const X_LABEL_GAP_PX = 12;
const BAR_GAP_PX = 1;
const BAR_OPACITY = 0.85;
const OVERFLOW_BAR_OPACITY = 0.4;
/** Every group but the hovered one fades to this. */
const DIMMED_BAR_OPACITY = 0.25;
const DIMMED_OVERFLOW_BAR_OPACITY = 0.12;
const OVERFLOW_AXIS_LABEL = '∞';
/** Headroom above the tallest bar: a fraction on linear, a factor on log. Marker
* labels sit at the top of the plot, so they take more. */
const LINEAR_HEADROOM = 1.1;
const LINEAR_HEADROOM_WITH_MARKERS = 1.3;
const LOG_HEADROOM = 2;
const LOG_HEADROOM_WITH_MARKERS = 6;
function resolveYRange({
yMax,
isLogY,
stack,
hasMarkers,
}: {
yMax: number;
isLogY: boolean;
stack: StackMode;
hasMarkers: boolean;
}): [number, number] {
if (stack === StackMode.Percent) {
return [0, hasMarkers ? 100 * LINEAR_HEADROOM_WITH_MARKERS : 100];
}
if (isLogY) {
const headroom = hasMarkers ? LOG_HEADROOM_WITH_MARKERS : LOG_HEADROOM;
return [LOG_Y_BASE, Math.max(yMax, LOG_Y_BASE) * headroom];
}
const headroom = hasMarkers ? LINEAR_HEADROOM_WITH_MARKERS : LINEAR_HEADROOM;
return [0, yMax > 0 ? yMax * headroom : 1];
}
export interface BuildDistributionConfigArgs {
id: string;
xAxis: DistributionXAxis;
layout: DistributionBarsLayout;
labels: string[];
colors: string[];
stack: StackMode;
isLogY: boolean;
isDarkMode: boolean;
/** Drawn across the plot, labelled at the top. */
markers: DistributionPercentile[];
/** Unit of the bucket bounds; counts take none. */
xAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}
export function buildDistributionConfig({
id,
xAxis,
layout,
labels,
colors,
stack,
isLogY,
isDarkMode,
markers,
xAxisUnit,
decimalPrecision,
}: BuildDistributionConfigArgs): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder({ id });
// uPlot's focus picks the series nearest in value, meaningless for stacked bars; the
// setCursor hook focuses the one under the cursor itself. Alpha 1 leaves the
// dimming to the bar fills.
builder.setFocus({ alpha: 1 });
// The x range is pinned to the buckets, so a drag-zoom would only flash a box.
builder.setCursor({
focus: { prox: -1 },
points: { show: false },
drag: { x: false, y: false },
});
const formatBound = createBoundFormatter(xAxisUnit, decimalPrecision);
builder.addScale({
scaleKey: 'x',
time: false,
auto: false,
range: (): [number, number] => [xAxis.min, xAxis.max],
});
const yRange = resolveYRange({
yMax: layout.yMax,
isLogY,
stack,
hasMarkers: markers.length > 0,
});
builder.addScale({
scaleKey: 'y',
time: false,
auto: false,
distribution: isLogY ? DistributionType.Logarithmic : DistributionType.Linear,
range: (): [number, number] => yRange,
});
const axisSplits =
xAxis.overflowSplit === null
? xAxis.splits
: [...xAxis.splits, xAxis.overflowSplit];
const splitLabels = new Map<number, string>();
xAxis.splits.forEach((split, index) => {
splitLabels.set(split, formatBound(xAxis.rows[index].upper));
});
if (xAxis.overflowSplit !== null) {
splitLabels.set(xAxis.overflowSplit, OVERFLOW_AXIS_LABEL);
}
const labelOf = (split: number): string =>
splitLabels.get(split) ?? formatBound(xAxis.toBucketValue(split));
builder.addAxis({
scaleKey: 'x',
side: 2,
isDarkMode,
// Thinned by each label's own width: bound labels run from `5 s` to
// `16.67 mins`, so no fixed spacing keeps them apart.
splits: (self, axisIdx): number[] => {
const visible = axisSplits.filter(
(split) => split >= xAxis.min && split <= xAxis.max,
);
const font = self.axes[axisIdx]?.font?.[0];
if (font) {
self.ctx.font = font;
}
const kept = thinLabelledSplits({
positions: visible.map((split) => self.valToPos(split, 'x')),
widths: visible.map(
(split) => self.ctx.measureText(labelOf(split)).width / uPlot.pxRatio,
),
gap: X_LABEL_GAP_PX,
});
return kept.map((index) => visible[index]);
},
values: (_, splits): string[] => splits.map(labelOf),
});
builder.addAxis({
scaleKey: 'y',
side: 3,
isDarkMode,
yAxisUnit: stack === StackMode.Percent ? 'percent' : 'short',
// The marker headroom runs a share axis past 100%, which labels nothing real.
...(stack === StackMode.Percent && {
filter: (_self: uPlot, splits: number[]): Array<number | null> =>
splits.map((split) => (split <= 100 ? split : null)),
}),
});
let hatchPattern: CanvasPattern | null | undefined;
const getHatchPattern = (
ctx: CanvasRenderingContext2D,
): CanvasPattern | null => {
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
return hatchPattern;
};
// Read by every series' fill on each draw; a change redraws the paths.
const hover: {
series: number | null;
focused: number | null;
marker: number | null;
} = { series: null, focused: null, marker: null };
const plotMarkers = groupPercentileMarkers(
markers.filter(
({ axisValue }) => axisValue >= xAxis.min && axisValue <= xAxis.max,
),
);
let markerHitAreas: PercentileMarkerHitArea[] = [];
const isDimmed = (seriesIndex: number): boolean =>
hover.series !== null && hover.series !== seriesIndex;
labels.forEach((label, seriesIndex) => {
const color = colors[seriesIndex];
builder.addSeries({
scaleKey: 'y',
label,
drawStyle: DrawStyle.Bar,
pathBuilder: createVariableWidthBarsPathBuilder({
getBars: () => layout.rects[seriesIndex] ?? [],
fill: () =>
withOpacity(
color,
isDimmed(seriesIndex) ? DIMMED_BAR_OPACITY : BAR_OPACITY,
),
hatchedFill: () =>
withOpacity(
color,
isDimmed(seriesIndex)
? DIMMED_OVERFLOW_BAR_OPACITY
: OVERFLOW_BAR_OPACITY,
),
getHatchPattern,
gapPx: BAR_GAP_PX,
}),
lineColor: color,
lineWidth: 0,
colorMapping: {},
isDarkMode,
showPoints: false,
spanGaps: false,
});
});
builder.addHook('draw', (u: uPlot): void => {
markerHitAreas = drawPercentileMarkers(
u,
plotMarkers,
formatBound,
isDarkMode,
hover.marker,
);
});
// Registered as a hook, not a plugin, so it runs before TooltipPlugin's: the tooltip
// only shows for a focused series, and must find it focused when it positions.
builder.addHook('setCursor', (u: uPlot): void => {
const { left = -1, top = -1 } = u.cursor;
const isOnPlot = left >= 0 && top >= 0;
const x = isOnPlot ? u.posToVal(left, 'x') : Number.NaN;
const bucket = isOnPlot ? resolveRowIndex(xAxis.edges, x) : null;
const marker = isOnPlot
? resolveHoveredMarker(markerHitAreas, left, top, MARKER_HIT_PX)
: null;
// Over a marker, the bars neither dim nor show their tooltip.
const hovered =
bucket === null || marker !== null
? null
: resolveNearestSeries(layout, bucket, x, u.posToVal(top, 'y'));
// An empty bucket still gets a tooltip, as a bar chart's zero column does; with no
// nearest group to name, the first stands in.
const isEmptyBucket = bucket !== null && marker === null && hovered === null;
const focused = isEmptyBucket && labels.length > 0 ? 0 : hovered;
if (focused !== hover.focused) {
hover.focused = focused;
u.setSeries(focused === null ? null : focused + 1, { focus: true });
}
if (marker !== hover.marker) {
// The crosshair would cut through the marker's badges.
u.root
.querySelectorAll<HTMLElement>('.u-cursor-x, .u-cursor-y')
.forEach((line) => {
line.style.visibility = marker === null ? '' : 'hidden';
});
}
if (hovered !== hover.series || marker !== hover.marker) {
hover.series = hovered;
hover.marker = marker;
u.redraw(true, false);
}
});
return builder;
}

View File

@@ -1,242 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { themeColors } from 'constants/theme';
import uPlot from 'uplot';
import {
DistributionPercentile,
formatPercentileBadge,
formatPercentileValue,
} from './percentiles';
const MARKER_DASH: [number, number] = [3, 3];
const MARKER_FONT_SIZE_PX = 9;
/** Space between a badge's name and its diamond's edges. */
const MARKER_BADGE_PADDING_PX = 2;
const MARKER_BADGE_GAP_PX = 2;
const MARKER_VALUE_FONT_SIZE_PX = 11;
/** How far from a marker line the cursor still hovers it, in CSS pixels. */
export const MARKER_HIT_PX = 4;
const MARKER_PILL_PADDING_X_PX = 6;
const MARKER_PILL_PADDING_Y_PX = 3;
const MARKER_PILL_RADIUS_PX = 4;
/** Height of the pill's notch pointing up at the line; also its gap from the axis. */
const MARKER_PILL_POINTER_PX = 4;
interface HitRect {
left: number;
right: number;
top: number;
bottom: number;
}
/** A drawn marker's hover targets, in CSS pixels relative to the plot. */
export interface PercentileMarkerHitArea {
x: number;
badges: HitRect[];
}
/**
* The marker under `(x, y)`: one whose badge it is over, else the line nearest
* `x` within `tolerance`. `null` when none is.
*/
export function resolveHoveredMarker(
areas: PercentileMarkerHitArea[],
x: number,
y: number,
tolerance: number,
): number | null {
const overBadge = areas.findIndex(({ badges }) =>
badges.some(
(badge) =>
x >= badge.left && x <= badge.right && y >= badge.top && y <= badge.bottom,
),
);
if (overBadge !== -1) {
return overBadge;
}
let nearest: number | null = null;
let nearestDistance = tolerance;
areas.forEach((area, index) => {
const distance = Math.abs(area.x - x);
if (distance <= nearestDistance) {
nearest = index;
nearestDistance = distance;
}
});
return nearest;
}
/**
* Stacks marker badges into rows so none overlap: each goes in the first row whose
* last badge ends before it starts. Input and output are in the same pixel space.
*/
export function layoutMarkerLabels(
labels: Array<{ x: number; width: number }>,
gap: number,
): number[] {
const rowEnds: number[] = [];
const order = labels
.map((label, index) => ({ ...label, index }))
.sort((a, b) => a.x - b.x);
const rows = new Array<number>(labels.length).fill(0);
order.forEach(({ x, width, index }) => {
let row = rowEnds.findIndex((end) => x >= end + gap);
if (row === -1) {
row = rowEnds.length;
rowEnds.push(0);
}
rowEnds[row] = x + width;
rows[index] = row;
});
return rows;
}
/** A square diamond centred on `(x, y)` with half-diagonal `r`, filled then outlined. */
function drawDiamond(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
r: number,
fill: string,
stroke: string,
): void {
ctx.beginPath();
ctx.moveTo(x, y - r);
ctx.lineTo(x + r, y);
ctx.lineTo(x, y + r);
ctx.lineTo(x - r, y);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = stroke;
ctx.stroke();
}
/**
* Dashed lines across the plot with a diamond badge at each end naming the
* percentile, one per group on the same spot; badges stack into rows so none overlap. The hovered marker is
* emphasised and shows its value on the x axis. Returns each marker's hover targets.
*/
export function drawPercentileMarkers(
u: uPlot,
markers: DistributionPercentile[][],
formatBound: (value: number) => string,
isDarkMode: boolean,
hovered: number | null,
): PercentileMarkerHitArea[] {
if (markers.length === 0) {
return [];
}
const { ctx, bbox } = u;
const { pxRatio } = uPlot;
const color = isDarkMode ? themeColors.white : themeColors.black;
const background = isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100;
const bottom = bbox.top + bbox.height;
ctx.save();
ctx.font = `600 ${MARKER_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const named = markers.map((group) => {
const text = formatPercentileBadge(group);
return { text, width: ctx.measureText(text).width };
});
// One square diamond for all, fitting the widest name's box: |dx| + |dy| ≤ r at
// its corners. Cap height stands in for the text's height.
const r =
(Math.max(...named.map(({ width }) => width)) +
MARKER_FONT_SIZE_PX * pxRatio * 0.7) /
2 +
MARKER_BADGE_PADDING_PX * pxRatio;
const badges = markers.map((group, index) => ({
text: named[index].text,
x: u.valToPos(group[0].axisValue, 'x', true),
}));
const rows = layoutMarkerLabels(
badges.map(({ x }) => ({ x: x - r, width: 2 * r })),
MARKER_BADGE_GAP_PX * pxRatio,
);
const rowStep = 2 * r + MARKER_BADGE_GAP_PX * pxRatio;
const badgeCentres = (index: number): [number, number] => {
const offset = r + rows[index] * rowStep;
return [bbox.top + offset, bottom - offset];
};
const drawMarker = (index: number): void => {
const { text, x } = badges[index];
const isHovered = index === hovered;
ctx.globalAlpha = isHovered ? 1 : 0.55;
ctx.strokeStyle = color;
ctx.lineWidth = pxRatio;
ctx.setLineDash(MARKER_DASH.map((dash) => dash * pxRatio));
ctx.beginPath();
ctx.moveTo(x, bbox.top);
ctx.lineTo(x, bottom);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.setLineDash([]);
badgeCentres(index).forEach((y) => {
drawDiamond(ctx, x, y, r, isHovered ? color : background, color);
ctx.fillStyle = isHovered ? background : color;
ctx.fillText(text, x, y);
});
if (!isHovered) {
return;
}
// The value sits on the x axis under the line, over the tick labels, in a pill
// whose notch points up at the line.
ctx.font = `500 ${MARKER_VALUE_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
const value = formatPercentileValue(markers[index], formatBound);
const padX = MARKER_PILL_PADDING_X_PX * pxRatio;
const padY = MARKER_PILL_PADDING_Y_PX * pxRatio;
const pointer = MARKER_PILL_POINTER_PX * pxRatio;
const width = ctx.measureText(value).width + 2 * padX;
const height = MARKER_VALUE_FONT_SIZE_PX * pxRatio + 2 * padY;
const left = Math.min(Math.max(x - width / 2, 0), ctx.canvas.width - width);
const top = bottom + 2 * pointer;
const notchX = Math.min(
Math.max(x, left + pointer + MARKER_PILL_RADIUS_PX * pxRatio),
left + width - pointer - MARKER_PILL_RADIUS_PX * pxRatio,
);
// A halo in the chart's background hides the tick labels it overlaps.
ctx.fillStyle = background;
ctx.fillRect(left - padX, top, width + 2 * padX, height + 2 * padY);
ctx.fillStyle = isDarkMode ? Color.BG_SLATE_400 : Color.BG_INK_400;
ctx.beginPath();
ctx.roundRect(left, top, width, height, MARKER_PILL_RADIUS_PX * pxRatio);
ctx.moveTo(notchX - pointer, top);
ctx.lineTo(notchX, top - pointer);
ctx.lineTo(notchX + pointer, top);
ctx.closePath();
ctx.fill();
ctx.fillStyle = Color.BG_VANILLA_100;
ctx.fillText(value, left + width / 2, top + height / 2);
};
// The hovered one last, so it draws over its neighbours.
badges.forEach((_, index) => index !== hovered && drawMarker(index));
if (hovered !== null && hovered < badges.length) {
drawMarker(hovered);
}
ctx.restore();
return badges.map(({ x }, index) => {
const toPlot = (value: number, origin: number): number =>
(value - origin) / pxRatio;
return {
x: toPlot(x, bbox.left),
badges: badgeCentres(index).map((y) => ({
left: toPlot(x - r, bbox.left),
right: toPlot(x + r, bbox.left),
top: toPlot(y - r, bbox.top),
bottom: toPlot(y + r, bbox.top),
})),
};
});
}

View File

@@ -1,232 +0,0 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import {
cropHeatmapYAxis,
resolveHeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { DistributionBarRect, DistributionXAxis } from './types';
/** Bottom of a log count axis: a bar has to start somewhere above zero. */
export const LOG_Y_BASE = 1;
/**
* N bounds give N + 1 buckets: `≤ first`, the finite ones, and `> last`. Every bucket
* is equally wide: bucket `i` spans `[i, i + 1]`, and ticks still name the bounds.
*/
export function resolveDistributionXAxis(buckets: number[]): DistributionXAxis {
const { rows, overflowSplit } = resolveHeatmapYAxis(
buckets,
HeatmapAxisScale.Linear,
);
const edges = rows.map((_, index) => index).concat(rows.length);
return {
rows,
edges,
splits: rows.slice(0, -1).map((_, index) => index + 1),
overflowSplit: overflowSplit === null ? null : rows.length,
toBucketValue: (axisValue: number): number => {
const index = Math.min(rows.length - 1, Math.max(0, Math.floor(axisValue)));
const row = rows[index];
return row.lower + (axisValue - index) * (row.upper - row.lower);
},
min: 0,
max: rows.length,
};
}
/** Sum of each bucket across the visible series. */
export function sumVisibleCounts(
counts: number[][],
visible: boolean[],
bucketCount: number,
): number[] {
const totals = new Array<number>(bucketCount).fill(0);
counts.forEach((seriesCounts, seriesIndex) => {
if (!visible[seriesIndex]) {
return;
}
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
totals[bucket] += seriesCounts[bucket] ?? 0;
}
});
return totals;
}
/** Drops empty buckets past the last occupied one, as the heatmap does. */
export function cropDistributionXAxis(
axis: DistributionXAxis,
totals: number[],
): DistributionXAxis {
return cropHeatmapYAxis(
axis,
totals.map((total) => [total]),
);
}
/**
* Narrows the axis window to the buckets overlapping `[min, max]`, never below one.
* A bucket straddling a bound stays whole: cutting it would misstate its count.
*/
export function clampDistributionXAxis(
axis: DistributionXAxis,
min?: number,
max?: number,
): DistributionXAxis {
const { rows, edges } = axis;
let first = edges.indexOf(axis.min);
let last = edges.indexOf(axis.max) - 1;
while (min !== undefined && first < last && rows[first].upper <= min) {
first += 1;
}
while (max !== undefined && last > first && rows[last].lower >= max) {
last -= 1;
}
return {
...axis,
overflowSplit: rows[last].isOverflow ? axis.overflowSplit : null,
min: edges[first],
max: edges[last + 1],
};
}
export interface DistributionBarsLayout {
/** One list per series, in series order. */
rects: DistributionBarRect[][];
/** Top of the tallest bar inside the axis window, for the count axis range. */
yMax: number;
}
/**
* Every bar, in scale space. `normal` stacks the series in each bucket, `percent`
* stacks their shares of it, `none` splits the bucket width between them.
*/
export function resolveDistributionBars({
xAxis,
counts,
stack,
isLogY,
}: {
xAxis: DistributionXAxis;
/** `counts[series][bucket]`. */
counts: number[][];
stack: StackMode;
isLogY: boolean;
}): DistributionBarsLayout {
const { rows, edges } = xAxis;
const rects: DistributionBarRect[][] = counts.map(() => []);
const base = isLogY ? LOG_Y_BASE : 0;
let yMax = 0;
rows.forEach((row, bucketIndex) => {
const x0 = edges[bucketIndex];
const x1 = edges[bucketIndex + 1];
// Buckets outside the window are off the plot and mustn't set its height.
const isInWindow = x0 >= xAxis.min && x1 <= xAxis.max;
const total = counts.reduce(
(sum, seriesCounts) => sum + (seriesCounts[bucketIndex] ?? 0),
0,
);
let cumulative = 0;
counts.forEach((seriesCounts, seriesIndex) => {
const count = seriesCounts[bucketIndex] ?? 0;
if (count <= 0) {
return;
}
const bar = { bucketIndex, hatched: row.isOverflow };
if (stack === StackMode.None) {
// Below the log base a bar would draw downwards from the axis.
if (count <= base) {
return;
}
const width = (x1 - x0) / counts.length;
rects[seriesIndex].push({
...bar,
x0: x0 + seriesIndex * width,
x1: x0 + (seriesIndex + 1) * width,
y0: base,
y1: count,
});
if (isInWindow) {
yMax = Math.max(yMax, count);
}
return;
}
const scale = stack === StackMode.Percent ? 100 / total : 1;
const y0 = cumulative * scale;
cumulative += count;
const y1 = cumulative * scale;
if (y1 <= base) {
return;
}
rects[seriesIndex].push({
...bar,
x0,
x1,
y0: Math.max(y0, base),
y1,
});
if (isInWindow) {
yMax = Math.max(yMax, y1);
}
});
});
return { rects, yMax };
}
/**
* The group nearest `(x, y)` vertically in `bucket`, as uPlot focuses bar series;
* side by side, only the sub-bar under `x` competes. `null` over an empty bucket.
*/
export function resolveNearestSeries(
layout: DistributionBarsLayout,
bucket: number,
x: number,
y: number,
): number | null {
const candidates = layout.rects.flatMap((rects, series) =>
rects
.filter((rect) => rect.bucketIndex === bucket)
.map((rect) => ({ rect, series })),
);
const underX = candidates.filter(({ rect }) => x >= rect.x0 && x < rect.x1);
let nearest: number | null = null;
let nearestDistance = Number.POSITIVE_INFINITY;
(underX.length > 0 ? underX : candidates).forEach(({ rect, series }) => {
const distance = Math.max(rect.y0 - y, y - rect.y1, 0);
if (distance < nearestDistance) {
nearest = series;
nearestDistance = distance;
}
});
return nearest;
}
/**
* Indexes of the ticks whose centred labels fit without touching, walking from the
* right so the `∞` edge survives. Positions and widths share one pixel space.
*/
export function thinLabelledSplits({
positions,
widths,
gap,
}: {
positions: number[];
widths: number[];
gap: number;
}): number[] {
const kept: number[] = [];
let leftEdge = Number.POSITIVE_INFINITY;
for (let index = positions.length - 1; index >= 0; index -= 1) {
const half = widths[index] / 2;
if (positions[index] + half + gap <= leftEdge) {
kept.push(index);
leftEdge = positions[index] - half;
}
}
return kept.reverse();
}

View File

@@ -1,110 +0,0 @@
import { DistributionXAxis } from './types';
export interface DistributionPercentile {
/** 0–100, as configured. */
percentile: number;
/** The estimate; in overflow, the last finite bound it is known to exceed. */
value: number;
/** Where the marker is drawn, in x-scale space. */
axisValue: number;
isOverflow: boolean;
}
/**
* Percentiles of the summed counts, by `histogram_quantile`'s method: find the bucket
* holding the target rank and interpolate linearly inside it, with the lowest bucket
* starting at zero. So the values match a time-series panel's percentiles.
*/
export function computeDistributionPercentiles({
xAxis,
totals,
percentiles,
}: {
xAxis: DistributionXAxis;
/** Summed visible counts per bucket. */
totals: number[];
percentiles: readonly number[];
}): DistributionPercentile[] {
const { rows, edges } = xAxis;
const total = totals.reduce((sum, count) => sum + count, 0);
if (total <= 0 || rows.length === 0) {
return [];
}
return percentiles.flatMap<DistributionPercentile>((percentile) => {
const rank = (percentile / 100) * total;
let below = 0;
for (let index = 0; index < rows.length; index += 1) {
const count = totals[index] ?? 0;
if (count > 0 && below + count >= rank) {
const row = rows[index];
if (row.isOverflow) {
return [
{
percentile,
value: row.lower,
axisValue: edges[index],
isOverflow: true,
},
];
}
const fraction = (rank - below) / count;
const lower = row.isUnderflow ? Math.min(0, row.upper) : row.lower;
return [
{
percentile,
value: lower + fraction * (row.upper - lower),
axisValue: edges[index] + fraction * (edges[index + 1] - edges[index]),
isOverflow: false,
},
];
}
below += count;
}
return [];
});
}
export function formatPercentileName(percentile: number): string {
return `p${Number(percentile.toFixed(3))}`;
}
/**
* Markers that fall on the same spot, sharing one badge: the percentiles landing
* in overflow all sit on its edge. Each group is in ascending percentile order.
*/
export function groupPercentileMarkers(
markers: DistributionPercentile[],
): DistributionPercentile[][] {
const groups = new Map<number, DistributionPercentile[]>();
markers.forEach((marker) => {
groups.set(marker.axisValue, [
...(groups.get(marker.axisValue) ?? []),
marker,
]);
});
return [...groups.values()].map((group) =>
[...group].sort((a, b) => a.percentile - b.percentile),
);
}
/** `p90` for one marker; `p90+` for a group, starting at its lowest percentile. */
export function formatPercentileBadge(group: DistributionPercentile[]): string {
const name = formatPercentileName(group[0].percentile);
return group.length > 1 ? `${name}+` : name;
}
/**
* `p99 ≈ 412 ms` for an estimate; `p90, p99 > 5 s` where only the overflow bound is
* known. A group shares one spot, so one value and overflow flag.
*/
export function formatPercentileValue(
group: DistributionPercentile[],
formatBound: (value: number) => string,
): string {
const names = group
.map(({ percentile }) => formatPercentileName(percentile))
.join(', ');
const [{ value, isOverflow }] = group;
return `${names} ${isOverflow ? '>' : '≈'} ${formatBound(value)}`;
}

View File

@@ -1,133 +0,0 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import { DistributionSeries, DistributionXAxis } from './types';
/** Colours stop being told apart past this many stacked groups. */
export const MAX_STACKED_GROUPS = 10;
/** Narrowest a side-by-side bar may get, in CSS pixels. */
const MIN_SIDE_BY_SIDE_BAR_PX = 3;
export const OTHER_GROUP_LABEL = 'Other';
/**
* How many series fit: a fixed cap when stacked, and side by side as many as keep
* every bar in the narrowest bucket at least `MIN_SIDE_BY_SIDE_BAR_PX` wide.
*/
export function resolveMaxDrawnGroups({
stack,
xAxis,
plotWidth,
}: {
stack: StackMode;
xAxis: DistributionXAxis;
/** Approximate width of the plotting area, in CSS pixels. */
plotWidth: number;
}): number {
if (stack !== StackMode.None) {
return MAX_STACKED_GROUPS;
}
const span = xAxis.max - xAxis.min;
let narrowest = Number.POSITIVE_INFINITY;
for (let index = 0; index + 1 < xAxis.edges.length; index += 1) {
if (xAxis.edges[index] < xAxis.max) {
narrowest = Math.min(narrowest, xAxis.edges[index + 1] - xAxis.edges[index]);
}
}
if (!(span > 0) || !Number.isFinite(narrowest)) {
return MAX_STACKED_GROUPS;
}
const narrowestPx = (plotWidth * narrowest) / span;
return Math.min(
MAX_STACKED_GROUPS,
Math.max(1, Math.floor(narrowestPx / MIN_SIDE_BY_SIDE_BAR_PX)),
);
}
export interface DrawnGroups {
labels: string[];
counts: number[][];
colors: string[];
/** Each drawn group's index among the source series; `null` for Other. */
sourceIndexes: (number | null)[];
/** Visible groups summed into the Other series; 0 when none were. */
otherGroupCount: number;
/** Visible groups in all, drawn or folded. */
visibleGroupCount: number;
}
function sum(counts: number[]): number {
return counts.reduce((total, count) => total + count, 0);
}
/**
* The visible groups to draw: all of them when they fit, else the largest by total
* and one Other series summing the rest. Exact, because bucket counts add. Drawn
* groups keep legend order.
*/
export function groupIntoOther({
labels,
counts,
colors,
visible,
maxGroups,
otherColor,
}: {
labels: string[];
counts: number[][];
colors: string[];
visible: boolean[];
maxGroups: number;
otherColor: string;
}): DrawnGroups {
const visibleIndexes = labels
.map((_, index) => index)
.filter((index) => visible[index]);
if (visibleIndexes.length <= maxGroups) {
return {
labels: visibleIndexes.map((index) => labels[index]),
counts: visibleIndexes.map((index) => counts[index]),
colors: visibleIndexes.map((index) => colors[index]),
sourceIndexes: visibleIndexes,
otherGroupCount: 0,
visibleGroupCount: visibleIndexes.length,
};
}
const keep = new Set(
[...visibleIndexes]
.sort((a, b) => sum(counts[b]) - sum(counts[a]) || a - b)
.slice(0, Math.max(0, maxGroups - 1)),
);
const kept = visibleIndexes.filter((index) => keep.has(index));
const folded = visibleIndexes.filter((index) => !keep.has(index));
const bucketCount = Math.max(...folded.map((index) => counts[index].length));
const other = Array.from({ length: bucketCount }, (_, bucket) =>
folded.reduce((total, index) => total + (counts[index][bucket] ?? 0), 0),
);
return {
labels: [...kept.map((index) => labels[index]), OTHER_GROUP_LABEL],
counts: [...kept.map((index) => counts[index]), other],
colors: [...kept.map((index) => colors[index]), otherColor],
sourceIndexes: [...kept, null],
otherGroupCount: folded.length,
visibleGroupCount: visibleIndexes.length,
};
}
/**
* The drawn group at `drawnIndex` as a source series and its colour; `null` for Other,
* which sums several groups no single filter can name.
*/
export function resolveClickedGroup(
drawn: DrawnGroups,
drawnIndex: number | null,
series: DistributionSeries[],
): { series: DistributionSeries; color: string } | null {
const source =
drawnIndex === null ? null : (drawn.sourceIndexes[drawnIndex] ?? null);
if (drawnIndex === null || source === null) {
return null;
}
return { series: series[source], color: drawn.colors[drawnIndex] };
}

View File

@@ -1,31 +0,0 @@
import type { VariableWidthBar } from 'lib/uPlotV2/paths/variableWidthBars';
import type { HeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
export interface DistributionSeries {
label: string;
/** Group-by key → value, for drilling into this group. Empty when ungrouped. */
groupLabels?: Record<string, string>;
/** One count per bucket bound plus the trailing overflow, summed over the range. */
counts: number[];
}
/** A click on a group's bar; never on Other, which no filter can name. */
export interface DistributionBarClick {
series: DistributionSeries;
color: string;
/** Viewport coordinates of the click. */
coordinates: { x: number; y: number };
}
export enum DistributionYScale {
Linear = 'linear',
Log = 'log',
}
/** The bucket axis in x-scale space. The heatmap's row axis, laid sideways. */
export type DistributionXAxis = HeatmapYAxis;
/** `x` in bucket-axis units, `y` in counts (or percent); the overflow bucket is hatched. */
export interface DistributionBarRect extends VariableWidthBar {
bucketIndex: number;
}

View File

@@ -1,56 +0,0 @@
import { useCallback, useRef } from 'react';
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { type DistributionBarsLayout, resolveNearestSeries } from './layout';
import { type DrawnGroups, resolveClickedGroup } from './topGroups';
import type {
DistributionBarClick,
DistributionSeries,
DistributionXAxis,
} from './types';
/** Resolves a plot click to the group under it; Other and empty buckets report none. */
export function useDistributionBarClick({
xAxis,
layout,
drawn,
series,
onBarClick,
}: {
xAxis: DistributionXAxis;
layout: DistributionBarsLayout;
drawn: DrawnGroups;
series: DistributionSeries[];
onBarClick?: (click: DistributionBarClick) => void;
}): ((data: ChartClickData) => void) | undefined {
const onBarClickRef = useRef(onBarClick);
onBarClickRef.current = onBarClick;
const handleClick = useCallback(
({
xValue,
yValue,
absoluteMouseX,
absoluteMouseY,
}: ChartClickData): void => {
const bucket = resolveRowIndex(xAxis.edges, xValue);
const clicked = resolveClickedGroup(
drawn,
bucket === null
? null
: resolveNearestSeries(layout, bucket, xValue, yValue),
series,
);
if (clicked) {
onBarClickRef.current?.({
...clicked,
coordinates: { x: absoluteMouseX, y: absoluteMouseY },
});
}
},
[xAxis, layout, drawn, series],
);
return onBarClick ? handleClick : undefined;
}

View File

@@ -1,32 +0,0 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import uPlot from 'uplot';
import { DistributionXAxis } from './types';
export function createBoundFormatter(
unit?: string,
decimalPrecision?: PrecisionOption,
): (value: number) => string {
return (value: number): string =>
getToolTipValue(String(value), unit, decimalPrecision);
}
/**
* `[bucket centres, ...counts per series]`. The series draw from the layout, so the
* data only feeds uPlot's cursor and tooltip.
*/
export function prepareDistributionChartData(
xAxis: DistributionXAxis,
counts: number[][],
): uPlot.AlignedData {
const centres = xAxis.rows.map(
(_, index) => (xAxis.edges[index] + xAxis.edges[index + 1]) / 2,
);
return [
centres,
...counts.map((seriesCounts) =>
centres.map((_, index) => seriesCounts[index] ?? 0),
),
] as unknown as uPlot.AlignedData;
}

View File

@@ -26,11 +26,6 @@ import {
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { StackMode } from 'lib/uPlotV2/config/types';
import type {
DistributionBarClick,
DistributionSeries,
DistributionYScale,
} from 'lib/visualization/charts/Distribution/types';
interface BaseChartProps {
width: number;
@@ -172,48 +167,6 @@ export interface HeatmapChartProps {
'data-testid'?: string;
}
/**
* Whole-range bucket counts, one series per group, drawn as bars whose widths follow
* the bucket bounds. Builds its own config, since `buckets` fully determines the x
* axis. `buckets` and `series` must be referentially stable: a new identity rebuilds
* the plot.
*/
export interface DistributionChartProps {
/** Widget id; the group selection persists under it. */
id: string;
/** Ascending. N boundaries describe N+1 buckets, the last one overflow. */
buckets: number[];
series: DistributionSeries[];
width: number;
height: number;
isDarkMode: boolean;
/** Buckets wholly below it are hidden. */
xMin?: number;
/** Buckets wholly above it are hidden; unset crops trailing empty buckets. */
xMax?: number;
/** Default linear. Ignored under `percent`, whose axis is a share. */
yScale?: DistributionYScale;
/** Default normal. `none` draws the groups side by side. */
stack?: StackMode;
/** Unit of the bucket bounds; counts are always plain numbers. */
xAxisUnit?: string;
decimalPrecision?: PrecisionOption;
/** Hex colours keyed by group label, overriding the automatic palette. */
customColors?: Record<string, string>;
/** 0–100. Marked across the plot, over the visible groups combined. */
percentiles?: readonly number[];
/** Fires for a click on a group's bar; not on Other or empty space. */
onBarClick?: (click: DistributionBarClick) => void;
showLegend?: boolean;
legendPosition?: LegendPosition;
showTooltip?: boolean;
canPinTooltip?: boolean;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
tooltipPortalRoot?: HTMLElement | null;
layoutChildren?: React.ReactNode;
'data-testid'?: string;
}
/**
* One resolved pie/donut slice: a display label, its (already parsed) positive
* numeric value, and the colour used for the arc + legend swatch.

View File

@@ -40,6 +40,7 @@ const make = (
state: RuletypesAlertStateDTO.inactive,
labels: { severity: 'info' },
annotations: {},
source: '',
ruleType: RuletypesRuleTypeDTO.threshold_rule,
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
evaluation: {

View File

@@ -41,8 +41,6 @@
}
.alert-details {
margin-top: 10px;
&__divider {
--divider-color: var(--l1-border);
--divider-margin: 16px 0;

View File

@@ -56,6 +56,7 @@
}
.alert-rules-container {
margin-top: 10px;
flex: 1;
min-height: 0;
display: flex;
@@ -132,6 +133,10 @@
display: flex;
flex-direction: column;
overflow: hidden;
> .alert-rules-container {
margin-top: 0;
}
}
}
}

View File

@@ -379,6 +379,8 @@ const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
createdBy: 'ada@signoz.io',
updatedBy: 'grace@signoz.io',
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
version: 'v5',
source: 'http://localhost/alerts',
notificationSettings: {
groupBy: ['alertname'],
usePolicy: false,

View File

@@ -12,7 +12,6 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { VIEWS } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
import { DEFAULT_PARAMS } from 'container/ApiMonitoring/queryParams';
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { rest } from 'msw';
@@ -124,7 +123,17 @@ const timeRange = (): Partial<AppState> => {
};
};
const serviceFilterExpression = "service.name = 'checkout'";
const serviceFilterItems = {
op: 'AND',
items: [
{
id: 'storybook-service-filter',
key: { key: 'service.name', dataType: 'string', type: 'resource' },
op: '=',
value: 'checkout',
},
],
};
interface RouteValues {
drawer: DrawerState;
@@ -151,14 +160,12 @@ const apiMonitoringRoute = ({
selectedView: VIEW_OF[drawer],
selectedEndPointName:
drawer === 'endpoint-stats' ? endpointUrl(domainName, 0) : '',
...(serviceFilter ? { endPointDetailsLocalFilters: serviceFilterItems } : {}),
};
const filterParam = serviceFilter
? `&${ENDPOINT_STATS_EXPRESSION_KEY}=${encodeURIComponent(serviceFilterExpression)}`
: '';
return `${ROUTES.API_MONITORING}?apiMonitoringParams=${encodeURIComponent(
JSON.stringify(params),
)}${filterParam}`;
)}`;
};
/** The parts of a query spec the handler tells the page's requests apart by. */

View File

@@ -26,9 +26,11 @@ export interface AlertDef {
labels?: Labels;
annotations?: Labels;
evalWindow?: string;
source?: string;
disabled?: boolean;
preferredChannels?: string[];
broadcastToAll?: boolean;
version?: string;
}
export interface RuleCondition {

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/SigNoz/clickhouse-go-mock v0.14.0
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.1
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0

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