mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-12 19:31:13 +00:00
Compare commits
26 Commits
issue_nerv
...
feat/distr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0434768585 | ||
|
|
643921d4bf | ||
|
|
d00471c6b9 | ||
|
|
b91f7e9835 | ||
|
|
0b48326b10 | ||
|
|
95ee58eba5 | ||
|
|
9100d9aea6 | ||
|
|
3acfb0c8c0 | ||
|
|
75994b0d6f | ||
|
|
a1b97a1404 | ||
|
|
1b37430401 | ||
|
|
0d69faf6ae | ||
|
|
4f802cd077 | ||
|
|
5a491bd258 | ||
|
|
51b6570221 | ||
|
|
fd6dca4658 | ||
|
|
d8d64c5c1f | ||
|
|
02ed7d6a9c | ||
|
|
f5b1d71675 | ||
|
|
eb92d8dee0 | ||
|
|
083500731e | ||
|
|
6cc664d954 | ||
|
|
dfb906fe0a | ||
|
|
4b6f040e8a | ||
|
|
3bfb8708e9 | ||
|
|
876d84ac53 |
@@ -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. A secret; returned
|
||||
by create, update and fetch by ID, never by list.
|
||||
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.
|
||||
format: password
|
||||
type: string
|
||||
email:
|
||||
description: The Atlassian account email used for basic auth.
|
||||
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
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- apiToken
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
@@ -642,16 +642,6 @@ 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
|
||||
@@ -4527,6 +4517,99 @@ 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
|
||||
@@ -5297,6 +5380,7 @@ 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'
|
||||
@@ -5319,6 +5403,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5333,6 +5418,7 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5364,6 +5450,21 @@ 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:
|
||||
@@ -5517,9 +5618,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
|
||||
or signoz/HeatmapPanel, and spec holds that kind''s options ({} accepts
|
||||
every default).'
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel,
|
||||
signoz/HeatmapPanel or signoz/DistributionPanel, 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,
|
||||
@@ -5655,8 +5756,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, trace for trace
|
||||
panels.'
|
||||
panels, raw for list panels, heatmap for heatmap panels, distribution
|
||||
for distribution panels, trace for trace panels.'
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQuerySpec'
|
||||
@@ -5813,9 +5914,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 panels reject signoz/AIBuilderQuery
|
||||
and signoz/TraceOperator; list panels accept only signoz/BuilderQuery
|
||||
and signoz/AIBuilderQuery.'
|
||||
panels reject signoz/PromQLQuery; heatmap and distribution panels reject
|
||||
signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only
|
||||
signoz/BuilderQuery and signoz/AIBuilderQuery.'
|
||||
required:
|
||||
- plugin
|
||||
type: object
|
||||
@@ -11773,15 +11874,6 @@ 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
|
||||
@@ -11900,11 +11992,6 @@ 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'
|
||||
@@ -11915,10 +12002,6 @@ components:
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
deprecated: true
|
||||
description: The query payload version. Nothing reads it.
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- state
|
||||
@@ -25559,7 +25642,6 @@ 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
|
||||
@@ -25626,7 +25708,6 @@ 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
|
||||
@@ -25699,7 +25780,6 @@ 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
|
||||
@@ -25755,7 +25835,6 @@ 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.
|
||||
@@ -25847,7 +25926,6 @@ 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`.
|
||||
@@ -25916,7 +25994,6 @@ 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`
|
||||
@@ -26015,7 +26092,6 @@ 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
|
||||
@@ -26115,7 +26191,6 @@ 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
|
||||
@@ -26205,7 +26280,6 @@ 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
|
||||
@@ -26274,7 +26348,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -26525,7 +26598,6 @@ 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
|
||||
@@ -26592,7 +26664,6 @@ 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
|
||||
@@ -26665,7 +26736,6 @@ 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
|
||||
@@ -26721,7 +26791,6 @@ 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.
|
||||
@@ -26813,7 +26882,6 @@ 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`.
|
||||
@@ -26882,7 +26950,6 @@ 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`
|
||||
@@ -26981,7 +27048,6 @@ 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
|
||||
@@ -27081,7 +27147,6 @@ 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
|
||||
@@ -27171,7 +27236,6 @@ 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
|
||||
@@ -27240,7 +27304,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -27392,7 +27455,6 @@ 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
|
||||
@@ -27459,7 +27521,6 @@ 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
|
||||
@@ -27532,7 +27593,6 @@ 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
|
||||
@@ -27588,7 +27648,6 @@ 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.
|
||||
@@ -27680,7 +27739,6 @@ 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`.
|
||||
@@ -27749,7 +27807,6 @@ 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`
|
||||
@@ -27848,7 +27905,6 @@ 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
|
||||
@@ -27948,7 +28004,6 @@ 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
|
||||
@@ -28038,7 +28093,6 @@ 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
|
||||
@@ -28107,7 +28161,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -29021,7 +29074,6 @@ 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
|
||||
@@ -29088,7 +29140,6 @@ 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
|
||||
@@ -29161,7 +29212,6 @@ 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
|
||||
@@ -29217,7 +29267,6 @@ 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.
|
||||
@@ -29309,7 +29358,6 @@ 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`.
|
||||
@@ -29378,7 +29426,6 @@ 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`
|
||||
@@ -29477,7 +29524,6 @@ 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
|
||||
@@ -29577,7 +29623,6 @@ 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
|
||||
@@ -29667,7 +29712,6 @@ 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
|
||||
@@ -29736,7 +29780,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
|
||||
@@ -11,7 +11,7 @@ const config: Config.InitialOptions = {
|
||||
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
||||
modulePathIgnorePatterns: ['dist'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
||||
moduleNameMapper: {
|
||||
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
|
||||
'<rootDir>/__mocks__/fileMock.ts',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ErrorEvent, StackFrame } from '@sentry/react';
|
||||
import { AxiosError, AxiosHeaders } from 'axios';
|
||||
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExpectedRequestFailure,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from '../sentryFilters';
|
||||
@@ -138,3 +140,36 @@ describe('SENTRY_IGNORE_ERRORS', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpectedRequestFailure', () => {
|
||||
function axiosErrorWithStatus(status: number): AxiosError {
|
||||
const config = { headers: new AxiosHeaders() };
|
||||
return new AxiosError(
|
||||
`Request failed with status code ${status}`,
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
undefined,
|
||||
{ status, statusText: '', headers: {}, config, data: {} },
|
||||
);
|
||||
}
|
||||
|
||||
it.each([401, 403])('drops a %i response', (status) => {
|
||||
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a network error', () => {
|
||||
expect(
|
||||
isExpectedRequestFailure(
|
||||
new AxiosError('Network Error', AxiosError.ERR_NETWORK),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([400, 404, 500, 503])('keeps a %i response', (status) => {
|
||||
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an error that is not from axios', () => {
|
||||
expect(isExpectedRequestFailure(new Error('Network Error'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import defaultRoutes, {
|
||||
} from './routes';
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExpectedRequestFailure,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from './sentryFilters';
|
||||
@@ -341,6 +342,9 @@ function App(): JSX.Element {
|
||||
if (error?.name === 'AbortError') {
|
||||
return null;
|
||||
}
|
||||
if (isExpectedRequestFailure(hint?.originalException)) {
|
||||
return null;
|
||||
}
|
||||
// Ignore benign Monaco cancellation errors (name 'Canceled').
|
||||
if (error?.name === 'Canceled') {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ErrorEvent } from '@sentry/react';
|
||||
import { AxiosError, isAxiosError } from 'axios';
|
||||
|
||||
export const SENTRY_IGNORE_ERRORS = [
|
||||
// A tab that outlived a deploy requests hashed assets the new build no longer
|
||||
@@ -14,6 +15,18 @@ export const SENTRY_IGNORE_ERRORS = [
|
||||
/Called on script loaded before session recording is available/,
|
||||
];
|
||||
|
||||
// Not UI bugs: 401/403 come from the session or role, ERR_NETWORK from a
|
||||
// dropped connection.
|
||||
export function isExpectedRequestFailure(error: unknown): boolean {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
const status = error.response?.status;
|
||||
return (
|
||||
error.code === AxiosError.ERR_NETWORK || status === 401 || status === 403
|
||||
);
|
||||
}
|
||||
|
||||
const APP_ASSETS_PATH = '/assets/';
|
||||
|
||||
// Frames without a position are `[native code]`, which says nothing about the
|
||||
|
||||
@@ -829,33 +829,26 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
|
||||
export interface AlertmanagertypesChannelJiraAuthDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
|
||||
* @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.
|
||||
*/
|
||||
apiToken: string;
|
||||
apiToken?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth.
|
||||
* @description The Atlassian account email used for basic auth. Required when deployment is cloud.
|
||||
*/
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
|
||||
email?: string;
|
||||
/**
|
||||
* @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.
|
||||
* @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;
|
||||
pat?: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelJiraAuthDTO =
|
||||
| AlertmanagertypesChannelJiraCloudAuthDTO
|
||||
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
|
||||
|
||||
export enum AlertmanagertypesJiraDeploymentDTO {
|
||||
cloud = 'cloud',
|
||||
selfhosted = 'selfhosted',
|
||||
@@ -13048,16 +13041,6 @@ 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 {
|
||||
@@ -13150,11 +13133,6 @@ 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.
|
||||
*/
|
||||
@@ -13168,11 +13146,6 @@ export interface RuletypesRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The query payload version. Nothing reads it.
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
|
||||
1
frontend/src/assets/Logos/pixeltable.svg
Normal file
1
frontend/src/assets/Logos/pixeltable.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 726 B |
@@ -48,22 +48,59 @@ export function QuerySearchV2Provider({
|
||||
}, [initialExpression, store]);
|
||||
|
||||
const isInitialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current && urlExpression) {
|
||||
const cleanedExpression = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
urlExpression,
|
||||
);
|
||||
store.getState().initializeFromUrl(cleanedExpression);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [urlExpression, initialExpression, store]);
|
||||
|
||||
const lastCommittedRef = useRef(committedExpression);
|
||||
useEffect(() => {
|
||||
if (isInitialized.current || !urlExpression) {
|
||||
setUrlExpression(committedExpression || null);
|
||||
return;
|
||||
}
|
||||
}, [committedExpression, setUrlExpression, urlExpression]);
|
||||
isInitialized.current = true;
|
||||
store
|
||||
.getState()
|
||||
.initializeFromUrl(
|
||||
getUserExpressionFromCombined(initialExpression, urlExpression),
|
||||
);
|
||||
}, [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;
|
||||
}
|
||||
lastCommittedRef.current = committedExpression;
|
||||
isInitialized.current = true;
|
||||
const next = committedExpression || null;
|
||||
if (next === urlExpression) {
|
||||
return;
|
||||
}
|
||||
void setUrlExpression(next);
|
||||
}, [committedExpression, urlExpression, setUrlExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
useExpression,
|
||||
useInitialExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchInitialExpressionProp,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
@@ -34,10 +35,22 @@ 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;
|
||||
@@ -46,6 +59,7 @@ function useTestHooks(): {
|
||||
expression: useExpression(),
|
||||
userExpression: useUserExpression(),
|
||||
initialExpression: useInitialExpression(),
|
||||
inputExpression: useInputExpression(),
|
||||
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
|
||||
onChange: useQuerySearchOnChange(),
|
||||
onRun: useQuerySearchOnRun(),
|
||||
@@ -56,6 +70,7 @@ describe('QuerySearchExpressionProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlValue = null;
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('should provide initial context values', () => {
|
||||
@@ -110,6 +125,96 @@ 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());
|
||||
|
||||
@@ -15,11 +15,41 @@ $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;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CircleCheck, Info, TriangleAlert, Filter } from '@signozhq/icons';
|
||||
import {
|
||||
CircleCheck,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
Filter,
|
||||
Play,
|
||||
} from '@signozhq/icons';
|
||||
import {
|
||||
autocompletion,
|
||||
closeCompletion,
|
||||
@@ -16,6 +22,7 @@ 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,
|
||||
@@ -59,6 +66,7 @@ import {
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import { ScopeChip } from './ScopeChip/ScopeChip';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -105,9 +113,14 @@ 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,
|
||||
@@ -125,10 +138,13 @@ function QuerySearch({
|
||||
queryData,
|
||||
dataSource,
|
||||
onRun,
|
||||
showRunButton,
|
||||
isLoadingQueries,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
showScopeChip: showScopeChipProp,
|
||||
metricNamespace,
|
||||
valueSuggestionsOverride,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
@@ -148,6 +164,7 @@ function QuerySearch({
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
const isScopedFilter = initialExpression !== undefined;
|
||||
const showScopeChip = !!showScopeChipProp && !!initialExpression;
|
||||
|
||||
const validateExpressionForEditor = useCallback(
|
||||
(editorDoc: string): void => {
|
||||
@@ -173,6 +190,18 @@ 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;
|
||||
@@ -1557,169 +1586,182 @@ function QuerySearch({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-editor-container">
|
||||
{isScopedFilter ? (
|
||||
<Tooltip title={initialExpression || ''} placement="left">
|
||||
<div className="query-search-initial-scope-label">
|
||||
<Filter
|
||||
<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
|
||||
size={14}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</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,
|
||||
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', {
|
||||
<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 && !showScopeChip,
|
||||
})}
|
||||
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}
|
||||
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>
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -1813,12 +1855,15 @@ 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;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -224,6 +224,134 @@ 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'";
|
||||
|
||||
@@ -69,8 +69,6 @@ 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:
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
getAllEndpointsWidgetData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getGroupByExpressionFromRowData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
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,
|
||||
@@ -23,8 +27,6 @@ function AllEndPoints({
|
||||
groupBy,
|
||||
setGroupBy,
|
||||
timeRange,
|
||||
initialFilters,
|
||||
setInitialFiltersEndPointStats,
|
||||
}: {
|
||||
domainName: string;
|
||||
setSelectedEndPointName: (name: string) => void;
|
||||
@@ -35,113 +37,50 @@ function AllEndPoints({
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
|
||||
}): JSX.Element {
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
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 }[]
|
||||
>([]);
|
||||
const [, setEndPointStatsExpression] = useQueryState(
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
// --- FILTERS STATE SYNC ---
|
||||
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 };
|
||||
const {
|
||||
expression: filterExpression,
|
||||
initialExpression: scopeExpression,
|
||||
queryData,
|
||||
onDraftChange,
|
||||
onRun,
|
||||
onBlur: handleFilterBlur,
|
||||
} = useFilterExpression({
|
||||
groupBy,
|
||||
});
|
||||
|
||||
// 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, filters),
|
||||
[groupBy, domainName, filters],
|
||||
() => 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],
|
||||
);
|
||||
|
||||
// --- GROUP BY STATE SYNC (existing) ---
|
||||
const handleGroupByChange = useCallback(
|
||||
(value: IBuilderQuery['groupBy']) => {
|
||||
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('');
|
||||
setGroupBy(value);
|
||||
// Update params
|
||||
setParams({ groupBy: newGroupBy.map((g) => g.key) });
|
||||
setParams({ groupBy: value.map((g) => g.key) });
|
||||
},
|
||||
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
|
||||
[setGroupBy, setParams],
|
||||
);
|
||||
|
||||
// Sync params to local groupBy state on param change
|
||||
@@ -154,77 +93,31 @@ function AllEndPoints({
|
||||
groupBy.map((g) => g.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, allAvailableGroupByOptions, groupBy, setGroupBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupByFiltersData?.payload) {
|
||||
// Update dropdown options
|
||||
setGroupByOptions(
|
||||
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
|
||||
value: filter.key,
|
||||
label: filter.key,
|
||||
})) || [],
|
||||
setGroupBy(
|
||||
params.groupBy.map((key) => chooseAutocompleteFromCustomValue([], 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]);
|
||||
}, [params.groupBy, groupBy, setGroupBy]);
|
||||
|
||||
const onRowClick = useCallback(
|
||||
(props: any): void => {
|
||||
setSelectedEndPointName(props[SPAN_ATTRIBUTES.HTTP_URL] as string);
|
||||
setSelectedView(VIEWS.ENDPOINT_STATS);
|
||||
const initialItems = [
|
||||
...(filters?.items || []),
|
||||
...(getGroupByFiltersFromGroupByValues(props, groupBy)?.items || []),
|
||||
];
|
||||
setInitialFiltersEndPointStats({
|
||||
items: initialItems,
|
||||
op: 'AND',
|
||||
});
|
||||
const statsExpression = combineInitialAndUserExpression(
|
||||
getGroupByExpressionFromRowData(props),
|
||||
filterExpression,
|
||||
);
|
||||
void setEndPointStatsExpression(statsExpression || null);
|
||||
setParams({
|
||||
selectedEndPointName: props[SPAN_ATTRIBUTES.HTTP_URL] as string,
|
||||
selectedView: VIEWS.ENDPOINT_STATS,
|
||||
endPointDetailsLocalFilters: {
|
||||
items: initialItems,
|
||||
op: 'AND',
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
filters,
|
||||
setInitialFiltersEndPointStats,
|
||||
filterExpression,
|
||||
setEndPointStatsExpression,
|
||||
setSelectedEndPointName,
|
||||
setSelectedView,
|
||||
groupBy,
|
||||
setParams,
|
||||
],
|
||||
);
|
||||
@@ -232,28 +125,28 @@ function AllEndPoints({
|
||||
return (
|
||||
<div className="all-endpoints-container">
|
||||
<div className="all-endpoints-header">
|
||||
<div className="filter-container">
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={handleFilterChange}
|
||||
<div className="filter-container" onBlur={handleFilterBlur}>
|
||||
<QuerySearch
|
||||
queryData={queryData}
|
||||
onChange={onDraftChange}
|
||||
onRun={handleRun}
|
||||
showRunButton
|
||||
isLoadingQueries={isFetchingTable}
|
||||
initialExpression={scopeExpression}
|
||||
showScopeChip
|
||||
dataSource={DataSource.TRACES}
|
||||
placeholder="Search for filters..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group-by-container">
|
||||
<div className="group-by-label"> Group by </div>
|
||||
<Select
|
||||
className="group-by-select"
|
||||
loading={isLoadingGroupByFilters}
|
||||
mode="multiple"
|
||||
value={groupBy}
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder="Search for attribute"
|
||||
options={groupByOptions}
|
||||
<GroupByFilter
|
||||
query={queryData}
|
||||
onChange={handleGroupByChange}
|
||||
onSearch={(value: string): void => setGroupBySearchValue(value)}
|
||||
/>{' '}
|
||||
disabled={false}
|
||||
placeholder="Search for attribute"
|
||||
/>
|
||||
</div>
|
||||
<div className="endpoints-table-container">
|
||||
<GridCard
|
||||
|
||||
@@ -195,16 +195,6 @@
|
||||
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 {
|
||||
@@ -365,16 +355,9 @@
|
||||
.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 {
|
||||
@@ -416,29 +399,8 @@
|
||||
|
||||
.endpoint-details-filters-container {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-select-item,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -17,7 +18,12 @@ 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';
|
||||
@@ -34,14 +40,14 @@ function DomainDetails({
|
||||
selectedDomainIndex,
|
||||
setSelectedDomainIndex,
|
||||
domainListLength,
|
||||
domainListFilters,
|
||||
domainListFilterExpression,
|
||||
}: {
|
||||
domainData: any;
|
||||
handleClose: () => void;
|
||||
selectedDomainIndex: number;
|
||||
setSelectedDomainIndex: (index: number) => void;
|
||||
domainListLength: number;
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
domainListFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(
|
||||
@@ -53,8 +59,16 @@ function DomainDetails({
|
||||
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
|
||||
IBuilderQuery['groupBy']
|
||||
>([]);
|
||||
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] =
|
||||
useState<IBuilderQuery['filters']>(domainListFilters);
|
||||
|
||||
const domainName = domainData?.domainName ?? '';
|
||||
const allEndpointsScope = useMemo(
|
||||
() => getDomainScopeExpression(domainName),
|
||||
[domainName],
|
||||
);
|
||||
const endPointStatsScope = useMemo(
|
||||
() => getDomainScopeExpression(domainName, selectedEndPointName),
|
||||
[domainName, selectedEndPointName],
|
||||
);
|
||||
|
||||
const handleTabChange = (value: string): void => {
|
||||
setSelectedView(value as VIEWS);
|
||||
@@ -222,7 +236,7 @@ function DomainDetails({
|
||||
<>
|
||||
<DomainMetrics
|
||||
domainName={domainData.domainName}
|
||||
domainListFilters={domainListFilters}
|
||||
domainListFilterExpression={domainListFilterExpression}
|
||||
timeRange={modalTimeRange}
|
||||
/>
|
||||
<div className="views-tabs-container">
|
||||
@@ -253,34 +267,43 @@ function DomainDetails({
|
||||
/>
|
||||
</div>
|
||||
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
|
||||
<AllEndPoints
|
||||
domainName={domainData.domainName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
setSelectedView={setSelectedView}
|
||||
groupBy={endPointsGroupBy}
|
||||
setGroupBy={setEndPointsGroupBy}
|
||||
timeRange={modalTimeRange}
|
||||
initialFilters={domainListFilters}
|
||||
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
|
||||
/>
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}
|
||||
initialExpression={allEndpointsScope}
|
||||
persistOnUnmount
|
||||
>
|
||||
<AllEndPoints
|
||||
domainName={domainData.domainName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
setSelectedView={setSelectedView}
|
||||
groupBy={endPointsGroupBy}
|
||||
setGroupBy={setEndPointsGroupBy}
|
||||
timeRange={modalTimeRange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
|
||||
<EndPointDetails
|
||||
domainName={domainData.domainName}
|
||||
endPointName={selectedEndPointName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
initialFilters={initialFiltersEndPointStats}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
/>
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
|
||||
initialExpression={endPointStatsScope}
|
||||
persistOnUnmount
|
||||
>
|
||||
<EndPointDetails
|
||||
domainName={domainData.domainName}
|
||||
endPointName={selectedEndPointName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.TOP_ERRORS && (
|
||||
<TopErrors
|
||||
domainName={domainData.domainName}
|
||||
timeRange={modalTimeRange}
|
||||
initialFilters={domainListFilters}
|
||||
initialFilterExpression={domainListFilterExpression}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
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,36 +19,28 @@ 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 EndPointsDropDown from './components/EndPointsDropDown';
|
||||
import EndPointPicker from './components/EndPointPicker';
|
||||
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
|
||||
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
|
||||
import StatusCodeTable from './components/StatusCodeTable';
|
||||
import { SPAN_ATTRIBUTES } from './constants';
|
||||
|
||||
const httpUrlKey = {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
type: 'tag',
|
||||
};
|
||||
import { useFilterExpression } from './hooks/useFilterExpression';
|
||||
import { useWidgetQueries } from './hooks/useWidgetQueries';
|
||||
|
||||
function EndPointDetails({
|
||||
domainName,
|
||||
endPointName,
|
||||
setSelectedEndPointName,
|
||||
initialFilters,
|
||||
timeRange,
|
||||
handleTimeChange,
|
||||
}: {
|
||||
domainName: string;
|
||||
endPointName: string;
|
||||
setSelectedEndPointName: (value: string) => void;
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -59,159 +51,66 @@ function EndPointDetails({
|
||||
) => void;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
|
||||
const currentQuery = initialQueriesMap[DataSource.TRACES];
|
||||
// Search filters; the endpoint filter is combined in at query time
|
||||
const {
|
||||
expression: filterExpression,
|
||||
initialExpression: scopeExpression,
|
||||
queryData,
|
||||
onDraftChange,
|
||||
onRun,
|
||||
onBlur: handleFilterBlur,
|
||||
} = useFilterExpression();
|
||||
|
||||
// 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 httpUrlExpression = useMemo(
|
||||
() => getHttpUrlExpression(endPointName),
|
||||
[endPointName],
|
||||
);
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.TRACES,
|
||||
filters, // Use the local filters state
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[filters, currentQuery],
|
||||
const scopedExpression = useMemo(
|
||||
() => combineInitialAndUserExpression(httpUrlExpression, filterExpression),
|
||||
[httpUrlExpression, filterExpression],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
const isServicesFilterApplied = useMemo(
|
||||
() => filters?.items?.some((item) => item.key?.key === 'service.name'),
|
||||
[filters],
|
||||
() =>
|
||||
extractQueryPairs(filterExpression).some(
|
||||
(pair) => pair.key === 'service.name',
|
||||
),
|
||||
[filterExpression],
|
||||
);
|
||||
|
||||
const endPointDetailsQueryPayload = useMemo(
|
||||
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
|
||||
[domainName, filters, minTime, maxTime],
|
||||
() =>
|
||||
getEndPointDetailsQueryPayload(
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
scopedExpression,
|
||||
),
|
||||
[domainName, scopedExpression, 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) => {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
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,
|
||||
})),
|
||||
);
|
||||
|
||||
const [
|
||||
endPointMetricsDataQuery,
|
||||
endPointStatusCodeDataQuery,
|
||||
endPointDropDownDataQuery,
|
||||
endPointDependentServicesDataQuery,
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
endPointStatusCodeLatencyBarChartsDataQuery,
|
||||
@@ -222,7 +121,6 @@ function EndPointDetails({
|
||||
endPointDetailsDataQueries[2],
|
||||
endPointDetailsDataQueries[3],
|
||||
endPointDetailsDataQueries[4],
|
||||
endPointDetailsDataQueries[5],
|
||||
],
|
||||
[endPointDetailsDataQueries],
|
||||
);
|
||||
@@ -234,10 +132,32 @@ function EndPointDetails({
|
||||
|
||||
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
|
||||
() => [
|
||||
getRateOverTimeWidgetData(domainName, endPointName, filters),
|
||||
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
|
||||
getRateOverTimeWidgetData(domainName, endPointName, scopedExpression),
|
||||
getLatencyOverTimeWidgetData(domainName, endPointName, scopedExpression),
|
||||
],
|
||||
[domainName, endPointName, filters], // Use combinedFilters
|
||||
[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],
|
||||
);
|
||||
|
||||
// // [TODO] Fix this later
|
||||
@@ -257,19 +177,23 @@ function EndPointDetails({
|
||||
return (
|
||||
<div className="endpoint-details-container">
|
||||
<div className="endpoint-details-filters-container">
|
||||
<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}
|
||||
<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}
|
||||
placeholder="Search for filters..."
|
||||
/>
|
||||
</div>
|
||||
@@ -299,7 +223,7 @@ function EndPointDetails({
|
||||
endPointStatusCodeLatencyBarChartsDataQuery
|
||||
}
|
||||
domainName={domainName}
|
||||
filters={filters}
|
||||
filterExpression={scopedExpression}
|
||||
timeRange={timeRange}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
|
||||
import { QueryFunctionContext, useQuery } from 'react-query';
|
||||
import { Spin, Table, Tooltip } from 'antd';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
@@ -7,41 +7,36 @@ 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,
|
||||
getEndPointDetailsQueryPayload,
|
||||
getHttpUrlExpression,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
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 { SuccessResponseV2 } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
|
||||
import EndPointsDropDown from './components/EndPointsDropDown';
|
||||
import EndPointPicker from './components/EndPointPicker';
|
||||
import ErrorState from './components/ErrorState';
|
||||
import { SPAN_ATTRIBUTES } from './constants';
|
||||
|
||||
function TopErrors({
|
||||
domainName,
|
||||
timeRange,
|
||||
initialFilters,
|
||||
initialFilterExpression,
|
||||
}: {
|
||||
domainName: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
initialFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
|
||||
@@ -55,27 +50,10 @@ function TopErrors({
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
{
|
||||
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',
|
||||
},
|
||||
combineInitialAndUserExpression(
|
||||
getHttpUrlExpression(endPointName),
|
||||
initialFilterExpression,
|
||||
),
|
||||
showStatusCodeErrors,
|
||||
),
|
||||
[
|
||||
@@ -83,7 +61,7 @@ function TopErrors({
|
||||
endPointName,
|
||||
minTime,
|
||||
maxTime,
|
||||
initialFilters,
|
||||
initialFilterExpression,
|
||||
showStatusCodeErrors,
|
||||
],
|
||||
);
|
||||
@@ -120,35 +98,6 @@ 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) {
|
||||
@@ -162,14 +111,13 @@ function TopErrors({
|
||||
return (
|
||||
<div className="all-endpoints-container">
|
||||
<div className="top-errors-dropdown-container">
|
||||
<div className="endpoint-details-filters-container-dropdown">
|
||||
<EndPointsDropDown
|
||||
selectedEndPointName={endPointName}
|
||||
setSelectedEndPointName={setSelectedEndPointName}
|
||||
endPointDropDownDataQuery={endPointDropDownDataQuery}
|
||||
parentContainerDiv=".endpoint-details-filters-container"
|
||||
/>
|
||||
</div>
|
||||
<EndPointPicker
|
||||
domainName={domainName}
|
||||
timeRange={timeRange}
|
||||
filterExpression=""
|
||||
value={endPointName}
|
||||
onChange={setSelectedEndPointName}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Switch
|
||||
color="primary"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton, Table, TablePaginationConfig } from 'antd';
|
||||
import { Table, TablePaginationConfig } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
@@ -54,10 +54,6 @@ function DependentServices({
|
||||
[isExpanded, dependentServicesData.length],
|
||||
);
|
||||
|
||||
if (isLoading || isRefetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ErrorState refetch={refetch} />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -34,10 +33,7 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
|
||||
startTime: 1758259531000,
|
||||
endTime: 1758261331000,
|
||||
},
|
||||
domainListFilters: {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
} as IBuilderQuery['filters'],
|
||||
domainListFilterExpression: '',
|
||||
};
|
||||
|
||||
const mockSuccessResponse = {
|
||||
@@ -174,35 +170,12 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
|
||||
it('includes custom filters in filter expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
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,
|
||||
};
|
||||
const customFilterExpression =
|
||||
"service.name = 'my-service' AND deployment.environment = 'production'";
|
||||
|
||||
renderComponent({
|
||||
...mockProps,
|
||||
domainListFilters: customFilters,
|
||||
domainListFilterExpression: customFilterExpression,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -14,18 +14,17 @@ 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,
|
||||
domainListFilters,
|
||||
domainListFilterExpression,
|
||||
}: {
|
||||
domainName: string;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
domainListFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
|
||||
@@ -35,9 +34,9 @@ function DomainMetrics({
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
domainListFilters,
|
||||
domainListFilterExpression,
|
||||
),
|
||||
[domainName, minTime, maxTime, domainListFilters],
|
||||
[domainName, minTime, maxTime, domainListFilterExpression],
|
||||
);
|
||||
|
||||
// Since only one query here
|
||||
|
||||
@@ -114,10 +114,7 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
|
||||
const domainName = 'api.example.com';
|
||||
const startTime = 1758259531000;
|
||||
const endTime = 1758261331000;
|
||||
const filters = {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
};
|
||||
const filters = '';
|
||||
|
||||
// Get the actual payload that would be generated
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
@@ -237,31 +234,8 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
|
||||
it('includes custom domainListFilters in all query expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
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 customFilters =
|
||||
"service.name = 'payment-service' AND deployment.environment = 'staging'";
|
||||
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
'api.internal.com',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
@@ -1,65 +0,0 @@
|
||||
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;
|
||||
@@ -24,7 +24,6 @@ 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 {
|
||||
@@ -37,7 +36,7 @@ function StatusCodeBarCharts({
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
endPointStatusCodeLatencyBarChartsDataQuery,
|
||||
domainName,
|
||||
filters,
|
||||
filterExpression,
|
||||
timeRange,
|
||||
onDragSelect,
|
||||
}: {
|
||||
@@ -50,7 +49,7 @@ function StatusCodeBarCharts({
|
||||
unknown
|
||||
>;
|
||||
domainName: string;
|
||||
filters: IBuilderQuery['filters'];
|
||||
filterExpression: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -130,12 +129,8 @@ function StatusCodeBarCharts({
|
||||
);
|
||||
|
||||
const widget = useMemo<Widgets>(
|
||||
() =>
|
||||
getStatusCodeBarChartWidgetData(domainName, {
|
||||
items: [...(filters?.items || [])],
|
||||
op: filters?.op || 'AND',
|
||||
}),
|
||||
[domainName, filters],
|
||||
() => getStatusCodeBarChartWidgetData(domainName, filterExpression),
|
||||
[domainName, filterExpression],
|
||||
);
|
||||
|
||||
const activeApiResponse = useMemo(
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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'",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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';
|
||||
@@ -27,16 +28,26 @@ 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,
|
||||
);
|
||||
@@ -55,6 +66,8 @@ function DomainList(): JSX.Element {
|
||||
});
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
const listFilterExpression =
|
||||
compositeData?.builder?.queryData[0]?.filter?.expression ?? '';
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
@@ -155,6 +168,7 @@ 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}
|
||||
/>
|
||||
@@ -225,6 +239,11 @@ 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', {});
|
||||
}
|
||||
},
|
||||
@@ -245,8 +264,12 @@ function DomainList(): JSX.Element {
|
||||
handleClose={(): void => {
|
||||
setSelectedDomainIndex(-1);
|
||||
setParams(DEFAULT_PARAMS);
|
||||
void setDrawerFilters({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: null,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: null,
|
||||
});
|
||||
}}
|
||||
domainListFilters={query?.filters}
|
||||
domainListFilterExpression={listFilterExpression}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
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 {
|
||||
@@ -11,20 +9,14 @@ import {
|
||||
getFormattedEndPointDropDownData,
|
||||
getFormattedEndPointStatusCodeChartData,
|
||||
getFormattedEndPointStatusCodeData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getGroupByExpressionFromRowData,
|
||||
getHttpUrlExpression,
|
||||
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
|
||||
@@ -90,140 +82,39 @@ describe('API Monitoring Utils', () => {
|
||||
expect(result[0].lastUsed).toBe('-');
|
||||
});
|
||||
});
|
||||
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',
|
||||
};
|
||||
|
||||
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[],
|
||||
);
|
||||
|
||||
// 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
|
||||
describe('getGroupByExpressionFromRowData', () => {
|
||||
it('builds an AND expression from the row values, skipping the url and metric columns', () => {
|
||||
expect(
|
||||
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);
|
||||
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");
|
||||
});
|
||||
|
||||
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[],
|
||||
it('escapes quotes in values', () => {
|
||||
expect(getGroupByExpressionFromRowData({ 'user.name': "o'brien" })).toBe(
|
||||
"user.name = 'o\\'brien'",
|
||||
);
|
||||
|
||||
// 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[] = [];
|
||||
it('matches spans without the attribute when the group has no value', () => {
|
||||
expect(
|
||||
getGroupByExpressionFromRowData({
|
||||
'service.name': 'cart',
|
||||
'ai.embeddings': null,
|
||||
}),
|
||||
).toBe("service.name = 'cart' AND ai.embeddings NOT EXISTS");
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = getGroupByFiltersFromGroupByValues(rowData, groupBy);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.op).toBe('AND');
|
||||
expect(result?.items).toHaveLength(0);
|
||||
it('returns an empty expression for a row without group columns', () => {
|
||||
expect(getGroupByExpressionFromRowData({ http_url: '/x', A: 1 })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -703,13 +594,9 @@ 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,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
const result = getStatusCodeBarChartWidgetData(domainName, '');
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
@@ -723,47 +610,29 @@ describe('API Monitoring Utils', () => {
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should have domain filter
|
||||
const domainFilter = queryData.filters?.items?.find(
|
||||
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
expect(queryData.filter?.expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}'`,
|
||||
);
|
||||
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 customFilter = {
|
||||
id: 'custom-filter',
|
||||
key: {
|
||||
dataType: 'string',
|
||||
key: 'custom.key',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'custom-value',
|
||||
};
|
||||
const filters = { items: [customFilter], op: 'AND' };
|
||||
const customFilterExpression = "custom.key = 'custom-value'";
|
||||
|
||||
// Act
|
||||
const result = getStatusCodeBarChartWidgetData(
|
||||
domainName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
customFilterExpression,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should include our custom filter
|
||||
const includedFilter = queryData.filters?.items?.find(
|
||||
(item) => item.id === 'custom-filter',
|
||||
// Should include our custom filter after the domain filter
|
||||
expect(queryData.filter?.expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}' AND (${customFilterExpression})`,
|
||||
);
|
||||
expect(includedFilter).toBeDefined();
|
||||
if (includedFilter) {
|
||||
expect(includedFilter.value).toBe('custom-value');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -927,4 +796,17 @@ 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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
getAllEndpointsWidgetData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getGroupByExpressionFromRowData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
|
||||
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 AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
|
||||
import { FilterProviders } from './FilterProviders';
|
||||
import {
|
||||
SPAN_ATTRIBUTES,
|
||||
VIEWS,
|
||||
@@ -14,7 +20,7 @@ import {
|
||||
// Mock the dependencies
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
getAllEndpointsWidgetData: jest.fn(),
|
||||
getGroupByFiltersFromGroupByValues: jest.fn(),
|
||||
getGroupByExpressionFromRowData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/WidgetCard/Card', () => ({
|
||||
@@ -34,53 +40,47 @@ jest.mock('container/WidgetCard/Card', () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
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('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('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>
|
||||
)),
|
||||
};
|
||||
});
|
||||
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>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock useApiMonitoringParams hook
|
||||
jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
...jest.requireActual('container/ApiMonitoring/queryParams'),
|
||||
useApiMonitoringParams: jest.fn().mockReturnValue([
|
||||
{
|
||||
showIP: true,
|
||||
@@ -88,8 +88,6 @@ jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
},
|
||||
@@ -108,29 +106,21 @@ describe('AllEndPoints', () => {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
},
|
||||
initialFilters: { op: 'AND', items: [] },
|
||||
setInitialFiltersEndPointStats: jest.fn(),
|
||||
};
|
||||
|
||||
const renderAllEndPoints = (): void => {
|
||||
render(
|
||||
<FilterProviders queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}>
|
||||
<AllEndPoints {...mockProps} />
|
||||
</FilterProviders>,
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
@@ -139,10 +129,9 @@ describe('AllEndPoints', () => {
|
||||
queryData: [],
|
||||
});
|
||||
|
||||
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
|
||||
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
|
||||
op: 'AND',
|
||||
});
|
||||
(getGroupByExpressionFromRowData as jest.Mock).mockReturnValue(
|
||||
"status = '200'",
|
||||
);
|
||||
});
|
||||
|
||||
// Add cleanup after each test
|
||||
@@ -151,7 +140,7 @@ describe('AllEndPoints', () => {
|
||||
});
|
||||
|
||||
it('renders component correctly', () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Verify basic component rendering
|
||||
expect(screen.getByText('Group by')).toBeInTheDocument();
|
||||
@@ -161,7 +150,7 @@ describe('AllEndPoints', () => {
|
||||
});
|
||||
|
||||
it('handles filter changes', () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Trigger filter change
|
||||
fireEvent.click(screen.getByTestId('filter-change-button'));
|
||||
@@ -170,25 +159,24 @@ describe('AllEndPoints', () => {
|
||||
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'test-domain',
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
|
||||
op: 'AND',
|
||||
}),
|
||||
"test = 'test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('handles group by changes', () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Trigger group by change
|
||||
fireEvent.click(screen.getByTestId('select-change-button'));
|
||||
|
||||
// Check if setGroupBy was called with updated group by value
|
||||
expect(mockProps.setGroupBy).toHaveBeenCalled();
|
||||
// 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' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles row click in grid card', async () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Trigger row click
|
||||
fireEvent.click(screen.getByTestId('row-click-button'));
|
||||
@@ -196,7 +184,11 @@ describe('AllEndPoints', () => {
|
||||
// Check if proper functions were called
|
||||
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
|
||||
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
|
||||
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
|
||||
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
|
||||
expect(getGroupByExpressionFromRowData).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
|
||||
"status = '200'",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,16 +14,12 @@ 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: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
const emptyGroupBy: BaseAutocompleteData[] = [];
|
||||
|
||||
describe('1. V5 Format Migration - All Four Queries', () => {
|
||||
|
||||
@@ -121,8 +121,7 @@ describe('DependentServices', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders loading state correctly', () => {
|
||||
// Arrange
|
||||
it('keeps the table in place while loading', () => {
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: true,
|
||||
isRefetching: false,
|
||||
@@ -131,16 +130,15 @@ describe('DependentServices', () => {
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
render(
|
||||
<DependentServices
|
||||
dependentServicesQuery={mockQuery as any}
|
||||
timeRange={mockTimeRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dependent-services-table')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
});
|
||||
|
||||
it('renders error state correctly', () => {
|
||||
@@ -225,8 +223,7 @@ describe('DependentServices', () => {
|
||||
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
it('handles refetching state correctly', () => {
|
||||
// Arrange
|
||||
it('shows the rows under the spinner while refetching', () => {
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: false,
|
||||
isRefetching: true,
|
||||
@@ -235,16 +232,16 @@ describe('DependentServices', () => {
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
render(
|
||||
<DependentServices
|
||||
dependentServicesQuery={mockQuery as any}
|
||||
timeRange={mockTimeRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
expect(screen.getByTestId('row-count')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('table-row-0')).toHaveTextContent('auth-service');
|
||||
});
|
||||
|
||||
it('handles row click correctly', () => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useQueries } from 'react-query';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
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 {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
extractPortAndEndpoint,
|
||||
@@ -11,14 +13,13 @@ import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
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', () => ({
|
||||
@@ -26,72 +27,32 @@ 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: [
|
||||
'endPointMetricsData',
|
||||
'endPointStatusCodeData',
|
||||
'endPointDropDownData',
|
||||
'endPointDependentServicesData',
|
||||
'endPointStatusCodeBarChartsData',
|
||||
'endPointStatusCodeLatencyBarChartsData',
|
||||
],
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY: jest.requireActual(
|
||||
'container/ApiMonitoring/utils',
|
||||
).END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
extractPortAndEndpoint: jest.fn(),
|
||||
getHttpUrlExpression: jest.requireActual('container/ApiMonitoring/utils')
|
||||
.getHttpUrlExpression,
|
||||
getEndPointDetailsQueryPayload: jest.fn(),
|
||||
getLatencyOverTimeWidgetData: jest.fn(),
|
||||
getRateOverTimeWidgetData: jest.fn(),
|
||||
}));
|
||||
|
||||
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>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
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>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock all child components to simplify testing
|
||||
jest.mock(
|
||||
@@ -107,15 +68,15 @@ jest.mock(
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ setSelectedEndPointName }) => (
|
||||
default: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="endpoints-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-endpoint-button"
|
||||
onClick={(): void => setSelectedEndPointName('/api/new-endpoint')}
|
||||
onClick={(): void => onChange('/api/new-endpoint')}
|
||||
>
|
||||
Select Endpoint
|
||||
</button>
|
||||
@@ -172,8 +133,23 @@ 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(6).fill({
|
||||
const mockQueryResults = Array(5).fill({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -184,7 +160,6 @@ describe('EndPointDetails Component', () => {
|
||||
domainName: 'test-domain',
|
||||
endPointName: '/api/test',
|
||||
setSelectedEndPointName: jest.fn(),
|
||||
initialFilters: { items: [], op: 'AND' } as TagFilter,
|
||||
timeRange: {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
@@ -197,6 +172,7 @@ describe('EndPointDetails Component', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetNuqsState();
|
||||
|
||||
(extractPortAndEndpoint as jest.Mock).mockReturnValue({
|
||||
port: '8080',
|
||||
@@ -209,7 +185,6 @@ 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({
|
||||
@@ -226,7 +201,7 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('renders the component correctly', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Check all major components are rendered
|
||||
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
|
||||
@@ -246,44 +221,29 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('calls getEndPointDetailsQueryPayload with correct parameters', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
op: 'AND',
|
||||
}),
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds endpoint filter to initial filters', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates filters when QueryBuilderSearch changes', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
it('updates filters when the search expression is run', async () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Trigger filter change
|
||||
fireEvent.click(screen.getByTestId('filter-change-button'));
|
||||
@@ -294,19 +254,37 @@ 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({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'test.key' }),
|
||||
value: 'test-value',
|
||||
}),
|
||||
]),
|
||||
queryData: expect.objectContaining({
|
||||
filter: { expression: "service.name = 'checkout'" },
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(EndPointPicker).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filterExpression: "service.name = 'checkout'",
|
||||
value: '/api/test',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles endpoint dropdown selection', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Trigger endpoint selection
|
||||
fireEvent.click(screen.getByTestId('select-endpoint-button'));
|
||||
@@ -318,63 +296,50 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('does not display dependent services when service filter is applied', () => {
|
||||
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} />);
|
||||
renderStats(
|
||||
<EndPointDetails {...mockProps} />,
|
||||
"service.name = 'test-service'",
|
||||
);
|
||||
|
||||
// Dependent services should not be displayed
|
||||
expect(screen.queryByTestId('dependent-services')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes the correct parameters to widget data generators', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getRateOverTimeWidgetData).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
);
|
||||
|
||||
expect(getLatencyOverTimeWidgetData).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/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,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates correct query parameters for useQueries', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Check if useQueries was called with correct parameters
|
||||
expect(useQueries).toHaveBeenCalledWith(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* V5 Migration Tests for Endpoint Dropdown Query
|
||||
*
|
||||
* These tests validate the migration from V4 to V5 format for the third payload
|
||||
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
|
||||
* These tests validate the migration from V4 to V5 format for
|
||||
* getEndPointDropDownQueryPayload (endpoint picker options):
|
||||
* - Filter format change: filters.items[] → filter.expression
|
||||
* - Domain handling: http_host = '${domainName}'
|
||||
* - Kind filter: kind_string = 'Client'
|
||||
@@ -10,9 +10,8 @@
|
||||
* - Aggregation: count() expression
|
||||
* - GroupBy: http_url with type 'attribute'
|
||||
*/
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { getEndPointDropDownQueryPayload } 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';
|
||||
|
||||
@@ -20,22 +19,16 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockStartTime = 1000;
|
||||
const mockEndTime = 2000;
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. V5 Format Migration - Structure and Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
const dropdownQuery = getEndPointDropDownQueryPayload(
|
||||
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)
|
||||
@@ -77,46 +70,21 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
|
||||
|
||||
describe('2. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
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 customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
const dropdownQuery = getEndPointDropDownQueryPayload(
|
||||
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 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 AND (service.name = 'user-service' AND deployment.environment = 'production')`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,11 @@ 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: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. Rate Over Time - V5 Payload Structure', () => {
|
||||
it('generates V5 filter expression format (not V3 filters.items)', () => {
|
||||
@@ -51,31 +46,8 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
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 customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const widget = getRateOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
@@ -137,21 +109,8 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const widget = getLatencyOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
@@ -163,7 +122,7 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
|
||||
// Verify domain filter is present
|
||||
expect(queryData?.filter?.expression).toContain(
|
||||
`http_host = '${mockDomainName}' service.name = 'user-service'`,
|
||||
`http_host = '${mockDomainName}' AND (service.name = 'user-service'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
@@ -156,7 +155,6 @@ jest.mock('antd', () => {
|
||||
|
||||
describe('StatusCodeBarCharts', () => {
|
||||
// Default props for tests
|
||||
const mockFilters: IBuilderQuery['filters'] = { items: [], op: 'AND' };
|
||||
const mockTimeRange = {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
@@ -251,7 +249,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -286,7 +284,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -328,7 +326,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -372,7 +370,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -419,7 +417,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -433,26 +431,13 @@ 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 mockCustomFilters = {
|
||||
items: [
|
||||
{
|
||||
id: 'custom-filter',
|
||||
key: { key: 'test-key' },
|
||||
op: '=',
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const mockCustomFilterExpression = "test-key = 'test-value'";
|
||||
|
||||
const mockData = {
|
||||
payload: mockFormattedData,
|
||||
@@ -480,7 +465,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockCustomFilters as IBuilderQuery['filters']}
|
||||
filterExpression={mockCustomFilterExpression}
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -489,12 +474,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
// Assert widget creation was called with the correct parameters
|
||||
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
|
||||
mockDomainName,
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'custom-filter' }),
|
||||
]),
|
||||
op: 'AND',
|
||||
}),
|
||||
mockCustomFilterExpression,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (5th and 6th payloads):
|
||||
* in getEndPointDetailsQueryPayload (4th and 5th payloads):
|
||||
* - Number of Calls Chart (count aggregation)
|
||||
* - Latency Chart (p99 aggregation)
|
||||
*
|
||||
@@ -15,16 +15,12 @@
|
||||
*/
|
||||
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: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
|
||||
it('generates correct V5 payload for count aggregation grouped by status code', () => {
|
||||
@@ -35,8 +31,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 5th payload (index 4) is the number of calls bar chart
|
||||
const callsChartQuery = payload[4];
|
||||
// 4th payload (index 3) is the number of calls bar chart
|
||||
const callsChartQuery = payload[3];
|
||||
const queryA = callsChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
@@ -84,8 +80,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 6th payload (index 5) is the latency bar chart
|
||||
const latencyChartQuery = payload[5];
|
||||
// 5th payload (index 4) is the latency bar chart
|
||||
const latencyChartQuery = payload[4];
|
||||
const queryA = latencyChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
@@ -130,31 +126,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression for both charts', () => {
|
||||
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 customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
@@ -163,8 +136,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const callsChartQuery = payload[4];
|
||||
const latencyChartQuery = payload[5];
|
||||
const callsChartQuery = payload[3];
|
||||
const latencyChartQuery = payload[4];
|
||||
|
||||
const callsExpression =
|
||||
callsChartQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
@@ -13,16 +13,12 @@
|
||||
*/
|
||||
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: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. V5 Format Migration with Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure and base filters', () => {
|
||||
@@ -109,31 +105,8 @@ describe('StatusCodeTable - V5 Migration Validation', () => {
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
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 customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import { BuilderQuery } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
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';
|
||||
|
||||
// Mock the EndPointsDropDown component to avoid issues
|
||||
interface QueryRangeRequestBody {
|
||||
compositeQuery: { queries: { spec: { filter: { expression: string } } }[] };
|
||||
}
|
||||
|
||||
// Mock the EndPointPicker component to avoid issues
|
||||
jest.mock(
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
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>
|
||||
)),
|
||||
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>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -43,10 +52,7 @@ describe('TopErrors', () => {
|
||||
startTime: 1000000000,
|
||||
endTime: 1000010000,
|
||||
},
|
||||
initialFilters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
initialFilterExpression: '',
|
||||
};
|
||||
|
||||
// Helper function to wait for table data to load
|
||||
@@ -227,22 +233,30 @@ describe('TopErrors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates endpoint filter when dropdown value changes', async () => {
|
||||
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);
|
||||
}),
|
||||
);
|
||||
|
||||
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(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(expressions[expressions.length - 1]).toContain(
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/new-endpoint'`,
|
||||
);
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
@@ -306,7 +320,7 @@ describe('TopErrors', () => {
|
||||
'test-domain',
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
{ items: [], op: 'AND' },
|
||||
'',
|
||||
false,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ApiMonitoringParams,
|
||||
DEFAULT_PARAMS,
|
||||
getApiMonitoringParams,
|
||||
getLegacyFilterExpressions,
|
||||
setApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { mockNavigate } from 'tests/router-mocks';
|
||||
@@ -52,6 +53,50 @@ 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', () => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
5
frontend/src/container/ApiMonitoring/filterParamKeys.ts
Normal file
5
frontend/src/container/ApiMonitoring/filterParamKeys.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// 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';
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
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';
|
||||
|
||||
// --- Types for all API Monitoring query params ---
|
||||
export interface ApiMonitoringParams {
|
||||
@@ -9,8 +13,6 @@ 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
|
||||
@@ -22,8 +24,6 @@ export const DEFAULT_PARAMS: ApiMonitoringParams = {
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
};
|
||||
@@ -35,15 +35,50 @@ function encodeParams(params: ApiMonitoringParams): string {
|
||||
return encodeURIComponent(JSON.stringify(params));
|
||||
}
|
||||
|
||||
function decodeParams(value: string | null): ApiMonitoringParams {
|
||||
// Older shared links carry the filters as TagFilter items.
|
||||
type LegacyApiMonitoringParams = ApiMonitoringParams & {
|
||||
allEndpointsLocalFilters?: TagFilter;
|
||||
endPointDetailsLocalFilters?: TagFilter;
|
||||
};
|
||||
|
||||
function parseParams(value: string | null): LegacyApiMonitoringParams | null {
|
||||
if (!value) {
|
||||
return DEFAULT_PARAMS;
|
||||
return null;
|
||||
}
|
||||
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 ---
|
||||
@@ -74,11 +109,18 @@ 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, location.search, replace);
|
||||
setApiMonitoringParams(
|
||||
newParams,
|
||||
`?${getUnstableCurrentSearchParams().toString()}`,
|
||||
replace,
|
||||
);
|
||||
},
|
||||
[location.search],
|
||||
[],
|
||||
);
|
||||
|
||||
return [params, setParams];
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
@@ -56,30 +57,28 @@ export const getDisplayValue = (value: unknown): string =>
|
||||
isEmptyFilterValue(value) ? '-' : String(value);
|
||||
|
||||
export const getDomainNameFilterExpression = (domainName: string): string =>
|
||||
`http_host = '${domainName}'`;
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = ${formatValueForExpression(domainName)}`;
|
||||
|
||||
export const clientKindExpression = `kind_string = 'Client'`;
|
||||
export const clientKindExpression = "kind_string = 'Client'";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
export const getHttpUrlExpression = (endPointName: string): string =>
|
||||
endPointName
|
||||
? `${SPAN_ATTRIBUTES.HTTP_URL} = ${formatValueForExpression(endPointName, '=')}`
|
||||
: '';
|
||||
|
||||
const { filter } = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
baseExpression,
|
||||
);
|
||||
return filter.expression;
|
||||
};
|
||||
// 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 ');
|
||||
|
||||
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
@@ -347,7 +346,7 @@ export const getDomainMetricsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
@@ -368,9 +367,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)} AND ${urlExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'A',
|
||||
@@ -396,9 +395,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'B',
|
||||
@@ -424,9 +423,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'C',
|
||||
@@ -452,9 +451,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'D',
|
||||
@@ -824,7 +823,7 @@ export const getEndPointsQueryPayload = (
|
||||
|
||||
function buildFilterExpression(
|
||||
domainName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
showStatusCodeErrors: boolean,
|
||||
): string {
|
||||
const baseFilterParts = [
|
||||
@@ -836,21 +835,22 @@ function buildFilterExpression(
|
||||
if (showStatusCodeErrors) {
|
||||
baseFilterParts.push('status_message EXISTS');
|
||||
}
|
||||
const filterExpression = baseFilterParts.join(' AND ');
|
||||
|
||||
return convertFiltersWithUrlHandling(filters, filterExpression);
|
||||
return combineInitialAndUserExpression(
|
||||
baseFilterParts.join(' AND '),
|
||||
filterExpression,
|
||||
);
|
||||
}
|
||||
|
||||
export const getTopErrorsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
showStatusCodeErrors = true,
|
||||
): QueryRangePayloadV5 => {
|
||||
const filterExpression = buildFilterExpression(
|
||||
const combinedExpression = buildFilterExpression(
|
||||
domainName,
|
||||
filters,
|
||||
filterExpression,
|
||||
showStatusCodeErrors,
|
||||
);
|
||||
|
||||
@@ -869,7 +869,7 @@ export const getTopErrorsQueryPayload = (
|
||||
stepInterval: 60,
|
||||
disabled: false,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: filterExpression },
|
||||
filter: { expression: combinedExpression },
|
||||
groupBy: [
|
||||
{
|
||||
name: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
@@ -1367,15 +1367,14 @@ export const createFiltersForSelectedRowData = (
|
||||
|
||||
// First query payload for endpoint metrics
|
||||
// Second query payload for endpoint status code table
|
||||
// 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
|
||||
// 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
|
||||
export const getEndPointDetailsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
@@ -1394,11 +1393,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1424,11 +1423,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1454,11 +1453,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1484,11 +1483,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1514,11 +1513,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'E',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1586,11 +1585,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1622,11 +1621,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1660,11 +1659,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'C',
|
||||
@@ -1713,77 +1712,6 @@ 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,
|
||||
@@ -1801,11 +1729,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1838,11 +1766,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1875,11 +1803,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1912,11 +1840,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1991,11 +1919,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2062,11 +1990,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2118,100 +2046,82 @@ export const getEndPointDetailsQueryPayload = (
|
||||
},
|
||||
];
|
||||
|
||||
export const getEndPointZeroStateQueryPayload = (
|
||||
export const getEndPointDropDownQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
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: [],
|
||||
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,
|
||||
],
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
dataType: DataTypes.String,
|
||||
type: 'attribute',
|
||||
},
|
||||
],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: true,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
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,
|
||||
});
|
||||
|
||||
interface EndPointMetricsResponseRow {
|
||||
data: {
|
||||
@@ -2369,8 +2279,8 @@ export const statusCodeWidgetInfo = [
|
||||
|
||||
interface EndPointDropDownResponseRow {
|
||||
data: {
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]: string;
|
||||
A: number;
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]?: string;
|
||||
A?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2712,7 +2622,7 @@ export const groupStatusCodes = (
|
||||
|
||||
export const getStatusCodeBarChartWidgetData = (
|
||||
domainName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): Widgets => ({
|
||||
query: {
|
||||
builder: {
|
||||
@@ -2728,22 +2638,13 @@ export const getStatusCodeBarChartWidgetData = (
|
||||
dataSource: DataSource.TRACES,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'c6724407',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
...(filters?.items || []),
|
||||
],
|
||||
op: 'AND',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
getDomainNameFilterExpression(domainName),
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
@@ -2824,18 +2725,15 @@ 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,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): Widgets => {
|
||||
const isGroupedByAttribute = groupBy.length > 0;
|
||||
@@ -2857,11 +2755,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2889,11 +2787,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2921,11 +2819,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2953,11 +2851,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: true,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -3075,38 +2973,26 @@ export const getAllEndpointsWidgetData = (
|
||||
return widget;
|
||||
};
|
||||
|
||||
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'A', 'B', 'C', 'F1'];
|
||||
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'key', 'A', 'B', 'C', 'F1'];
|
||||
|
||||
export const getGroupByFiltersFromGroupByValues = (
|
||||
rowData: any,
|
||||
groupBy: BaseAutocompleteData[],
|
||||
): IBuilderQuery['filters'] => {
|
||||
const items = Object.keys(rowData)
|
||||
// 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)
|
||||
.filter((key) => !keysToRemove.includes(key))
|
||||
.map((key) => {
|
||||
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',
|
||||
};
|
||||
};
|
||||
const value = rowData[key];
|
||||
return value === null || value === undefined
|
||||
? `${key} NOT EXISTS`
|
||||
: `${key} = ${formatValueForExpression(value)}`;
|
||||
})
|
||||
.join(' AND ');
|
||||
|
||||
export const getRateOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): Widgets => {
|
||||
let legend = domainName;
|
||||
if (endPointName) {
|
||||
@@ -3130,9 +3016,9 @@ export const getRateOverTimeWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`http_host = '${domainName}'`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -3156,7 +3042,7 @@ export const getRateOverTimeWidgetData = (
|
||||
export const getLatencyOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): Widgets => {
|
||||
let legend = domainName;
|
||||
if (endPointName) {
|
||||
@@ -3180,9 +3066,9 @@ export const getLatencyOverTimeWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`http_host = '${domainName}'`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
|
||||
@@ -174,7 +174,6 @@ function QuerySection(): JSX.Element {
|
||||
runQuery={runQueryHandler}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
alertDef={alertDef}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
isAnomalyDetection={isAnomalyDetection}
|
||||
key={currentQuery.queryType}
|
||||
|
||||
@@ -23,20 +23,11 @@ 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?: FlattenUnion<AlertmanagertypesChannelJiraAuthDTO>;
|
||||
auth?: AlertmanagertypesChannelJiraAuthDTO;
|
||||
};
|
||||
|
||||
/** Merged so switching kind keeps the fields the kinds share. */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryPromQLData,
|
||||
@@ -28,7 +27,6 @@ const defaultAnnotations = {
|
||||
|
||||
export const alertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -62,7 +60,6 @@ export const alertDefaults: AlertDef = {
|
||||
|
||||
export const anamolyAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
@@ -107,7 +104,6 @@ export const anamolyAlertDefaults: AlertDef = {
|
||||
|
||||
export const logAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.LOGS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -139,7 +135,6 @@ export const logAlertDefaults: AlertDef = {
|
||||
|
||||
export const traceAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.TRACES_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -171,7 +166,6 @@ export const traceAlertDefaults: AlertDef = {
|
||||
|
||||
export const exceptionAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.EXCEPTIONS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
|
||||
@@ -176,7 +176,6 @@ function ChartPreview({
|
||||
},
|
||||
originalGraphType: graphType,
|
||||
},
|
||||
// alertDef?.version || DEFAULT_ENTITY_VERSION,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey: [
|
||||
|
||||
@@ -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_V4 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
@@ -15,7 +15,6 @@ 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';
|
||||
@@ -37,7 +36,6 @@ function QuerySection({
|
||||
runQuery,
|
||||
isLoadingQueries,
|
||||
handleCancelQuery,
|
||||
alertDef,
|
||||
panelType,
|
||||
ruleId,
|
||||
isAnomalyDetection,
|
||||
@@ -71,12 +69,8 @@ function QuerySection({
|
||||
signalSource: signalSource === 'meter' ? 'meter' : '',
|
||||
}}
|
||||
showTraceOperator={alertType === AlertTypes.TRACES_BASED_ALERT}
|
||||
showFunctions={
|
||||
(alertType === AlertTypes.METRICS_BASED_ALERT &&
|
||||
alertDef.version === ENTITY_VERSION_V4) ||
|
||||
alertType === AlertTypes.LOGS_BASED_ALERT
|
||||
}
|
||||
version={alertDef.version || 'v3'}
|
||||
showFunctions={alertType === AlertTypes.LOGS_BASED_ALERT}
|
||||
version={ENTITY_VERSION_V5}
|
||||
onSignalSourceChange={handleSignalSourceChange}
|
||||
signalSourceChangeEnabled
|
||||
/>
|
||||
@@ -301,7 +295,6 @@ interface QuerySectionProps {
|
||||
runQuery: VoidFunction;
|
||||
isLoadingQueries: boolean;
|
||||
handleCancelQuery: () => void;
|
||||
alertDef: AlertDef;
|
||||
panelType: PANEL_TYPES;
|
||||
ruleId: string;
|
||||
isAnomalyDetection?: boolean;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { QuickFiltersLayoutProps } from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -14,6 +15,19 @@ import { act, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import Explorer from '../Explorer';
|
||||
|
||||
jest.mock(
|
||||
'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: QuickFiltersLayoutProps): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
jest.mock('../QuerySection/QuerySection', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
jest.mock('../ListView/ListView', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="ai-list-view" />,
|
||||
@@ -74,9 +88,18 @@ describe('AI observability Explorer', () => {
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('ai-trace-view')).toBeInTheDocument();
|
||||
expect(
|
||||
JSON.parse(urlParam(history, URL_OPTIONS) ?? '').selectColumns,
|
||||
).toStrictEqual(traceColumns);
|
||||
|
||||
await user.click(screen.getByTestId('search-view'));
|
||||
|
||||
expect(screen.getByTestId('ai-list-view')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('ai-trace-view')).not.toBeInTheDocument();
|
||||
expect(JSON.parse(urlParam(history, QueryParams.panelTypes) ?? '')).toBe(
|
||||
PANEL_TYPES.LIST,
|
||||
);
|
||||
expect(JSON.parse(urlParam(history, URL_OPTIONS) ?? '')).toStrictEqual({
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: [],
|
||||
|
||||
@@ -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(--l2-background);
|
||||
--tanstack-table-cell-bg: var(--l1-background);
|
||||
--tanstack-table-cell-color: var(--l2-foreground);
|
||||
--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-row-hover-bg: var(--l1-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l1-background-active);
|
||||
--tanstack-table-resize-handle-bg: transparent;
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
|
||||
--tanstack-table-row-height: 42px;
|
||||
|
||||
@@ -48,16 +48,6 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +240,6 @@ function ListAlertRules(): JSX.Element {
|
||||
showTotalCount: true,
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
enableAlternatingRowColors
|
||||
plainTextCellLineClamp={2}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -107,9 +107,7 @@ export function getAlertRuleColumns(
|
||||
return <TanStackTable.Text>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelColumn labels={tagKeys} value={labels} color="highlight-danger" />
|
||||
);
|
||||
return <LabelColumn labels={tagKeys} value={labels} color="secondary" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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';
|
||||
@@ -15,11 +17,7 @@ import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
@@ -126,7 +124,7 @@ const generateAPIMonitoringPath = (
|
||||
domainName: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): string => {
|
||||
const basePath = ROUTES.API_MONITORING;
|
||||
return `${basePath}?${createQueryParams({
|
||||
@@ -138,8 +136,8 @@ const generateAPIMonitoringPath = (
|
||||
endTime,
|
||||
},
|
||||
selectedInterval: 'custom',
|
||||
endPointDetailsLocalFilters: filters,
|
||||
}),
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: filterExpression,
|
||||
})}`;
|
||||
};
|
||||
export function onViewAPIMonitoringPopupClick({
|
||||
@@ -153,41 +151,15 @@ export function onViewAPIMonitoringPopupClick({
|
||||
return (e?: React.MouseEvent): void => {
|
||||
const endTime = timestamp + (stepInterval || 60);
|
||||
const startTime = timestamp - (stepInterval || 60);
|
||||
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 filterExpression = [
|
||||
...(isError ? ["hasError IN ['true']"] : []),
|
||||
`service.name = ${formatValueForExpression(servicename)}`,
|
||||
].join(' AND ');
|
||||
const newPath = generateAPIMonitoringPath(
|
||||
domainName,
|
||||
startTime,
|
||||
endTime,
|
||||
filters,
|
||||
filterExpression,
|
||||
);
|
||||
|
||||
safeNavigate(newPath, { newTab: !!e && isModifierKeyPressed(e) });
|
||||
|
||||
@@ -153,6 +153,7 @@ 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';
|
||||
@@ -2579,7 +2580,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-ec2-metrics-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: ec2Url,
|
||||
link: '/integrations?integration=aws-integration&service=ec2',
|
||||
link: '/integrations/aws?service=ec2',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -2637,7 +2638,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-ecs-ec2-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: ecsUrl,
|
||||
link: '/integrations?integration=aws-integration&service=ecs',
|
||||
link: '/integrations/aws?service=ecs',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -2699,7 +2700,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-eks-ec2-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: eksUrl,
|
||||
link: '/integrations?integration=aws-integration&service=eks',
|
||||
link: '/integrations/aws?service=eks',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -2823,21 +2824,21 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-rds-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: rdsUrl,
|
||||
link: '/integrations?integration=aws-integration&service=rds',
|
||||
link: '/integrations/aws?service=rds',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
key: 'aws-rds-mysql-manual',
|
||||
label: 'MySQL Manual',
|
||||
imgUrl: rdsUrl,
|
||||
link: '/integrations?integration=builtin-aws_rds_mysql',
|
||||
link: '/integrations/builtin-aws_rds_mysql',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
key: 'aws-rds-postgresql-manual',
|
||||
label: 'PostgreSQL Manual',
|
||||
imgUrl: rdsUrl,
|
||||
link: '/integrations?integration=builtin-aws_rds_postgresql',
|
||||
link: '/integrations/builtin-aws_rds_postgresql',
|
||||
internalRedirect: true,
|
||||
},
|
||||
],
|
||||
@@ -2896,7 +2897,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-lambda-logs-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/integrations?integration=aws-integration&service=lambda',
|
||||
link: '/integrations/aws?service=lambda',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -2954,7 +2955,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-lambda-metrics-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/integrations?integration=aws-integration&service=lambda',
|
||||
link: '/integrations/aws?service=lambda',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -4710,6 +4711,7 @@ const onboardingConfigWithLinks = [
|
||||
'messaging',
|
||||
'messaging queues',
|
||||
'messaging-queues-celery',
|
||||
'python celery',
|
||||
'queues',
|
||||
'task queue tracing',
|
||||
],
|
||||
@@ -4930,7 +4932,7 @@ const onboardingConfigWithLinks = [
|
||||
'redis metrics',
|
||||
],
|
||||
imgUrl: redisUrl,
|
||||
link: '/integrations?integration=builtin-redis',
|
||||
link: '/integrations/builtin-redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -4946,7 +4948,7 @@ const onboardingConfigWithLinks = [
|
||||
'postgresql metrics',
|
||||
],
|
||||
imgUrl: postgresqlUrl,
|
||||
link: '/integrations?integration=builtin-postgres',
|
||||
link: '/integrations/builtin-postgres',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -4962,7 +4964,7 @@ const onboardingConfigWithLinks = [
|
||||
'proxy',
|
||||
],
|
||||
imgUrl: nginxUrl,
|
||||
link: '/integrations?integration=builtin-nginx',
|
||||
link: '/integrations/builtin-nginx',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -4983,7 +4985,7 @@ const onboardingConfigWithLinks = [
|
||||
'self',
|
||||
],
|
||||
imgUrl: mongoUrl,
|
||||
link: '/integrations?integration=builtin-mongo',
|
||||
link: '/integrations/builtin-mongo',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5001,7 +5003,7 @@ const onboardingConfigWithLinks = [
|
||||
'olap database',
|
||||
],
|
||||
imgUrl: clickhouseUrl,
|
||||
link: '/integrations?integration=builtin-clickhouse',
|
||||
link: '/integrations/builtin-clickhouse',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5056,7 +5058,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-alb-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: awsAlbUrl,
|
||||
link: '/integrations?integration=aws-integration&service=alb',
|
||||
link: '/integrations/aws?service=alb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5097,7 +5099,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-api-gateway-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: apiGatewayUrl,
|
||||
link: '/integrations?integration=aws-integration&service=api-gateway',
|
||||
link: '/integrations/aws?service=api-gateway',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5142,7 +5144,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-dynamodb-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: dynamodbUrl,
|
||||
link: '/integrations?integration=aws-integration&service=dynamodb',
|
||||
link: '/integrations/aws?service=dynamodb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5189,14 +5191,14 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-elasticache-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: elasticacheUrl,
|
||||
link: '/integrations?integration=aws-integration&service=elasticache',
|
||||
link: '/integrations/aws?service=elasticache',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
key: 'aws-elasticache-redis-manual',
|
||||
label: 'Redis Manual',
|
||||
imgUrl: elasticacheUrl,
|
||||
link: '/integrations?integration=builtin-aws_elasticache_redis',
|
||||
link: '/integrations/builtin-aws_elasticache_redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
],
|
||||
@@ -5235,7 +5237,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-msk-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: amazonMskUrl,
|
||||
link: '/integrations?integration=aws-integration&service=msk',
|
||||
link: '/integrations/aws?service=msk',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5278,7 +5280,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-s3-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: s3Url,
|
||||
link: '/integrations?integration=aws-integration&service=s3sync',
|
||||
link: '/integrations/aws?service=s3sync',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5320,7 +5322,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-sns-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: snsUrl,
|
||||
link: '/integrations?integration=aws-integration&service=sns',
|
||||
link: '/integrations/aws?service=sns',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -5362,7 +5364,7 @@ const onboardingConfigWithLinks = [
|
||||
key: 'aws-sqs-one-click',
|
||||
label: 'One Click AWS',
|
||||
imgUrl: sqsUrl,
|
||||
link: '/integrations?integration=aws-integration&service=sqs',
|
||||
link: '/integrations/aws?service=sqs',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
@@ -7249,12 +7251,14 @@ 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',
|
||||
@@ -7263,6 +7267,8 @@ const onboardingConfigWithLinks = [
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click',
|
||||
'one click gcp integration',
|
||||
'opentelemetry collector gcp',
|
||||
'pub/sub',
|
||||
],
|
||||
@@ -8155,29 +8161,50 @@ const onboardingConfigWithLinks = [
|
||||
link: '/docs/instrumentation/javascript/opentelemetry-pm2/',
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-one-click-integration',
|
||||
label: 'GCP One-Click Integration',
|
||||
imgUrl: gcpUrl,
|
||||
tags: ['GCP', 'metrics', 'logs'],
|
||||
module: 'integrations',
|
||||
dataSource: 'pixeltable',
|
||||
label: 'Pixeltable',
|
||||
imgUrl: pixeltableUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'gcp',
|
||||
'gcp agent',
|
||||
'gcp integration',
|
||||
'gcp logs',
|
||||
'gcp metrics',
|
||||
'gcp one click',
|
||||
'google cloud',
|
||||
'google cloud integration',
|
||||
'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',
|
||||
'logs',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click',
|
||||
'one click gcp integration',
|
||||
'otel claude cowork',
|
||||
'telemetry',
|
||||
],
|
||||
id: 'gcp-one-click-integration',
|
||||
link: '/docs/integrations/gcp/one-click-gcp-integrations/',
|
||||
id: 'claude-cowork',
|
||||
link: '/docs/claude-cowork-monitoring/',
|
||||
},
|
||||
];
|
||||
export default onboardingConfigWithLinks;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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]]);
|
||||
});
|
||||
});
|
||||
88
frontend/src/lib/uPlotV2/paths/variableWidthBars.ts
Normal file
88
frontend/src/lib/uPlotV2/paths/variableWidthBars.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.notice {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
29
frontend/src/lib/visualization/charts/Distribution/colors.ts
Normal file
29
frontend/src/lib/visualization/charts/Distribution/colors.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
288
frontend/src/lib/visualization/charts/Distribution/config.ts
Normal file
288
frontend/src/lib/visualization/charts/Distribution/config.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
232
frontend/src/lib/visualization/charts/Distribution/layout.ts
Normal file
232
frontend/src/lib/visualization/charts/Distribution/layout.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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)}`;
|
||||
}
|
||||
133
frontend/src/lib/visualization/charts/Distribution/topGroups.ts
Normal file
133
frontend/src/lib/visualization/charts/Distribution/topGroups.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
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] };
|
||||
}
|
||||
31
frontend/src/lib/visualization/charts/Distribution/types.ts
Normal file
31
frontend/src/lib/visualization/charts/Distribution/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
}
|
||||
32
frontend/src/lib/visualization/charts/Distribution/utils.ts
Normal file
32
frontend/src/lib/visualization/charts/Distribution/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
}
|
||||
@@ -26,6 +26,11 @@ 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;
|
||||
@@ -167,6 +172,48 @@ 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.
|
||||
|
||||
@@ -40,7 +40,6 @@ const make = (
|
||||
state: RuletypesAlertStateDTO.inactive,
|
||||
labels: { severity: 'info' },
|
||||
annotations: {},
|
||||
source: '',
|
||||
ruleType: RuletypesRuleTypeDTO.threshold_rule,
|
||||
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
|
||||
evaluation: {
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
margin-top: 10px;
|
||||
|
||||
&__divider {
|
||||
--divider-color: var(--l1-border);
|
||||
--divider-margin: 16px 0;
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
}
|
||||
|
||||
.alert-rules-container {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
@@ -133,10 +132,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
> .alert-rules-container {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,8 +379,6 @@ 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,
|
||||
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
@@ -123,17 +124,7 @@ const timeRange = (): Partial<AppState> => {
|
||||
};
|
||||
};
|
||||
|
||||
const serviceFilterItems = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'storybook-service-filter',
|
||||
key: { key: 'service.name', dataType: 'string', type: 'resource' },
|
||||
op: '=',
|
||||
value: 'checkout',
|
||||
},
|
||||
],
|
||||
};
|
||||
const serviceFilterExpression = "service.name = 'checkout'";
|
||||
|
||||
interface RouteValues {
|
||||
drawer: DrawerState;
|
||||
@@ -160,12 +151,14 @@ 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. */
|
||||
|
||||
@@ -26,11 +26,9 @@ 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
2
go.mod
@@ -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.1
|
||||
github.com/SigNoz/signoz-semantic-conventions/gen/go v0.0.2
|
||||
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
Reference in New Issue
Block a user