mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-25 17:52:23 +00:00
Compare commits
38 Commits
query-rang
...
SIG-3496
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3664c31fe | ||
|
|
82c54b1d36 | ||
|
|
39f5fb7290 | ||
|
|
3a55ce93bc | ||
|
|
6ec2989e5c | ||
|
|
016da679b9 | ||
|
|
33b28999e5 | ||
|
|
ac6fb559b2 | ||
|
|
ff028e366b | ||
|
|
c579614d56 | ||
|
|
78ba2ba356 | ||
|
|
7fd4762e2a | ||
|
|
b37650e73b | ||
|
|
9c55b7de60 | ||
|
|
cf395430f3 | ||
|
|
85d92342fe | ||
|
|
dfdc7caaf8 | ||
|
|
a2ed3421dc | ||
|
|
b8a01ba89a | ||
|
|
d9867e1a84 | ||
|
|
bd7e059553 | ||
|
|
880bfa3b84 | ||
|
|
7b26eb4554 | ||
|
|
24728965b2 | ||
|
|
c4307e4a37 | ||
|
|
191cc1ada9 | ||
|
|
c7c081fc76 | ||
|
|
86fb21c809 | ||
|
|
3956d77d7f | ||
|
|
f67d2a538a | ||
|
|
2212147065 | ||
|
|
7a80d8ab1f | ||
|
|
fbb54b7c47 | ||
|
|
a8c0658066 | ||
|
|
c6d7ad1041 | ||
|
|
c9d7643efa | ||
|
|
a109b8ad5a | ||
|
|
ec5d45230a |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.112.1
|
||||
image: signoz/signoz:v0.113.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.142.1
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -241,7 +241,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.112.1
|
||||
image: signoz/signoz:v0.113.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
@@ -139,7 +139,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.142.1
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -167,7 +167,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.112.1}
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -229,7 +229,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.112.1}
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.1}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -157,7 +157,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
# Query Range v5 — Design Principles & Architectural Contracts
|
||||
|
||||
## Purpose of This Document
|
||||
|
||||
This document defines the design principles, invariants, and architectural contracts of the Query Range v5 system. It is intended for the authors working on the querier and querier related parts codebase. Any change to the system must align with the principles described here. If a change would violate a principle, it must be flagged and discussed.
|
||||
|
||||
---
|
||||
|
||||
## Core Architectural Principle
|
||||
|
||||
**The user speaks OpenTelemetry. The storage speaks ClickHouse. The system translates between them. These two worlds must never leak into each other.**
|
||||
|
||||
Every design choice in Query Range flows from this separation. The user-facing API surface deals exclusively in `TelemetryFieldKey`: a representation of fields as they exist in the OpenTelemetry data model. The storage layer deals in ClickHouse column expressions, table names, and SQL fragments. The translation between them is mediated by a small set of composable abstractions with strict boundaries.
|
||||
|
||||
---
|
||||
|
||||
## The Central Type: `TelemetryFieldKey`
|
||||
|
||||
`TelemetryFieldKey` is the atomic unit of the entire query system. Every filter, aggregation, group-by, order-by, and select operation is expressed in terms of field keys. Understanding its design contracts is non-negotiable.
|
||||
|
||||
### Identity
|
||||
|
||||
A field key is identified by three dimensions:
|
||||
|
||||
- **Name** — the field name as the user knows it (`service.name`, `http.method`, `trace_id`)
|
||||
- **FieldContext** — where the field lives in the OTel model (`resource`, `attribute`, `span`, `log`, `body`, `scope`, `event`, `metric`)
|
||||
- **FieldDataType** — the data type (`string`, `bool`, `number`/`float64`/`int64`, array variants)
|
||||
|
||||
### Invariant: Same name does not mean same field
|
||||
|
||||
`status` as an attribute, `status` as a body JSON key, and `status` as a span-level field are **three different fields**. The context disambiguates. Code that resolves or compares field keys must always consider all three dimensions, never just the name.
|
||||
|
||||
### Invariant: Normalization happens once, at the boundary
|
||||
|
||||
`TelemetryFieldKey.Normalize()` is called during JSON unmarshaling. After normalization, the text representation `resource.service.name:string` and the programmatic construction `{Name: "service.name", FieldContext: Resource, FieldDataType: String}` are identical.
|
||||
|
||||
**Consequence:** Downstream code must never re-parse or re-normalize field keys. If you find yourself splitting on `.` or `:` deep in the pipeline, something is wrong — the normalization should have already happened.
|
||||
|
||||
### Invariant: The text format is `context.name:datatype`
|
||||
|
||||
Parsing rules (implemented in `GetFieldKeyFromKeyText` and `Normalize`):
|
||||
|
||||
1. Data type is extracted from the right, after the last `:`.
|
||||
2. Field context is extracted from the left, before the first `.`, if it matches a known context prefix.
|
||||
3. Everything remaining is the name.
|
||||
|
||||
Special case: `log.body.X` normalizes to `{FieldContext: body, Name: X}` — the `log.body.` prefix collapses because body fields under log are a nested context.
|
||||
|
||||
### Invariant: Historical aliases must be preserved
|
||||
|
||||
The `fieldContexts` map includes aliases (`tag` -> `attribute`, `spanfield` -> `span`, `logfield` -> `log`). These exist because older database entries use these names. Removing or changing these aliases will break existing saved queries and dashboard configurations.
|
||||
|
||||
---
|
||||
|
||||
## The Abstraction Stack
|
||||
|
||||
The query pipeline is built from four interfaces that compose vertically. Each layer has a single responsibility. Each layer depends only on the layers below it. This layering is intentional and must be preserved.
|
||||
|
||||
```
|
||||
StatementBuilder <- Orchestrates everything into executable SQL
|
||||
├── AggExprRewriter <- Rewrites aggregation expressions (maps field refs to columns)
|
||||
├── ConditionBuilder <- Builds WHERE predicates (field + operator + value -> SQL)
|
||||
└── FieldMapper <- Maps TelemetryFieldKey -> ClickHouse column expression
|
||||
```
|
||||
|
||||
### FieldMapper
|
||||
|
||||
**Contract:** Given a `TelemetryFieldKey`, return a ClickHouse column expression that yields the value for that field when used in a SELECT.
|
||||
|
||||
**Principle:** This is the *only* place where field-to-column translation happens. No other layer should contain knowledge of how fields map to storage. If you need a column expression, go through the FieldMapper.
|
||||
|
||||
**Why:** The user says `http.request.method`. ClickHouse might store it as `attributes_string['http.request.method']`, or as a materialized column `` `attribute_string_http$$request$$method` ``, or via a JSON access path in a body column. This variation is entirely contained within the FieldMapper. Everything above it is storage-agnostic.
|
||||
|
||||
### ConditionBuilder
|
||||
|
||||
**Contract:** Given a field key, an operator, and a value, produce a valid SQL predicate for a WHERE clause.
|
||||
|
||||
**Dependency:** Uses FieldMapper for the left-hand side of the condition.
|
||||
|
||||
**Principle:** The ConditionBuilder owns all the complexity of operator semantics, i.e type casting, array operators (`hasAny`/`hasAll` vs `=`), existence checks, and negative operator behavior. This complexity must not leak upward into the StatementBuilder.
|
||||
|
||||
### AggExprRewriter
|
||||
|
||||
**Contract:** Given a user-facing aggregation expression like `sum(duration_nano)`, resolve field references within it and produce valid ClickHouse SQL.
|
||||
|
||||
**Dependency:** Uses FieldMapper to resolve field names within expressions.
|
||||
|
||||
**Principle:** Aggregation expressions are user-authored strings that contain field references. The rewriter parses them, identifies field references, resolves each through the FieldMapper, and reassembles the expression.
|
||||
|
||||
### StatementBuilder
|
||||
|
||||
**Contract:** Given a complete `QueryBuilderQuery`, a time range, and a request type, produces an executable SQL statement.
|
||||
|
||||
**Dependency:** Uses all three abstractions above.
|
||||
|
||||
**Principle:** This is the composition layer. It does not contain field mapping logic, condition building logic, or expression rewriting logic. It orchestrates the other abstractions. If you find storage-specific logic creeping into the StatementBuilder, push it down into the appropriate abstraction.
|
||||
|
||||
### Invariant: No layer skipping
|
||||
|
||||
The StatementBuilder must not call FieldMapper directly to build conditions, it goes through the ConditionBuilder. The AggExprRewriter must not hardcode column names, it goes through the FieldMapper. Skipping layers creates hidden coupling and makes the system fragile to storage changes.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions as Constraints
|
||||
|
||||
### Constraint: Formula evaluation happens in Go, not in ClickHouse
|
||||
|
||||
Formulas (`A + B`, `A / B`, `sqrt(A*A + B*B)`) are evaluated application-side by `FormulaEvaluator`, not via ClickHouse JOINs.
|
||||
|
||||
**Why this is a constraint, not just an implementation choice:** The original JOIN-based approach was abandoned because ClickHouse evaluates joins right-to-left, serializing execution unnecessarily. Running queries independently allows parallelism and caching of intermediate results. Any future optimization must not reintroduce the JOIN pattern without solving the serialization problem.
|
||||
|
||||
**Consequence:** Individual query results must be independently cacheable. Formula evaluation must handle label matching, timestamp alignment, and missing values without requiring the queries to coordinate at the SQL level.
|
||||
|
||||
### Constraint: Zero-defaulting is aggregation-dependent
|
||||
|
||||
Only additive/counting aggregations (`count`, `count_distinct`, `sum`, `rate`) default missing values to zero. Statistical aggregations (`avg`, `min`, `max`, percentiles) must show gaps.
|
||||
|
||||
**Why:** Absence of data has different meanings. No error requests in a time bucket means error count = 0. No requests at all means average latency is *unknown*, not 0. Conflating these is a correctness bug, not a display preference.
|
||||
|
||||
**Enforcement:** `GetQueriesSupportingZeroDefault` determines which queries can default to zero. The `FormulaEvaluator` consumes this via `canDefaultZero`. Changes to aggregation handling must preserve this distinction.
|
||||
|
||||
### Constraint: Existence semantics differ for positive vs negative operators
|
||||
|
||||
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence. `http.method = GET` means "the field exists AND equals GET".
|
||||
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) do **not** add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
|
||||
|
||||
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. This is documented in `AddDefaultExistsFilter`.
|
||||
|
||||
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Do not add operators without considering this.
|
||||
|
||||
### Constraint: Post-processing functions operate on result sets, not in SQL
|
||||
|
||||
Functions like `cutOffMin`, `ewma`, `median`, `timeShift`, `fillZero`, `runningDiff`, and `cumulativeSum` are applied in Go on the returned time series, not pushed into ClickHouse SQL.
|
||||
|
||||
**Why:** These are sequential time-series transformations that require complete, ordered result sets. Pushing them into SQL would complicate query generation, prevent caching of raw results, and make the functions harder to test. They are applied via `ApplyFunctions` after query execution.
|
||||
|
||||
**Consequence:** New time-series transformation functions should follow this pattern i.e implement them as Go functions on `*TimeSeries`, not as SQL modifications.
|
||||
|
||||
### Constraint: The API surface rejects unknown fields with suggestions
|
||||
|
||||
All request types use custom `UnmarshalJSON` that calls `DisallowUnknownFields`. Unknown fields trigger error messages with Levenshtein-based suggestions ("did you mean: 'groupBy'?").
|
||||
|
||||
**Why:** Silent acceptance of unknown fields causes subtle bugs. A misspelled `groupBy` results in ungrouped data with no indication of what went wrong. Failing fast with suggestions turns errors into actionable feedback.
|
||||
|
||||
**Consequence:** Any new request type or query spec struct must implement custom unmarshaling with `UnmarshalJSONWithContext`. Do not use default `json.Unmarshal` for user-facing types.
|
||||
|
||||
### Constraint: Validation is context-sensitive to request type
|
||||
|
||||
What's valid depends on the `RequestType`. For aggregation requests (`time_series`, `scalar`, `distribution`), fields like `groupBy`, `aggregations`, `having`, and aggregation-referenced `orderBy` are validated. For non-aggregation requests (`raw`, `raw_stream`, `trace`), these fields are ignored.
|
||||
|
||||
**Why:** A raw log query doesn't have aggregations, so requiring `aggregations` would be wrong. But a time-series query without aggregations is meaningless. The validation rules are request-type-aware to avoid both false positives and false negatives.
|
||||
|
||||
**Consequence:** When adding new fields to query specs, consider which request types they apply to and gate validation accordingly.
|
||||
|
||||
---
|
||||
|
||||
## The Composite Query Model
|
||||
|
||||
### Structure
|
||||
|
||||
A `QueryRangeRequest` contains a `CompositeQuery` which holds `[]QueryEnvelope`. Each envelope is a discriminated union: a `Type` field determines how `Spec` is decoded.
|
||||
|
||||
### Invariant: Query names are unique within a composite query
|
||||
|
||||
Builder queries must have unique names. Formulas reference queries by name (`A`, `B`, `A.0`, `A.my_alias`). Duplicate names would make formula evaluation ambiguous.
|
||||
|
||||
### Invariant: Multi-aggregation uses indexed or aliased references
|
||||
|
||||
A single builder query can have multiple aggregations. They are accessed in formulas via:
|
||||
- Index: `A.0`, `A.1` (zero-based)
|
||||
- Alias: `A.total`, `A.error_count`
|
||||
|
||||
The default (just `A`) resolves to index 0. This is the formula evaluation contract and must be preserved.
|
||||
|
||||
### Invariant: Type-specific decoding through signal detection
|
||||
|
||||
Builder queries are decoded by first peeking at the `signal` field in the raw JSON, then unmarshaling into the appropriate generic type (`QueryBuilderQuery[TraceAggregation]`, `QueryBuilderQuery[LogAggregation]`, `QueryBuilderQuery[MetricAggregation]`). This two-pass decoding is intentional — it allows each signal to have its own aggregation schema while sharing the query structure.
|
||||
|
||||
---
|
||||
|
||||
## The Metadata Layer
|
||||
|
||||
### MetadataStore
|
||||
|
||||
The `MetadataStore` interface provides runtime field discovery and type resolution. It answers questions like "what fields exist for this signal?" and "what are the data types of field X?".
|
||||
|
||||
### Principle: Fields can be ambiguous until resolved
|
||||
|
||||
The same name can map to multiple `TelemetryFieldKey` variants (different contexts, different types). The metadata store returns *all* variants. Resolution to a single field happens during query building, using the query's signal and any explicit context/type hints from the user.
|
||||
|
||||
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field.
|
||||
|
||||
### Principle: Materialized fields are a performance optimization, not a semantic distinction
|
||||
|
||||
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the FieldMapper to generate a simpler column expression. The user should never need to know whether a field is materialized.
|
||||
|
||||
### Principle: JSON body fields require access plans
|
||||
|
||||
Fields inside JSON body columns (`body.response.errors[].code`) need pre-computed `JSONAccessPlan` trees that encode the traversal path, including branching at array boundaries between `Array(JSON)` and `Array(Dynamic)` representations. These plans are computed during metadata resolution, not during query execution.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Inviolable Rules
|
||||
|
||||
1. **User-facing types never contain ClickHouse column names or SQL fragments.**
|
||||
2. **Field-to-column translation only happens in FieldMapper.**
|
||||
3. **Normalization happens once at the API boundary, never deeper.**
|
||||
4. **Historical aliases in fieldContexts and fieldDataTypes must not be removed.**
|
||||
5. **Formula evaluation stays in Go — do not push it into ClickHouse JOINs.**
|
||||
6. **Zero-defaulting is aggregation-type-dependent — do not universally default to zero.**
|
||||
7. **Positive operators imply existence, negative operators do not.**
|
||||
8. **Post-processing functions operate on Go result sets, not in SQL.**
|
||||
9. **All user-facing types reject unknown JSON fields with suggestions.**
|
||||
10. **Validation rules are gated by request type.**
|
||||
11. **Query names must be unique within a composite query.**
|
||||
12. **The four-layer abstraction stack (FieldMapper -> ConditionBuilder -> AggExprRewriter -> StatementBuilder) must not be bypassed or flattened.**
|
||||
@@ -86,8 +86,13 @@ function LogDetailInner({
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Don't close if clicking on explicitly ignored regions
|
||||
if (target.closest('[data-log-detail-ignore="true"]')) {
|
||||
// Don't close if clicking on drawer content, overlays, or portal elements
|
||||
if (
|
||||
target.closest('[data-log-detail-ignore="true"]') ||
|
||||
target.closest('.cm-tooltip-autocomplete') ||
|
||||
target.closest('.drawer-popover') ||
|
||||
target.closest('.query-status-popover')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -400,7 +405,11 @@ function LogDetailInner({
|
||||
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
|
||||
<div className="log-detail-drawer__log">
|
||||
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
|
||||
<Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(log?.body)}
|
||||
placement="left"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -466,6 +475,7 @@ function LogDetailInner({
|
||||
title="Show Filters"
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
@@ -481,6 +491,7 @@ function LogDetailInner({
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
|
||||
@@ -27,7 +27,11 @@ function AddToQueryHOC({
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className={cx('addToQueryContainer', fontSize)} onClick={handleQueryAdd}>
|
||||
<Popover placement="top" content={popOverContent}>
|
||||
<Popover
|
||||
overlayClassName="drawer-popover"
|
||||
placement="top"
|
||||
content={popOverContent}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ function CopyClipboardHOC({
|
||||
<span onClick={onClick} role="presentation" tabIndex={-1}>
|
||||
<Popover
|
||||
placement="top"
|
||||
overlayClassName="drawer-popover"
|
||||
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
|
||||
|
||||
export const defaultTableStyle: CSSProperties = {
|
||||
minWidth: '40rem',
|
||||
maxWidth: '60rem',
|
||||
maxWidth: '90rem',
|
||||
};
|
||||
|
||||
export const defaultListViewPanelStyle: CSSProperties = {
|
||||
|
||||
@@ -1328,7 +1328,10 @@ function QuerySearch({
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-editor-container">
|
||||
<Tooltip title={getTooltipContent()} placement="left">
|
||||
<Tooltip
|
||||
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
|
||||
placement="left"
|
||||
>
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/search-syntax/"
|
||||
target="_blank"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
enqueueFetchOfAllVariables,
|
||||
@@ -31,6 +32,9 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardId = useDashboardVariablesSelector(
|
||||
(state) => state.dashboardId,
|
||||
);
|
||||
const sortedVariablesArray = useDashboardVariablesSelector(
|
||||
(state) => state.sortedVariablesArray,
|
||||
);
|
||||
@@ -96,6 +100,28 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
updateUrlVariable(name || id, value);
|
||||
}
|
||||
|
||||
// Synchronously update the external store with the new variable value so that
|
||||
// child variables see the updated parent value when they refetch, rather than
|
||||
// waiting for setSelectedDashboard → useEffect → updateDashboardVariablesStore.
|
||||
const updatedVariables = { ...dashboardVariables };
|
||||
if (updatedVariables[id]) {
|
||||
updatedVariables[id] = {
|
||||
...updatedVariables[id],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
if (updatedVariables[name]) {
|
||||
updatedVariables[name] = {
|
||||
...updatedVariables[name],
|
||||
selectedValue: value,
|
||||
allSelected,
|
||||
haveCustomValuesSelected,
|
||||
};
|
||||
}
|
||||
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
|
||||
|
||||
setSelectedDashboard((prev) => {
|
||||
if (prev) {
|
||||
const oldVariables = { ...prev?.data.variables };
|
||||
@@ -130,10 +156,12 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Cascade: enqueue query-type descendants for refetching
|
||||
// Cascade: enqueue query-type descendants for refetching.
|
||||
// Safe to call synchronously now that the store already has the updated value.
|
||||
enqueueDescendantsOfVariable(name);
|
||||
},
|
||||
[
|
||||
dashboardId,
|
||||
dashboardVariables,
|
||||
updateLocalStorageDashboardVariables,
|
||||
updateUrlVariable,
|
||||
|
||||
@@ -5,7 +5,7 @@ import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQ
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
import { isArray, isEmpty, isString } from 'lodash-es';
|
||||
import { isArray, isEmpty } from 'lodash-es';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -54,7 +54,7 @@ function QueryVariableInput({
|
||||
onChange,
|
||||
onDropdownVisibleChange,
|
||||
handleClear,
|
||||
applyDefaultIfNeeded,
|
||||
getDefaultValue,
|
||||
} = useDashboardVariableSelectHelper({
|
||||
variableData,
|
||||
optionsData,
|
||||
@@ -68,81 +68,93 @@ function QueryVariableInput({
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
|
||||
// This is just a check given the previously undefined typed name prop. Not significant
|
||||
// This will be changed when we change the schema
|
||||
// TODO: @AshwinBhatkal Perses
|
||||
if (!variableData.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the response is not an array, premature return
|
||||
if (
|
||||
variablesRes?.variableValues &&
|
||||
Array.isArray(variablesRes?.variableValues)
|
||||
!variablesRes?.variableValues ||
|
||||
!Array.isArray(variablesRes?.variableValues)
|
||||
) {
|
||||
const newOptionsData = sortValues(
|
||||
variablesRes?.variableValues,
|
||||
variableData.sort,
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedNewOptions = sortValues(
|
||||
variablesRes.variableValues,
|
||||
variableData.sort,
|
||||
);
|
||||
const sortedOldOptions = sortValues(optionsData, variableData.sort);
|
||||
|
||||
// if options are the same as before, no need to update state or check for selected value validity
|
||||
// ! selectedValue needs to be set in the first pass though, as options are initially empty array and we need to apply default if needed
|
||||
// Expecatation is that when oldOptions are not empty, then there is always some selectedValue
|
||||
if (areArraysEqual(sortedNewOptions, sortedOldOptions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOptionsData(sortedNewOptions);
|
||||
|
||||
let isSelectedValueMissingInNewOptions = false;
|
||||
|
||||
// Check if currently selected value(s) are present in the new options list
|
||||
if (isArray(variableData.selectedValue)) {
|
||||
isSelectedValueMissingInNewOptions = variableData.selectedValue.some(
|
||||
(val) => !sortedNewOptions.includes(val),
|
||||
);
|
||||
} else if (
|
||||
variableData.selectedValue &&
|
||||
!sortedNewOptions.includes(variableData.selectedValue)
|
||||
) {
|
||||
isSelectedValueMissingInNewOptions = true;
|
||||
}
|
||||
|
||||
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
|
||||
// If multi-select with ALL option enabled, and ALL is currently selected, we want to maintain that state and select all new options
|
||||
// This block does not depend on selected value because of ALL and also because we would only come here if options are different from the previous
|
||||
if (
|
||||
variableData.multiSelect &&
|
||||
variableData.showALLOption &&
|
||||
variableData.allSelected &&
|
||||
isSelectedValueMissingInNewOptions
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, sortedNewOptions, true);
|
||||
|
||||
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
|
||||
let valueNotInList = false;
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(sortedNewOptions.map((option) => option.toString()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArray(variableData.selectedValue)) {
|
||||
variableData.selectedValue.forEach((val) => {
|
||||
if (!newOptionsData.includes(val)) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
isString(variableData.selectedValue) &&
|
||||
!newOptionsData.includes(variableData.selectedValue)
|
||||
) {
|
||||
valueNotInList = true;
|
||||
}
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
|
||||
if (variableData.name && (valueNotInList || variableData.allSelected)) {
|
||||
if (
|
||||
variableData.allSelected &&
|
||||
variableData.multiSelect &&
|
||||
variableData.showALLOption
|
||||
) {
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
newOptionsData,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
sortedNewOptions.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
sortedNewOptions.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
|
||||
// Update tempSelection to maintain ALL state when dropdown is open
|
||||
if (tempSelection !== undefined) {
|
||||
setTempSelection(newOptionsData.map((option) => option.toString()));
|
||||
}
|
||||
} else {
|
||||
const value = variableData.selectedValue;
|
||||
let allSelected = false;
|
||||
|
||||
if (variableData.multiSelect) {
|
||||
const { selectedValue } = variableData;
|
||||
allSelected =
|
||||
newOptionsData.length > 0 &&
|
||||
Array.isArray(selectedValue) &&
|
||||
newOptionsData.every((option) => selectedValue.includes(option));
|
||||
}
|
||||
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOptionsData(newOptionsData);
|
||||
// Apply default if no value is selected (e.g., new variable, first load)
|
||||
applyDefaultIfNeeded(newOptionsData);
|
||||
if (
|
||||
variableData.name &&
|
||||
variableData.id &&
|
||||
!isEmpty(variableData.selectedValue)
|
||||
) {
|
||||
onValueUpdate(variableData.name, variableData.id, value, allSelected);
|
||||
} else {
|
||||
const defaultValue = getDefaultValue(sortedNewOptions);
|
||||
if (defaultValue !== undefined) {
|
||||
onValueUpdate(
|
||||
variableData.name,
|
||||
variableData.id,
|
||||
defaultValue,
|
||||
allSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -155,7 +167,7 @@ function QueryVariableInput({
|
||||
onValueUpdate,
|
||||
tempSelection,
|
||||
setTempSelection,
|
||||
applyDefaultIfNeeded,
|
||||
getDefaultValue,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { act, render } from '@testing-library/react';
|
||||
import * as dashboardVariablesStoreModule from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
dashboardVariablesStore,
|
||||
setDashboardVariablesStore,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
IDashboardVariablesStoreState,
|
||||
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import {
|
||||
enqueueDescendantsOfVariable,
|
||||
enqueueFetchOfAllVariables,
|
||||
initializeVariableFetchStore,
|
||||
} from 'providers/Dashboard/store/variableFetchStore';
|
||||
@@ -17,6 +19,17 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DashboardVariableSelection from '../DashboardVariableSelection';
|
||||
|
||||
// Mutable container to capture the onValueUpdate callback from VariableItem
|
||||
const mockVariableItemCallbacks: {
|
||||
onValueUpdate?: (
|
||||
name: string,
|
||||
id: string,
|
||||
value: IDashboardVariable['selectedValue'],
|
||||
allSelected: boolean,
|
||||
haveCustomValuesSelected?: boolean,
|
||||
) => void;
|
||||
} = {};
|
||||
|
||||
// Mock providers/Dashboard/Dashboard
|
||||
const mockSetSelectedDashboard = jest.fn();
|
||||
const mockUpdateLocalStorageDashboardVariables = jest.fn();
|
||||
@@ -56,10 +69,14 @@ jest.mock('react-redux', () => ({
|
||||
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
|
||||
}));
|
||||
|
||||
// Mock VariableItem to avoid rendering complexity
|
||||
// VariableItem mock captures the onValueUpdate prop for use in onValueUpdate tests
|
||||
jest.mock('../VariableItem', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="variable-item" />,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: (props: any): JSX.Element => {
|
||||
mockVariableItemCallbacks.onValueUpdate = props.onValueUpdate;
|
||||
return <div data-testid="variable-item" />;
|
||||
},
|
||||
}));
|
||||
|
||||
function createVariable(
|
||||
@@ -200,4 +217,162 @@ describe('DashboardVariableSelection', () => {
|
||||
expect(initializeVariableFetchStore).not.toHaveBeenCalled();
|
||||
expect(enqueueFetchOfAllVariables).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('onValueUpdate', () => {
|
||||
let updateStoreSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
jest.clearAllMocks();
|
||||
// Real implementation pass-through — we just want to observe calls
|
||||
updateStoreSpy = jest.spyOn(
|
||||
dashboardVariablesStoreModule,
|
||||
'updateDashboardVariablesStore',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
updateStoreSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('updates dashboardVariablesStore synchronously before enqueueDescendantsOfVariable', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
render(<DashboardVariableSelection />);
|
||||
|
||||
const callOrder: string[] = [];
|
||||
updateStoreSpy.mockImplementation(() => {
|
||||
callOrder.push('updateDashboardVariablesStore');
|
||||
});
|
||||
(enqueueDescendantsOfVariable as jest.Mock).mockImplementation(() => {
|
||||
callOrder.push('enqueueDescendantsOfVariable');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockVariableItemCallbacks.onValueUpdate?.(
|
||||
'env',
|
||||
'env-id',
|
||||
'production',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(callOrder).toEqual([
|
||||
'updateDashboardVariablesStore',
|
||||
'enqueueDescendantsOfVariable',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes updated variable value to dashboardVariablesStore', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
id: 'env-id',
|
||||
order: 0,
|
||||
selectedValue: 'staging',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
render(<DashboardVariableSelection />);
|
||||
|
||||
// Clear spy calls that happened during setup/render
|
||||
updateStoreSpy.mockClear();
|
||||
|
||||
act(() => {
|
||||
mockVariableItemCallbacks.onValueUpdate?.(
|
||||
'env',
|
||||
'env-id',
|
||||
'production',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(updateStoreSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dashboardId: 'dash-1',
|
||||
variables: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
selectedValue: 'production',
|
||||
allSelected: false,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls enqueueDescendantsOfVariable synchronously without a timer', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({ name: 'env', id: 'env-id', order: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
render(<DashboardVariableSelection />);
|
||||
|
||||
act(() => {
|
||||
mockVariableItemCallbacks.onValueUpdate?.(
|
||||
'env',
|
||||
'env-id',
|
||||
'production',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Must be called immediately — no timer advancement needed
|
||||
expect(enqueueDescendantsOfVariable).toHaveBeenCalledWith('env');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('propagates allSelected and haveCustomValuesSelected to the store', () => {
|
||||
setDashboardVariablesStore({
|
||||
dashboardId: 'dash-1',
|
||||
variables: {
|
||||
env: createVariable({
|
||||
name: 'env',
|
||||
id: 'env-id',
|
||||
order: 0,
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
render(<DashboardVariableSelection />);
|
||||
updateStoreSpy.mockClear();
|
||||
|
||||
act(() => {
|
||||
mockVariableItemCallbacks.onValueUpdate?.(
|
||||
'env',
|
||||
'env-id',
|
||||
['production', 'staging'],
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
expect(updateStoreSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
selectedValue: ['production', 'staging'],
|
||||
allSelected: true,
|
||||
haveCustomValuesSelected: false,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import { variableFetchStore } from 'providers/Dashboard/store/variableFetchStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import QueryVariableInput from '../QueryVariableInput';
|
||||
|
||||
jest.mock('api/dashboard/variables/dashboardVariablesQuery');
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue({ minTime: 1000, maxTime: 2000 }),
|
||||
}));
|
||||
|
||||
function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
children,
|
||||
queryClient,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
queryClient: QueryClient;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function createVariable(
|
||||
overrides: Partial<IDashboardVariable> = {},
|
||||
): IDashboardVariable {
|
||||
return {
|
||||
id: 'env-id',
|
||||
name: 'env',
|
||||
description: '',
|
||||
type: 'QUERY',
|
||||
sort: 'DISABLED',
|
||||
showALLOption: false,
|
||||
multiSelect: false,
|
||||
order: 0,
|
||||
queryValue: 'SELECT env FROM table',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Put the named variable into 'loading' state so useQuery fires on mount */
|
||||
function setVariableLoading(name: string): void {
|
||||
variableFetchStore.update((draft) => {
|
||||
draft.states[name] = 'loading';
|
||||
draft.cycleIds[name] = (draft.cycleIds[name] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
function resetFetchStore(): void {
|
||||
variableFetchStore.set(() => ({
|
||||
states: {},
|
||||
lastUpdated: {},
|
||||
cycleIds: {},
|
||||
}));
|
||||
}
|
||||
|
||||
describe('QueryVariableInput - getOptions logic', () => {
|
||||
const mockOnValueUpdate = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetFetchStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetFetchStore();
|
||||
});
|
||||
|
||||
it('applies default value (first option) when selectedValue is empty on first load', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging', 'dev'] },
|
||||
});
|
||||
|
||||
const variable = createVariable({ selectedValue: undefined });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'env',
|
||||
'env-id',
|
||||
'production', // first option by default
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps existing selectedValue when it is present in new options', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging'] },
|
||||
});
|
||||
|
||||
const variable = createVariable({ selectedValue: 'staging' });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'env',
|
||||
'env-id',
|
||||
'staging',
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('selects all new options when allSelected=true and value is missing from new options', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging'] },
|
||||
});
|
||||
|
||||
const variable = createVariable({
|
||||
selectedValue: ['old-env'],
|
||||
allSelected: true,
|
||||
multiSelect: true,
|
||||
showALLOption: true,
|
||||
});
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledWith(
|
||||
'env',
|
||||
'env-id',
|
||||
['production', 'staging'],
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onValueUpdate a second time when options have not changed', async () => {
|
||||
const mockQueryFn = jest.fn().mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production', 'staging'] },
|
||||
});
|
||||
(dashboardVariablesQuery as jest.Mock).mockImplementation(mockQueryFn);
|
||||
|
||||
const variable = createVariable({ selectedValue: 'production' });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Wait for first fetch and onValueUpdate call
|
||||
await waitFor(() => {
|
||||
expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
mockOnValueUpdate.mockClear();
|
||||
|
||||
// Trigger a second fetch cycle with the same API response
|
||||
act(() => {
|
||||
variableFetchStore.update((draft) => {
|
||||
draft.states['env'] = 'revalidating';
|
||||
draft.cycleIds['env'] = (draft.cycleIds['env'] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for second query to fire
|
||||
await waitFor(() => {
|
||||
expect(mockQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Options are unchanged, so onValueUpdate must not fire again
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onValueUpdate when API returns a non-array response', async () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: null },
|
||||
});
|
||||
|
||||
const variable = createVariable({ selectedValue: 'production' });
|
||||
setVariableLoading('env');
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dashboardVariablesQuery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire the query when variableData.name is empty', () => {
|
||||
(dashboardVariablesQuery as jest.Mock).mockResolvedValue({
|
||||
statusCode: 200,
|
||||
payload: { variableValues: ['production'] },
|
||||
});
|
||||
|
||||
// Variable with no name — useVariableFetchState will be called with ''
|
||||
// and the query key will have an empty name, leaving it disabled
|
||||
const variable = createVariable({ name: '' });
|
||||
// Note: we do NOT put it in 'loading' state since name is empty
|
||||
// (no variableFetchStore entry for '' means isVariableFetching=false)
|
||||
|
||||
const queryClient = createTestQueryClient();
|
||||
render(
|
||||
<Wrapper queryClient={queryClient}>
|
||||
<QueryVariableInput
|
||||
variableData={variable}
|
||||
existingVariables={{ 'env-id': variable }}
|
||||
onValueUpdate={mockOnValueUpdate}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(dashboardVariablesQuery).not.toHaveBeenCalled();
|
||||
expect(mockOnValueUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,9 @@ interface UseDashboardVariableSelectHelperReturn {
|
||||
applyDefaultIfNeeded: (
|
||||
overrideOptions?: (string | number | boolean)[],
|
||||
) => void;
|
||||
getDefaultValue: (
|
||||
overrideOptions?: (string | number | boolean)[],
|
||||
) => string | string[] | undefined;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -248,5 +251,6 @@ export function useDashboardVariableSelectHelper({
|
||||
defaultValue,
|
||||
onChange,
|
||||
applyDefaultIfNeeded,
|
||||
getDefaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,9 +121,23 @@ function BodyTitleRenderer({
|
||||
return (
|
||||
<TitleWrapper onClick={handleNodeClick}>
|
||||
{typeof value !== 'object' && (
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
<span
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
>
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={['click']}
|
||||
dropdownRender={(originNode): React.ReactNode => (
|
||||
<div data-log-detail-ignore="true">{originNode}</div>
|
||||
)}
|
||||
>
|
||||
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
</span>
|
||||
)}
|
||||
{title.toString()}{' '}
|
||||
{!parentIsArray && typeof value !== 'object' && (
|
||||
|
||||
@@ -13,7 +13,7 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
|
||||
<span className="field-renderer-container">
|
||||
{dataType && newField && logType ? (
|
||||
<>
|
||||
<Tooltip placement="left" title={newField}>
|
||||
<Tooltip placement="left" title={newField} mouseLeaveDelay={0}>
|
||||
<Typography.Text ellipsis className="label">
|
||||
{newField}{' '}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -46,7 +46,7 @@ function Overview({
|
||||
handleChangeSelectedView,
|
||||
}: Props): JSX.Element {
|
||||
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
const [isAttributesExpanded, setIsAttributesExpanded] = useState<boolean>(
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -245,7 +245,7 @@ function TableView({
|
||||
<Typography.Text>{renderedField}</Typography.Text>
|
||||
|
||||
{traceId && (
|
||||
<Tooltip title="Inspect in Trace">
|
||||
<Tooltip title="Inspect in Trace" mouseLeaveDelay={0}>
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
onClick={(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
import { getColorsForSeverityLabels, isRedLike } from '../utils';
|
||||
|
||||
describe('getColorsForSeverityLabels', () => {
|
||||
it('should return slate for blank labels', () => {
|
||||
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_SLATE_300);
|
||||
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_SLATE_300);
|
||||
});
|
||||
|
||||
it('should return correct colors for known severity variants', () => {
|
||||
expect(getColorsForSeverityLabels('INFO', 0)).toBe(Color.BG_ROBIN_600);
|
||||
expect(getColorsForSeverityLabels('ERROR', 0)).toBe(Color.BG_CHERRY_600);
|
||||
expect(getColorsForSeverityLabels('WARN', 0)).toBe(Color.BG_AMBER_600);
|
||||
expect(getColorsForSeverityLabels('DEBUG', 0)).toBe(Color.BG_AQUA_600);
|
||||
expect(getColorsForSeverityLabels('TRACE', 0)).toBe(Color.BG_FOREST_600);
|
||||
expect(getColorsForSeverityLabels('FATAL', 0)).toBe(Color.BG_SAKURA_600);
|
||||
});
|
||||
|
||||
it('should return non-red colors for unrecognized labels at any index', () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const color = getColorsForSeverityLabels('4', i);
|
||||
expect(isRedLike(color)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return non-red colors for numeric severity text', () => {
|
||||
const numericLabels = ['1', '2', '4', '9', '13', '17', '21'];
|
||||
numericLabels.forEach((label) => {
|
||||
const color = getColorsForSeverityLabels(label, 0);
|
||||
expect(isRedLike(color)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { colors } from 'lib/getRandomColor';
|
||||
|
||||
// Function to determine if a color is "red-like" based on its RGB values
|
||||
export function isRedLike(hex: string): boolean {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return r > 180 && r > g * 1.4 && r > b * 1.4;
|
||||
}
|
||||
|
||||
const SAFE_FALLBACK_COLORS = colors.filter((c) => !isRedLike(c));
|
||||
|
||||
const SEVERITY_VARIANT_COLORS: Record<string, string> = {
|
||||
TRACE: Color.BG_FOREST_600,
|
||||
Trace: Color.BG_FOREST_500,
|
||||
@@ -67,8 +76,13 @@ export function getColorsForSeverityLabels(
|
||||
label: string,
|
||||
index: number,
|
||||
): string {
|
||||
// Check if we have a direct mapping for this severity variant
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[label.trim()];
|
||||
const trimmed = label.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return Color.BG_SLATE_300;
|
||||
}
|
||||
|
||||
const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
|
||||
if (variantColor) {
|
||||
return variantColor;
|
||||
}
|
||||
@@ -103,5 +117,8 @@ export function getColorsForSeverityLabels(
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
|
||||
return colors[index % colors.length] || themeColors.red;
|
||||
return (
|
||||
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
|
||||
Color.BG_SLATE_400
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,23 +111,19 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
);
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: number, log: Record<string, unknown>): JSX.Element => {
|
||||
return (
|
||||
<div key={log.id as string}>
|
||||
<TableRow
|
||||
tableColumns={tableColumns}
|
||||
index={index}
|
||||
log={log}
|
||||
logs={tableViewProps.logs}
|
||||
hasActions
|
||||
fontSize={tableViewProps.fontSize}
|
||||
onShowLogDetails={onSetActiveLog}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
onClearActiveLog={onCloseActiveLog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(index: number, log: Record<string, unknown>): JSX.Element => (
|
||||
<TableRow
|
||||
tableColumns={tableColumns}
|
||||
index={index}
|
||||
log={log}
|
||||
logs={tableViewProps.logs}
|
||||
hasActions
|
||||
fontSize={tableViewProps.fontSize}
|
||||
onShowLogDetails={onSetActiveLog}
|
||||
isActiveLog={activeLog?.id === log.id}
|
||||
onClearActiveLog={onCloseActiveLog}
|
||||
/>
|
||||
),
|
||||
[
|
||||
tableColumns,
|
||||
onSetActiveLog,
|
||||
|
||||
@@ -349,7 +349,7 @@ function Explorer(): JSX.Element {
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
{isMetricDetailsOpen && (
|
||||
{isMetricDetailsOpen && selectedMetricName && (
|
||||
<MetricDetails
|
||||
metricName={selectedMetricName}
|
||||
isOpen={isMetricDetailsOpen}
|
||||
|
||||
@@ -39,10 +39,7 @@ function RelatedMetricsCard({ metric }: RelatedMetricsCardProps): JSX.Element {
|
||||
dataSource={DataSource.METRICS}
|
||||
/>
|
||||
)}
|
||||
<DashboardsAndAlertsPopover
|
||||
dashboards={metric.dashboards}
|
||||
alerts={metric.alerts}
|
||||
/>
|
||||
<DashboardsAndAlertsPopover metricName={metric.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card, Input, Select, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import classNames from 'classnames';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
@@ -40,8 +40,10 @@ import {
|
||||
* returns true if the feature flag is enabled, false otherwise
|
||||
* Show the inspect button in metrics explorer if the feature flag is enabled
|
||||
*/
|
||||
export function isInspectEnabled(metricType: MetricType | undefined): boolean {
|
||||
return metricType === MetricType.GAUGE;
|
||||
export function isInspectEnabled(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
): boolean {
|
||||
return metricType === MetrictypesTypeDTO.gauge;
|
||||
}
|
||||
|
||||
export function getAllTimestampsOfMetrics(
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Collapse, Input, Menu, Popover, Typography } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Input,
|
||||
Menu,
|
||||
Popover,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricAttributes } from 'api/generated/services/metrics';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -12,9 +21,33 @@ import { PANEL_TYPES } from '../../../constants/queryBuilder';
|
||||
import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import { AllAttributesProps, AllAttributesValueProps } from './types';
|
||||
import MetricDetailsErrorState from './MetricDetailsErrorState';
|
||||
import {
|
||||
AllAttributesEmptyTextProps,
|
||||
AllAttributesProps,
|
||||
AllAttributesValueProps,
|
||||
} from './types';
|
||||
import { getMetricDetailsQuery } from './utils';
|
||||
|
||||
const ALL_ATTRIBUTES_KEY = 'all-attributes';
|
||||
|
||||
function AllAttributesEmptyText({
|
||||
isErrorAttributes,
|
||||
refetchAttributes,
|
||||
}: AllAttributesEmptyTextProps): JSX.Element {
|
||||
if (isErrorAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchAttributes}
|
||||
errorMessage="Something went wrong while fetching attributes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Typography.Text>No attributes found</Typography.Text>;
|
||||
}
|
||||
|
||||
export function AllAttributesValue({
|
||||
filterKey,
|
||||
filterValue,
|
||||
@@ -110,13 +143,23 @@ export function AllAttributesValue({
|
||||
|
||||
function AllAttributes({
|
||||
metricName,
|
||||
attributes,
|
||||
metricType,
|
||||
}: AllAttributesProps): JSX.Element {
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const [activeKey, setActiveKey] = useState<string | string[]>(
|
||||
'all-attributes',
|
||||
);
|
||||
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
|
||||
|
||||
const {
|
||||
data: attributesData,
|
||||
isLoading: isLoadingAttributes,
|
||||
isError: isErrorAttributes,
|
||||
refetch: refetchAttributes,
|
||||
} = useGetMetricAttributes({
|
||||
metricName,
|
||||
});
|
||||
|
||||
const attributes = useMemo(() => attributesData?.data.attributes ?? [], [
|
||||
attributesData,
|
||||
]);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
@@ -178,7 +221,7 @@ function AllAttributes({
|
||||
attributes.filter(
|
||||
(attribute) =>
|
||||
attribute.key.toLowerCase().includes(searchString.toLowerCase()) ||
|
||||
attribute.value.some((value) =>
|
||||
attribute.values?.some((value) =>
|
||||
value.toLowerCase().includes(searchString.toLowerCase()),
|
||||
),
|
||||
),
|
||||
@@ -195,7 +238,7 @@ function AllAttributes({
|
||||
},
|
||||
value: {
|
||||
key: attribute.key,
|
||||
value: attribute.value,
|
||||
value: attribute.values,
|
||||
},
|
||||
}))
|
||||
: [],
|
||||
@@ -270,6 +313,7 @@ function AllAttributes({
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
disabled={isLoadingAttributes || isErrorAttributes}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -277,25 +321,49 @@ function AllAttributes({
|
||||
children: (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
loading={isLoadingAttributes}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
className="metrics-accordion-content all-attributes-content"
|
||||
scroll={{ y: 600 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<AllAttributesEmptyText
|
||||
isErrorAttributes={isErrorAttributes}
|
||||
refetchAttributes={refetchAttributes}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[columns, tableData, searchString],
|
||||
[
|
||||
searchString,
|
||||
isLoadingAttributes,
|
||||
isErrorAttributes,
|
||||
columns,
|
||||
tableData,
|
||||
refetchAttributes,
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoadingAttributes) {
|
||||
return (
|
||||
<div className="all-attributes-skeleton-container">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
className="metrics-accordion metrics-metadata-accordion"
|
||||
className="metrics-accordion"
|
||||
activeKey={activeKey}
|
||||
onChange={(keys): void => setActiveKey(keys)}
|
||||
onChange={(keys): void => setActiveKey(keys as string[])}
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,36 +2,84 @@ import { useMemo } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Dropdown, Typography } from 'antd';
|
||||
import { Skeleton } from 'antd/lib';
|
||||
import {
|
||||
useGetMetricAlerts,
|
||||
useGetMetricDashboards,
|
||||
} from 'api/generated/services/metrics';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { Bell, Grid } from 'lucide-react';
|
||||
import { pluralize } from 'utils/pluralize';
|
||||
|
||||
import { DashboardsAndAlertsPopoverProps } from './types';
|
||||
|
||||
function DashboardsAndAlertsPopover({
|
||||
alerts,
|
||||
dashboards,
|
||||
metricName,
|
||||
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const params = useUrlQuery();
|
||||
|
||||
const {
|
||||
data: alertsData,
|
||||
isLoading: isLoadingAlerts,
|
||||
isError: isErrorAlerts,
|
||||
} = useGetMetricAlerts(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: !!metricName,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: dashboardsData,
|
||||
isLoading: isLoadingDashboards,
|
||||
isError: isErrorDashboards,
|
||||
} = useGetMetricDashboards(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: !!metricName,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const alerts = useMemo(() => {
|
||||
return alertsData?.data.alerts ?? [];
|
||||
}, [alertsData]);
|
||||
|
||||
const dashboards = useMemo(() => {
|
||||
const currentDashboards = dashboardsData?.data.dashboards ?? [];
|
||||
// Remove duplicate dashboards
|
||||
return currentDashboards.filter(
|
||||
(dashboard, index, self) =>
|
||||
index === self.findIndex((t) => t.dashboardId === dashboard.dashboardId),
|
||||
);
|
||||
}, [dashboardsData]);
|
||||
|
||||
const alertsPopoverContent = useMemo(() => {
|
||||
if (alerts && alerts.length > 0) {
|
||||
return alerts.map((alert) => ({
|
||||
key: alert.alert_id,
|
||||
key: alert.alertId,
|
||||
label: (
|
||||
<Typography.Link
|
||||
key={alert.alert_id}
|
||||
key={alert.alertId}
|
||||
onClick={(): void => {
|
||||
params.set(QueryParams.ruleId, alert.alert_id);
|
||||
params.set(QueryParams.ruleId, alert.alertId);
|
||||
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
>
|
||||
{alert.alert_name || alert.alert_id}
|
||||
{alert.alertName || alert.alertId}
|
||||
</Typography.Link>
|
||||
),
|
||||
}));
|
||||
@@ -39,41 +87,44 @@ function DashboardsAndAlertsPopover({
|
||||
return null;
|
||||
}, [alerts, params]);
|
||||
|
||||
const uniqueDashboards = useMemo(
|
||||
() =>
|
||||
dashboards?.filter(
|
||||
(item, index, self) =>
|
||||
index === self.findIndex((t) => t.dashboard_id === item.dashboard_id),
|
||||
),
|
||||
[dashboards],
|
||||
);
|
||||
|
||||
const dashboardsPopoverContent = useMemo(() => {
|
||||
if (uniqueDashboards && uniqueDashboards.length > 0) {
|
||||
return uniqueDashboards.map((dashboard) => ({
|
||||
key: dashboard.dashboard_id,
|
||||
if (dashboards && dashboards.length > 0) {
|
||||
return dashboards.map((dashboard) => ({
|
||||
key: dashboard.dashboardId,
|
||||
label: (
|
||||
<Typography.Link
|
||||
key={dashboard.dashboard_id}
|
||||
key={dashboard.dashboardId}
|
||||
onClick={(): void => {
|
||||
safeNavigate(
|
||||
generatePath(ROUTES.DASHBOARD, {
|
||||
dashboardId: dashboard.dashboard_id,
|
||||
dashboardId: dashboard.dashboardId,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="dashboards-popover-content-item"
|
||||
>
|
||||
{dashboard.dashboard_name || dashboard.dashboard_id}
|
||||
{dashboard.dashboardName || dashboard.dashboardId}
|
||||
</Typography.Link>
|
||||
),
|
||||
}));
|
||||
}
|
||||
return null;
|
||||
}, [uniqueDashboards, safeNavigate]);
|
||||
}, [dashboards, safeNavigate]);
|
||||
|
||||
if (!dashboardsPopoverContent && !alertsPopoverContent) {
|
||||
return null;
|
||||
if (isLoadingAlerts || isLoadingDashboards) {
|
||||
return (
|
||||
<div className="dashboards-and-alerts-popover-container">
|
||||
<Skeleton title={false} paragraph={{ rows: 1 }} active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If there are no dashboards or alerts or both have errors, don't show the popover
|
||||
const hidePopover =
|
||||
(!dashboardsPopoverContent && !alertsPopoverContent) ||
|
||||
(isErrorAlerts && isErrorDashboards);
|
||||
if (hidePopover) {
|
||||
return <div className="dashboards-and-alerts-popover-container" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -92,8 +143,7 @@ function DashboardsAndAlertsPopover({
|
||||
>
|
||||
<Grid size={12} color={Color.BG_SIENNA_500} />
|
||||
<Typography.Text>
|
||||
{uniqueDashboards?.length} dashboard
|
||||
{uniqueDashboards?.length === 1 ? '' : 's'}
|
||||
{pluralize(dashboards.length, 'dashboard')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
@@ -112,7 +162,7 @@ function DashboardsAndAlertsPopover({
|
||||
>
|
||||
<Bell size={12} color={Color.BG_SAKURA_500} />
|
||||
<Typography.Text>
|
||||
{alerts?.length} alert {alerts?.length === 1 ? 'rule' : 'rules'}
|
||||
{pluralize(alerts.length, 'alert rule')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { useGetMetricHighlights } from 'api/generated/services/metrics';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
|
||||
import { HighlightsProps } from './types';
|
||||
import {
|
||||
formatNumberToCompactFormat,
|
||||
formatTimestampToReadableDate,
|
||||
} from './utils';
|
||||
|
||||
function Highlights({ metricName }: HighlightsProps): JSX.Element {
|
||||
const {
|
||||
data: metricHighlightsData,
|
||||
isLoading: isLoadingMetricHighlights,
|
||||
isError: isErrorMetricHighlights,
|
||||
refetch: refetchMetricHighlights,
|
||||
} = useGetMetricHighlights(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: !!metricName,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const metricHighlights = metricHighlightsData?.data;
|
||||
|
||||
const timeSeriesActive = formatNumberToCompactFormat(
|
||||
metricHighlights?.activeTimeSeries,
|
||||
);
|
||||
const timeSeriesTotal = formatNumberToCompactFormat(
|
||||
metricHighlights?.totalTimeSeries,
|
||||
);
|
||||
const lastReceivedText = formatTimestampToReadableDate(
|
||||
metricHighlights?.lastReceived,
|
||||
);
|
||||
|
||||
if (isLoadingMetricHighlights) {
|
||||
return (
|
||||
<div
|
||||
className="metric-details-content-grid"
|
||||
data-testid="metric-highlights-loading-state"
|
||||
>
|
||||
<Skeleton title={false} paragraph={{ rows: 2 }} active />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isErrorMetricHighlights) {
|
||||
return (
|
||||
<div className="metric-details-content-grid">
|
||||
<div
|
||||
className="metric-highlights-error-state"
|
||||
data-testid="metric-highlights-error-state"
|
||||
>
|
||||
<InfoIcon size={16} color={Color.BG_CHERRY_500} />
|
||||
<Typography.Text>
|
||||
Something went wrong while fetching metric highlights
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="link"
|
||||
size="large"
|
||||
onClick={(): void => {
|
||||
refetchMetricHighlights();
|
||||
}}
|
||||
>
|
||||
Retry ?
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="metric-details-content-grid">
|
||||
<div className="labels-row">
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
SAMPLES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
TIME SERIES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
LAST RECEIVED
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="values-row">
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-data-points"
|
||||
>
|
||||
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-time-series-total"
|
||||
>
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="metric-details-grid-value"
|
||||
data-testid="metric-highlights-last-received"
|
||||
>
|
||||
<Tooltip title={lastReceivedText}>{lastReceivedText}</Tooltip>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Highlights;
|
||||
@@ -1,45 +1,58 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button, Collapse, Input, Select, Typography } from 'antd';
|
||||
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
|
||||
import {
|
||||
invalidateGetMetricMetadata,
|
||||
invalidateListMetrics,
|
||||
useUpdateMetricMetadata,
|
||||
} from 'api/generated/services/metrics';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Edit2, Save, X } from 'lucide-react';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import { MetricTypeViewRenderer } from '../Summary/utils';
|
||||
import {
|
||||
METRIC_TYPE_LABEL_MAP,
|
||||
METRIC_TYPE_VALUES_MAP,
|
||||
} from '../Summary/constants';
|
||||
import { MetricTypeRenderer } from '../Summary/utils';
|
||||
import { METRIC_METADATA_KEYS } from './constants';
|
||||
import { MetadataProps } from './types';
|
||||
import { determineIsMonotonic } from './utils';
|
||||
METRIC_METADATA_KEYS,
|
||||
METRIC_METADATA_TEMPORALITY_OPTIONS,
|
||||
METRIC_METADATA_TYPE_OPTIONS,
|
||||
METRIC_METADATA_UPDATE_ERROR_MESSAGE,
|
||||
} from './constants';
|
||||
import MetricDetailsErrorState from './MetricDetailsErrorState';
|
||||
import { MetadataProps, MetricMetadataFormState, TableFields } from './types';
|
||||
import { transformUpdateMetricMetadataRequest } from './utils';
|
||||
|
||||
function Metadata({
|
||||
metricName,
|
||||
metadata,
|
||||
refetchMetricDetails,
|
||||
isErrorMetricMetadata,
|
||||
isLoadingMetricMetadata,
|
||||
refetchMetricMetadata,
|
||||
}: MetadataProps): JSX.Element {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const [
|
||||
metricMetadata,
|
||||
setMetricMetadata,
|
||||
] = useState<UpdateMetricMetadataProps>({
|
||||
metricType: metadata?.metric_type || MetricType.SUM,
|
||||
description: metadata?.description || '',
|
||||
temporality: metadata?.temporality,
|
||||
unit: metadata?.unit,
|
||||
metricMetadataState,
|
||||
setMetricMetadataState,
|
||||
] = useState<MetricMetadataFormState>({
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
description: '',
|
||||
temporality: MetrictypesTemporalityDTO.unspecified,
|
||||
unit: '',
|
||||
isMonotonic: false,
|
||||
});
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
@@ -51,110 +64,135 @@ function Metadata({
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Initialize state from metadata api data
|
||||
useEffect(() => {
|
||||
if (metadata) {
|
||||
setMetricMetadataState({
|
||||
type: metadata.type,
|
||||
description: metadata.description,
|
||||
temporality: metadata.temporality,
|
||||
unit: metadata.unit,
|
||||
isMonotonic: metadata.isMonotonic,
|
||||
});
|
||||
}
|
||||
}, [metadata]);
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
metadata
|
||||
? Object.keys({
|
||||
...metadata,
|
||||
temporality: metadata?.temporality,
|
||||
})
|
||||
// Filter out monotonic as user input is not required
|
||||
.filter((key) => key !== 'monotonic')
|
||||
.map((key) => ({
|
||||
? Object.keys(metadata).map((key) => ({
|
||||
key,
|
||||
value: {
|
||||
value: metadata[key as keyof typeof metadata],
|
||||
key,
|
||||
value: {
|
||||
value: metadata[key as keyof typeof metadata],
|
||||
key,
|
||||
},
|
||||
}))
|
||||
},
|
||||
}))
|
||||
: [],
|
||||
[metadata],
|
||||
);
|
||||
|
||||
// Render un-editable field value
|
||||
const renderUneditableField = useCallback((key: string, value: string) => {
|
||||
if (key === 'metric_type') {
|
||||
return <MetricTypeRenderer type={value as MetricType} />;
|
||||
}
|
||||
let fieldValue = value;
|
||||
if (key === 'unit') {
|
||||
fieldValue = getUniversalNameFromMetricUnit(value);
|
||||
}
|
||||
return <FieldRenderer field={fieldValue || '-'} />;
|
||||
}, []);
|
||||
const renderUneditableField = useCallback(
|
||||
(key: keyof MetricMetadataFormState, value: string) => {
|
||||
if (isErrorMetricMetadata) {
|
||||
return <FieldRenderer field="-" />;
|
||||
}
|
||||
if (key === TableFields.TYPE) {
|
||||
return <MetricTypeViewRenderer type={value as MetrictypesTypeDTO} />;
|
||||
}
|
||||
if (key === TableFields.IS_MONOTONIC) {
|
||||
return <FieldRenderer field={value ? 'Yes' : 'No'} />;
|
||||
}
|
||||
if (key === TableFields.Temporality) {
|
||||
const temporality = METRIC_METADATA_TEMPORALITY_OPTIONS.find(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
return <FieldRenderer field={temporality?.label || '-'} />;
|
||||
}
|
||||
let fieldValue = value;
|
||||
if (key === TableFields.UNIT) {
|
||||
fieldValue = getUniversalNameFromMetricUnit(value);
|
||||
}
|
||||
return <FieldRenderer field={fieldValue || '-'} />;
|
||||
},
|
||||
[isErrorMetricMetadata],
|
||||
);
|
||||
|
||||
const renderColumnValue = useCallback(
|
||||
(field: { value: string; key: string }): JSX.Element => {
|
||||
(field: {
|
||||
value: string;
|
||||
key: keyof MetricMetadataFormState;
|
||||
}): JSX.Element => {
|
||||
if (!isEditing) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
// Don't allow editing of unit if it's already set
|
||||
const metricUnitAlreadySet = field.key === 'unit' && Boolean(metadata?.unit);
|
||||
const metricUnitAlreadySet =
|
||||
field.key === TableFields.UNIT && Boolean(metadata?.unit);
|
||||
if (metricUnitAlreadySet) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
if (field.key === 'metric_type') {
|
||||
// Monotonic is not editable
|
||||
if (field.key === TableFields.IS_MONOTONIC) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
if (field.key === TableFields.TYPE) {
|
||||
return (
|
||||
<Select
|
||||
data-testid="metric-type-select"
|
||||
options={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({
|
||||
value: key,
|
||||
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
|
||||
}))}
|
||||
value={metricMetadata.metricType}
|
||||
options={METRIC_METADATA_TYPE_OPTIONS}
|
||||
value={metricMetadataState.type}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
setMetricMetadataState((prev) => ({
|
||||
...prev,
|
||||
metricType: value as MetricType,
|
||||
type: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'unit') {
|
||||
if (field.key === TableFields.UNIT) {
|
||||
return (
|
||||
<YAxisUnitSelector
|
||||
value={metricMetadata.unit}
|
||||
value={metricMetadataState.unit}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({ ...prev, unit: value }));
|
||||
setMetricMetadataState((prev) => ({ ...prev, unit: value }));
|
||||
}}
|
||||
data-testid="unit-select"
|
||||
source={YAxisSource.EXPLORER}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'temporality') {
|
||||
if (field.key === TableFields.Temporality) {
|
||||
const temporalityValue =
|
||||
metricMetadataState.temporality === MetrictypesTemporalityDTO.unspecified
|
||||
? undefined
|
||||
: metricMetadataState.temporality;
|
||||
return (
|
||||
<Select
|
||||
data-testid="temporality-select"
|
||||
options={Object.values(Temporality).map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}))}
|
||||
value={metricMetadata.temporality}
|
||||
options={METRIC_METADATA_TEMPORALITY_OPTIONS}
|
||||
value={temporalityValue}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
setMetricMetadataState((prev) => ({
|
||||
...prev,
|
||||
temporality: value as Temporality,
|
||||
temporality: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'description') {
|
||||
if (field.key === TableFields.DESCRIPTION) {
|
||||
return (
|
||||
<Input
|
||||
data-testid="description-input"
|
||||
name={field.key}
|
||||
defaultValue={
|
||||
metricMetadata[
|
||||
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
|
||||
]
|
||||
}
|
||||
defaultValue={metricMetadataState.description}
|
||||
onChange={(e): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
setMetricMetadataState((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}));
|
||||
@@ -164,7 +202,7 @@ function Metadata({
|
||||
}
|
||||
return <FieldRenderer field="-" />;
|
||||
},
|
||||
[isEditing, metadata?.unit, metricMetadata, renderUneditableField],
|
||||
[isEditing, metadata?.unit, metricMetadataState, renderUneditableField],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<DataType> = useMemo(
|
||||
@@ -201,52 +239,61 @@ function Metadata({
|
||||
const handleSave = useCallback(() => {
|
||||
updateMetricMetadata(
|
||||
{
|
||||
metricName,
|
||||
payload: {
|
||||
...metricMetadata,
|
||||
isMonotonic: determineIsMonotonic(
|
||||
metricMetadata.metricType,
|
||||
metricMetadata.temporality,
|
||||
),
|
||||
pathParams: {
|
||||
metricName,
|
||||
},
|
||||
data: transformUpdateMetricMetadataRequest(metricName, metricMetadataState),
|
||||
},
|
||||
{
|
||||
onSuccess: (response): void => {
|
||||
if (response?.statusCode === 200) {
|
||||
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Metadata updated successfully',
|
||||
});
|
||||
refetchMetricDetails();
|
||||
setIsEditing(false);
|
||||
queryClient.invalidateQueries(['metricsList']);
|
||||
} else {
|
||||
notifications.error({
|
||||
message:
|
||||
'Failed to update metadata, please try again. If the issue persists, please contact support.',
|
||||
});
|
||||
}
|
||||
onSuccess: (): void => {
|
||||
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
|
||||
[MetricsExplorerEventKeys.MetricName]: metricName,
|
||||
[MetricsExplorerEventKeys.Tab]: 'summary',
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Metadata updated successfully',
|
||||
});
|
||||
setIsEditing(false);
|
||||
invalidateListMetrics(queryClient);
|
||||
invalidateGetMetricMetadata(queryClient, {
|
||||
metricName,
|
||||
});
|
||||
},
|
||||
onError: (): void =>
|
||||
onError: (error): void => {
|
||||
const errorMessage = (error as AxiosError<RenderErrorResponseDTO>).response
|
||||
?.data.error?.message;
|
||||
notifications.error({
|
||||
message:
|
||||
'Failed to update metadata, please try again. If the issue persists, please contact support.',
|
||||
}),
|
||||
message: errorMessage || METRIC_METADATA_UPDATE_ERROR_MESSAGE,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [
|
||||
updateMetricMetadata,
|
||||
metricName,
|
||||
metricMetadata,
|
||||
metricMetadataState,
|
||||
notifications,
|
||||
refetchMetricDetails,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const cancelEdit = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement, MouseEvent>): void => {
|
||||
e.stopPropagation();
|
||||
if (metadata) {
|
||||
setMetricMetadataState({
|
||||
type: metadata.type,
|
||||
description: metadata.description,
|
||||
unit: metadata.unit,
|
||||
temporality: metadata.temporality,
|
||||
isMonotonic: metadata.isMonotonic,
|
||||
});
|
||||
}
|
||||
setIsEditing(false);
|
||||
},
|
||||
[metadata],
|
||||
);
|
||||
|
||||
const actionButton = useMemo(() => {
|
||||
if (isEditing) {
|
||||
return (
|
||||
@@ -254,10 +301,7 @@ function Metadata({
|
||||
<Button
|
||||
className="action-button"
|
||||
type="text"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onClick={cancelEdit}
|
||||
disabled={isUpdatingMetricsMetadata}
|
||||
>
|
||||
<X size={14} />
|
||||
@@ -278,6 +322,9 @@ function Metadata({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isErrorMetricMetadata) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="action-menu">
|
||||
<Button
|
||||
@@ -294,7 +341,13 @@ function Metadata({
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}, [handleSave, isEditing, isUpdatingMetricsMetadata]);
|
||||
}, [
|
||||
isEditing,
|
||||
isErrorMetricMetadata,
|
||||
isUpdatingMetricsMetadata,
|
||||
cancelEdit,
|
||||
handleSave,
|
||||
]);
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
@@ -306,7 +359,14 @@ function Metadata({
|
||||
</div>
|
||||
),
|
||||
key: 'metric-metadata',
|
||||
children: (
|
||||
children: isErrorMetricMetadata ? (
|
||||
<div className="metric-metadata-error-state">
|
||||
<MetricDetailsErrorState
|
||||
refetch={refetchMetricMetadata}
|
||||
errorMessage="Something went wrong while fetching metric metadata"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
@@ -318,9 +378,23 @@ function Metadata({
|
||||
),
|
||||
},
|
||||
],
|
||||
[actionButton, columns, tableData],
|
||||
[
|
||||
actionButton,
|
||||
columns,
|
||||
isErrorMetricMetadata,
|
||||
refetchMetricMetadata,
|
||||
tableData,
|
||||
],
|
||||
);
|
||||
|
||||
if (isLoadingMetricMetadata) {
|
||||
return (
|
||||
<div className="metrics-metadata-skeleton-container">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
bordered
|
||||
|
||||
@@ -38,7 +38,12 @@
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.metrics-metadata-error {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.metric-details-content-grid {
|
||||
height: 50px;
|
||||
.labels-row,
|
||||
.values-row {
|
||||
display: grid;
|
||||
@@ -47,6 +52,18 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-highlights-error-state {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
|
||||
.ant-btn {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.labels-row {
|
||||
margin-bottom: 8px;
|
||||
|
||||
@@ -72,6 +89,7 @@
|
||||
.dashboards-and-alerts-popover-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: 32px;
|
||||
|
||||
.dashboards-and-alerts-popover {
|
||||
border-radius: 20px;
|
||||
@@ -102,7 +120,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-metadata-skeleton-container {
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
.all-attributes-skeleton-container {
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.metrics-accordion {
|
||||
.all-attributes-error-state {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.ant-table-body {
|
||||
&::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
@@ -148,7 +178,6 @@
|
||||
|
||||
.all-attributes-search-input {
|
||||
width: 300px;
|
||||
border: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +190,7 @@
|
||||
.ant-typography:first-child {
|
||||
font-family: 'Geist Mono';
|
||||
color: var(--bg-robin-400);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.all-attributes-contribution {
|
||||
@@ -217,6 +247,10 @@
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0;
|
||||
|
||||
.metric-metadata-error-state {
|
||||
height: 267px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
@@ -237,6 +271,7 @@
|
||||
}
|
||||
|
||||
.metric-metadata-value {
|
||||
height: 67px;
|
||||
background: rgba(22, 25, 34, 0.4);
|
||||
overflow-x: scroll;
|
||||
.field-renderer-container {
|
||||
@@ -330,18 +365,26 @@
|
||||
.metric-details-content {
|
||||
.metrics-accordion {
|
||||
.metrics-accordion-header {
|
||||
.action-button {
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-400);
|
||||
.action-menu {
|
||||
.action-button {
|
||||
.ant-typography {
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-accordion-content {
|
||||
.metric-metadata-key {
|
||||
.field-renderer-container {
|
||||
.label {
|
||||
color: var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
|
||||
.all-attributes-key {
|
||||
.ant-typography:last-child {
|
||||
color: var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-200);
|
||||
background-color: var(--bg-robin-300);
|
||||
}
|
||||
}
|
||||
@@ -395,3 +438,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metric-details-error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
Empty,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { Button, Divider, Drawer, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
|
||||
import { useGetMetricMetadata } from 'api/generated/services/metrics';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Compass, Crosshair, X } from 'lucide-react';
|
||||
|
||||
@@ -19,16 +11,12 @@ import ROUTES from '../../../constants/routes';
|
||||
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import { isInspectEnabled } from '../Inspect/utils';
|
||||
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
|
||||
import AllAttributes from './AllAttributes';
|
||||
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
|
||||
import Highlights from './Highlights';
|
||||
import Metadata from './Metadata';
|
||||
import { MetricDetailsProps } from './types';
|
||||
import {
|
||||
formatNumberToCompactFormat,
|
||||
formatTimestampToReadableDate,
|
||||
getMetricDetailsQuery,
|
||||
} from './utils';
|
||||
import { getMetricDetailsQuery } from './utils';
|
||||
|
||||
import './MetricDetails.styles.scss';
|
||||
import '../Summary/Summary.styles.scss';
|
||||
@@ -43,55 +31,49 @@ function MetricDetails({
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error: metricDetailsError,
|
||||
refetch: refetchMetricDetails,
|
||||
} = useGetMetricDetails(metricName ?? '', {
|
||||
enabled: !!metricName,
|
||||
});
|
||||
|
||||
const metric = data?.payload?.data;
|
||||
|
||||
const lastReceived = useMemo(() => {
|
||||
if (!metric) {
|
||||
return null;
|
||||
}
|
||||
return formatTimestampToReadableDate(metric.lastReceived);
|
||||
}, [metric]);
|
||||
|
||||
const showInspectFeature = useMemo(
|
||||
() => isInspectEnabled(metric?.metadata?.metric_type),
|
||||
[metric],
|
||||
data: metricMetadataResponse,
|
||||
isLoading: isLoadingMetricMetadata,
|
||||
isError: isErrorMetricMetadata,
|
||||
refetch: refetchMetricMetadata,
|
||||
} = useGetMetricMetadata(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: !!metricName,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isMetricDetailsLoading = isLoading || isFetching;
|
||||
|
||||
const timeSeries = useMemo(() => {
|
||||
if (!metric) {
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse) {
|
||||
return null;
|
||||
}
|
||||
const timeSeriesActive = formatNumberToCompactFormat(metric.timeSeriesActive);
|
||||
const timeSeriesTotal = formatNumberToCompactFormat(metric.timeSeriesTotal);
|
||||
const {
|
||||
type,
|
||||
description,
|
||||
unit,
|
||||
temporality,
|
||||
isMonotonic,
|
||||
} = metricMetadataResponse.data;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title="Active time series are those that have received data points in the last 1
|
||||
hour."
|
||||
placement="top"
|
||||
>
|
||||
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [metric]);
|
||||
return {
|
||||
type,
|
||||
description,
|
||||
unit,
|
||||
temporality,
|
||||
isMonotonic,
|
||||
};
|
||||
}, [metricMetadataResponse]);
|
||||
|
||||
const showInspectFeature = useMemo(() => isInspectEnabled(metadata?.type), [
|
||||
metadata?.type,
|
||||
]);
|
||||
|
||||
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
|
||||
if (metricName) {
|
||||
const compositeQuery = getMetricDetailsQuery(
|
||||
metricName,
|
||||
metric?.metadata?.metric_type,
|
||||
);
|
||||
const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
{
|
||||
@@ -107,9 +89,7 @@ function MetricDetails({
|
||||
[MetricsExplorerEventKeys.Modal]: 'metric-details',
|
||||
});
|
||||
}
|
||||
}, [metricName, handleExplorerTabChange, metric?.metadata?.metric_type]);
|
||||
|
||||
const isMetricDetailsError = metricDetailsError || !metric;
|
||||
}, [metricName, handleExplorerTabChange, metadata?.type]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MetricsExplorerEvents.ModalOpened, {
|
||||
@@ -117,6 +97,9 @@ function MetricDetails({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isActionButtonDisabled =
|
||||
!metricName || isLoadingMetricMetadata || isErrorMetricMetadata;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="60%"
|
||||
@@ -124,13 +107,13 @@ function MetricDetails({
|
||||
<div className="metric-details-header">
|
||||
<div className="metric-details-title">
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>{metric?.name}</Typography.Text>
|
||||
<Typography.Text>{metricName}</Typography.Text>
|
||||
</div>
|
||||
<div className="metric-details-header-buttons">
|
||||
<Button
|
||||
onClick={goToMetricsExplorerwithSelectedMetric}
|
||||
icon={<Compass size={16} />}
|
||||
disabled={!metricName}
|
||||
disabled={isActionButtonDisabled}
|
||||
data-testid="open-in-explorer-button"
|
||||
>
|
||||
Open in Explorer
|
||||
@@ -140,10 +123,11 @@ function MetricDetails({
|
||||
<Button
|
||||
className="inspect-metrics-button"
|
||||
aria-label="Inspect Metric"
|
||||
disabled={isActionButtonDisabled}
|
||||
icon={<Crosshair size={18} />}
|
||||
onClick={(): void => {
|
||||
if (metric?.name) {
|
||||
openInspectModal(metric.name);
|
||||
if (metricName) {
|
||||
openInspectModal(metricName);
|
||||
}
|
||||
}}
|
||||
data-testid="inspect-metric-button"
|
||||
@@ -163,60 +147,18 @@ function MetricDetails({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} />}
|
||||
>
|
||||
{isMetricDetailsLoading && (
|
||||
<div data-testid="metric-details-skeleton">
|
||||
<Skeleton active />
|
||||
</div>
|
||||
)}
|
||||
{isMetricDetailsError && !isMetricDetailsLoading && (
|
||||
<Empty description="Error fetching metric details" />
|
||||
)}
|
||||
{!isMetricDetailsLoading && !isMetricDetailsError && (
|
||||
<div className="metric-details-content">
|
||||
<div className="metric-details-content-grid">
|
||||
<div className="labels-row">
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
SAMPLES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
TIME SERIES
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="metric-details-grid-label">
|
||||
LAST RECEIVED
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="values-row">
|
||||
<Typography.Text className="metric-details-grid-value">
|
||||
<Tooltip title={metric?.samples.toLocaleString()}>
|
||||
{formatNumberIntoHumanReadableFormat(metric?.samples)}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="metric-details-grid-value">
|
||||
<Tooltip title={timeSeries}>{timeSeries}</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="metric-details-grid-value">
|
||||
<Tooltip title={lastReceived}>{lastReceived}</Tooltip>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardsAndAlertsPopover
|
||||
dashboards={metric.dashboards}
|
||||
alerts={metric.alerts}
|
||||
/>
|
||||
<Metadata
|
||||
metricName={metric?.name}
|
||||
metadata={metric.metadata}
|
||||
refetchMetricDetails={refetchMetricDetails}
|
||||
/>
|
||||
{metric.attributes && (
|
||||
<AllAttributes
|
||||
metricName={metric?.name}
|
||||
attributes={metric.attributes}
|
||||
metricType={metric?.metadata?.metric_type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="metric-details-content">
|
||||
<Highlights metricName={metricName} />
|
||||
<DashboardsAndAlertsPopover metricName={metricName} />
|
||||
<Metadata
|
||||
metricName={metricName}
|
||||
metadata={metadata}
|
||||
isErrorMetricMetadata={isErrorMetricMetadata}
|
||||
isLoadingMetricMetadata={isLoadingMetricMetadata}
|
||||
refetchMetricMetadata={refetchMetricMetadata}
|
||||
/>
|
||||
<AllAttributes metricName={metricName} metricType={metadata?.type} />
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Typography } from 'antd';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
|
||||
import { MetricDetailsErrorStateProps } from './types';
|
||||
|
||||
function MetricDetailsErrorState({
|
||||
refetch,
|
||||
errorMessage,
|
||||
}: MetricDetailsErrorStateProps): JSX.Element {
|
||||
return (
|
||||
<div className="metric-details-error-state">
|
||||
<InfoIcon size={20} color={Color.BG_CHERRY_500} />
|
||||
<Typography.Text>{errorMessage}</Typography.Text>
|
||||
{refetch && <Button onClick={refetch}>Retry</Button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricDetailsErrorState;
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as reactUseHooks from 'react-use';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
import { MetricDetailsAttribute } from '../../../../api/metricsExplorer/getMetricDetails';
|
||||
import ROUTES from '../../../../constants/routes';
|
||||
import AllAttributes, { AllAttributesValue } from '../AllAttributes';
|
||||
import { getMockMetricAttributesData, MOCK_METRIC_NAME } from './testUtlls';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@@ -20,33 +22,28 @@ jest
|
||||
handleExplorerTabChange: mockHandleExplorerTabChange,
|
||||
});
|
||||
|
||||
const mockMetricName = 'test-metric';
|
||||
const mockMetricType = MetricType.GAUGE;
|
||||
const mockAttributes: MetricDetailsAttribute[] = [
|
||||
{
|
||||
key: 'attribute1',
|
||||
value: ['value1', 'value2'],
|
||||
valueCount: 2,
|
||||
},
|
||||
{
|
||||
key: 'attribute2',
|
||||
value: ['value3'],
|
||||
valueCount: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUseCopyToClipboard = jest.fn();
|
||||
jest
|
||||
.spyOn(reactUseHooks, 'useCopyToClipboard')
|
||||
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
|
||||
|
||||
const useGetMetricAttributesMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricAttributes',
|
||||
);
|
||||
|
||||
describe('AllAttributes', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders attributes section with title', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -56,9 +53,8 @@ describe('AllAttributes', () => {
|
||||
it('renders all attribute keys and values', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -75,9 +71,8 @@ describe('AllAttributes', () => {
|
||||
it('renders value counts correctly', () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -86,41 +81,44 @@ describe('AllAttributes', () => {
|
||||
});
|
||||
|
||||
it('handles empty attributes array', () => {
|
||||
useGetMetricAttributesMock.mockReturnValue({
|
||||
...getMockMetricAttributesData({
|
||||
data: {
|
||||
attributes: [],
|
||||
totalKeys: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={mockMetricName}
|
||||
attributes={[]}
|
||||
metricType={mockMetricType}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('All Attributes')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No data')).toBeInTheDocument();
|
||||
expect(screen.getByText('No attributes found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking on an attribute key opens the explorer with the attribute filter applied', () => {
|
||||
it('clicking on an attribute key opens the explorer with the attribute filter applied', async () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('attribute1'));
|
||||
await userEvent.click(screen.getByText('attribute1'));
|
||||
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters attributes based on search input', () => {
|
||||
it('filters attributes based on search input', async () => {
|
||||
render(
|
||||
<AllAttributes
|
||||
metricName={mockMetricName}
|
||||
attributes={mockAttributes}
|
||||
metricType={mockMetricType}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metricType={MetrictypesTypeDTO.gauge}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText('Search'), {
|
||||
target: { value: 'value1' },
|
||||
});
|
||||
await userEvent.type(screen.getByPlaceholderText('Search'), 'value1');
|
||||
|
||||
expect(screen.getByText('attribute1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
@@ -144,7 +142,7 @@ describe('AllAttributesValue', () => {
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads more attributes when show more button is clicked', () => {
|
||||
it('loads more attributes when show more button is clicked', async () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
@@ -155,7 +153,7 @@ describe('AllAttributesValue', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('value6')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Show More'));
|
||||
await userEvent.click(screen.getByText('Show More'));
|
||||
expect(screen.getByText('value6')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -172,7 +170,7 @@ describe('AllAttributesValue', () => {
|
||||
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copy button should copy the attribute value to the clipboard', () => {
|
||||
it('copy button should copy the attribute value to the clipboard', async () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
@@ -183,13 +181,13 @@ describe('AllAttributesValue', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('value1'));
|
||||
await userEvent.click(screen.getByText('value1'));
|
||||
expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Copy Attribute'));
|
||||
await userEvent.click(screen.getByText('Copy Attribute'));
|
||||
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
|
||||
});
|
||||
|
||||
it('explorer button should go to metrics explore with the attribute filter applied', () => {
|
||||
it('explorer button should go to metrics explore with the attribute filter applied', async () => {
|
||||
render(
|
||||
<AllAttributesValue
|
||||
filterKey="attribute1"
|
||||
@@ -200,10 +198,10 @@ describe('AllAttributesValue', () => {
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('value1'));
|
||||
await userEvent.click(screen.getByText('value1'));
|
||||
|
||||
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Open in Explorer'));
|
||||
await userEvent.click(screen.getByText('Open in Explorer'));
|
||||
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
|
||||
'attribute1',
|
||||
'value1',
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
|
||||
|
||||
const mockAlert1 = {
|
||||
alert_id: '1',
|
||||
alert_name: 'Alert 1',
|
||||
};
|
||||
const mockAlert2 = {
|
||||
alert_id: '2',
|
||||
alert_name: 'Alert 2',
|
||||
};
|
||||
const mockDashboard1 = {
|
||||
dashboard_id: '1',
|
||||
dashboard_name: 'Dashboard 1',
|
||||
};
|
||||
const mockDashboard2 = {
|
||||
dashboard_id: '2',
|
||||
dashboard_name: 'Dashboard 2',
|
||||
};
|
||||
const mockAlerts = [mockAlert1, mockAlert2];
|
||||
const mockDashboards = [mockDashboard1, mockDashboard2];
|
||||
import {
|
||||
getMockAlertsData,
|
||||
getMockDashboardsData,
|
||||
MOCK_ALERT_1,
|
||||
MOCK_ALERT_2,
|
||||
MOCK_DASHBOARD_1,
|
||||
MOCK_DASHBOARD_2,
|
||||
MOCK_METRIC_NAME,
|
||||
} from './testUtlls';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
@@ -28,7 +20,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSetQuery = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
set: mockSetQuery,
|
||||
@@ -39,125 +30,156 @@ jest.mock('hooks/useUrlQuery', () => ({
|
||||
default: jest.fn(() => mockUrlQuery),
|
||||
}));
|
||||
|
||||
describe('DashboardsAndAlertsPopover', () => {
|
||||
it('renders the popover correctly with multiple dashboards and alerts', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={mockDashboards}
|
||||
/>,
|
||||
);
|
||||
const useGetMetricAlertsMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricAlerts',
|
||||
);
|
||||
const useGetMetricDashboardsMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricDashboards',
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(`${mockDashboards.length} dashboards`),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`${mockAlerts.length} alert rules`),
|
||||
).toBeInTheDocument();
|
||||
describe('DashboardsAndAlertsPopover', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricAlertsMock.mockReturnValue(getMockAlertsData());
|
||||
useGetMetricDashboardsMock.mockReturnValue(getMockDashboardsData());
|
||||
});
|
||||
|
||||
it('renders the popover correctly with multiple dashboards and alerts', () => {
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(screen.getByText(`2 dashboards`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`2 alert rules`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders null with no dashboards and alerts', () => {
|
||||
const { container } = render(
|
||||
<DashboardsAndAlertsPopover alerts={[]} dashboards={[]} />,
|
||||
useGetMetricAlertsMock.mockReturnValue(
|
||||
getMockAlertsData({
|
||||
data: {
|
||||
alerts: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
useGetMetricDashboardsMock.mockReturnValue(
|
||||
getMockDashboardsData({
|
||||
data: {
|
||||
dashboards: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('dashboards-and-alerts-popover-container'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders popover with single dashboard and alert', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={[mockAlert1]}
|
||||
dashboards={[mockDashboard1]}
|
||||
/>,
|
||||
useGetMetricAlertsMock.mockReturnValue(
|
||||
getMockAlertsData({
|
||||
data: {
|
||||
alerts: [MOCK_ALERT_1],
|
||||
},
|
||||
}),
|
||||
);
|
||||
useGetMetricDashboardsMock.mockReturnValue(
|
||||
getMockDashboardsData({
|
||||
data: {
|
||||
dashboards: [MOCK_DASHBOARD_1],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(screen.getByText(`1 dashboard`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`1 alert rule`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders popover with dashboard id if name is not available', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={[{ ...mockDashboard1, dashboard_name: undefined } as any]}
|
||||
/>,
|
||||
it('renders popover with dashboard id if name is not available', async () => {
|
||||
useGetMetricDashboardsMock.mockReturnValue(
|
||||
getMockDashboardsData({
|
||||
data: {
|
||||
dashboards: [{ ...MOCK_DASHBOARD_1, dashboardName: '' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(`1 dashboard`));
|
||||
expect(screen.getByText(mockDashboard1.dashboard_id)).toBeInTheDocument();
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
await userEvent.click(screen.getByText(`1 dashboard`));
|
||||
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardId)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders popover with alert id if name is not available', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={[{ ...mockAlert1, alert_name: undefined } as any]}
|
||||
dashboards={mockDashboards}
|
||||
/>,
|
||||
it('renders popover with alert id if name is not available', async () => {
|
||||
useGetMetricAlertsMock.mockReturnValue(
|
||||
getMockAlertsData({
|
||||
data: {
|
||||
alerts: [{ ...MOCK_ALERT_1, alertName: '' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(`1 alert rule`));
|
||||
expect(screen.getByText(mockAlert1.alert_id)).toBeInTheDocument();
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
await userEvent.click(screen.getByText(`1 alert rule`));
|
||||
expect(screen.getByText(MOCK_ALERT_1.alertId)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the dashboard when the dashboard is clicked', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={mockDashboards}
|
||||
/>,
|
||||
);
|
||||
it('navigates to the dashboard when the dashboard is clicked', async () => {
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
// Click on 2 dashboards button
|
||||
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
|
||||
await userEvent.click(screen.getByText(`2 dashboards`));
|
||||
// Popover showing list of 2 dashboards should be visible
|
||||
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardName)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_DASHBOARD_2.dashboardName)).toBeInTheDocument();
|
||||
|
||||
// Click on the first dashboard
|
||||
fireEvent.click(screen.getByText(mockDashboard1.dashboard_name));
|
||||
await userEvent.click(screen.getByText(MOCK_DASHBOARD_1.dashboardName));
|
||||
|
||||
// Should navigate to the dashboard
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/dashboard/${mockDashboard1.dashboard_id}`,
|
||||
`/dashboard/${MOCK_DASHBOARD_1.dashboardId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the alert when the alert is clicked', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={mockDashboards}
|
||||
/>,
|
||||
);
|
||||
it('navigates to the alert when the alert is clicked', async () => {
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
// Click on 2 alert rules button
|
||||
fireEvent.click(screen.getByText(`${mockAlerts.length} alert rules`));
|
||||
await userEvent.click(screen.getByText(`2 alert rules`));
|
||||
// Popover showing list of 2 alert rules should be visible
|
||||
expect(screen.getByText(mockAlert1.alert_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockAlert2.alert_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_ALERT_1.alertName)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_ALERT_2.alertName)).toBeInTheDocument();
|
||||
|
||||
// Click on the first alert rule
|
||||
fireEvent.click(screen.getByText(mockAlert1.alert_name));
|
||||
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
|
||||
|
||||
// Should navigate to the alert rule
|
||||
expect(mockSetQuery).toHaveBeenCalledWith(
|
||||
QueryParams.ruleId,
|
||||
mockAlert1.alert_id,
|
||||
MOCK_ALERT_1.alertId,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders unique dashboards even when there are duplicates', () => {
|
||||
render(
|
||||
<DashboardsAndAlertsPopover
|
||||
alerts={mockAlerts}
|
||||
dashboards={[...mockDashboards, mockDashboard1]}
|
||||
/>,
|
||||
it('renders unique dashboards even when there are duplicates', async () => {
|
||||
useGetMetricDashboardsMock.mockReturnValue(
|
||||
getMockDashboardsData({
|
||||
data: {
|
||||
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2, MOCK_DASHBOARD_1],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByText(`${mockDashboards.length} dashboards`),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
|
||||
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
|
||||
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(screen.getByText('2 dashboards')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('2 dashboards'));
|
||||
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardName)).toBeInTheDocument();
|
||||
expect(screen.getByText(MOCK_DASHBOARD_2.dashboardName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
|
||||
import Highlights from '../Highlights';
|
||||
import { formatTimestampToReadableDate } from '../utils';
|
||||
import { getMockMetricHighlightsData, MOCK_METRIC_NAME } from './testUtlls';
|
||||
|
||||
const useGetMetricHighlightsMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricHighlights',
|
||||
);
|
||||
|
||||
describe('Highlights', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricHighlightsMock.mockReturnValue(getMockMetricHighlightsData());
|
||||
});
|
||||
|
||||
it('should render all highlights data correctly', () => {
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
const dataPoints = screen.getByTestId('metric-highlights-data-points');
|
||||
const timeSeriesTotal = screen.getByTestId(
|
||||
'metric-highlights-time-series-total',
|
||||
);
|
||||
const lastReceived = screen.getByTestId('metric-highlights-last-received');
|
||||
|
||||
expect(dataPoints.textContent).toBe('1M+');
|
||||
expect(timeSeriesTotal.textContent).toBe('1M total ⎯ 1M active');
|
||||
expect(lastReceived.textContent).toBe(
|
||||
formatTimestampToReadableDate('2026-01-24T00:00:00Z'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render error state correctly', () => {
|
||||
useGetMetricHighlightsMock.mockReturnValue(
|
||||
getMockMetricHighlightsData(
|
||||
{},
|
||||
{
|
||||
isError: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('metric-highlights-error-state'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading state when data is loading', () => {
|
||||
useGetMetricHighlightsMock.mockReturnValue(
|
||||
getMockMetricHighlightsData(
|
||||
{},
|
||||
{
|
||||
isLoading: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<Highlights metricName={MOCK_METRIC_NAME} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('metric-highlights-loading-state'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,24 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import {
|
||||
GetMetricMetadata200,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import {
|
||||
UniversalYAxisUnit,
|
||||
YAxisUnitSelectorProps,
|
||||
} from 'components/YAxisUnitSelector/types';
|
||||
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import * as useNotificationsHooks from 'hooks/useNotifications';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
import Metadata from '../Metadata';
|
||||
import { MetricMetadata } from '../types';
|
||||
import { transformMetricMetadata } from '../utils';
|
||||
import { getMockMetricMetadataData, MOCK_METRIC_NAME } from './testUtlls';
|
||||
|
||||
// Mock antd select for testing
|
||||
jest.mock('antd', () => ({
|
||||
@@ -72,13 +80,18 @@ jest.mock('react-query', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseUpdateMetricMetadataHook = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useUpdateMetricMetadata',
|
||||
);
|
||||
type UseUpdateMetricMetadataResult = ReturnType<
|
||||
typeof metricsExplorerHooks.useUpdateMetricMetadata
|
||||
>;
|
||||
const mockUseUpdateMetricMetadata = jest.fn();
|
||||
jest
|
||||
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
|
||||
.mockReturnValue({
|
||||
mutate: mockUseUpdateMetricMetadata,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const mockMetricMetadata = transformMetricMetadata(
|
||||
getMockMetricMetadataData().data as GetMetricMetadata200,
|
||||
) as MetricMetadata;
|
||||
|
||||
const mockErrorNotification = jest.fn();
|
||||
const mockSuccessNotification = jest.fn();
|
||||
@@ -89,47 +102,50 @@ jest.spyOn(useNotificationsHooks, 'useNotifications').mockReturnValue({
|
||||
},
|
||||
} as any);
|
||||
|
||||
const mockMetricName = 'test_metric';
|
||||
const mockMetricMetadata = {
|
||||
metric_type: MetricType.GAUGE,
|
||||
description: 'test_description',
|
||||
unit: 'test_unit',
|
||||
temporality: Temporality.DELTA,
|
||||
};
|
||||
const mockRefetchMetricDetails = jest.fn();
|
||||
const mockRefetchMetricMetadata = jest.fn();
|
||||
|
||||
describe('Metadata', () => {
|
||||
beforeEach(() => {
|
||||
mockUseUpdateMetricMetadataHook.mockReturnValue(({
|
||||
mutate: mockUseUpdateMetricMetadata,
|
||||
} as Partial<UseUpdateMetricMetadataResult>) as UseUpdateMetricMetadataResult);
|
||||
});
|
||||
|
||||
it('should render the metadata properly', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Metric Type')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricMetadata.metric_type)).toBeInTheDocument();
|
||||
expect(screen.getByText('Gauge')).toBeInTheDocument();
|
||||
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricMetadata.description)).toBeInTheDocument();
|
||||
expect(screen.getByText('Unit')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricMetadata.unit)).toBeInTheDocument();
|
||||
expect(screen.getByText('Temporality')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricMetadata.temporality)).toBeInTheDocument();
|
||||
expect(screen.getByText('Delta')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('editing the metadata should show the form inputs', () => {
|
||||
it('editing the metadata should show the form inputs', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
fireEvent.click(editButton);
|
||||
await userEvent.click(editButton);
|
||||
|
||||
expect(screen.getByTestId('metric-type-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('temporality-select')).toBeInTheDocument();
|
||||
@@ -139,57 +155,53 @@ describe('Metadata', () => {
|
||||
it('should update the metadata when the form is submitted', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={{
|
||||
...mockMetricMetadata,
|
||||
unit: '',
|
||||
}}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
fireEvent.click(editButton);
|
||||
await userEvent.click(editButton);
|
||||
|
||||
const metricDescriptionInput = screen.getByTestId('description-input');
|
||||
expect(metricDescriptionInput).toBeInTheDocument();
|
||||
fireEvent.change(metricDescriptionInput, {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
await userEvent.clear(metricDescriptionInput);
|
||||
await userEvent.type(metricDescriptionInput, 'Updated description');
|
||||
|
||||
const metricTypeSelect = screen.getByTestId('metric-type-select');
|
||||
expect(metricTypeSelect).toBeInTheDocument();
|
||||
fireEvent.change(metricTypeSelect, {
|
||||
target: { value: MetricType.SUM },
|
||||
});
|
||||
await userEvent.selectOptions(metricTypeSelect, MetrictypesTypeDTO.sum);
|
||||
|
||||
const temporalitySelect = screen.getByTestId('temporality-select');
|
||||
expect(temporalitySelect).toBeInTheDocument();
|
||||
fireEvent.change(temporalitySelect, {
|
||||
target: { value: Temporality.CUMULATIVE },
|
||||
});
|
||||
await userEvent.selectOptions(temporalitySelect, Temporality.CUMULATIVE);
|
||||
|
||||
const unitSelect = screen.getByTestId('unit-select');
|
||||
expect(unitSelect).toBeInTheDocument();
|
||||
fireEvent.change(unitSelect, {
|
||||
target: { value: 'By' },
|
||||
});
|
||||
await userEvent.selectOptions(unitSelect, 'By');
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
fireEvent.click(saveButton);
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metricName: mockMetricName,
|
||||
payload: expect.objectContaining({
|
||||
description: 'Updated description',
|
||||
metricType: MetricType.SUM,
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
data: expect.objectContaining({
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
unit: 'By',
|
||||
isMonotonic: true,
|
||||
}),
|
||||
pathParams: {
|
||||
metricName: MOCK_METRIC_NAME,
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
@@ -201,56 +213,56 @@ describe('Metadata', () => {
|
||||
it('should show success notification when metadata is updated successfully', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
fireEvent.click(editButton);
|
||||
await userEvent.click(editButton);
|
||||
|
||||
const metricDescriptionInput = screen.getByTestId('description-input');
|
||||
fireEvent.change(metricDescriptionInput, {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
await userEvent.clear(metricDescriptionInput);
|
||||
await userEvent.type(metricDescriptionInput, 'Updated description');
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
fireEvent.click(saveButton);
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
const onSuccessCallback =
|
||||
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback({ statusCode: 200 });
|
||||
onSuccessCallback({ status: 200 });
|
||||
|
||||
expect(mockSuccessNotification).toHaveBeenCalledWith({
|
||||
message: 'Metadata updated successfully',
|
||||
});
|
||||
expect(mockRefetchMetricDetails).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show error notification when metadata update fails with non-200 response', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
fireEvent.click(editButton);
|
||||
await userEvent.click(editButton);
|
||||
|
||||
const metricDescriptionInput = screen.getByTestId('description-input');
|
||||
fireEvent.change(metricDescriptionInput, {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
await userEvent.clear(metricDescriptionInput);
|
||||
await userEvent.type(metricDescriptionInput, 'Updated description');
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
fireEvent.click(saveButton);
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
const onSuccessCallback =
|
||||
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
|
||||
onSuccessCallback({ statusCode: 500 });
|
||||
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
|
||||
onErrorCallback({ status: 500 });
|
||||
|
||||
expect(mockErrorNotification).toHaveBeenCalledWith({
|
||||
message:
|
||||
@@ -261,22 +273,23 @@ describe('Metadata', () => {
|
||||
it('should show error notification when metadata update fails', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
fireEvent.click(editButton);
|
||||
await userEvent.click(editButton);
|
||||
|
||||
const metricDescriptionInput = screen.getByTestId('description-input');
|
||||
fireEvent.change(metricDescriptionInput, {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
await userEvent.clear(metricDescriptionInput);
|
||||
await userEvent.type(metricDescriptionInput, 'Updated description');
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
fireEvent.click(saveButton);
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
|
||||
|
||||
@@ -289,39 +302,43 @@ describe('Metadata', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('cancel button should cancel the edit mode', () => {
|
||||
it('cancel button should cancel the edit mode', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
fireEvent.click(editButton);
|
||||
await userEvent.click(editButton);
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
expect(cancelButton).toBeInTheDocument();
|
||||
fireEvent.click(cancelButton);
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
const editButton2 = screen.getByText('Edit');
|
||||
expect(editButton2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not allow editing of unit if it is already set', () => {
|
||||
it('should not allow editing of unit if it is already set', async () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metricName={MOCK_METRIC_NAME}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
isErrorMetricMetadata={false}
|
||||
isLoadingMetricMetadata={false}
|
||||
refetchMetricMetadata={mockRefetchMetricMetadata}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
fireEvent.click(editButton);
|
||||
await userEvent.click(editButton);
|
||||
|
||||
const unitSelect = screen.queryByTestId('unit-select');
|
||||
expect(unitSelect).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,68 +1,16 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
|
||||
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
|
||||
import { userEvent } from 'tests/test-utils';
|
||||
|
||||
import MetricDetails from '../MetricDetails';
|
||||
import { getMockMetricMetadataData } from './testUtlls';
|
||||
|
||||
const mockMetricName = 'test-metric';
|
||||
const mockMetricDescription = 'description for a test metric';
|
||||
const mockMetricData: MetricDetailsType = {
|
||||
name: mockMetricName,
|
||||
description: mockMetricDescription,
|
||||
unit: 'count',
|
||||
attributes: [
|
||||
{
|
||||
key: 'test-attribute',
|
||||
value: ['test-value'],
|
||||
valueCount: 1,
|
||||
},
|
||||
],
|
||||
alerts: [],
|
||||
dashboards: [],
|
||||
metadata: {
|
||||
metric_type: MetricType.SUM,
|
||||
description: mockMetricDescription,
|
||||
unit: 'count',
|
||||
},
|
||||
type: '',
|
||||
timeseries: 0,
|
||||
samples: 0,
|
||||
timeSeriesTotal: 0,
|
||||
timeSeriesActive: 0,
|
||||
lastReceived: '',
|
||||
};
|
||||
const mockOpenInspectModal = jest.fn();
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
const mockUseGetMetricDetailsData = {
|
||||
data: {
|
||||
payload: {
|
||||
data: mockMetricData,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
|
||||
.mockReturnValue(mockUseGetMetricDetailsData as any);
|
||||
|
||||
jest.spyOn(useUpdateMetricMetadata, 'useUpdateMetricMetadata').mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const mockHandleExplorerTabChange = jest.fn();
|
||||
jest
|
||||
.spyOn(useHandleExplorerTabChange, 'useHandleExplorerTabChange')
|
||||
@@ -88,7 +36,50 @@ jest.mock('react-query', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/MetricsExplorer/MetricDetails/AllAttributes',
|
||||
() =>
|
||||
function MockAllAttributes(): JSX.Element {
|
||||
return <div data-testid="all-attributes">All Attributes</div>;
|
||||
},
|
||||
);
|
||||
jest.mock(
|
||||
'container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover',
|
||||
() =>
|
||||
function MockDashboardsAndAlertsPopover(): JSX.Element {
|
||||
return (
|
||||
<div data-testid="dashboards-and-alerts-popover">
|
||||
Dashboards and Alerts Popover
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
jest.mock(
|
||||
'container/MetricsExplorer/MetricDetails/Highlights',
|
||||
() =>
|
||||
function MockHighlights(): JSX.Element {
|
||||
return <div data-testid="highlights">Highlights</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/MetricsExplorer/MetricDetails/Metadata',
|
||||
() =>
|
||||
function MockMetadata(): JSX.Element {
|
||||
return <div data-testid="metadata">Metadata</div>;
|
||||
},
|
||||
);
|
||||
|
||||
const useGetMetricMetadataMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricMetadata',
|
||||
);
|
||||
|
||||
describe('MetricDetails', () => {
|
||||
beforeEach(() => {
|
||||
useGetMetricMetadataMock.mockReturnValue(getMockMetricMetadataData());
|
||||
});
|
||||
|
||||
it('renders metric details correctly', () => {
|
||||
render(
|
||||
<MetricDetails
|
||||
@@ -101,27 +92,15 @@ describe('MetricDetails', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText(mockMetricName)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricDescription)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('all-attributes')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(getUniversalNameFromMetricUnit(mockMetricData.unit)),
|
||||
screen.getByTestId('dashboards-and-alerts-popover'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('highlights')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('metadata')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the "open in explorer" and "inspect" buttons', () => {
|
||||
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValueOnce({
|
||||
...mockUseGetMetricDetailsData,
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
...mockMetricData,
|
||||
metadata: {
|
||||
...mockMetricData.metadata,
|
||||
metric_type: MetricType.GAUGE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
it('renders the "open in explorer" and "inspect" buttons', async () => {
|
||||
render(
|
||||
<MetricDetails
|
||||
onClose={mockOnClose}
|
||||
@@ -135,93 +114,10 @@ describe('MetricDetails', () => {
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('inspect-metric-button')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('open-in-explorer-button'));
|
||||
await userEvent.click(screen.getByTestId('open-in-explorer-button'));
|
||||
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('inspect-metric-button'));
|
||||
await userEvent.click(screen.getByTestId('inspect-metric-button'));
|
||||
expect(mockOpenInspectModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render error state when metric details are not found', () => {
|
||||
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
|
||||
...mockUseGetMetricDetailsData,
|
||||
isError: true,
|
||||
error: {
|
||||
message: 'Error fetching metric details',
|
||||
},
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<MetricDetails
|
||||
onClose={mockOnClose}
|
||||
isOpen
|
||||
metricName={mockMetricName}
|
||||
isModalTimeSelection
|
||||
openInspectModal={mockOpenInspectModal}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Error fetching metric details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading state when metric details are loading', () => {
|
||||
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
|
||||
...mockUseGetMetricDetailsData,
|
||||
isLoading: true,
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<MetricDetails
|
||||
onClose={mockOnClose}
|
||||
isOpen
|
||||
metricName={mockMetricName}
|
||||
isModalTimeSelection
|
||||
openInspectModal={mockOpenInspectModal}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('metric-details-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all attributes section', () => {
|
||||
jest
|
||||
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
|
||||
.mockReturnValue(mockUseGetMetricDetailsData as any);
|
||||
render(
|
||||
<MetricDetails
|
||||
onClose={mockOnClose}
|
||||
isOpen
|
||||
metricName={mockMetricName}
|
||||
isModalTimeSelection
|
||||
openInspectModal={mockOpenInspectModal}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('All Attributes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render all attributes section when relevant data is not present', () => {
|
||||
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
|
||||
...mockUseGetMetricDetailsData,
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
...mockMetricData,
|
||||
attributes: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
render(
|
||||
<MetricDetails
|
||||
onClose={mockOnClose}
|
||||
isOpen
|
||||
metricName={mockMetricName}
|
||||
isModalTimeSelection
|
||||
openInspectModal={mockOpenInspectModal}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('All Attributes')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import {
|
||||
GetMetricAlerts200,
|
||||
GetMetricAttributes200,
|
||||
GetMetricDashboards200,
|
||||
GetMetricHighlights200,
|
||||
GetMetricMetadata200,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const MOCK_METRIC_NAME = 'test-metric';
|
||||
|
||||
export function getMockMetricHighlightsData(
|
||||
overrides?: Partial<GetMetricHighlights200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
dataPoints: 1000000,
|
||||
lastReceived: '2026-01-24T00:00:00Z',
|
||||
totalTimeSeries: 1000000,
|
||||
activeTimeSeries: 1000000,
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights>;
|
||||
}
|
||||
|
||||
export const MOCK_DASHBOARD_1 = {
|
||||
dashboardName: 'Dashboard 1',
|
||||
dashboardId: '1',
|
||||
widgetId: '1',
|
||||
widgetName: 'Widget 1',
|
||||
};
|
||||
export const MOCK_DASHBOARD_2 = {
|
||||
dashboardName: 'Dashboard 2',
|
||||
dashboardId: '2',
|
||||
widgetId: '2',
|
||||
widgetName: 'Widget 2',
|
||||
};
|
||||
export const MOCK_ALERT_1 = {
|
||||
alertName: 'Alert 1',
|
||||
alertId: '1',
|
||||
};
|
||||
export const MOCK_ALERT_2 = {
|
||||
alertName: 'Alert 2',
|
||||
alertId: '2',
|
||||
};
|
||||
|
||||
export function getMockDashboardsData(
|
||||
overrides?: Partial<GetMetricDashboards200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2],
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards>;
|
||||
}
|
||||
|
||||
export function getMockAlertsData(
|
||||
overrides?: Partial<GetMetricAlerts200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
alerts: [MOCK_ALERT_1, MOCK_ALERT_2],
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts>;
|
||||
}
|
||||
|
||||
export function getMockMetricAttributesData(
|
||||
overrides?: Partial<GetMetricAttributes200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
attributes: [
|
||||
{
|
||||
key: 'attribute1',
|
||||
values: ['value1', 'value2'],
|
||||
valueCount: 2,
|
||||
},
|
||||
{
|
||||
key: 'attribute2',
|
||||
values: ['value3'],
|
||||
valueCount: 1,
|
||||
},
|
||||
],
|
||||
totalKeys: 2,
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes>;
|
||||
}
|
||||
|
||||
export function getMockMetricMetadataData(
|
||||
overrides?: Partial<GetMetricMetadata200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
}: {
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
description: 'test_description',
|
||||
type: MetrictypesTypeDTO.gauge,
|
||||
unit: 'test_unit',
|
||||
temporality: MetrictypesTemporalityDTO.delta,
|
||||
isMonotonic: false,
|
||||
},
|
||||
status: 'success',
|
||||
...overrides,
|
||||
},
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata>;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
determineIsMonotonic,
|
||||
@@ -10,35 +12,48 @@ import {
|
||||
describe('MetricDetails utils', () => {
|
||||
describe('determineIsMonotonic', () => {
|
||||
it('should return true for histogram metrics', () => {
|
||||
expect(determineIsMonotonic(MetricType.HISTOGRAM)).toBe(true);
|
||||
expect(determineIsMonotonic(MetrictypesTypeDTO.histogram)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for exponential histogram metrics', () => {
|
||||
expect(determineIsMonotonic(MetricType.EXPONENTIAL_HISTOGRAM)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for gauge metrics', () => {
|
||||
expect(determineIsMonotonic(MetricType.GAUGE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for summary metrics', () => {
|
||||
expect(determineIsMonotonic(MetricType.SUMMARY)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for sum metrics with cumulative temporality', () => {
|
||||
expect(determineIsMonotonic(MetricType.SUM, Temporality.CUMULATIVE)).toBe(
|
||||
expect(determineIsMonotonic(MetrictypesTypeDTO.exponentialhistogram)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for gauge metrics', () => {
|
||||
expect(determineIsMonotonic(MetrictypesTypeDTO.gauge)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for summary metrics', () => {
|
||||
expect(determineIsMonotonic(MetrictypesTypeDTO.summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for sum metrics with cumulative temporality', () => {
|
||||
expect(
|
||||
determineIsMonotonic(
|
||||
MetrictypesTypeDTO.sum,
|
||||
MetrictypesTemporalityDTO.cumulative,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for sum metrics with delta temporality', () => {
|
||||
expect(determineIsMonotonic(MetricType.SUM, Temporality.DELTA)).toBe(false);
|
||||
expect(
|
||||
determineIsMonotonic(
|
||||
MetrictypesTypeDTO.sum,
|
||||
MetrictypesTemporalityDTO.delta,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false by default', () => {
|
||||
expect(determineIsMonotonic('' as MetricType, '' as Temporality)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
determineIsMonotonic(
|
||||
'' as MetrictypesTypeDTO,
|
||||
'' as MetrictypesTemporalityDTO,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,13 +130,16 @@ describe('MetricDetails utils', () => {
|
||||
const API_GATEWAY = 'api-gateway';
|
||||
|
||||
it('should create correct query for SUM metric type', () => {
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetricType.SUM,
|
||||
MetrictypesTypeDTO.sum,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
|
||||
@@ -129,13 +147,16 @@ describe('MetricDetails utils', () => {
|
||||
});
|
||||
|
||||
it('should create correct query for GAUGE metric type', () => {
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.GAUGE);
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.gauge,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetricType.GAUGE,
|
||||
MetrictypesTypeDTO.gauge,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
|
||||
@@ -143,13 +164,16 @@ describe('MetricDetails utils', () => {
|
||||
});
|
||||
|
||||
it('should create correct query for SUMMARY metric type', () => {
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUMMARY);
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.summary,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetricType.SUMMARY,
|
||||
MetrictypesTypeDTO.summary,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -157,13 +181,16 @@ describe('MetricDetails utils', () => {
|
||||
});
|
||||
|
||||
it('should create correct query for HISTOGRAM metric type', () => {
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.HISTOGRAM);
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.histogram,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetricType.HISTOGRAM,
|
||||
MetrictypesTypeDTO.histogram,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -173,14 +200,14 @@ describe('MetricDetails utils', () => {
|
||||
it('should create correct query for EXPONENTIAL_HISTOGRAM metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetricType.EXPONENTIAL_HISTOGRAM,
|
||||
MetrictypesTypeDTO.exponentialhistogram,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.key).toBe(
|
||||
TEST_METRIC_NAME,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
MetricType.EXPONENTIAL_HISTOGRAM,
|
||||
MetrictypesTypeDTO.exponentialhistogram,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('noop');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('noop');
|
||||
@@ -203,7 +230,7 @@ describe('MetricDetails utils', () => {
|
||||
const filter = { key: 'service', value: API_GATEWAY };
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetricType.SUM,
|
||||
MetrictypesTypeDTO.sum,
|
||||
filter,
|
||||
);
|
||||
|
||||
@@ -221,7 +248,7 @@ describe('MetricDetails utils', () => {
|
||||
const groupBy = 'service';
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetricType.SUM,
|
||||
MetrictypesTypeDTO.sum,
|
||||
undefined,
|
||||
groupBy,
|
||||
);
|
||||
@@ -236,7 +263,7 @@ describe('MetricDetails utils', () => {
|
||||
const groupBy = 'endpoint';
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetricType.SUM,
|
||||
MetrictypesTypeDTO.sum,
|
||||
filter,
|
||||
groupBy,
|
||||
);
|
||||
@@ -250,7 +277,10 @@ describe('MetricDetails utils', () => {
|
||||
});
|
||||
|
||||
it('should not include filters or groupBy when not provided', () => {
|
||||
const query = getMetricDetailsQuery(TEST_METRIC_NAME, MetricType.SUM);
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.filters?.items).toHaveLength(0);
|
||||
expect(query.builder.queryData[0]?.groupBy).toHaveLength(0);
|
||||
|
||||
@@ -1,6 +1,55 @@
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const METRIC_METADATA_KEYS = {
|
||||
description: 'Description',
|
||||
unit: 'Unit',
|
||||
metric_type: 'Metric Type',
|
||||
type: 'Metric Type',
|
||||
temporality: 'Temporality',
|
||||
isMonotonic: 'Monotonic',
|
||||
};
|
||||
|
||||
export const METRIC_METADATA_TEMPORALITY_OPTIONS: Array<{
|
||||
value: MetrictypesTemporalityDTO;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
value: MetrictypesTemporalityDTO.delta,
|
||||
label: 'Delta',
|
||||
},
|
||||
{
|
||||
value: MetrictypesTemporalityDTO.cumulative,
|
||||
label: 'Cumulative',
|
||||
},
|
||||
];
|
||||
|
||||
export const METRIC_METADATA_TYPE_OPTIONS: Array<{
|
||||
value: MetrictypesTypeDTO;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
value: MetrictypesTypeDTO.sum,
|
||||
label: 'Sum',
|
||||
},
|
||||
{
|
||||
value: MetrictypesTypeDTO.gauge,
|
||||
label: 'Gauge',
|
||||
},
|
||||
{
|
||||
value: MetrictypesTypeDTO.histogram,
|
||||
label: 'Histogram',
|
||||
},
|
||||
{
|
||||
value: MetrictypesTypeDTO.summary,
|
||||
label: 'Summary',
|
||||
},
|
||||
{
|
||||
value: MetrictypesTypeDTO.exponentialhistogram,
|
||||
label: 'Exponential Histogram',
|
||||
},
|
||||
];
|
||||
|
||||
export const METRIC_METADATA_UPDATE_ERROR_MESSAGE =
|
||||
'Failed to update metadata, please try again. If the issue persists, please contact support.';
|
||||
|
||||
@@ -1,34 +1,39 @@
|
||||
import {
|
||||
MetricDetails,
|
||||
MetricDetailsAlert,
|
||||
MetricDetailsAttribute,
|
||||
MetricDetailsDashboard,
|
||||
} from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
MetricsexplorertypesMetricAlertDTO,
|
||||
MetricsexplorertypesMetricAttributeDTO,
|
||||
MetricsexplorertypesMetricDashboardDTO,
|
||||
MetricsexplorertypesMetricHighlightsResponseDTO,
|
||||
MetricsexplorertypesMetricMetadataDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export interface MetricDetailsProps {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
metricName: string | null;
|
||||
metricName: string;
|
||||
isModalTimeSelection: boolean;
|
||||
openInspectModal?: (metricName: string) => void;
|
||||
}
|
||||
|
||||
export interface HighlightsProps {
|
||||
metricName: string;
|
||||
}
|
||||
export interface DashboardsAndAlertsPopoverProps {
|
||||
dashboards: MetricDetailsDashboard[] | null;
|
||||
alerts: MetricDetailsAlert[] | null;
|
||||
metricName: string;
|
||||
}
|
||||
|
||||
export interface MetadataProps {
|
||||
metricName: string;
|
||||
metadata: MetricDetails['metadata'] | undefined;
|
||||
refetchMetricDetails: () => void;
|
||||
metadata: MetricMetadata | null;
|
||||
isErrorMetricMetadata: boolean;
|
||||
isLoadingMetricMetadata: boolean;
|
||||
refetchMetricMetadata: () => void;
|
||||
}
|
||||
|
||||
export interface AllAttributesProps {
|
||||
attributes: MetricDetailsAttribute[];
|
||||
metricName: string;
|
||||
metricType: MetricType | undefined;
|
||||
metricType: MetrictypesTypeDTO | undefined;
|
||||
}
|
||||
|
||||
export interface AllAttributesValueProps {
|
||||
@@ -36,3 +41,38 @@ export interface AllAttributesValueProps {
|
||||
filterValue: string[];
|
||||
goToMetricsExploreWithAppliedAttribute: (key: string, value: string) => void;
|
||||
}
|
||||
|
||||
export interface AllAttributesEmptyTextProps {
|
||||
isErrorAttributes: boolean;
|
||||
refetchAttributes: () => void;
|
||||
}
|
||||
|
||||
export type MetricHighlight = MetricsexplorertypesMetricHighlightsResponseDTO;
|
||||
|
||||
export type MetricAlert = MetricsexplorertypesMetricAlertDTO;
|
||||
|
||||
export type MetricDashboard = MetricsexplorertypesMetricDashboardDTO;
|
||||
|
||||
export type MetricMetadata = MetricsexplorertypesMetricMetadataDTO;
|
||||
export interface MetricMetadataFormState {
|
||||
type: MetrictypesTypeDTO;
|
||||
description: string;
|
||||
temporality?: MetrictypesTemporalityDTO;
|
||||
unit: string;
|
||||
isMonotonic: boolean;
|
||||
}
|
||||
|
||||
export type MetricAttribute = MetricsexplorertypesMetricAttributeDTO;
|
||||
|
||||
export enum TableFields {
|
||||
DESCRIPTION = 'description',
|
||||
UNIT = 'unit',
|
||||
TYPE = 'type',
|
||||
Temporality = 'temporality',
|
||||
IS_MONOTONIC = 'isMonotonic',
|
||||
}
|
||||
|
||||
export interface MetricDetailsErrorStateProps {
|
||||
refetch?: () => void;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataMutationBody } from 'api/generated/services/metrics';
|
||||
import {
|
||||
GetMetricMetadata200,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
export function formatTimestampToReadableDate(timestamp: string): string {
|
||||
import { MetricMetadata, MetricMetadataFormState } from './types';
|
||||
|
||||
export function formatTimestampToReadableDate(
|
||||
timestamp: number | string | undefined,
|
||||
): string {
|
||||
if (!timestamp) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
@@ -39,7 +50,10 @@ export function formatTimestampToReadableDate(timestamp: string): string {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function formatNumberToCompactFormat(num: number): string {
|
||||
export function formatNumberToCompactFormat(num: number | undefined): string {
|
||||
if (!num) {
|
||||
return '-';
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
@@ -47,27 +61,30 @@ export function formatNumberToCompactFormat(num: number): string {
|
||||
}
|
||||
|
||||
export function determineIsMonotonic(
|
||||
metricType: MetricType,
|
||||
temporality?: Temporality,
|
||||
metricType: MetrictypesTypeDTO,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
): boolean {
|
||||
if (
|
||||
metricType === MetricType.HISTOGRAM ||
|
||||
metricType === MetricType.EXPONENTIAL_HISTOGRAM
|
||||
metricType === MetrictypesTypeDTO.histogram ||
|
||||
metricType === MetrictypesTypeDTO.exponentialhistogram
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (metricType === MetricType.GAUGE || metricType === MetricType.SUMMARY) {
|
||||
if (
|
||||
metricType === MetrictypesTypeDTO.gauge ||
|
||||
metricType === MetrictypesTypeDTO.summary
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (metricType === MetricType.SUM) {
|
||||
return temporality === Temporality.CUMULATIVE;
|
||||
if (metricType === MetrictypesTypeDTO.sum) {
|
||||
return temporality === MetrictypesTemporalityDTO.cumulative;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getMetricDetailsQuery(
|
||||
metricName: string,
|
||||
metricType: MetricType | undefined,
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
filter?: { key: string; value: string },
|
||||
groupBy?: string,
|
||||
): Query {
|
||||
@@ -75,23 +92,23 @@ export function getMetricDetailsQuery(
|
||||
let spaceAggregation;
|
||||
let aggregateOperator;
|
||||
switch (metricType) {
|
||||
case MetricType.SUM:
|
||||
case MetrictypesTypeDTO.sum:
|
||||
timeAggregation = 'rate';
|
||||
spaceAggregation = 'sum';
|
||||
aggregateOperator = 'rate';
|
||||
break;
|
||||
case MetricType.GAUGE:
|
||||
case MetrictypesTypeDTO.gauge:
|
||||
timeAggregation = 'avg';
|
||||
spaceAggregation = 'avg';
|
||||
aggregateOperator = 'avg';
|
||||
break;
|
||||
case MetricType.SUMMARY:
|
||||
case MetrictypesTypeDTO.summary:
|
||||
timeAggregation = 'noop';
|
||||
spaceAggregation = 'sum';
|
||||
aggregateOperator = 'noop';
|
||||
break;
|
||||
case MetricType.HISTOGRAM:
|
||||
case MetricType.EXPONENTIAL_HISTOGRAM:
|
||||
case MetrictypesTypeDTO.histogram:
|
||||
case MetrictypesTypeDTO.exponentialhistogram:
|
||||
timeAggregation = 'noop';
|
||||
spaceAggregation = 'p90';
|
||||
aggregateOperator = 'noop';
|
||||
@@ -160,3 +177,38 @@ export function getMetricDetailsQuery(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function transformMetricMetadata(
|
||||
apiData: GetMetricMetadata200 | undefined,
|
||||
): MetricMetadata | null {
|
||||
if (!apiData || !apiData.data) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } = apiData.data;
|
||||
|
||||
return {
|
||||
type,
|
||||
description,
|
||||
unit,
|
||||
temporality,
|
||||
isMonotonic,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformUpdateMetricMetadataRequest(
|
||||
metricName: string,
|
||||
metricMetadata: MetricMetadataFormState,
|
||||
): UpdateMetricMetadataMutationBody {
|
||||
return {
|
||||
metricName: metricName,
|
||||
type: metricMetadata.type,
|
||||
description: metricMetadata.description,
|
||||
unit: metricMetadata.unit,
|
||||
temporality:
|
||||
metricMetadata.temporality ?? MetrictypesTemporalityDTO.unspecified,
|
||||
isMonotonic: determineIsMonotonic(
|
||||
metricMetadata.type,
|
||||
metricMetadata.temporality,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
@@ -128,7 +129,7 @@ function Summary(): JSX.Element {
|
||||
} = useGetMetricsList(metricsListQuery, {
|
||||
enabled: !!metricsListQuery && !isInspectModalOpen,
|
||||
queryKey: [
|
||||
'metricsList',
|
||||
REACT_QUERY_KEY.GET_METRICS_LIST,
|
||||
queryFiltersWithoutId,
|
||||
orderBy,
|
||||
pageSize,
|
||||
@@ -323,7 +324,7 @@ function Summary(): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isMetricDetailsOpen && (
|
||||
{isMetricDetailsOpen && selectedMetricName && (
|
||||
<MetricDetails
|
||||
isOpen={isMetricDetailsOpen}
|
||||
onClose={closeMetricDetails}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
|
||||
import { TreemapViewType } from './types';
|
||||
@@ -25,7 +26,16 @@ export const METRIC_TYPE_LABEL_MAP = {
|
||||
[MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram',
|
||||
};
|
||||
|
||||
export const METRIC_TYPE_VALUES_MAP = {
|
||||
export const METRIC_TYPE_VIEW_LABEL_MAP: Record<MetrictypesTypeDTO, string> = {
|
||||
[MetrictypesTypeDTO.sum]: 'Sum',
|
||||
[MetrictypesTypeDTO.gauge]: 'Gauge',
|
||||
[MetrictypesTypeDTO.histogram]: 'Histogram',
|
||||
[MetrictypesTypeDTO.summary]: 'Summary',
|
||||
[MetrictypesTypeDTO.exponentialhistogram]: 'Exp. Histogram',
|
||||
};
|
||||
|
||||
// TODO(@amlannandy): To remove this once API migration is complete
|
||||
export const METRIC_TYPE_VALUES_MAP: Record<MetricType, string> = {
|
||||
[MetricType.SUM]: 'Sum',
|
||||
[MetricType.GAUGE]: 'Gauge',
|
||||
[MetricType.HISTOGRAM]: 'Histogram',
|
||||
@@ -33,6 +43,14 @@ export const METRIC_TYPE_VALUES_MAP = {
|
||||
[MetricType.EXPONENTIAL_HISTOGRAM]: 'ExponentialHistogram',
|
||||
};
|
||||
|
||||
export const METRIC_TYPE_VIEW_VALUES_MAP: Record<MetrictypesTypeDTO, string> = {
|
||||
[MetrictypesTypeDTO.sum]: 'Sum',
|
||||
[MetrictypesTypeDTO.gauge]: 'Gauge',
|
||||
[MetrictypesTypeDTO.histogram]: 'Histogram',
|
||||
[MetrictypesTypeDTO.summary]: 'Summary',
|
||||
[MetrictypesTypeDTO.exponentialhistogram]: 'ExponentialHistogram',
|
||||
};
|
||||
|
||||
export const IS_METRIC_DETAILS_OPEN_KEY = 'isMetricDetailsOpen';
|
||||
export const IS_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen';
|
||||
export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
MetricsListItemData,
|
||||
MetricsListPayload,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { METRIC_TYPE_LABEL_MAP } from './constants';
|
||||
import { METRIC_TYPE_LABEL_MAP, METRIC_TYPE_VIEW_LABEL_MAP } from './constants';
|
||||
import MetricNameSearch from './MetricNameSearch';
|
||||
import MetricTypeSearch from './MetricTypeSearch';
|
||||
import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types';
|
||||
@@ -143,6 +144,66 @@ export function MetricTypeRenderer({
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricTypeViewRenderer({
|
||||
type,
|
||||
}: {
|
||||
type: MetrictypesTypeDTO;
|
||||
}): JSX.Element {
|
||||
const [icon, color] = useMemo(() => {
|
||||
switch (type) {
|
||||
case MetrictypesTypeDTO.sum:
|
||||
return [
|
||||
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
|
||||
Color.BG_ROBIN_500,
|
||||
];
|
||||
case MetrictypesTypeDTO.gauge:
|
||||
return [
|
||||
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
|
||||
Color.BG_SAKURA_500,
|
||||
];
|
||||
case MetrictypesTypeDTO.histogram:
|
||||
return [
|
||||
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
|
||||
Color.BG_SIENNA_500,
|
||||
];
|
||||
case MetrictypesTypeDTO.summary:
|
||||
return [
|
||||
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
|
||||
Color.BG_FOREST_500,
|
||||
];
|
||||
case MetrictypesTypeDTO.exponentialhistogram:
|
||||
return [
|
||||
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
|
||||
Color.BG_AQUA_500,
|
||||
];
|
||||
default:
|
||||
return [null, ''];
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
const metricTypeRendererStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: `${color}33`,
|
||||
border: `1px solid ${color}`,
|
||||
color,
|
||||
}),
|
||||
[color],
|
||||
);
|
||||
|
||||
const metricTypeRendererTextStyle = useMemo(() => ({ color, fontSize: 12 }), [
|
||||
color,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="metric-type-renderer" style={metricTypeRendererStyle}>
|
||||
{icon}
|
||||
<Typography.Text style={metricTypeRendererTextStyle}>
|
||||
{METRIC_TYPE_VIEW_LABEL_MAP[type]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ValidateRowValueWrapper({
|
||||
value,
|
||||
children,
|
||||
@@ -160,6 +221,9 @@ export const formatNumberIntoHumanReadableFormat = (
|
||||
num: number,
|
||||
addPlusSign = true,
|
||||
): string => {
|
||||
if (!num) {
|
||||
return '-';
|
||||
}
|
||||
function format(num: number, divisor: number, suffix: string): string {
|
||||
const value = num / divisor;
|
||||
return value % 1 === 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form } from 'antd';
|
||||
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -30,7 +30,7 @@ function TagFilterInput({
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryBuilderSearch
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -86,7 +86,7 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
}));
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const attributeKeysURL = `${BASE_URL}/api/v3/autocomplete/attribute_keys`;
|
||||
const attributeKeysURL = `${BASE_URL}/api/v3/filter_suggestions`;
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
beforeAll(() => {
|
||||
@@ -333,26 +333,34 @@ describe('PipelinePage container test', () => {
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: [
|
||||
attributes: [
|
||||
{
|
||||
key: 'otelServiceName',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
},
|
||||
{
|
||||
key: 'service.instance.id',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -973,6 +973,7 @@ function QueryBuilderSearchV2(
|
||||
return (
|
||||
<div className="query-builder-search-v2">
|
||||
<Select
|
||||
data-testid={'qb-search-select'}
|
||||
ref={selectRef}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useIsTextSelected } from 'hooks/useIsTextSelected';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
import useLogDetailHandlers from '../useLogDetailHandlers';
|
||||
|
||||
jest.mock('hooks/logs/useActiveLog');
|
||||
jest.mock('hooks/useIsTextSelected');
|
||||
|
||||
const mockOnSetActiveLog = jest.fn();
|
||||
const mockOnClearActiveLog = jest.fn();
|
||||
const mockOnAddToQuery = jest.fn();
|
||||
const mockOnGroupByAttribute = jest.fn();
|
||||
const mockIsTextSelected = jest.fn();
|
||||
|
||||
const mockLog: ILog = {
|
||||
id: 'log-1',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
date: '2024-01-01',
|
||||
body: 'test log body',
|
||||
severityText: 'INFO',
|
||||
severityNumber: 9,
|
||||
traceFlags: 0,
|
||||
traceId: '',
|
||||
spanID: '',
|
||||
attributesString: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
resources_string: {},
|
||||
scope_string: {},
|
||||
attributes_string: {},
|
||||
severity_text: '',
|
||||
severity_number: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(useIsTextSelected).mockReturnValue(mockIsTextSelected);
|
||||
|
||||
jest.mocked(useActiveLog).mockReturnValue({
|
||||
activeLog: null,
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not open log detail when text is selected', () => {
|
||||
mockIsTextSelected.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useLogDetailHandlers());
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetActiveLog(mockLog);
|
||||
});
|
||||
|
||||
expect(mockOnSetActiveLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open log detail when no text is selected', () => {
|
||||
mockIsTextSelected.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useLogDetailHandlers());
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetActiveLog(mockLog);
|
||||
});
|
||||
|
||||
expect(mockOnSetActiveLog).toHaveBeenCalledWith(mockLog);
|
||||
});
|
||||
|
||||
it('should toggle off when clicking the same active log', () => {
|
||||
mockIsTextSelected.mockReturnValue(false);
|
||||
|
||||
jest.mocked(useActiveLog).mockReturnValue({
|
||||
activeLog: mockLog,
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLogDetailHandlers());
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetActiveLog(mockLog);
|
||||
});
|
||||
|
||||
expect(mockOnClearActiveLog).toHaveBeenCalled();
|
||||
expect(mockOnSetActiveLog).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -54,6 +56,20 @@ export const useActiveLog = (): UseActiveLog => {
|
||||
|
||||
const [activeLog, setActiveLog] = useState<ILog | null>(null);
|
||||
|
||||
// Close drawer/clear active log when query in URL changes
|
||||
const urlQuery = useUrlQuery();
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
|
||||
const prevQueryRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevQueryRef.current !== null &&
|
||||
prevQueryRef.current !== compositeQuery
|
||||
) {
|
||||
setActiveLog(null);
|
||||
}
|
||||
prevQueryRef.current = compositeQuery;
|
||||
}, [compositeQuery]);
|
||||
|
||||
const onSetDetailedLogData = useCallback(
|
||||
(logData: ILog) => {
|
||||
dispatch({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useState } from 'react';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import type { UseActiveLog } from 'hooks/logs/types';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useIsTextSelected } from 'hooks/useIsTextSelected';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
type SelectedTab = typeof VIEW_TYPES[keyof typeof VIEW_TYPES] | undefined;
|
||||
@@ -28,9 +29,13 @@ function useLogDetailHandlers({
|
||||
onAddToQuery,
|
||||
} = useActiveLog();
|
||||
const [selectedTab, setSelectedTab] = useState<SelectedTab>(defaultTab);
|
||||
const isTextSelected = useIsTextSelected();
|
||||
|
||||
const handleSetActiveLog = useCallback(
|
||||
(log: ILog, nextTab: SelectedTab = defaultTab): void => {
|
||||
if (isTextSelected()) {
|
||||
return;
|
||||
}
|
||||
if (activeLog?.id === log.id) {
|
||||
onClearActiveLog();
|
||||
setSelectedTab(undefined);
|
||||
@@ -39,7 +44,7 @@ function useLogDetailHandlers({
|
||||
onSetActiveLog(log);
|
||||
setSelectedTab(nextTab ?? defaultTab);
|
||||
},
|
||||
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog],
|
||||
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog, isTextSelected],
|
||||
);
|
||||
|
||||
const handleCloseLogDetail = useCallback((): void => {
|
||||
|
||||
10
frontend/src/hooks/useIsTextSelected.ts
Normal file
10
frontend/src/hooks/useIsTextSelected.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useIsTextSelected(): () => boolean {
|
||||
return useCallback((): boolean => {
|
||||
const selection = window.getSelection();
|
||||
return (
|
||||
!!selection && !selection.isCollapsed && selection.toString().length > 0
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
13
frontend/src/utils/pluralize.ts
Normal file
13
frontend/src/utils/pluralize.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function pluralize(
|
||||
count: number,
|
||||
singular: string,
|
||||
plural?: string,
|
||||
): string {
|
||||
if (count === 1) {
|
||||
return `${count} ${singular}`;
|
||||
}
|
||||
if (plural) {
|
||||
return `${count} ${plural}`;
|
||||
}
|
||||
return `${count} ${singular}s`;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
|
||||
}
|
||||
|
||||
func (m *module) listPromotedPaths(ctx context.Context) ([]string, error) {
|
||||
paths, err := m.metadataStore.ListPromotedPaths(ctx)
|
||||
paths, err := m.metadataStore.GetPromotedPaths(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (m *module) PromoteAndIndexPaths(
|
||||
pathsStr = append(pathsStr, path.Path)
|
||||
}
|
||||
|
||||
existingPromotedPaths, err := m.metadataStore.ListPromotedPaths(ctx, pathsStr...)
|
||||
existingPromotedPaths, err := m.metadataStore.GetPromotedPaths(ctx, pathsStr...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
"github.com/SigNoz/signoz-otel-collector/constants"
|
||||
"github.com/SigNoz/signoz-otel-collector/utils"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
@@ -113,7 +112,7 @@ func (t *telemetryMetaStore) buildBodyJSONPaths(ctx context.Context,
|
||||
|
||||
for _, fieldKey := range fieldKeys {
|
||||
promotedKey := strings.Split(fieldKey.Name, telemetrytypes.ArraySep)[0]
|
||||
fieldKey.Materialized = promoted.Contains(promotedKey)
|
||||
fieldKey.Materialized = promoted[promotedKey]
|
||||
fieldKey.Indexes = indexes[fieldKey.Name]
|
||||
}
|
||||
|
||||
@@ -295,33 +294,6 @@ func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ..
|
||||
return indexes, nil
|
||||
}
|
||||
|
||||
func (t *telemetryMetaStore) ListPromotedPaths(ctx context.Context, paths ...string) (map[string]struct{}, error) {
|
||||
sb := sqlbuilder.Select("path").From(fmt.Sprintf("%s.%s", DBName, PromotedPathsTableName))
|
||||
pathConditions := []string{}
|
||||
for _, path := range paths {
|
||||
pathConditions = append(pathConditions, sb.Equal("path", path))
|
||||
}
|
||||
sb.Where(sb.Or(pathConditions...))
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, CodeFailLoadPromotedPaths, "failed to load promoted paths")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
next := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var path string
|
||||
if err := rows.Scan(&path); err != nil {
|
||||
return nil, errors.WrapInternalf(err, CodeFailLoadPromotedPaths, "failed to scan promoted path")
|
||||
}
|
||||
next[path] = struct{}{}
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// TODO(Piyush): Remove this if not used in future
|
||||
func (t *telemetryMetaStore) ListJSONValues(ctx context.Context, path string, limit int) (*telemetrytypes.TelemetryFieldValues, bool, error) {
|
||||
path = CleanPathPrefixes(path)
|
||||
@@ -484,11 +456,12 @@ func derefValue(v any) any {
|
||||
return val.Interface()
|
||||
}
|
||||
|
||||
// IsPathPromoted checks if a specific path is promoted
|
||||
// IsPathPromoted checks if a specific path is promoted (Column Evolution table: field_name for logs body).
|
||||
func (t *telemetryMetaStore) IsPathPromoted(ctx context.Context, path string) (bool, error) {
|
||||
split := strings.Split(path, telemetrytypes.ArraySep)
|
||||
query := fmt.Sprintf("SELECT 1 FROM %s.%s WHERE path = ? LIMIT 1", DBName, PromotedPathsTableName)
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, split[0])
|
||||
pathSegment := split[0]
|
||||
query := fmt.Sprintf("SELECT 1 FROM %s.%s WHERE signal = ? AND column_name = ? AND field_context = ? AND field_name = ? LIMIT 1", DBName, PromotedPathsTableName)
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, telemetrytypes.SignalLogs, telemetrylogs.LogsV2BodyPromotedColumn, telemetrytypes.FieldContextBody, pathSegment)
|
||||
if err != nil {
|
||||
return false, errors.WrapInternalf(err, CodeFailCheckPathPromoted, "failed to check if path %s is promoted", path)
|
||||
}
|
||||
@@ -497,15 +470,24 @@ func (t *telemetryMetaStore) IsPathPromoted(ctx context.Context, path string) (b
|
||||
return rows.Next(), nil
|
||||
}
|
||||
|
||||
// GetPromotedPaths checks if a specific path is promoted
|
||||
func (t *telemetryMetaStore) GetPromotedPaths(ctx context.Context, paths ...string) (*utils.ConcurrentSet[string], error) {
|
||||
sb := sqlbuilder.Select("path").From(fmt.Sprintf("%s.%s", DBName, PromotedPathsTableName))
|
||||
pathConditions := []string{}
|
||||
for _, path := range paths {
|
||||
split := strings.Split(path, telemetrytypes.ArraySep)
|
||||
pathConditions = append(pathConditions, sb.Equal("path", split[0]))
|
||||
// GetPromotedPaths returns promoted paths from the Column Evolution table (field_name for logs body).
|
||||
func (t *telemetryMetaStore) GetPromotedPaths(ctx context.Context, paths ...string) (map[string]bool, error) {
|
||||
sb := sqlbuilder.Select("field_name").From(fmt.Sprintf("%s.%s", DBName, PromotedPathsTableName))
|
||||
conditions := []string{
|
||||
sb.Equal("signal", telemetrytypes.SignalLogs),
|
||||
sb.Equal("column_name", telemetrylogs.LogsV2BodyPromotedColumn),
|
||||
sb.Equal("field_context", telemetrytypes.FieldContextBody),
|
||||
sb.NotEqual("field_name", "__all__"),
|
||||
}
|
||||
sb.Where(sb.Or(pathConditions...))
|
||||
if len(paths) > 0 {
|
||||
pathArgs := make([]interface{}, len(paths))
|
||||
for i, path := range paths {
|
||||
split := strings.Split(path, telemetrytypes.ArraySep)
|
||||
pathArgs[i] = split[0]
|
||||
}
|
||||
conditions = append(conditions, sb.In("field_name", pathArgs))
|
||||
}
|
||||
sb.Where(sb.And(conditions...))
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
|
||||
@@ -514,13 +496,13 @@ func (t *telemetryMetaStore) GetPromotedPaths(ctx context.Context, paths ...stri
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
promotedPaths := utils.NewConcurrentSet[string]()
|
||||
promotedPaths := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var path string
|
||||
if err := rows.Scan(&path); err != nil {
|
||||
var fieldName string
|
||||
if err := rows.Scan(&fieldName); err != nil {
|
||||
return nil, errors.WrapInternalf(err, CodeFailCheckPathPromoted, "failed to scan promoted path")
|
||||
}
|
||||
promotedPaths.Insert(path)
|
||||
promotedPaths[fieldName] = true
|
||||
}
|
||||
|
||||
return promotedPaths, nil
|
||||
@@ -534,21 +516,22 @@ func CleanPathPrefixes(path string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// PromotePaths inserts promoted paths into the Column Evolution table (same schema as signoz-otel-collector metadata_migrations).
|
||||
func (t *telemetryMetaStore) PromotePaths(ctx context.Context, paths ...string) error {
|
||||
batch, err := t.telemetrystore.ClickhouseDB().PrepareBatch(ctx,
|
||||
fmt.Sprintf("INSERT INTO %s.%s (path, created_at) VALUES", DBName,
|
||||
fmt.Sprintf("INSERT INTO %s.%s (signal, column_name, column_type, field_context, field_name, version, release_time) VALUES", DBName,
|
||||
PromotedPathsTableName))
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, CodeFailedToPrepareBatch, "failed to prepare batch")
|
||||
}
|
||||
|
||||
nowMs := uint64(time.Now().UnixMilli())
|
||||
releaseTime := time.Now().UnixNano()
|
||||
for _, p := range paths {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if err := batch.Append(trimmed, nowMs); err != nil {
|
||||
if err := batch.Append(telemetrytypes.SignalLogs, telemetrylogs.LogsV2BodyPromotedColumn, "JSON()", telemetrytypes.FieldContextBody, trimmed, 0, releaseTime); err != nil {
|
||||
_ = batch.Abort()
|
||||
return errors.WrapInternalf(err, CodeFailedToAppendPath, "failed to append path")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const (
|
||||
AttributesMetadataTableName = "distributed_attributes_metadata"
|
||||
AttributesMetadataLocalTableName = "attributes_metadata"
|
||||
PathTypesTableName = otelcollectorconst.DistributedPathTypesTable
|
||||
PromotedPathsTableName = otelcollectorconst.DistributedPromotedPathsTable
|
||||
// Column Evolution table stores promoted paths as (signal, column_name, field_context, field_name); see signoz-otel-collector metadata_migrations.
|
||||
PromotedPathsTableName = "distributed_column_evolution_metadata"
|
||||
SkipIndexTableName = "system.data_skipping_indices"
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ type MetadataStore interface {
|
||||
ListLogsJSONIndexes(ctx context.Context, filters ...string) (map[string][]schemamigrator.Index, error)
|
||||
|
||||
// ListPromotedPaths lists the promoted paths.
|
||||
ListPromotedPaths(ctx context.Context, paths ...string) (map[string]struct{}, error)
|
||||
GetPromotedPaths(ctx context.Context, paths ...string) (map[string]bool, error)
|
||||
|
||||
// PromotePaths promotes the paths.
|
||||
PromotePaths(ctx context.Context, paths ...string) error
|
||||
|
||||
@@ -16,7 +16,7 @@ type MockMetadataStore struct {
|
||||
RelatedValuesMap map[string][]string
|
||||
AllValuesMap map[string]*telemetrytypes.TelemetryFieldValues
|
||||
TemporalityMap map[string]metrictypes.Temporality
|
||||
PromotedPathsMap map[string]struct{}
|
||||
PromotedPathsMap map[string]bool
|
||||
LogsJSONIndexesMap map[string][]schemamigrator.Index
|
||||
LookupKeysMap map[telemetrytypes.MetricMetadataLookupKey]int64
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func NewMockMetadataStore() *MockMetadataStore {
|
||||
RelatedValuesMap: make(map[string][]string),
|
||||
AllValuesMap: make(map[string]*telemetrytypes.TelemetryFieldValues),
|
||||
TemporalityMap: make(map[string]metrictypes.Temporality),
|
||||
PromotedPathsMap: make(map[string]struct{}),
|
||||
PromotedPathsMap: make(map[string]bool),
|
||||
LogsJSONIndexesMap: make(map[string][]schemamigrator.Index),
|
||||
LookupKeysMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
|
||||
}
|
||||
@@ -295,13 +295,13 @@ func (m *MockMetadataStore) SetTemporality(metricName string, temporality metric
|
||||
// PromotePaths promotes the paths.
|
||||
func (m *MockMetadataStore) PromotePaths(ctx context.Context, paths ...string) error {
|
||||
for _, path := range paths {
|
||||
m.PromotedPathsMap[path] = struct{}{}
|
||||
m.PromotedPathsMap[path] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPromotedPaths lists the promoted paths.
|
||||
func (m *MockMetadataStore) ListPromotedPaths(ctx context.Context, paths ...string) (map[string]struct{}, error) {
|
||||
// GetPromotedPaths returns the promoted paths.
|
||||
func (m *MockMetadataStore) GetPromotedPaths(ctx context.Context, paths ...string) (map[string]bool, error) {
|
||||
return m.PromotedPathsMap, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user