Compare commits

..

10 Commits

Author SHA1 Message Date
amlannandy
b7129a4fa7 chore: additional changes 2026-02-25 17:18:04 +07:00
amlannandy
69c299ebaf chore: corresponding fix for generated api changes 2026-02-24 16:01:07 +07:00
amlannandy
27dc3af9ac chore: address comments 2026-02-24 14:14:59 +07:00
amlannandy
f9d6fd9a84 chore: additional fixes 2026-02-24 14:14:59 +07:00
amlannandy
0a34ff85b7 chore: address comments 2026-02-24 14:14:59 +07:00
amlannandy
55e40b8d81 chore: additional performance fix 2026-02-24 14:14:59 +07:00
amlannandy
70aea449ae chore: fix CI 2026-02-24 14:14:59 +07:00
amlannandy
dbcbb06917 chore: additional fixes 2026-02-24 14:14:59 +07:00
amlannandy
f79ff8502f chore: search bar replacement 2026-02-24 14:14:59 +07:00
amlannandy
84730b663d chore: metrics explorer summary page api migration 2026-02-24 14:14:58 +07:00
52 changed files with 1829 additions and 1401 deletions

View File

@@ -54,7 +54,7 @@ jobs:
- sqlite
clickhouse-version:
- 25.5.6
- 25.12.5
- 25.10.5
schema-migrator-version:
- v0.142.0
postgres-version:

View File

@@ -82,12 +82,6 @@ exporters:
timeout: 45s
sending_queue:
enabled: false
metadataexporter:
cache:
provider: in_memory
dsn: tcp://clickhouse:9000/signoz_metadata
enabled: true
timeout: 45s
service:
telemetry:
logs:
@@ -99,19 +93,19 @@ service:
traces:
receivers: [otlp]
processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces, metadataexporter, signozmeter]
exporters: [clickhousetraces, signozmeter]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus:
receivers: [prometheus]
processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
exporters: [signozclickhousemetrics, signozmeter]
logs:
receivers: [otlp]
processors: [batch]
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
exporters: [clickhouselogsexporter, signozmeter]
metrics/meter:
receivers: [signozmeter]
processors: [batch/meter]

View File

@@ -82,12 +82,6 @@ exporters:
timeout: 45s
sending_queue:
enabled: false
metadataexporter:
cache:
provider: in_memory
dsn: tcp://clickhouse:9000/signoz_metadata
enabled: true
timeout: 45s
service:
telemetry:
logs:
@@ -99,19 +93,19 @@ service:
traces:
receivers: [otlp]
processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces, metadataexporter, signozmeter]
exporters: [clickhousetraces, signozmeter]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus:
receivers: [prometheus]
processors: [batch]
exporters: [signozclickhousemetrics, metadataexporter, signozmeter]
exporters: [signozclickhousemetrics, signozmeter]
logs:
receivers: [otlp]
processors: [batch]
exporters: [clickhouselogsexporter, metadataexporter, signozmeter]
exporters: [clickhouselogsexporter, signozmeter]
metrics/meter:
receivers: [signozmeter]
processors: [batch/meter]

View File

@@ -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.**

View File

@@ -308,15 +308,3 @@ export const PublicDashboardPage = Loadable(
/* webpackChunkName: "Public Dashboard Page" */ 'pages/PublicDashboard'
),
);
export const AlertTypeSelectionPage = Loadable(
() =>
import(
/* webpackChunkName: "Alert Type Selection Page" */ 'pages/AlertTypeSelection'
),
);
export const MeterExplorerPage = Loadable(
() =>
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
);

View File

@@ -1,10 +1,12 @@
import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import AlertTypeSelectionPage from 'pages/AlertTypeSelection';
import MessagingQueues from 'pages/MessagingQueues';
import MeterExplorer from 'pages/MeterExplorer';
import {
AlertHistory,
AlertOverview,
AlertTypeSelectionPage,
AllAlertChannels,
AllErrors,
ApiMonitoring,
@@ -27,8 +29,6 @@ import {
LogsExplorer,
LogsIndexToFields,
LogsSaveViews,
MessagingQueuesMainPage,
MeterExplorerPage,
MetricsExplorer,
OldLogsExplorer,
Onboarding,
@@ -399,28 +399,28 @@ const routes: AppRoutes[] = [
{
path: ROUTES.MESSAGING_QUEUES_KAFKA,
exact: true,
component: MessagingQueuesMainPage,
component: MessagingQueues,
key: 'MESSAGING_QUEUES_KAFKA',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_CELERY_TASK,
exact: true,
component: MessagingQueuesMainPage,
component: MessagingQueues,
key: 'MESSAGING_QUEUES_CELERY_TASK',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_OVERVIEW,
exact: true,
component: MessagingQueuesMainPage,
component: MessagingQueues,
key: 'MESSAGING_QUEUES_OVERVIEW',
isPrivate: true,
},
{
path: ROUTES.MESSAGING_QUEUES_KAFKA_DETAIL,
exact: true,
component: MessagingQueuesMainPage,
component: MessagingQueues,
key: 'MESSAGING_QUEUES_KAFKA_DETAIL',
isPrivate: true,
},
@@ -463,21 +463,21 @@ const routes: AppRoutes[] = [
{
path: ROUTES.METER,
exact: true,
component: MeterExplorerPage,
component: MeterExplorer,
key: 'METER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER,
exact: true,
component: MeterExplorerPage,
component: MeterExplorer,
key: 'METER_EXPLORER',
isPrivate: true,
},
{
path: ROUTES.METER_EXPLORER_VIEWS,
exact: true,
component: MeterExplorerPage,
component: MeterExplorer,
key: 'METER_EXPLORER_VIEWS',
isPrivate: true,
},

View File

@@ -86,6 +86,7 @@ interface QuerySearchProps {
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showFilterSuggestionsWithoutMetric?: boolean;
}
function QuerySearch({
@@ -96,6 +97,7 @@ function QuerySearch({
onRun,
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -252,7 +254,8 @@ function QuerySearch({
async (searchText?: string): Promise<void> => {
if (
dataSource === DataSource.METRICS &&
!queryData.aggregateAttribute?.key
!queryData.aggregateAttribute?.key &&
!showFilterSuggestionsWithoutMetric
) {
setKeySuggestions([]);
return;
@@ -301,6 +304,7 @@ function QuerySearch({
queryData.aggregateAttribute?.key,
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
],
);
@@ -1562,6 +1566,7 @@ QuerySearch.defaultProps = {
hardcodedAttributeKeys: undefined,
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
};
export default QuerySearch;

View File

@@ -1,5 +1,4 @@
import { YAxisCategoryNames } from '../constants';
import { UniversalYAxisUnit, YAxisCategory } from '../types';
import { UniversalYAxisUnit } from '../types';
import {
getUniversalNameFromMetricUnit,
mapMetricUnitToUniversalUnit,
@@ -42,29 +41,29 @@ describe('YAxisUnitSelector utils', () => {
describe('mergeCategories', () => {
it('merges categories correctly', () => {
const categories1: YAxisCategory[] = [
const categories1 = [
{
name: YAxisCategoryNames.Data,
name: 'Data',
units: [
{ name: 'bytes', id: UniversalYAxisUnit.BYTES },
{ name: 'kilobytes', id: UniversalYAxisUnit.KILOBYTES },
],
},
];
const categories2: YAxisCategory[] = [
const categories2 = [
{
name: YAxisCategoryNames.Data,
name: 'Data',
units: [{ name: 'bits', id: UniversalYAxisUnit.BITS }],
},
{
name: YAxisCategoryNames.Time,
name: 'Time',
units: [{ name: 'seconds', id: UniversalYAxisUnit.SECONDS }],
},
];
const mergedCategories = mergeCategories(categories1, categories2);
expect(mergedCategories).toEqual([
{
name: YAxisCategoryNames.Data,
name: 'Data',
units: [
{ name: 'bytes', id: UniversalYAxisUnit.BYTES },
{ name: 'kilobytes', id: UniversalYAxisUnit.KILOBYTES },
@@ -72,7 +71,7 @@ describe('YAxisUnitSelector utils', () => {
],
},
{
name: YAxisCategoryNames.Time,
name: 'Time',
units: [{ name: 'seconds', id: UniversalYAxisUnit.SECONDS }],
},
]);

View File

@@ -1,36 +1,5 @@
import { UnitFamilyConfig, UniversalYAxisUnit, YAxisUnit } from './types';
export enum YAxisCategoryNames {
Time = 'Time',
Data = 'Data',
DataRate = 'Data Rate',
Count = 'Count',
Operations = 'Operations',
Percentage = 'Percentage',
Boolean = 'Boolean',
None = 'None',
HashRate = 'Hash Rate',
Miscellaneous = 'Miscellaneous',
Acceleration = 'Acceleration',
Angular = 'Angular',
Area = 'Area',
Flops = 'FLOPs',
Concentration = 'Concentration',
Currency = 'Currency',
Datetime = 'Datetime',
PowerElectrical = 'Power/Electrical',
Flow = 'Flow',
Force = 'Force',
Mass = 'Mass',
Length = 'Length',
Pressure = 'Pressure',
Radiation = 'Radiation',
RotationSpeed = 'Rotation Speed',
Temperature = 'Temperature',
Velocity = 'Velocity',
Volume = 'Volume',
}
// Mapping of universal y-axis units to their AWS, UCUM, and OpenMetrics equivalents (if available)
export const UniversalYAxisUnitMappings: Partial<
Record<UniversalYAxisUnit, Set<YAxisUnit> | null>

View File

@@ -1,11 +1,10 @@
import { Y_AXIS_UNIT_NAMES } from './constants';
import { YAxisCategoryNames } from './constants';
import { UniversalYAxisUnit, YAxisCategory } from './types';
// Base categories for the universal y-axis units
export const BASE_Y_AXIS_CATEGORIES: YAxisCategory[] = [
{
name: YAxisCategoryNames.Time,
name: 'Time',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.SECONDS],
@@ -38,7 +37,7 @@ export const BASE_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Data,
name: 'Data',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BYTES],
@@ -155,7 +154,7 @@ export const BASE_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.DataRate,
name: 'Data Rate',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BYTES_SECOND],
@@ -296,7 +295,7 @@ export const BASE_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Count,
name: 'Count',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.COUNT],
@@ -313,7 +312,7 @@ export const BASE_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Operations,
name: 'Operations',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.OPS_SECOND],
@@ -354,7 +353,7 @@ export const BASE_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Percentage,
name: 'Percentage',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PERCENT],
@@ -367,7 +366,7 @@ export const BASE_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Boolean,
name: 'Boolean',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TRUE_FALSE],
@@ -383,7 +382,7 @@ export const BASE_Y_AXIS_CATEGORIES: YAxisCategory[] = [
export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
{
name: YAxisCategoryNames.Time,
name: 'Time',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.DURATION_MS],
@@ -420,7 +419,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.DataRate,
name: 'Data Rate',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.DATA_RATE_PACKETS_PER_SECOND],
@@ -429,7 +428,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Boolean,
name: 'Boolean',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ON_OFF],
@@ -438,7 +437,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.None,
name: 'None',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.NONE],
@@ -447,7 +446,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.HashRate,
name: 'Hash Rate',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.HASH_RATE_HASHES_PER_SECOND],
@@ -480,7 +479,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Miscellaneous,
name: 'Miscellaneous',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MISC_STRING],
@@ -521,7 +520,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Acceleration,
name: 'Acceleration',
units: [
{
name:
@@ -542,7 +541,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Angular,
name: 'Angular',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ANGULAR_DEGREE],
@@ -567,7 +566,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Area,
name: 'Area',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.AREA_SQUARE_METERS],
@@ -584,7 +583,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Flops,
name: 'FLOPs',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.FLOPS_FLOPS],
@@ -621,7 +620,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Concentration,
name: 'Concentration',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.CONCENTRATION_PPM],
@@ -678,7 +677,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Currency,
name: 'Currency',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.CURRENCY_USD],
@@ -775,7 +774,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Datetime,
name: 'Datetime',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.DATETIME_ISO],
@@ -812,7 +811,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.PowerElectrical,
name: 'Power/Electrical',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.POWER_WATT],
@@ -969,7 +968,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Flow,
name: 'Flow',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.FLOW_GALLONS_PER_MINUTE],
@@ -1006,7 +1005,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Force,
name: 'Force',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.FORCE_NEWTON_METERS],
@@ -1027,7 +1026,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Mass,
name: 'Mass',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MASS_MILLIGRAM],
@@ -1052,7 +1051,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Length,
name: 'Length',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.LENGTH_MILLIMETER],
@@ -1081,7 +1080,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Pressure,
name: 'Pressure',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PRESSURE_MILLIBAR],
@@ -1118,7 +1117,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Radiation,
name: 'Radiation',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.RADIATION_BECQUEREL],
@@ -1175,7 +1174,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.RotationSpeed,
name: 'Rotation Speed',
units: [
{
name:
@@ -1201,7 +1200,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Temperature,
name: 'Temperature',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TEMPERATURE_CELSIUS],
@@ -1218,7 +1217,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Velocity,
name: 'Velocity',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.VELOCITY_METERS_PER_SECOND],
@@ -1239,7 +1238,7 @@ export const ADDITIONAL_Y_AXIS_CATEGORIES: YAxisCategory[] = [
],
},
{
name: YAxisCategoryNames.Volume,
name: 'Volume',
units: [
{
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.VOLUME_MILLILITER],

View File

@@ -1,5 +1,3 @@
import { YAxisCategoryNames } from './constants';
export interface YAxisUnitSelectorProps {
value: string | undefined;
onChange: (value: UniversalYAxisUnit) => void;
@@ -671,7 +669,7 @@ export interface UnitFamilyConfig {
}
export interface YAxisCategory {
name: YAxisCategoryNames;
name: string;
units: {
name: string;
id: UniversalYAxisUnit;

View File

@@ -172,51 +172,23 @@ function ExplorerOptions({
const { user } = useAppContext();
const handleConditionalQueryModification = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(defaultQuery: Query | null): string => {
const queryToUse = defaultQuery || query;
if (!queryToUse) {
throw new Error('No query provided');
}
if (
queryToUse?.builder?.queryData?.[0]?.aggregateOperator !==
StringOperators.NOOP &&
sourcepage !== DataSource.LOGS
StringOperators.NOOP
) {
return JSON.stringify(queryToUse);
}
// Convert NOOP to COUNT for alerts and strip orderBy for logs
// Modify aggregateOperator to count, as noop is not supported in alerts
const modifiedQuery = cloneDeep(queryToUse);
if (modifiedQuery && modifiedQuery.builder?.queryData) {
modifiedQuery.builder.queryData = modifiedQuery.builder.queryData.map(
(item) => {
const updatedItem = { ...item };
if (updatedItem.aggregateOperator === StringOperators.NOOP) {
updatedItem.aggregateOperator = StringOperators.COUNT;
}
modifiedQuery.builder.queryData[0].aggregateOperator = StringOperators.COUNT;
// Alerts do not support order by on logs explorer queries
if (sourcepage === DataSource.LOGS && panelType === PANEL_TYPES.LIST) {
updatedItem.orderBy = [];
}
return updatedItem;
},
);
}
try {
return JSON.stringify(modifiedQuery);
} catch (err) {
throw new Error(
'Failed to stringify modified query: ' +
(err instanceof Error ? err.message : String(err)),
);
}
return JSON.stringify(modifiedQuery);
},
[panelType, query, sourcepage],
[query],
);
const onCreateAlertsHandler = useCallback(
@@ -785,9 +757,9 @@ function ExplorerOptions({
);
}, [
disabled,
query,
isOneChartPerQuery,
onCreateAlertsHandler,
query,
splitedQueries,
]);

View File

@@ -1,4 +1,3 @@
import { useHistory } from 'react-router-dom';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
@@ -16,11 +15,6 @@ import { getExplorerToolBarVisibility } from '../utils';
// Mock dependencies
jest.mock('hooks/dashboard/useUpdateDashboard');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: jest.fn(),
}));
jest.mock('../utils', () => ({
getExplorerToolBarVisibility: jest.fn(),
generateRGBAFromHex: jest.fn(() => 'rgba(0, 0, 0, 0.08)'),
@@ -35,7 +29,6 @@ const mockGetExplorerToolBarVisibility = jest.mocked(
);
const mockUseUpdateDashboard = jest.mocked(useUpdateDashboard);
const mockUseHistory = jest.mocked(useHistory);
// Mock data
const TEST_QUERY_ID = 'test-query-id';
@@ -110,6 +103,7 @@ describe('ExplorerOptionWrapper', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetExplorerToolBarVisibility.mockReturnValue(true);
// Mock useUpdateDashboard to return a mutation object
mockUseUpdateDashboard.mockReturnValue(({
mutate: jest.fn(),
@@ -123,28 +117,6 @@ describe('ExplorerOptionWrapper', () => {
} as unknown) as ReturnType<typeof useUpdateDashboard>);
});
it('should navigate to alert creation page when "Create an Alert" is clicked in logs-explorer', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockPush = jest.fn();
mockUseHistory.mockReturnValue(({
push: mockPush,
} as unknown) as ReturnType<typeof useHistory>);
renderExplorerOptionWrapper({ sourcepage: DataSource.LOGS });
const createAlertButton = screen.getByRole('button', {
name: 'Create an Alert',
});
await user.click(createAlertButton);
expect(mockPush).toHaveBeenCalledTimes(1);
const calledWith = mockPush.mock.calls[0][0] as string;
const [path, search = ''] = calledWith.split('?');
expect(path).toBe('/alerts/new');
const params = new URLSearchParams(search);
expect(params.has('compositeQuery')).toBe(true);
});
describe('onExport functionality', () => {
it('should call onExport when New Dashboard button is clicked in export modal', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -0,0 +1,13 @@
import {
MetricsexplorertypesMetricMetadataDTO,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
export const MOCK_METRIC_METADATA: MetricsexplorertypesMetricMetadataDTO = {
type: MetrictypesTypeDTO.sum,
description: 'metric1 description',
unit: 'metric1 unit',
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true,
};

View File

@@ -21,7 +21,7 @@ import {
METRIC_TYPE_LABEL_MAP,
METRIC_TYPE_VALUES_MAP,
} from '../Summary/constants';
import { MetricTypeRenderer } from '../Summary/utils';
import MetricTypeRenderer from '../Summary/MetricTypeRenderer';
import { METRIC_METADATA_KEYS } from './constants';
import { MetadataProps } from './types';
import { determineIsMonotonic } from './utils';

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import {
Button,
Empty,
@@ -10,22 +9,24 @@ import {
Popover,
Spin,
} from 'antd';
import { Filter } from 'api/v5/v5';
import {
convertExpressionToFilters,
convertFiltersToExpression,
} from 'components/QueryBuilderV2/utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Search } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { SUMMARY_FILTERS_KEY } from './constants';
function MetricNameSearch({
queryFilters,
queryFilterExpression,
onFilterChange,
}: {
queryFilters: TagFilter;
queryFilterExpression: Filter;
onFilterChange: (value: string) => void;
}): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [searchString, setSearchString] = useState<string>('');
const [debouncedSearchString, setDebouncedSearchString] = useState<string>('');
@@ -67,9 +68,12 @@ function MetricNameSearch({
const handleSelect = useCallback(
(selectedMetricName: string): void => {
const queryFilters = convertExpressionToFilters(
queryFilterExpression.expression,
);
const newFilters = {
items: [
...queryFilters.items,
...queryFilters,
{
id: 'metric_name',
op: 'CONTAINS',
@@ -83,13 +87,11 @@ function MetricNameSearch({
],
op: 'and',
};
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
const newFilterExpression = convertFiltersToExpression(newFilters);
onFilterChange(newFilterExpression.expression);
setIsPopoverOpen(false);
},
[queryFilters.items, setSearchParams, searchParams],
[queryFilterExpression, onFilterChange],
);
const metricNameFilterValues = useMemo(

View File

@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Typography } from 'antd';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
} from 'lucide-react';
import { METRIC_TYPE_LABEL_MAP } from './constants';
// TODO: @amlannandy Delete this component after API migration is complete
function MetricTypeRenderer({ type }: { type: MetricType }): JSX.Element {
const [icon, color] = useMemo(() => {
switch (type) {
case MetricType.SUM:
return [
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
Color.BG_ROBIN_500,
];
case MetricType.GAUGE:
return [
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
Color.BG_SAKURA_500,
];
case MetricType.HISTOGRAM:
return [
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
Color.BG_SIENNA_500,
];
case MetricType.SUMMARY:
return [
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
Color.BG_FOREST_500,
];
case MetricType.EXPONENTIAL_HISTOGRAM:
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_LABEL_MAP[type]}
</Typography.Text>
</div>
);
}
export default MetricTypeRenderer;

View File

@@ -0,0 +1,79 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Typography } from 'antd';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
} from 'lucide-react';
import { METRIC_TYPE_LABEL_MAP_V2 } from './constants';
export function MetricTypeRendererV2({
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_LABEL_MAP_V2[type]}
</Typography.Text>
</div>
);
}
export default MetricTypeRendererV2;

View File

@@ -1,23 +1,19 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { Button, Menu, Popover, Tooltip } from 'antd';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { Search } from 'lucide-react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
METRIC_TYPE_LABEL_MAP,
METRIC_TYPE_VALUES_MAP,
SUMMARY_FILTERS_KEY,
} from './constants';
import { METRIC_TYPE_LABEL_MAP_V2 } from './constants';
function MetricTypeSearch({
queryFilters,
onFilterChange,
}: {
queryFilters: TagFilter;
onFilterChange: (expression: string) => void;
}): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const menuItems = useMemo(
@@ -26,9 +22,9 @@ function MetricTypeSearch({
key: 'all',
value: 'All',
},
...Object.keys(METRIC_TYPE_LABEL_MAP).map((key) => ({
key: METRIC_TYPE_VALUES_MAP[key as MetricType],
value: METRIC_TYPE_LABEL_MAP[key as MetricType],
...Object.keys(METRIC_TYPE_LABEL_MAP_V2).map((key) => ({
key: METRIC_TYPE_LABEL_MAP_V2[key as MetrictypesTypeDTO],
value: METRIC_TYPE_LABEL_MAP_V2[key as MetrictypesTypeDTO],
})),
],
[],
@@ -36,16 +32,17 @@ function MetricTypeSearch({
const handleSelect = useCallback(
(selectedMetricType: string): void => {
let newFilters;
if (selectedMetricType !== 'all') {
const newFilters = {
newFilters = {
items: [
...queryFilters.items,
{
id: 'metric_type',
id: 'type',
op: '=',
key: {
id: 'metric_type',
key: 'metric_type',
id: 'type',
key: 'type',
type: 'tag',
},
value: selectedMetricType,
@@ -53,23 +50,17 @@ function MetricTypeSearch({
],
op: 'AND',
};
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
} else {
const newFilters = {
items: queryFilters.items.filter((item) => item.id !== 'metric_type'),
newFilters = {
items: queryFilters.items.filter((item) => item.id !== 'type'),
op: 'AND',
};
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
}
const newFilterExpression = convertFiltersToExpression(newFilters);
onFilterChange(newFilterExpression.expression);
setIsPopoverOpen(false);
},
[queryFilters.items, setSearchParams, searchParams],
[queryFilters.items, onFilterChange],
);
const menu = (

View File

@@ -1,27 +1,55 @@
import { useCallback } from 'react';
import { Tooltip } from 'antd';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { HardHat, Info } from 'lucide-react';
import { Info } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
import { MetricsSearchProps } from './types';
function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element {
function MetricsSearch({
query,
onChange,
currentQueryFilterExpression,
setCurrentQueryFilterExpression,
isLoading,
}: MetricsSearchProps): JSX.Element {
const handleOnChange = useCallback(
(expression: string): void => {
setCurrentQueryFilterExpression(expression);
},
[setCurrentQueryFilterExpression],
);
const handleStageAndRunQuery = useCallback(() => {
onChange(currentQueryFilterExpression);
}, [currentQueryFilterExpression, onChange]);
return (
<div className="metrics-search-container">
<div className="qb-search-container">
<div data-testid="qb-search-container" className="qb-search-container">
<Tooltip
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
placement="right"
>
<Info size={16} />
</Tooltip>
<QueryBuilderSearch
query={query}
onChange={onChange}
suffixIcon={<HardHat size={16} />}
isMetricsExplorer
<QuerySearch
onChange={handleOnChange}
dataSource={DataSource.METRICS}
queryData={{
...query,
filter: {
...query?.filter,
expression: currentQueryFilterExpression,
},
}}
onRun={handleOnChange}
showFilterSuggestionsWithoutMetric
/>
</div>
<RunQueryBtn onStageRunQuery={handleStageAndRunQuery} disabled={isLoading} />
<div className="metrics-search-options">
<DateTimeSelectionV2
showAutoRefresh={false}

View File

@@ -9,6 +9,7 @@ import {
Typography,
} from 'antd';
import { SorterResult } from 'antd/es/table/interface';
import { Querybuildertypesv5OrderDirectionDTO } from 'api/generated/services/sigNoz.schemas';
import { Info } from 'lucide-react';
import { MetricsListItemRowData, MetricsTableProps } from './types';
@@ -24,7 +25,8 @@ function MetricsTable({
setOrderBy,
totalCount,
openMetricDetails,
queryFilters,
queryFilterExpression,
onFilterChange,
}: MetricsTableProps): JSX.Element {
const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback(
(
@@ -36,13 +38,20 @@ function MetricsTable({
): void => {
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: sorter.order === 'ascend' ? 'asc' : 'desc',
key: {
name: sorter.field as string,
},
direction:
sorter.order === 'ascend'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
});
} else {
setOrderBy({
columnName: 'samples',
order: 'desc',
key: {
name: 'samples',
},
direction: Querybuildertypesv5OrderDirectionDTO.desc,
});
}
},
@@ -51,19 +60,17 @@ function MetricsTable({
return (
<div className="metrics-table-container">
{!isError && !isLoading && (
<div className="metrics-table-title" data-testid="metrics-table-title">
<Typography.Title level={4} className="metrics-table-title">
List View
</Typography.Title>
<Tooltip
title="The table displays all metrics in the selected time range. Each row represents a unique metric, and its metric name, and metadata like description, type, unit, and samples/timeseries cardinality observed in the selected time range."
placement="right"
>
<Info size={16} />
</Tooltip>
</div>
)}
<div className="metrics-table-title" data-testid="metrics-table-title">
<Typography.Title level={4} className="metrics-table-title">
List View
</Typography.Title>
<Tooltip
title="The table displays all metrics in the selected time range. Each row represents a unique metric, and its metric name, and metadata like description, type, unit, and samples/timeseries cardinality observed in the selected time range."
placement="right"
>
<Info size={16} />
</Tooltip>
</div>
<Table
loading={{
spinning: isLoading,
@@ -75,7 +82,7 @@ function MetricsTable({
),
}}
dataSource={data}
columns={getMetricsTableColumns(queryFilters)}
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
locale={{
emptyText: isLoading ? null : (
<div

View File

@@ -1,9 +1,10 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useWindowSize } from 'react-use';
import { Group } from '@visx/group';
import { Treemap } from '@visx/hierarchy';
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
import { stratify, treemapBinary } from 'd3-hierarchy';
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
import { HierarchyNode, stratify, treemapBinary } from 'd3-hierarchy';
import { Info } from 'lucide-react';
import {
@@ -12,21 +13,20 @@ import {
TREEMAP_SQUARE_PADDING,
TREEMAP_VIEW_OPTIONS,
} from './constants';
import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types';
import { MetricsTreemapProps, TreemapContentProps, TreemapTile } from './types';
import {
getTreemapTileStyle,
getTreemapTileTextStyle,
transformTreemapData,
} from './utils';
function MetricsTreemap({
viewType,
data,
function TreemapContent({
isLoading,
isError,
data,
viewType,
openMetricDetails,
setHeatmapView,
}: MetricsTreemapProps): JSX.Element {
}: TreemapContentProps): JSX.Element {
const { width: windowWidth } = useWindowSize();
const treemapWidth = useMemo(
@@ -40,9 +40,9 @@ function MetricsTreemap({
const treemapData = useMemo(() => {
const extracedTreemapData =
(viewType === TreemapViewType.TIMESERIES
? data?.data?.[TreemapViewType.TIMESERIES]
: data?.data?.[TreemapViewType.SAMPLES]) || [];
(viewType === MetricsexplorertypesTreemapModeDTO.timeseries
? data?.timeseries
: data?.samples) || [];
return transformTreemapData(extracedTreemapData, viewType);
}, [data, viewType]);
@@ -54,41 +54,126 @@ function MetricsTreemap({
const xMax = treemapWidth - TREEMAP_MARGINS.LEFT - TREEMAP_MARGINS.RIGHT;
const yMax = TREEMAP_HEIGHT - TREEMAP_MARGINS.TOP - TREEMAP_MARGINS.BOTTOM;
const treemapStylesWithoutPadding = useMemo(
() => ({
width: treemapWidth,
height: TREEMAP_HEIGHT,
}),
[treemapWidth],
);
const treemapStylesWithPadding = useMemo(
() => ({
width: treemapWidth,
height: TREEMAP_HEIGHT,
paddingTop: 30,
}),
[treemapWidth],
);
const treemapTileStyle = useCallback(
(node: HierarchyNode<TreemapTile>) => ({
...getTreemapTileStyle(node.data),
...getTreemapTileTextStyle(),
}),
[],
);
if (isLoading) {
return (
<div data-testid="metrics-treemap-loading-state">
<Skeleton
style={{ width: treemapWidth, height: TREEMAP_HEIGHT + 55 }}
active
/>
<Skeleton style={treemapStylesWithoutPadding} active />
</div>
);
}
if (
!data ||
!data.data ||
(data?.status === 'success' && !data?.data?.[viewType])
) {
return (
<Empty
description="No metrics found"
data-testid="metrics-treemap-empty-state"
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
/>
);
}
if (data?.status === 'error' || isError) {
if (isError) {
return (
<Empty
description="Error fetching metrics. If the problem persists, please contact support."
data-testid="metrics-treemap-error-state"
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
style={treemapStylesWithPadding}
/>
);
}
if (!data || !data?.[viewType]?.length) {
return (
<Empty
description="No metrics found"
data-testid="metrics-treemap-empty-state"
style={treemapStylesWithPadding}
/>
);
}
return (
<svg width={treemapWidth} height={TREEMAP_HEIGHT} className="metrics-treemap">
<rect
width={treemapWidth}
height={TREEMAP_HEIGHT}
rx={14}
fill="transparent"
/>
<Treemap<TreemapTile>
top={TREEMAP_MARGINS.TOP}
root={transformedTreemapData}
size={[xMax, yMax]}
tile={treemapBinary}
round
>
{(treemap): JSX.Element => (
<Group>
{treemap
.descendants()
.reverse()
.map((node, i) => {
const nodeWidth = node.x1 - node.x0 - TREEMAP_SQUARE_PADDING;
const nodeHeight = node.y1 - node.y0 - TREEMAP_SQUARE_PADDING;
if (nodeWidth < 0 || nodeHeight < 0) {
return null;
}
return (
<Group
// eslint-disable-next-line react/no-array-index-key
key={node.data.id || `node-${i}`}
top={node.y0 + TREEMAP_MARGINS.TOP}
left={node.x0 + TREEMAP_MARGINS.LEFT}
>
{node.depth > 0 && (
<Tooltip
title={`${node.data.id}: ${node.data.displayValue}%`}
placement="top"
>
<foreignObject
width={nodeWidth}
height={nodeHeight}
onClick={(): void => openMetricDetails(node.data.id, 'treemap')}
>
<div style={treemapTileStyle(node)}>
{`${node.data.displayValue}%`}
</div>
</foreignObject>
</Tooltip>
)}
</Group>
);
})}
</Group>
)}
</Treemap>
</svg>
);
}
function MetricsTreemap({
viewType,
data,
isLoading,
isError,
openMetricDetails,
setHeatmapView,
}: MetricsTreemapProps): JSX.Element {
return (
<div
className="metrics-treemap-container"
@@ -108,72 +193,16 @@ function MetricsTreemap({
options={TREEMAP_VIEW_OPTIONS}
value={viewType}
onChange={setHeatmapView}
disabled={isLoading}
/>
</div>
<svg
width={treemapWidth}
height={TREEMAP_HEIGHT}
className="metrics-treemap"
>
<rect
width={treemapWidth}
height={TREEMAP_HEIGHT}
rx={14}
fill="transparent"
/>
<Treemap<TreemapTile>
top={TREEMAP_MARGINS.TOP}
root={transformedTreemapData}
size={[xMax, yMax]}
tile={treemapBinary}
round
>
{(treemap): JSX.Element => (
<Group>
{treemap
.descendants()
.reverse()
.map((node, i) => {
const nodeWidth = node.x1 - node.x0 - TREEMAP_SQUARE_PADDING;
const nodeHeight = node.y1 - node.y0 - TREEMAP_SQUARE_PADDING;
if (nodeWidth < 0 || nodeHeight < 0) {
return null;
}
return (
<Group
// eslint-disable-next-line react/no-array-index-key
key={node.data.id || `node-${i}`}
top={node.y0 + TREEMAP_MARGINS.TOP}
left={node.x0 + TREEMAP_MARGINS.LEFT}
>
{node.depth > 0 && (
<Tooltip
title={`${node.data.id}: ${node.data.displayValue}%`}
placement="top"
>
<foreignObject
width={nodeWidth}
height={nodeHeight}
onClick={(): void => openMetricDetails(node.data.id, 'treemap')}
>
<div
style={{
...getTreemapTileStyle(node.data),
...getTreemapTileTextStyle(),
}}
>
{`${node.data.displayValue}%`}
</div>
</foreignObject>
</Tooltip>
)}
</Group>
);
})}
</Group>
)}
</Treemap>
</svg>
<TreemapContent
isLoading={isLoading}
isError={isError}
data={data}
viewType={viewType}
openMetricDetails={openMetricDetails}
/>
</div>
);
}

View File

@@ -38,6 +38,7 @@
.metrics-search-container {
display: flex;
gap: 16px;
align-items: center;
.metrics-search-options {
display: flex;

View File

@@ -4,11 +4,24 @@ import { useSelector } from 'react-redux';
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 {
useGetMetricsStats,
useGetMetricsTreemap,
} from 'api/generated/services/metrics';
import {
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapModeDTO,
MetricsexplorertypesTreemapRequestDTO,
Querybuildertypesv5OrderByDTO,
Querybuildertypesv5OrderDirectionDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
convertExpressionToFilters,
convertFiltersToExpression,
} from 'components/QueryBuilderV2/utils';
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import NoLogs from 'container/NoLogs/NoLogs';
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
import { useGetMetricsTreeMap } from 'hooks/metricsExplorer/useGetMetricsTreeMap';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { AppState } from 'store/reducers';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -23,32 +36,38 @@ import {
IS_INSPECT_MODAL_OPEN_KEY,
IS_METRIC_DETAILS_OPEN_KEY,
SELECTED_METRIC_NAME_KEY,
SUMMARY_FILTERS_KEY,
} from './constants';
import MetricsSearch from './MetricsSearch';
import MetricsTable from './MetricsTable';
import MetricsTreemap from './MetricsTreemap';
import { OrderByPayload, TreemapViewType } from './types';
import {
convertNanoToMilliseconds,
formatDataForMetricsTable,
getMetricsListQuery,
} from './utils';
import { convertNanoToMilliseconds, formatDataForMetricsTable } from './utils';
import './Summary.styles.scss';
const DEFAULT_ORDER_BY: OrderByPayload = {
columnName: 'samples',
order: 'desc',
const DEFAULT_ORDER_BY: Querybuildertypesv5OrderByDTO = {
key: {
name: 'samples',
},
direction: Querybuildertypesv5OrderDirectionDTO.desc,
};
function Summary(): JSX.Element {
const { pageSize, setPageSize } = usePageSize('metricsExplorer');
const [currentPage, setCurrentPage] = useState(1);
const [orderBy, setOrderBy] = useState<OrderByPayload>(DEFAULT_ORDER_BY);
const [heatmapView, setHeatmapView] = useState<TreemapViewType>(
TreemapViewType.TIMESERIES,
const [orderBy, setOrderBy] = useState<Querybuildertypesv5OrderByDTO>(
DEFAULT_ORDER_BY,
);
const [
heatmapView,
setHeatmapView,
] = useState<MetricsexplorertypesTreemapModeDTO>(
MetricsexplorertypesTreemapModeDTO.timeseries,
);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
currentQuery,
]);
const [searchParams, setSearchParams] = useSearchParams();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
@@ -65,16 +84,10 @@ function Summary(): JSX.Element {
(state) => state.globalTime,
);
const queryFilters: TagFilter = useMemo(() => {
const encodedFilters = searchParams.get(SUMMARY_FILTERS_KEY);
if (encodedFilters) {
return JSON.parse(encodedFilters);
}
return {
items: [],
op: 'AND',
};
}, [searchParams]);
const [
currentQueryFilterExpression,
setCurrentQueryFilterExpression,
] = useState<string>(query?.filter?.expression || '');
useEffect(() => {
logEvent(MetricsExplorerEvents.TabChanged, {
@@ -87,105 +100,101 @@ function Summary(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// This is used to avoid the filters from being serialized with the id
const queryFiltersWithoutId = useMemo(() => {
const filtersWithoutId = {
...queryFilters,
items: queryFilters.items.map(({ id: _id, ...rest }) => rest),
};
return JSON.stringify(filtersWithoutId);
}, [queryFilters]);
const queryFilterExpression = useMemo(() => {
const filters = query.filters || { items: [], op: 'AND' };
return convertFiltersToExpression(filters);
}, [query.filters]);
const metricsListQuery = useMemo(() => {
const baseQuery = getMetricsListQuery();
const metricsListQuery: MetricsexplorertypesStatsRequestDTO = useMemo(() => {
return {
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime),
limit: pageSize,
offset: (currentPage - 1) * pageSize,
orderBy,
filter: {
expression: queryFilterExpression.expression,
},
};
}, [queryFilters, minTime, maxTime, orderBy, pageSize, currentPage]);
}, [
minTime,
maxTime,
orderBy,
pageSize,
currentPage,
queryFilterExpression.expression,
]);
const metricsTreemapQuery = useMemo(
const metricsTreemapQuery: MetricsexplorertypesTreemapRequestDTO = useMemo(
() => ({
limit: 100,
filters: queryFilters,
treemap: heatmapView,
start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime),
mode: heatmapView,
filter: {
expression: queryFilterExpression.expression,
},
}),
[queryFilters, heatmapView, minTime, maxTime],
[heatmapView, minTime, maxTime, queryFilterExpression.expression],
);
const {
data: metricsData,
isLoading: isMetricsLoading,
isFetching: isMetricsFetching,
isError: isMetricsError,
} = useGetMetricsList(metricsListQuery, {
enabled: !!metricsListQuery && !isInspectModalOpen,
queryKey: [
'metricsList',
queryFiltersWithoutId,
orderBy,
pageSize,
currentPage,
minTime,
maxTime,
],
});
const isListViewError = useMemo(
() => isMetricsError || !!(metricsData && metricsData.statusCode !== 200),
[isMetricsError, metricsData],
);
mutate: getMetricsStats,
isLoading: isGetMetricsStatsLoading,
isError: isGetMetricsStatsError,
} = useGetMetricsStats();
const {
data: treeMapData,
isLoading: isTreeMapLoading,
isFetching: isTreeMapFetching,
isError: isTreeMapError,
} = useGetMetricsTreeMap(metricsTreemapQuery, {
enabled: !!metricsTreemapQuery && !isInspectModalOpen,
queryKey: [
'metricsTreemap',
queryFiltersWithoutId,
heatmapView,
minTime,
maxTime,
],
});
mutate: getMetricsTreemap,
isLoading: isGetMetricsTreemapLoading,
isError: isGetMetricsTreemapError,
} = useGetMetricsTreemap();
const isProportionViewError = useMemo(
() => isTreeMapError || treeMapData?.statusCode !== 200,
[isTreeMapError, treeMapData],
);
useEffect(() => {
getMetricsStats({
data: metricsListQuery,
});
}, [metricsListQuery, getMetricsStats]);
useEffect(() => {
getMetricsTreemap({
data: metricsTreemapQuery,
});
}, [metricsTreemapQuery, getMetricsTreemap]);
const handleFilterChange = useCallback(
(value: TagFilter) => {
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(value),
(expression: string) => {
const newFilters: TagFilter = {
items: convertExpressionToFilters(expression),
op: 'AND',
};
redirectWithQueryBuilderData({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilters,
filter: {
expression,
},
},
],
},
});
setCurrentQueryFilterExpression(expression);
setCurrentPage(1);
if (value.items.length > 0) {
if (expression) {
logEvent(MetricsExplorerEvents.FilterApplied, {
[MetricsExplorerEventKeys.Tab]: 'summary',
});
}
},
[setSearchParams, searchParams],
);
const searchQuery = useMemo(
() => ({
...initialQueriesMap.metrics.builder.queryData[0],
filters: queryFilters,
}),
[queryFilters],
[currentQuery, redirectWithQueryBuilderData],
);
const onPaginationChange = (page: number, pageSize: number): void => {
@@ -202,7 +211,7 @@ function Summary(): JSX.Element {
};
const formattedMetricsData = useMemo(
() => formatDataForMetricsTable(metricsData?.payload?.data?.metrics || []),
() => formatDataForMetricsTable(metricsData?.data.metrics || []),
[metricsData],
);
@@ -254,7 +263,9 @@ function Summary(): JSX.Element {
});
};
const handleSetHeatmapView = (view: TreemapViewType): void => {
const handleSetHeatmapView = (
view: MetricsexplorertypesTreemapModeDTO,
): void => {
setHeatmapView(view);
logEvent(MetricsExplorerEvents.TreemapViewChanged, {
[MetricsExplorerEventKeys.Tab]: 'summary',
@@ -262,63 +273,62 @@ function Summary(): JSX.Element {
});
};
const handleSetOrderBy = (orderBy: OrderByPayload): void => {
const handleSetOrderBy = (orderBy: Querybuildertypesv5OrderByDTO): void => {
setOrderBy(orderBy);
logEvent(MetricsExplorerEvents.OrderByApplied, {
[MetricsExplorerEventKeys.Tab]: 'summary',
[MetricsExplorerEventKeys.ColumnName]: orderBy.columnName,
[MetricsExplorerEventKeys.Order]: orderBy.order,
[MetricsExplorerEventKeys.ColumnName]: orderBy.key?.name,
[MetricsExplorerEventKeys.Order]: orderBy.direction,
});
};
const isMetricsListDataEmpty = useMemo(
() =>
formattedMetricsData.length === 0 && !isMetricsLoading && !isMetricsFetching,
[formattedMetricsData, isMetricsLoading, isMetricsFetching],
);
const isMetricsListDataEmpty =
formattedMetricsData.length === 0 && !isGetMetricsStatsLoading;
const isMetricsTreeMapDataEmpty = useMemo(
() =>
!treeMapData?.payload?.data[heatmapView]?.length &&
!isTreeMapLoading &&
!isTreeMapFetching,
[
treeMapData?.payload?.data,
heatmapView,
isTreeMapLoading,
isTreeMapFetching,
],
);
const isMetricsTreeMapDataEmpty =
!treeMapData?.data[heatmapView]?.length && !isGetMetricsTreemapLoading;
const showFullScreenLoading =
(isGetMetricsStatsLoading || isGetMetricsTreemapLoading) &&
formattedMetricsData.length === 0 &&
!treeMapData?.data[heatmapView]?.length;
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-summary-tab">
<MetricsSearch query={searchQuery} onChange={handleFilterChange} />
{isMetricsLoading || isTreeMapLoading ? (
<MetricsSearch
query={query}
onChange={handleFilterChange}
currentQueryFilterExpression={currentQueryFilterExpression}
setCurrentQueryFilterExpression={setCurrentQueryFilterExpression}
isLoading={isGetMetricsStatsLoading || isGetMetricsTreemapLoading}
/>
{showFullScreenLoading ? (
<MetricsLoading />
) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? (
<NoLogs dataSource={DataSource.METRICS} />
) : (
<>
<MetricsTreemap
data={treeMapData?.payload}
isLoading={isTreeMapLoading || isTreeMapFetching}
isError={isProportionViewError}
data={treeMapData?.data}
isLoading={isGetMetricsTreemapLoading}
isError={isGetMetricsTreemapError}
viewType={heatmapView}
openMetricDetails={openMetricDetails}
setHeatmapView={handleSetHeatmapView}
/>
<MetricsTable
isLoading={isMetricsLoading || isMetricsFetching}
isError={isListViewError}
isLoading={isGetMetricsStatsLoading}
isError={isGetMetricsStatsError}
data={formattedMetricsData}
pageSize={pageSize}
currentPage={currentPage}
onPaginationChange={onPaginationChange}
setOrderBy={handleSetOrderBy}
totalCount={metricsData?.payload?.data?.total || 0}
totalCount={metricsData?.data.total || 0}
openMetricDetails={openMetricDetails}
queryFilters={queryFilters}
queryFilterExpression={queryFilterExpression}
onFilterChange={handleFilterChange}
/>
</>
)}

View File

@@ -0,0 +1,63 @@
import { Color } from '@signozhq/design-tokens';
import { render, screen } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import MetricTypeRenderer from '../MetricTypeRenderer';
jest.mock('lucide-react', () => {
return {
__esModule: true,
Diff: (): JSX.Element => <svg data-testid="diff-icon" />,
Gauge: (): JSX.Element => <svg data-testid="gauge-icon" />,
BarChart2: (): JSX.Element => <svg data-testid="bar-chart-2-icon" />,
BarChartHorizontal: (): JSX.Element => (
<svg data-testid="bar-chart-horizontal-icon" />
),
BarChart: (): JSX.Element => <svg data-testid="bar-chart-icon" />,
};
});
describe('MetricTypeRenderer', () => {
it('should render correct icon and color for each metric type', () => {
const types = [
{
type: MetricType.SUM,
color: Color.BG_ROBIN_500,
iconTestId: 'diff-icon',
},
{
type: MetricType.GAUGE,
color: Color.BG_SAKURA_500,
iconTestId: 'gauge-icon',
},
{
type: MetricType.HISTOGRAM,
color: Color.BG_SIENNA_500,
iconTestId: 'bar-chart-2-icon',
},
{
type: MetricType.SUMMARY,
color: Color.BG_FOREST_500,
iconTestId: 'bar-chart-horizontal-icon',
},
{
type: MetricType.EXPONENTIAL_HISTOGRAM,
color: Color.BG_AQUA_500,
iconTestId: 'bar-chart-icon',
},
];
types.forEach(({ type, color, iconTestId }) => {
const { container } = render(<MetricTypeRenderer type={type} />);
const rendererDiv = container.firstChild as HTMLElement;
expect(rendererDiv).toHaveStyle({
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
});
expect(screen.getByTestId(iconTestId)).toBeInTheDocument();
});
});
});

View File

@@ -1,10 +1,10 @@
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { Filter } from 'api/v5/v5';
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
import store from 'store';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import MetricsTable from '../MetricsTable';
import { MetricsListItemRowData } from '../types';
@@ -30,9 +30,8 @@ const mockData: MetricsListItemRowData[] = [
},
];
const mockQueryFilters: TagFilter = {
items: [],
op: 'AND',
const mockQueryFilterExpression: Filter = {
expression: '',
};
jest.mock('react-router-dom-v5-compat', () => {
@@ -82,7 +81,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -106,8 +106,9 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters}
queryFilterExpression={mockQueryFilterExpression}
isLoading
onFilterChange={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -130,7 +131,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -158,7 +160,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -187,7 +190,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={mockOpenMetricDetails}
queryFilters={mockQueryFilters}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -212,7 +216,8 @@ describe('MetricsTable', () => {
setOrderBy={mockSetOrderBy}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilters={mockQueryFilters}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -222,8 +227,10 @@ describe('MetricsTable', () => {
fireEvent.click(samplesHeader);
expect(mockSetOrderBy).toHaveBeenCalledWith({
columnName: 'samples',
order: 'asc',
key: {
name: 'samples',
},
direction: 'asc',
});
});
});

View File

@@ -1,10 +1,10 @@
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
import store from 'store';
import MetricsTreemap from '../MetricsTreemap';
import { TreemapViewType } from '../types';
jest.mock('d3-hierarchy', () => ({
stratify: jest.fn().mockReturnValue({
@@ -27,14 +27,14 @@ jest.mock('react-use', () => ({
const mockData = [
{
metric_name: 'Metric 1',
metricName: 'Metric 1',
percentage: 0.5,
total_value: 15,
totalValue: 15,
},
{
metric_name: 'Metric 2',
metricName: 'Metric 2',
percentage: 0.6,
total_value: 10,
totalValue: 10,
},
];
@@ -47,14 +47,11 @@ describe('MetricsTreemap', () => {
isLoading={false}
isError={false}
data={{
status: 'success',
data: {
timeseries: [mockData[0]],
samples: [mockData[1]],
},
timeseries: [mockData[0]],
samples: [mockData[1]],
}}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
viewType={MetricsexplorertypesTreemapModeDTO.samples}
setHeatmapView={jest.fn()}
/>
</Provider>
@@ -72,14 +69,11 @@ describe('MetricsTreemap', () => {
isLoading
isError={false}
data={{
status: 'success',
data: {
timeseries: [mockData[0]],
samples: [mockData[1]],
},
timeseries: [mockData[0]],
samples: [mockData[1]],
}}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
viewType={MetricsexplorertypesTreemapModeDTO.samples}
setHeatmapView={jest.fn()}
/>
</Provider>
@@ -99,14 +93,11 @@ describe('MetricsTreemap', () => {
isLoading={false}
isError
data={{
status: 'success',
data: {
timeseries: [mockData[0]],
samples: [mockData[1]],
},
timeseries: [mockData[0]],
samples: [mockData[1]],
}}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
viewType={MetricsexplorertypesTreemapModeDTO.samples}
setHeatmapView={jest.fn()}
/>
</Provider>
@@ -130,7 +121,7 @@ describe('MetricsTreemap', () => {
isError={false}
data={null}
openMetricDetails={jest.fn()}
viewType={TreemapViewType.SAMPLES}
viewType={MetricsexplorertypesTreemapModeDTO.samples}
setHeatmapView={jest.fn()}
/>
</Provider>

View File

@@ -1,109 +1,81 @@
import { Color } from '@signozhq/design-tokens';
import { render } from '@testing-library/react';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { Filter } from 'api/v5/v5';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TreemapViewType } from '../types';
import {
formatDataForMetricsTable,
getMetricsTableColumns,
MetricTypeRenderer,
} from '../utils';
import { formatDataForMetricsTable, getMetricsTableColumns } from '../utils';
const mockQueryExpression: Filter = {
expression: '',
};
const mockOnChange = jest.fn();
describe('metricsTableColumns', () => {
const mockQueryFilters: TagFilter = {
items: [],
op: 'AND',
};
it('should have correct column definitions', () => {
expect(getMetricsTableColumns(mockQueryFilters)).toHaveLength(6);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange),
).toHaveLength(6);
// Metric Name column
expect(getMetricsTableColumns(mockQueryFilters)[0].dataIndex).toBe(
'metric_name',
);
expect(getMetricsTableColumns(mockQueryFilters)[0].width).toBe(400);
expect(getMetricsTableColumns(mockQueryFilters)[0].sorter).toBe(false);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].dataIndex,
).toBe('metric_name');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].width,
).toBe(400);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].sorter,
).toBe(false);
// Description column
expect(getMetricsTableColumns(mockQueryFilters)[1].dataIndex).toBe(
'description',
);
expect(getMetricsTableColumns(mockQueryFilters)[1].width).toBe(400);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].dataIndex,
).toBe('description');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].width,
).toBe(400);
// Type column
expect(getMetricsTableColumns(mockQueryFilters)[2].dataIndex).toBe(
'metric_type',
);
expect(getMetricsTableColumns(mockQueryFilters)[2].width).toBe(150);
expect(getMetricsTableColumns(mockQueryFilters)[2].sorter).toBe(false);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].dataIndex,
).toBe('metric_type');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].width,
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].sorter,
).toBe(false);
// Unit column
expect(getMetricsTableColumns(mockQueryFilters)[3].dataIndex).toBe('unit');
expect(getMetricsTableColumns(mockQueryFilters)[3].width).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].dataIndex,
).toBe('unit');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].width,
).toBe(150);
// Samples column
expect(getMetricsTableColumns(mockQueryFilters)[4].dataIndex).toBe(
TreemapViewType.SAMPLES,
);
expect(getMetricsTableColumns(mockQueryFilters)[4].width).toBe(150);
expect(getMetricsTableColumns(mockQueryFilters)[4].sorter).toBe(true);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].dataIndex,
).toBe(TreemapViewType.SAMPLES);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].width,
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].sorter,
).toBe(true);
// Time Series column
expect(getMetricsTableColumns(mockQueryFilters)[5].dataIndex).toBe(
TreemapViewType.TIMESERIES,
);
expect(getMetricsTableColumns(mockQueryFilters)[5].width).toBe(150);
expect(getMetricsTableColumns(mockQueryFilters)[5].sorter).toBe(true);
});
describe('MetricTypeRenderer', () => {
it('should render correct icon and color for each metric type', () => {
const types = [
{
type: MetricType.SUM,
color: Color.BG_ROBIN_500,
},
{
type: MetricType.GAUGE,
color: Color.BG_SAKURA_500,
},
{
type: MetricType.HISTOGRAM,
color: Color.BG_SIENNA_500,
},
{
type: MetricType.SUMMARY,
color: Color.BG_FOREST_500,
},
{
type: MetricType.EXPONENTIAL_HISTOGRAM,
color: Color.BG_AQUA_500,
},
];
types.forEach(({ type, color }) => {
const { container } = render(<MetricTypeRenderer type={type} />);
const rendererDiv = container.firstChild as HTMLElement;
expect(rendererDiv).toHaveStyle({
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
});
});
});
it('should return empty icon and color for unknown metric type', () => {
const { container } = render(
<MetricTypeRenderer type={'UNKNOWN' as MetricType} />,
);
const rendererDiv = container.firstChild as HTMLElement;
expect(rendererDiv.querySelector('svg')).toBeNull();
});
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].dataIndex,
).toBe(TreemapViewType.TIMESERIES);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].width,
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].sorter,
).toBe(true);
});
});
@@ -111,12 +83,12 @@ describe('formatDataForMetricsTable', () => {
it('should format metrics data correctly', () => {
const mockData = [
{
metric_name: 'test_metric',
metricName: 'test_metric',
description: 'Test description',
type: MetricType.GAUGE,
type: MetrictypesTypeDTO.gauge,
unit: 'bytes',
[TreemapViewType.SAMPLES]: 1000,
[TreemapViewType.TIMESERIES]: 2000,
samples: 1000,
timeseries: 2000,
lastReceived: '2023-01-01T00:00:00Z',
},
];
@@ -163,12 +135,12 @@ describe('formatDataForMetricsTable', () => {
it('should handle empty/null values', () => {
const mockData = [
{
metric_name: '',
metricName: '',
description: '',
type: MetricType.GAUGE,
type: MetrictypesTypeDTO.gauge,
unit: '',
[TreemapViewType.SAMPLES]: 0,
[TreemapViewType.TIMESERIES]: 0,
samples: 0,
timeseries: 0,
lastReceived: '2023-01-01T00:00:00Z',
},
];

View File

@@ -1,15 +1,17 @@
import {
MetricsexplorertypesTreemapModeDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { TreemapViewType } from './types';
export const METRICS_TABLE_PAGE_SIZE = 10;
export const TREEMAP_VIEW_OPTIONS: {
value: TreemapViewType;
value: MetricsexplorertypesTreemapModeDTO;
label: string;
}[] = [
{ value: TreemapViewType.TIMESERIES, label: 'Time Series' },
{ value: TreemapViewType.SAMPLES, label: 'Samples' },
{ value: MetricsexplorertypesTreemapModeDTO.timeseries, label: 'Time Series' },
{ value: MetricsexplorertypesTreemapModeDTO.samples, label: 'Samples' },
];
export const TREEMAP_HEIGHT = 200;
@@ -17,6 +19,7 @@ export const TREEMAP_SQUARE_PADDING = 5;
export const TREEMAP_MARGINS = { TOP: 10, LEFT: 10, RIGHT: 10, BOTTOM: 10 };
// TODO: Remove this once API migration is complete
export const METRIC_TYPE_LABEL_MAP = {
[MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge',
@@ -25,6 +28,14 @@ export const METRIC_TYPE_LABEL_MAP = {
[MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram',
};
export const METRIC_TYPE_LABEL_MAP_V2 = {
[MetrictypesTypeDTO.sum]: 'Sum',
[MetrictypesTypeDTO.gauge]: 'Gauge',
[MetrictypesTypeDTO.histogram]: 'Histogram',
[MetrictypesTypeDTO.summary]: 'Summary',
[MetrictypesTypeDTO.exponentialhistogram]: 'Exp. Histogram',
};
export const METRIC_TYPE_VALUES_MAP = {
[MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge',
@@ -36,4 +47,3 @@ export const METRIC_TYPE_VALUES_MAP = {
export const IS_METRIC_DETAILS_OPEN_KEY = 'isMetricDetailsOpen';
export const IS_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen';
export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName';
export const SUMMARY_FILTERS_KEY = 'summaryFilters';

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { MetricsTreeMapResponse } from 'api/metricsExplorer/getMetricsTreeMap';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
MetricsexplorertypesTreemapModeDTO,
MetricsexplorertypesTreemapResponseDTO,
Querybuildertypesv5OrderByDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Filter } from 'api/v5/v5';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export interface MetricsTableProps {
isLoading: boolean;
@@ -12,24 +14,36 @@ export interface MetricsTableProps {
pageSize: number;
currentPage: number;
onPaginationChange: (page: number, pageSize: number) => void;
setOrderBy: (orderBy: OrderByPayload) => void;
setOrderBy: (orderBy: Querybuildertypesv5OrderByDTO) => void;
totalCount: number;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
queryFilters: TagFilter;
queryFilterExpression: Filter;
onFilterChange: (expression: string) => void;
}
export interface MetricsSearchProps {
query: IBuilderQuery;
onChange: (value: TagFilter) => void;
onChange: (expression: string) => void;
currentQueryFilterExpression: string;
setCurrentQueryFilterExpression: (expression: string) => void;
isLoading: boolean;
}
export interface MetricsTreemapProps {
data: MetricsTreeMapResponse | null | undefined;
data: MetricsexplorertypesTreemapResponseDTO | undefined;
isLoading: boolean;
isError: boolean;
viewType: TreemapViewType;
viewType: MetricsexplorertypesTreemapModeDTO;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
setHeatmapView: (value: MetricsexplorertypesTreemapModeDTO) => void;
}
export interface TreemapContentProps {
isLoading: boolean;
isError: boolean;
data: MetricsexplorertypesTreemapResponseDTO | undefined;
viewType: MetricsexplorertypesTreemapModeDTO;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
setHeatmapView: (value: TreemapViewType) => void;
}
export interface OrderByPayload {

View File

@@ -1,39 +1,31 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Tooltip, Typography } from 'antd';
import { Tooltip } from 'antd';
import { ColumnType } from 'antd/es/table';
import {
MetricsListItemData,
MetricsListPayload,
MetricType,
} from 'api/metricsExplorer/getMetricsList';
import {
SamplesData,
TimeseriesData,
} from 'api/metricsExplorer/getMetricsTreeMap';
MetricsexplorertypesStatDTO,
MetricsexplorertypesTreemapEntryDTO,
MetricsexplorertypesTreemapModeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetricsListPayload } from 'api/metricsExplorer/getMetricsList';
import { Filter } from 'api/v5/v5';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import {
BarChart,
BarChart2,
BarChartHorizontal,
Diff,
Gauge,
} from 'lucide-react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { METRIC_TYPE_LABEL_MAP } from './constants';
import MetricNameSearch from './MetricNameSearch';
import MetricTypeSearch from './MetricTypeSearch';
import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types';
import MetricTypeRendererV2 from './MetricTypeRendererV2';
import { MetricsListItemRowData, TreemapTile } from './types';
export const getMetricsTableColumns = (
queryFilters: TagFilter,
queryFilterExpression: Filter,
onFilterChange: (expression: string) => void,
): ColumnType<MetricsListItemRowData>[] => [
{
title: (
<div className="metric-name-column-header">
<span className="metric-name-column-header-text">METRIC</span>
<MetricNameSearch queryFilters={queryFilters} />
<MetricNameSearch
queryFilterExpression={queryFilterExpression}
onFilterChange={onFilterChange}
/>
</div>
),
dataIndex: 'metric_name',
@@ -55,7 +47,11 @@ export const getMetricsTableColumns = (
title: (
<div className="metric-type-column-header">
<span className="metric-type-column-header-text">TYPE</span>
<MetricTypeSearch queryFilters={queryFilters} />
{/* TODO: @amlannandy: Re-enable once API supports metric type filtering */}
{/* <MetricTypeSearch
queryFilters={queryFilters}
onFilterChange={onFilterChange}
/> */}
</div>
),
dataIndex: 'metric_type',
@@ -69,13 +65,13 @@ export const getMetricsTableColumns = (
},
{
title: 'SAMPLES',
dataIndex: TreemapViewType.SAMPLES,
dataIndex: MetricsexplorertypesTreemapModeDTO.samples,
width: 150,
sorter: true,
},
{
title: 'TIME SERIES',
dataIndex: TreemapViewType.TIMESERIES,
dataIndex: MetricsexplorertypesTreemapModeDTO.timeseries,
width: 150,
sorter: true,
},
@@ -89,60 +85,6 @@ export const getMetricsListQuery = (): MetricsListPayload => ({
orderBy: { columnName: 'metric_name', order: 'asc' },
});
export function MetricTypeRenderer({
type,
}: {
type: MetricType;
}): JSX.Element {
const [icon, color] = useMemo(() => {
switch (type) {
case MetricType.SUM:
return [
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
Color.BG_ROBIN_500,
];
case MetricType.GAUGE:
return [
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
Color.BG_SAKURA_500,
];
case MetricType.HISTOGRAM:
return [
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
Color.BG_SIENNA_500,
];
case MetricType.SUMMARY:
return [
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
Color.BG_FOREST_500,
];
case MetricType.EXPONENTIAL_HISTOGRAM:
return [
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
Color.BG_AQUA_500,
];
default:
return [null, ''];
}
}, [type]);
return (
<div
className="metric-type-renderer"
style={{
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
}}
>
{icon}
<Typography.Text style={{ color, fontSize: 12 }}>
{METRIC_TYPE_LABEL_MAP[type]}
</Typography.Text>
</div>
);
}
function ValidateRowValueWrapper({
value,
children,
@@ -182,13 +124,13 @@ export const formatNumberIntoHumanReadableFormat = (
};
export const formatDataForMetricsTable = (
data: MetricsListItemData[],
data: MetricsexplorertypesStatDTO[],
): MetricsListItemRowData[] =>
data.map((metric) => ({
key: metric.metric_name,
key: metric.metricName,
metric_name: (
<ValidateRowValueWrapper value={metric.metric_name}>
<Tooltip title={metric.metric_name}>{metric.metric_name}</Tooltip>
<ValidateRowValueWrapper value={metric.metricName}>
<Tooltip title={metric.metricName}>{metric.metricName}</Tooltip>
</ValidateRowValueWrapper>
),
description: (
@@ -198,39 +140,54 @@ export const formatDataForMetricsTable = (
</Tooltip>
</ValidateRowValueWrapper>
),
metric_type: <MetricTypeRenderer type={metric.type} />,
metric_type: <MetricTypeRendererV2 type={metric.type} />,
unit: (
<ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}>
{getUniversalNameFromMetricUnit(metric.unit)}
</ValidateRowValueWrapper>
),
[TreemapViewType.SAMPLES]: (
<ValidateRowValueWrapper value={metric[TreemapViewType.SAMPLES]}>
<Tooltip title={metric[TreemapViewType.SAMPLES].toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metric[TreemapViewType.SAMPLES])}
[MetricsexplorertypesTreemapModeDTO.samples]: (
<ValidateRowValueWrapper
value={metric[MetricsexplorertypesTreemapModeDTO.samples]}
>
<Tooltip
title={metric[MetricsexplorertypesTreemapModeDTO.samples].toLocaleString()}
>
{formatNumberIntoHumanReadableFormat(
metric[MetricsexplorertypesTreemapModeDTO.samples],
)}
</Tooltip>
</ValidateRowValueWrapper>
),
[TreemapViewType.TIMESERIES]: (
<ValidateRowValueWrapper value={metric[TreemapViewType.TIMESERIES]}>
<Tooltip title={metric[TreemapViewType.TIMESERIES].toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metric[TreemapViewType.TIMESERIES])}
[MetricsexplorertypesTreemapModeDTO.timeseries]: (
<ValidateRowValueWrapper
value={metric[MetricsexplorertypesTreemapModeDTO.timeseries]}
>
<Tooltip
title={metric[
MetricsexplorertypesTreemapModeDTO.timeseries
].toLocaleString()}
>
{formatNumberIntoHumanReadableFormat(
metric[MetricsexplorertypesTreemapModeDTO.timeseries],
)}
</Tooltip>
</ValidateRowValueWrapper>
),
}));
export const transformTreemapData = (
data: TimeseriesData[] | SamplesData[],
viewType: TreemapViewType,
data: MetricsexplorertypesTreemapEntryDTO[],
viewType: MetricsexplorertypesTreemapModeDTO,
): TreemapTile[] => {
const totalSize = (data as (TimeseriesData | SamplesData)[]).reduce(
(acc: number, item: TimeseriesData | SamplesData) => acc + item.percentage,
const totalSize = data.reduce(
(acc: number, item: MetricsexplorertypesTreemapEntryDTO) =>
acc + item.percentage,
0,
);
const children = data.map((item) => ({
id: item.metric_name,
id: item.metricName,
size: totalSize > 0 ? Number((item.percentage / totalSize).toFixed(2)) : 0,
displayValue: Number(item.percentage).toFixed(2),
parent: viewType,

View File

@@ -18,8 +18,8 @@ jest.mock('lib/query/createTableColumnsFromQuery', () => ({
jest.mock('container/NewWidget/utils', () => ({
unitOptions: jest.fn(() => [
{ value: 'none', label: 'None' },
{ value: '%', label: 'Percent (0 - 100)' },
{ value: 'ms', label: 'Milliseconds (ms)' },
{ value: 'percent', label: 'Percent' },
{ value: 'ms', label: 'Milliseconds' },
]),
}));
@@ -39,7 +39,7 @@ const defaultProps = {
],
thresholdTableOptions: 'cpu_usage',
columnUnits: { cpu_usage: 'percent', memory_usage: 'bytes' },
yAxisUnit: '%',
yAxisUnit: 'percent',
moveThreshold: jest.fn(),
};

View File

@@ -0,0 +1,99 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { AutoComplete, Input, Typography } from 'antd';
import { find } from 'lodash-es';
import { flattenedCategories } from './dataFormatCategories';
const findCategoryById = (
searchValue: string,
): Record<string, string> | undefined =>
find(flattenedCategories, (option) => option.id === searchValue);
const findCategoryByName = (
searchValue: string,
): Record<string, string> | undefined =>
find(flattenedCategories, (option) => option.name === searchValue);
type OnSelectType = Dispatch<SetStateAction<string>> | ((val: string) => void);
/**
* @deprecated Use DashboardYAxisUnitSelectorWrapper instead.
*/
function YAxisUnitSelector({
value,
onSelect,
fieldLabel,
handleClear,
}: {
value: string;
onSelect: OnSelectType;
fieldLabel: string;
handleClear?: () => void;
}): JSX.Element {
const [inputValue, setInputValue] = useState('');
// Sync input value with the actual value prop
useEffect(() => {
const category = findCategoryById(value);
setInputValue(category?.name || '');
}, [value]);
const onSelectHandler = (selectedValue: string): void => {
const category = findCategoryByName(selectedValue);
if (category) {
onSelect(category.id);
setInputValue(selectedValue);
}
};
const onChangeHandler = (inputValue: string): void => {
setInputValue(inputValue);
// Clear the yAxisUnit if input is empty or doesn't match any option
if (!inputValue) {
onSelect('');
}
};
const onClearHandler = (): void => {
setInputValue('');
onSelect('');
if (handleClear) {
handleClear();
}
};
const options = flattenedCategories.map((options) => ({
value: options.name,
}));
return (
<div className="y-axis-unit-selector">
<Typography.Text className="heading">{fieldLabel}</Typography.Text>
<AutoComplete
style={{ width: '100%' }}
rootClassName="y-axis-root-popover"
options={options}
allowClear
value={inputValue}
onChange={onChangeHandler}
onClear={onClearHandler}
onSelect={onSelectHandler}
filterOption={(inputValue, option): boolean => {
if (option) {
return (
option.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1
);
}
return false;
}}
>
<Input placeholder="Unit" rootClassName="input" />
</AutoComplete>
</div>
);
}
export default YAxisUnitSelector;
YAxisUnitSelector.defaultProps = {
handleClear: (): void => {},
};

View File

@@ -0,0 +1,240 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { act } from 'react-dom/test-utils';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import YAxisUnitSelector from '../YAxisUnitSelector';
// Mock the dataFormatCategories to have predictable test data
jest.mock('../dataFormatCategories', () => ({
flattenedCategories: [
{ id: 'seconds', name: 'seconds (s)' },
{ id: 'milliseconds', name: 'milliseconds (ms)' },
{ id: 'hours', name: 'hours (h)' },
{ id: 'minutes', name: 'minutes (m)' },
],
}));
const MOCK_SECONDS = 'seconds';
const MOCK_MILLISECONDS = 'milliseconds';
describe('YAxisUnitSelector', () => {
const defaultProps = {
value: MOCK_SECONDS,
onSelect: jest.fn(),
fieldLabel: 'Y Axis Unit',
handleClear: jest.fn(),
};
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
jest.clearAllMocks();
user = userEvent.setup();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Rendering (Read) & (write)', () => {
it('renders with correct field label', () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
expect(screen.getByText('Y Axis Unit')).toBeInTheDocument();
const input = screen.getByRole('combobox');
expect(input).toHaveValue('seconds (s)');
});
it('renders with custom field label', () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel="Custom Unit Label"
handleClear={defaultProps.handleClear}
/>,
);
expect(screen.getByText('Custom Unit Label')).toBeInTheDocument();
});
it('displays empty input when value prop is empty', () => {
render(
<YAxisUnitSelector
value=""
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
expect(screen.getByDisplayValue('')).toBeInTheDocument();
});
it('shows placeholder text', () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
expect(screen.getByPlaceholderText('Unit')).toBeInTheDocument();
});
it('handles numeric input', async () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
await user.clear(input);
await user.type(input, '12345');
expect(input).toHaveValue('12345');
});
it('handles mixed content input', async () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
await user.clear(input);
await user.type(input, 'Test123!@#');
expect(input).toHaveValue('Test123!@#');
});
});
describe('State Management', () => {
it('syncs input value with value prop changes', async () => {
const { rerender } = render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
// Initial value
expect(input).toHaveValue('seconds (s)');
// Change value prop
rerender(
<YAxisUnitSelector
value={MOCK_MILLISECONDS}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
await waitFor(() => {
expect(input).toHaveValue('milliseconds (ms)');
});
});
it('handles empty value prop correctly', async () => {
const { rerender } = render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
// Change to empty value
rerender(
<YAxisUnitSelector
value=""
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
await waitFor(() => {
expect(input).toHaveValue('');
});
});
it('handles invalid value prop gracefully', async () => {
const { rerender } = render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
// Change to invalid value
rerender(
<YAxisUnitSelector
value="invalid_id"
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
await waitFor(() => {
expect(input).toHaveValue('');
});
});
it('maintains local state during typing', async () => {
render(
<YAxisUnitSelector
value={defaultProps.value}
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
const input = screen.getByRole('combobox');
// first clear then type
await user.clear(input);
await user.type(input, 'test');
expect(input).toHaveValue('test');
// Value prop change should not override local typing
await act(async () => {
// Simulate prop change
render(
<YAxisUnitSelector
value="bytes"
onSelect={defaultProps.onSelect}
fieldLabel={defaultProps.fieldLabel}
handleClear={defaultProps.handleClear}
/>,
);
});
// Local typing should be preserved
expect(input).toHaveValue('test');
});
});
});

View File

@@ -1,53 +1,613 @@
import { flattenDeep } from 'lodash-es';
import {
UniversalUnitToGrafanaUnit,
YAxisCategoryNames,
} from 'components/YAxisUnitSelector/constants';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
import { convertValue } from 'lib/getConvertedValue';
AccelerationFormats,
AngularFormats,
AreaFormats,
BooleanFormats,
CategoryNames,
ConcentrationFormats,
CurrencyFormats,
DataFormats,
DataRateFormats,
DataTypeCategories,
DatetimeFormats,
FlopsFormats,
FlowFormats,
ForceFormats,
HashRateFormats,
LengthFormats,
MassFormats,
MiscellaneousFormats,
PowerElectricalFormats,
PressureFormats,
RadiationFormats,
RotationSpeedFormats,
TemperatureFormats,
ThroughputFormats,
TimeFormats,
VelocityFormats,
VolumeFormats,
} from './types';
// Function to get the category name for a given unit ID (Grafana or universal)
export const getCategoryName = (unitId: string): YAxisCategoryNames | null => {
const categories = getYAxisCategories(YAxisSource.DASHBOARDS);
export const dataTypeCategories: DataTypeCategories = [
{
name: CategoryNames.Time,
formats: [
{ name: 'Hertz (1/s)', id: TimeFormats.Hertz },
{ name: 'nanoseconds (ns)', id: TimeFormats.Nanoseconds },
{ name: 'microseconds (µs)', id: TimeFormats.Microseconds },
{ name: 'milliseconds (ms)', id: TimeFormats.Milliseconds },
{ name: 'seconds (s)', id: TimeFormats.Seconds },
{ name: 'minutes (m)', id: TimeFormats.Minutes },
{ name: 'hours (h)', id: TimeFormats.Hours },
{ name: 'days (d)', id: TimeFormats.Days },
{ name: 'duration in ms (dtdurationms)', id: TimeFormats.DurationMs },
{ name: 'duration in s (dtdurations)', id: TimeFormats.DurationS },
{ name: 'duration in h:m:s (dthms)', id: TimeFormats.DurationHms },
{ name: 'duration in d:h:m:s (dtdhms)', id: TimeFormats.DurationDhms },
{ name: 'timeticks (timeticks)', id: TimeFormats.Timeticks },
{ name: 'clock in ms (clockms)', id: TimeFormats.ClockMs },
{ name: 'clock in s (clocks)', id: TimeFormats.ClockS },
],
},
{
name: CategoryNames.Throughput,
formats: [
{ name: 'counts/sec (cps)', id: ThroughputFormats.CountsPerSec },
{ name: 'ops/sec (ops)', id: ThroughputFormats.OpsPerSec },
{ name: 'requests/sec (reqps)', id: ThroughputFormats.RequestsPerSec },
{ name: 'reads/sec (rps)', id: ThroughputFormats.ReadsPerSec },
{ name: 'writes/sec (wps)', id: ThroughputFormats.WritesPerSec },
{ name: 'I/O operations/sec (iops)', id: ThroughputFormats.IOOpsPerSec },
{ name: 'counts/min (cpm)', id: ThroughputFormats.CountsPerMin },
{ name: 'ops/min (opm)', id: ThroughputFormats.OpsPerMin },
{ name: 'reads/min (rpm)', id: ThroughputFormats.ReadsPerMin },
{ name: 'writes/min (wpm)', id: ThroughputFormats.WritesPerMin },
],
},
{
name: CategoryNames.Data,
formats: [
{ name: 'bytes(IEC)', id: DataFormats.BytesIEC },
{ name: 'bytes(SI)', id: DataFormats.BytesSI },
{ name: 'bits(IEC)', id: DataFormats.BitsIEC },
{ name: 'bits(SI)', id: DataFormats.BitsSI },
{ name: 'kibibytes', id: DataFormats.KibiBytes },
{ name: 'kilobytes', id: DataFormats.KiloBytes },
{ name: 'mebibytes', id: DataFormats.MebiBytes },
{ name: 'megabytes', id: DataFormats.MegaBytes },
{ name: 'gibibytes', id: DataFormats.GibiBytes },
{ name: 'gigabytes', id: DataFormats.GigaBytes },
{ name: 'tebibytes', id: DataFormats.TebiBytes },
{ name: 'terabytes', id: DataFormats.TeraBytes },
{ name: 'pebibytes', id: DataFormats.PebiBytes },
{ name: 'petabytes', id: DataFormats.PetaBytes },
],
},
{
name: CategoryNames.DataRate,
formats: [
{ name: 'packets/sec', id: DataRateFormats.PacketsPerSec },
{ name: 'bytes/sec(IEC)', id: DataRateFormats.BytesPerSecIEC },
{ name: 'bytes/sec(SI)', id: DataRateFormats.BytesPerSecSI },
{ name: 'bits/sec(IEC)', id: DataRateFormats.BitsPerSecIEC },
{ name: 'bits/sec(SI)', id: DataRateFormats.BitsPerSecSI },
{ name: 'kibibytes/sec', id: DataRateFormats.KibiBytesPerSec },
{ name: 'kibibits/sec', id: DataRateFormats.KibiBitsPerSec },
{ name: 'kilobytes/sec', id: DataRateFormats.KiloBytesPerSec },
{ name: 'kilobits/sec', id: DataRateFormats.KiloBitsPerSec },
{ name: 'mebibytes/sec', id: DataRateFormats.MebiBytesPerSec },
{ name: 'mebibits/sec', id: DataRateFormats.MebiBitsPerSec },
{ name: 'megabytes/sec', id: DataRateFormats.MegaBytesPerSec },
{ name: 'megabits/sec', id: DataRateFormats.MegaBitsPerSec },
{ name: 'gibibytes/sec', id: DataRateFormats.GibiBytesPerSec },
{ name: 'gibibits/sec', id: DataRateFormats.GibiBitsPerSec },
{ name: 'gigabytes/sec', id: DataRateFormats.GigaBytesPerSec },
{ name: 'gigabits/sec', id: DataRateFormats.GigaBitsPerSec },
{ name: 'tebibytes/sec', id: DataRateFormats.TebiBytesPerSec },
{ name: 'tebibits/sec', id: DataRateFormats.TebiBitsPerSec },
{ name: 'terabytes/sec', id: DataRateFormats.TeraBytesPerSec },
{ name: 'terabits/sec', id: DataRateFormats.TeraBitsPerSec },
{ name: 'pebibytes/sec', id: DataRateFormats.PebiBytesPerSec },
{ name: 'pebibits/sec', id: DataRateFormats.PebiBitsPerSec },
{ name: 'petabytes/sec', id: DataRateFormats.PetaBytesPerSec },
{ name: 'petabits/sec', id: DataRateFormats.PetaBitsPerSec },
],
},
{
name: CategoryNames.HashRate,
formats: [
{ name: 'hashes/sec', id: HashRateFormats.HashesPerSec },
{ name: 'kilohashes/sec', id: HashRateFormats.KiloHashesPerSec },
{ name: 'megahashes/sec', id: HashRateFormats.MegaHashesPerSec },
{ name: 'gigahashes/sec', id: HashRateFormats.GigaHashesPerSec },
{ name: 'terahashes/sec', id: HashRateFormats.TeraHashesPerSec },
{ name: 'petahashes/sec', id: HashRateFormats.PetaHashesPerSec },
{ name: 'exahashes/sec', id: HashRateFormats.ExaHashesPerSec },
],
},
{
name: CategoryNames.Miscellaneous,
formats: [
{ name: 'none', id: MiscellaneousFormats.None },
{ name: 'String', id: MiscellaneousFormats.String },
{ name: 'short', id: MiscellaneousFormats.Short },
{ name: 'Percent (0-100)', id: MiscellaneousFormats.Percent },
{ name: 'Percent (0.0-1.0)', id: MiscellaneousFormats.PercentUnit },
{ name: 'Humidity (%H)', id: MiscellaneousFormats.Humidity },
{ name: 'Decibel', id: MiscellaneousFormats.Decibel },
{ name: 'Hexadecimal (0x)', id: MiscellaneousFormats.Hexadecimal0x },
{ name: 'Hexadecimal', id: MiscellaneousFormats.Hexadecimal },
{ name: 'Scientific notation', id: MiscellaneousFormats.ScientificNotation },
{ name: 'Locale format', id: MiscellaneousFormats.LocaleFormat },
{ name: 'Pixels', id: MiscellaneousFormats.Pixels },
],
},
{
name: CategoryNames.Acceleration,
formats: [
{ name: 'Meters/sec²', id: AccelerationFormats.MetersPerSecondSquared },
{ name: 'Feet/sec²', id: AccelerationFormats.FeetPerSecondSquared },
{ name: 'G unit', id: AccelerationFormats.GUnit },
],
},
{
name: CategoryNames.Angle,
formats: [
{ name: 'Degrees (°)', id: AngularFormats.Degree },
{ name: 'Radians', id: AngularFormats.Radian },
{ name: 'Gradian', id: AngularFormats.Gradian },
{ name: 'Arc Minutes', id: AngularFormats.ArcMinute },
{ name: 'Arc Seconds', id: AngularFormats.ArcSecond },
],
},
{
name: CategoryNames.Area,
formats: [
{ name: 'Square Meters (m²)', id: AreaFormats.SquareMeters },
{ name: 'Square Feet (ft²)', id: AreaFormats.SquareFeet },
{ name: 'Square Miles (mi²)', id: AreaFormats.SquareMiles },
],
},
{
name: CategoryNames.Computation,
formats: [
{ name: 'FLOP/s', id: FlopsFormats.FLOPs },
{ name: 'MFLOP/s', id: FlopsFormats.MFLOPs },
{ name: 'GFLOP/s', id: FlopsFormats.GFLOPs },
{ name: 'TFLOP/s', id: FlopsFormats.TFLOPs },
{ name: 'PFLOP/s', id: FlopsFormats.PFLOPs },
{ name: 'EFLOP/s', id: FlopsFormats.EFLOPs },
{ name: 'ZFLOP/s', id: FlopsFormats.ZFLOPs },
{ name: 'YFLOP/s', id: FlopsFormats.YFLOPs },
],
},
{
name: CategoryNames.Concentration,
formats: [
{ name: 'parts-per-million (ppm)', id: ConcentrationFormats.PPM },
{ name: 'parts-per-billion (ppb)', id: ConcentrationFormats.PPB },
{ name: 'nanogram per cubic meter (ng/m³)', id: ConcentrationFormats.NgM3 },
{
name: 'nanogram per normal cubic meter (ng/Nm³)',
id: ConcentrationFormats.NgNM3,
},
{ name: 'microgram per cubic meter (μg/m³)', id: ConcentrationFormats.UgM3 },
{
name: 'microgram per normal cubic meter (μg/Nm³)',
id: ConcentrationFormats.UgNM3,
},
{ name: 'milligram per cubic meter (mg/m³)', id: ConcentrationFormats.MgM3 },
{
name: 'milligram per normal cubic meter (mg/Nm³)',
id: ConcentrationFormats.MgNM3,
},
{ name: 'gram per cubic meter (g/m³)', id: ConcentrationFormats.GM3 },
{
name: 'gram per normal cubic meter (g/Nm³)',
id: ConcentrationFormats.GNM3,
},
{ name: 'milligrams per decilitre (mg/dL)', id: ConcentrationFormats.MgDL },
{ name: 'millimoles per litre (mmol/L)', id: ConcentrationFormats.MmolL },
],
},
{
name: CategoryNames.Currency,
formats: [
{ name: 'Dollars ($)', id: CurrencyFormats.USD },
{ name: 'Pounds (£)', id: CurrencyFormats.GBP },
{ name: 'Euro (€)', id: CurrencyFormats.EUR },
{ name: 'Yen (¥)', id: CurrencyFormats.JPY },
{ name: 'Rubles (₽)', id: CurrencyFormats.RUB },
{ name: 'Hryvnias (₴)', id: CurrencyFormats.UAH },
{ name: 'Real (R$)', id: CurrencyFormats.BRL },
{ name: 'Danish Krone (kr)', id: CurrencyFormats.DKK },
{ name: 'Icelandic Króna (kr)', id: CurrencyFormats.ISK },
{ name: 'Norwegian Krone (kr)', id: CurrencyFormats.NOK },
{ name: 'Swedish Krona (kr)', id: CurrencyFormats.SEK },
{ name: 'Czech koruna (czk)', id: CurrencyFormats.CZK },
{ name: 'Swiss franc (CHF)', id: CurrencyFormats.CHF },
{ name: 'Polish Złoty (PLN)', id: CurrencyFormats.PLN },
{ name: 'Bitcoin (฿)', id: CurrencyFormats.BTC },
{ name: 'Milli Bitcoin (฿)', id: CurrencyFormats.MBTC },
{ name: 'Micro Bitcoin (฿)', id: CurrencyFormats.UBTC },
{ name: 'South African Rand (R)', id: CurrencyFormats.ZAR },
{ name: 'Indian Rupee (₹)', id: CurrencyFormats.INR },
{ name: 'South Korean Won (₩)', id: CurrencyFormats.KRW },
{ name: 'Indonesian Rupiah (Rp)', id: CurrencyFormats.IDR },
{ name: 'Philippine Peso (PHP)', id: CurrencyFormats.PHP },
{ name: 'Vietnamese Dong (VND)', id: CurrencyFormats.VND },
],
},
{
name: CategoryNames.Datetime,
formats: [
{ name: 'Datetime ISO', id: DatetimeFormats.ISO },
{
name: 'Datetime ISO (No date if today)',
id: DatetimeFormats.ISONoDateIfToday,
},
{ name: 'Datetime US', id: DatetimeFormats.US },
{
name: 'Datetime US (No date if today)',
id: DatetimeFormats.USNoDateIfToday,
},
{ name: 'Datetime local', id: DatetimeFormats.Local },
{
name: 'Datetime local (No date if today)',
id: DatetimeFormats.LocalNoDateIfToday,
},
{ name: 'Datetime default', id: DatetimeFormats.System },
{ name: 'From Now', id: DatetimeFormats.FromNow },
],
},
{
name: CategoryNames.Energy,
formats: [
{ name: 'Watt (W)', id: PowerElectricalFormats.WATT },
{ name: 'Kilowatt (kW)', id: PowerElectricalFormats.KWATT },
{ name: 'Megawatt (MW)', id: PowerElectricalFormats.MEGWATT },
{ name: 'Gigawatt (GW)', id: PowerElectricalFormats.GWATT },
{ name: 'Milliwatt (mW)', id: PowerElectricalFormats.MWATT },
{ name: 'Watt per square meter (W/m²)', id: PowerElectricalFormats.WM2 },
{ name: 'Volt-Ampere (VA)', id: PowerElectricalFormats.VOLTAMP },
{ name: 'Kilovolt-Ampere (kVA)', id: PowerElectricalFormats.KVOLTAMP },
{
name: 'Volt-Ampere reactive (VAr)',
id: PowerElectricalFormats.VOLTAMPREACT,
},
{
name: 'Kilovolt-Ampere reactive (kVAr)',
id: PowerElectricalFormats.KVOLTAMPREACT,
},
{ name: 'Watt-hour (Wh)', id: PowerElectricalFormats.WATTH },
{
name: 'Watt-hour per Kilogram (Wh/kg)',
id: PowerElectricalFormats.WATTHPERKG,
},
{ name: 'Kilowatt-hour (kWh)', id: PowerElectricalFormats.KWATTH },
{ name: 'Kilowatt-min (kWm)', id: PowerElectricalFormats.KWATTM },
{ name: 'Ampere-hour (Ah)', id: PowerElectricalFormats.AMPH },
{ name: 'Kiloampere-hour (kAh)', id: PowerElectricalFormats.KAMPH },
{ name: 'Milliampere-hour (mAh)', id: PowerElectricalFormats.MAMPH },
{ name: 'Joule (J)', id: PowerElectricalFormats.JOULE },
{ name: 'Electron volt (eV)', id: PowerElectricalFormats.EV },
{ name: 'Ampere (A)', id: PowerElectricalFormats.AMP },
{ name: 'Kiloampere (kA)', id: PowerElectricalFormats.KAMP },
{ name: 'Milliampere (mA)', id: PowerElectricalFormats.MAMP },
{ name: 'Volt (V)', id: PowerElectricalFormats.VOLT },
{ name: 'Kilovolt (kV)', id: PowerElectricalFormats.KVOLT },
{ name: 'Millivolt (mV)', id: PowerElectricalFormats.MVOLT },
{ name: 'Decibel-milliwatt (dBm)', id: PowerElectricalFormats.DBM },
{ name: 'Ohm (Ω)', id: PowerElectricalFormats.OHM },
{ name: 'Kiloohm (kΩ)', id: PowerElectricalFormats.KOHM },
{ name: 'Megaohm (MΩ)', id: PowerElectricalFormats.MOHM },
{ name: 'Farad (F)', id: PowerElectricalFormats.FARAD },
{ name: 'Microfarad (µF)', id: PowerElectricalFormats.µFARAD },
{ name: 'Nanofarad (nF)', id: PowerElectricalFormats.NFARAD },
{ name: 'Picofarad (pF)', id: PowerElectricalFormats.PFARAD },
{ name: 'Femtofarad (fF)', id: PowerElectricalFormats.FFARAD },
{ name: 'Henry (H)', id: PowerElectricalFormats.HENRY },
{ name: 'Millihenry (mH)', id: PowerElectricalFormats.MHENRY },
{ name: 'Microhenry (µH)', id: PowerElectricalFormats.µHENRY },
{ name: 'Lumens (Lm)', id: PowerElectricalFormats.LUMENS },
],
},
{
name: CategoryNames.Flow,
formats: [
{ name: 'Gallons/min (gpm)', id: FlowFormats.FLOWGPM },
{ name: 'Cubic meters/sec (cms)', id: FlowFormats.FLOWCMS },
{ name: 'Cubic feet/sec (cfs)', id: FlowFormats.FLOWCFS },
{ name: 'Cubic feet/min (cfm)', id: FlowFormats.FLOWCFM },
{ name: 'Litre/hour', id: FlowFormats.LITREH },
{ name: 'Litre/min (L/min)', id: FlowFormats.FLOWLPM },
{ name: 'milliLitre/min (mL/min)', id: FlowFormats.FLOWMLPM },
{ name: 'Lux (lx)', id: FlowFormats.LUX },
],
},
{
name: CategoryNames.Force,
formats: [
{ name: 'Newton-meters (Nm)', id: ForceFormats.FORCENM },
{ name: 'Kilonewton-meters (kNm)', id: ForceFormats.FORCEKNM },
{ name: 'Newtons (N)', id: ForceFormats.FORCEN },
{ name: 'Kilonewtons (kN)', id: ForceFormats.FORCEKN },
],
},
{
name: CategoryNames.Mass,
formats: [
{ name: 'milligram (mg)', id: MassFormats.MASSMG },
{ name: 'gram (g)', id: MassFormats.MASSG },
{ name: 'pound (lb)', id: MassFormats.MASSLB },
{ name: 'kilogram (kg)', id: MassFormats.MASSKG },
{ name: 'metric ton (t)', id: MassFormats.MASST },
],
},
{
name: CategoryNames.Length,
formats: [
{ name: 'millimeter (mm)', id: LengthFormats.LENGTHMM },
{ name: 'inch (in)', id: LengthFormats.LENGTHIN },
{ name: 'feet (ft)', id: LengthFormats.LENGTHFT },
{ name: 'meter (m)', id: LengthFormats.LENGTHM },
{ name: 'kilometer (km)', id: LengthFormats.LENGTHKM },
{ name: 'mile (mi)', id: LengthFormats.LENGTHMI },
],
},
{
name: CategoryNames.Pressure,
formats: [
{ name: 'Millibars', id: PressureFormats.PRESSUREMBAR },
{ name: 'Bars', id: PressureFormats.PRESSUREBAR },
{ name: 'Kilobars', id: PressureFormats.PRESSUREKBAR },
{ name: 'Pascals', id: PressureFormats.PRESSUREPA },
{ name: 'Hectopascals', id: PressureFormats.PRESSUREHPA },
{ name: 'Kilopascals', id: PressureFormats.PRESSUREKPA },
{ name: 'Inches of mercury', id: PressureFormats.PRESSUREHG },
{ name: 'PSI', id: PressureFormats.PRESSUREPSI },
],
},
{
name: CategoryNames.Radiation,
formats: [
{ name: 'Becquerel (Bq)', id: RadiationFormats.RADBQ },
{ name: 'curie (Ci)', id: RadiationFormats.RADCI },
{ name: 'Gray (Gy)', id: RadiationFormats.RADGY },
{ name: 'rad', id: RadiationFormats.RADRAD },
{ name: 'Sievert (Sv)', id: RadiationFormats.RADSV },
{ name: 'milliSievert (mSv)', id: RadiationFormats.RADMSV },
{ name: 'microSievert (µSv)', id: RadiationFormats.RADUSV },
{ name: 'rem', id: RadiationFormats.RADREM },
{ name: 'Exposure (C/kg)', id: RadiationFormats.RADEXPCKG },
{ name: 'roentgen (R)', id: RadiationFormats.RADR },
{ name: 'Sievert/hour (Sv/h)', id: RadiationFormats.RADSVH },
{ name: 'milliSievert/hour (mSv/h)', id: RadiationFormats.RADMSVH },
{ name: 'microSievert/hour (µSv/h)', id: RadiationFormats.RADUSVH },
],
},
{
name: CategoryNames.RotationSpeed,
formats: [
{ name: 'Revolutions per minute (rpm)', id: RotationSpeedFormats.ROTRPM },
{ name: 'Hertz (Hz)', id: RotationSpeedFormats.ROTHZ },
{ name: 'Radians per second (rad/s)', id: RotationSpeedFormats.ROTRADS },
{ name: 'Degrees per second (°/s)', id: RotationSpeedFormats.ROTDEGS },
],
},
{
name: CategoryNames.Temperature,
formats: [
{ name: 'Celsius (°C)', id: TemperatureFormats.CELSIUS },
{ name: 'Fahrenheit (°F)', id: TemperatureFormats.FAHRENHEIT },
{ name: 'Kelvin (K)', id: TemperatureFormats.KELVIN },
],
},
{
name: CategoryNames.Velocity,
formats: [
{ name: 'meters/second (m/s)', id: VelocityFormats.METERS_PER_SECOND },
{ name: 'kilometers/hour (km/h)', id: VelocityFormats.KILOMETERS_PER_HOUR },
{ name: 'miles/hour (mph)', id: VelocityFormats.MILES_PER_HOUR },
{ name: 'knot (kn)', id: VelocityFormats.KNOT },
],
},
{
name: CategoryNames.Volume,
formats: [
{ name: 'millilitre (mL)', id: VolumeFormats.MILLILITRE },
{ name: 'litre (L)', id: VolumeFormats.LITRE },
{ name: 'cubic meter', id: VolumeFormats.CUBIC_METER },
{ name: 'Normal cubic meter', id: VolumeFormats.NORMAL_CUBIC_METER },
{ name: 'cubic decimeter', id: VolumeFormats.CUBIC_DECIMETER },
{ name: 'gallons', id: VolumeFormats.GALLONS },
],
},
{
name: CategoryNames.Boolean,
formats: [
{ name: 'True / False', id: BooleanFormats.TRUE_FALSE },
{ name: 'Yes / No', id: BooleanFormats.YES_NO },
{ name: 'On / Off', id: BooleanFormats.ON_OFF },
],
},
];
const foundCategory = categories.find((category) =>
category.units.some((unit) => {
// Units in Y-axis categories use universal unit IDs.
// Thresholds / column units often use Grafana-style IDs.
// Treat a unit as matching if either:
// - it is already the universal ID, or
// - it matches the mapped Grafana ID for that universal unit.
if (unit.id === unitId) {
return true;
}
export const flattenedCategories = flattenDeep(
dataTypeCategories.map((category) => category.formats),
);
const grafanaId = UniversalUnitToGrafanaUnit[unit.id];
return grafanaId === unitId;
}),
);
return foundCategory ? foundCategory.name : null;
type ConversionFactors = {
[key: string]: {
[key: string]: number | null;
};
};
// Object containing conversion factors for various categories and formats
const conversionFactors: ConversionFactors = {
[CategoryNames.Time]: {
[TimeFormats.Hertz]: 1,
[TimeFormats.Nanoseconds]: 1e-9,
[TimeFormats.Microseconds]: 1e-6,
[TimeFormats.Milliseconds]: 1e-3,
[TimeFormats.Seconds]: 1,
[TimeFormats.Minutes]: 60,
[TimeFormats.Hours]: 3600,
[TimeFormats.Days]: 86400,
[TimeFormats.DurationMs]: 1e-3,
[TimeFormats.DurationS]: 1,
[TimeFormats.DurationHms]: null, // Requires special handling
[TimeFormats.DurationDhms]: null, // Requires special handling
[TimeFormats.Timeticks]: null, // Requires special handling
[TimeFormats.ClockMs]: 1e-3,
[TimeFormats.ClockS]: 1,
},
[CategoryNames.Throughput]: {
[ThroughputFormats.CountsPerSec]: 1,
[ThroughputFormats.OpsPerSec]: 1,
[ThroughputFormats.RequestsPerSec]: 1,
[ThroughputFormats.ReadsPerSec]: 1,
[ThroughputFormats.WritesPerSec]: 1,
[ThroughputFormats.IOOpsPerSec]: 1,
[ThroughputFormats.CountsPerMin]: 1 / 60,
[ThroughputFormats.OpsPerMin]: 1 / 60,
[ThroughputFormats.ReadsPerMin]: 1 / 60,
[ThroughputFormats.WritesPerMin]: 1 / 60,
},
[CategoryNames.Data]: {
[DataFormats.BytesIEC]: 1,
[DataFormats.BytesSI]: 1,
[DataFormats.BitsIEC]: 0.125,
[DataFormats.BitsSI]: 0.125,
[DataFormats.KibiBytes]: 1024,
[DataFormats.KiloBytes]: 1000,
[DataFormats.MebiBytes]: 1048576,
[DataFormats.MegaBytes]: 1000000,
[DataFormats.GibiBytes]: 1073741824,
[DataFormats.GigaBytes]: 1000000000,
[DataFormats.TebiBytes]: 1099511627776,
[DataFormats.TeraBytes]: 1000000000000,
[DataFormats.PebiBytes]: 1125899906842624,
[DataFormats.PetaBytes]: 1000000000000000,
},
[CategoryNames.DataRate]: {
[DataRateFormats.PacketsPerSec]: null, // Cannot convert directly to other data rates
[DataRateFormats.BytesPerSecIEC]: 1,
[DataRateFormats.BytesPerSecSI]: 1,
[DataRateFormats.BitsPerSecIEC]: 0.125,
[DataRateFormats.BitsPerSecSI]: 0.125,
[DataRateFormats.KibiBytesPerSec]: 1024,
[DataRateFormats.KibiBitsPerSec]: 128,
[DataRateFormats.KiloBytesPerSec]: 1000,
[DataRateFormats.KiloBitsPerSec]: 125,
[DataRateFormats.MebiBytesPerSec]: 1048576,
[DataRateFormats.MebiBitsPerSec]: 131072,
[DataRateFormats.MegaBytesPerSec]: 1000000,
[DataRateFormats.MegaBitsPerSec]: 125000,
[DataRateFormats.GibiBytesPerSec]: 1073741824,
[DataRateFormats.GibiBitsPerSec]: 134217728,
[DataRateFormats.GigaBytesPerSec]: 1000000000,
[DataRateFormats.GigaBitsPerSec]: 125000000,
[DataRateFormats.TebiBytesPerSec]: 1099511627776,
[DataRateFormats.TebiBitsPerSec]: 137438953472,
[DataRateFormats.TeraBytesPerSec]: 1000000000000,
[DataRateFormats.TeraBitsPerSec]: 125000000000,
[DataRateFormats.PebiBytesPerSec]: 1125899906842624,
[DataRateFormats.PebiBitsPerSec]: 140737488355328,
[DataRateFormats.PetaBytesPerSec]: 1000000000000000,
[DataRateFormats.PetaBitsPerSec]: 125000000000000,
},
[CategoryNames.Miscellaneous]: {
[MiscellaneousFormats.None]: null,
[MiscellaneousFormats.String]: null,
[MiscellaneousFormats.Short]: null,
[MiscellaneousFormats.Percent]: 1,
[MiscellaneousFormats.PercentUnit]: 100,
[MiscellaneousFormats.Humidity]: 1,
[MiscellaneousFormats.Decibel]: null,
[MiscellaneousFormats.Hexadecimal0x]: null,
[MiscellaneousFormats.Hexadecimal]: null,
[MiscellaneousFormats.ScientificNotation]: null,
[MiscellaneousFormats.LocaleFormat]: null,
[MiscellaneousFormats.Pixels]: null,
},
[CategoryNames.Boolean]: {
[BooleanFormats.TRUE_FALSE]: null, // Not convertible
[BooleanFormats.YES_NO]: null, // Not convertible
[BooleanFormats.ON_OFF]: null, // Not convertible
},
};
// Function to get the conversion factor between two units in a specific category
function getConversionFactor(
fromUnit: string,
toUnit: string,
category: CategoryNames,
): number | null {
// Retrieves the conversion factors for the specified category
const categoryFactors = conversionFactors[category];
if (!categoryFactors) {
return null; // Returns null if the category does not exist
}
const fromFactor = categoryFactors[fromUnit];
const toFactor = categoryFactors[toUnit];
if (
fromFactor === undefined ||
toFactor === undefined ||
fromFactor === null ||
toFactor === null
) {
return null; // Returns null if either unit does not exist or is not convertible
}
return fromFactor / toFactor; // Returns the conversion factor ratio
}
// Function to convert a value from one unit to another
export function convertUnit(
value: number,
fromUnitId?: string,
toUnitId?: string,
): number | null {
if (!fromUnitId || !toUnitId) {
let fromUnit: string | undefined;
let toUnit: string | undefined;
// Finds the category that contains the specified units and extracts fromUnit and toUnit using array methods
const category = dataTypeCategories.find((category) =>
category.formats.some((format) => {
if (format.id === fromUnitId) {
fromUnit = format.id;
}
if (format.id === toUnitId) {
toUnit = format.id;
}
return fromUnit && toUnit; // Break out early if both units are found
}),
);
if (!category || !fromUnit || !toUnit) {
return null;
}
} // Return null if category or units are not found
const fromCategory = getCategoryName(fromUnitId);
const toCategory = getCategoryName(toUnitId);
// If either unit is unknown or the categories don't match, the conversion is invalid
if (!fromCategory || !toCategory || fromCategory !== toCategory) {
// Gets the conversion factor for the specified units
const conversionFactor = getConversionFactor(
fromUnit,
toUnit,
category.name as any,
);
if (conversionFactor === null) {
return null;
}
} // Return null if conversion is not possible
// Delegate the actual numeric conversion (or identity) to the shared helper,
// which understands both Grafana-style and universal unit IDs.
return convertValue(value, fromUnitId, toUnitId);
return value * conversionFactor;
}
// Function to get the category name for a given unit ID
export const getCategoryName = (unitId: string): CategoryNames | null => {
// Finds the category that contains the specified unit ID
const foundCategory = dataTypeCategories.find((category) =>
category.formats.some((format) => format.id === unitId),
);
return foundCategory ? (foundCategory.name as CategoryNames) : null;
};

View File

@@ -2,9 +2,6 @@ import { Layout } from 'react-grid-layout';
import { DefaultOptionType } from 'antd/es/select';
import { omitIdFromQuery } from 'components/ExplorerCard/utils';
import { PrecisionOptionsEnum } from 'components/Graph/types';
import { YAxisCategoryNames } from 'components/YAxisUnitSelector/constants';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
@@ -24,7 +21,11 @@ import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { getCategoryName } from './RightContainer/dataFormatCategories';
import {
dataTypeCategories,
getCategoryName,
} from './RightContainer/dataFormatCategories';
import { CategoryNames } from './RightContainer/types';
export const getIsQueryModified = (
currentQuery: Query,
@@ -605,21 +606,14 @@ export const PANEL_TYPE_TO_QUERY_TYPES: Record<PANEL_TYPES, EQueryType[]> = {
* the label and value for each format.
*/
export const getCategorySelectOptionByName = (
name?: YAxisCategoryNames,
): DefaultOptionType[] => {
const categories = getYAxisCategories(YAxisSource.DASHBOARDS);
if (!categories.length) {
return [];
}
return (
categories
.find((category) => category.name === name)
?.units.map((unit) => ({
label: unit.name,
value: unit.id,
})) || []
);
};
name?: CategoryNames | string,
): DefaultOptionType[] =>
dataTypeCategories
.find((category) => category.name === name)
?.formats.map((format) => ({
label: format.name,
value: format.id,
})) || [];
/**
* Generates unit options based on the provided column unit.

View File

@@ -19,6 +19,7 @@ interface RunQueryBtnProps {
handleCancelQuery?: () => void;
onStageRunQuery?: () => void;
queryRangeKey?: QueryKey;
disabled?: boolean;
}
function RunQueryBtn({
@@ -28,6 +29,7 @@ function RunQueryBtn({
handleCancelQuery,
onStageRunQuery,
queryRangeKey,
disabled,
}: RunQueryBtnProps): JSX.Element {
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
const queryClient = useQueryClient();
@@ -61,7 +63,7 @@ function RunQueryBtn({
<Button
type="primary"
className={cx('run-query-btn periscope-btn primary', className)}
disabled={isLoading || !onStageRunQuery}
disabled={isLoading || !onStageRunQuery || disabled}
onClick={onStageRunQuery}
icon={<Play size={14} />}
>

View File

@@ -1,13 +1,10 @@
import { YAxisCategoryNames } from 'components/YAxisUnitSelector/constants';
import { CategoryNames } from 'container/NewWidget/RightContainer/types';
export const categoryToSupport: YAxisCategoryNames[] = [
YAxisCategoryNames.None,
YAxisCategoryNames.Data,
YAxisCategoryNames.DataRate,
YAxisCategoryNames.Time,
YAxisCategoryNames.Count,
YAxisCategoryNames.Operations,
YAxisCategoryNames.Percentage,
YAxisCategoryNames.Miscellaneous,
YAxisCategoryNames.Boolean,
export const categoryToSupport = [
CategoryNames.Data,
CategoryNames.DataRate,
CategoryNames.Time,
CategoryNames.Throughput,
CategoryNames.Miscellaneous,
CategoryNames.Boolean,
];

View File

@@ -3,7 +3,6 @@ package configflagger
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
@@ -33,10 +32,6 @@ func New(ctx context.Context, ps factory.ProviderSettings, c flagger.Config, reg
for name, value := range c.Config.Boolean {
feature, _, err := registry.GetByString(name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
settings.Logger().WarnContext(ctx, "skipping unknown feature flag", "name", name, "kind", "boolean")
continue
}
return nil, err
}
@@ -51,10 +46,6 @@ func New(ctx context.Context, ps factory.ProviderSettings, c flagger.Config, reg
for name, value := range c.Config.String {
feature, _, err := registry.GetByString(name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
settings.Logger().WarnContext(ctx, "skipping unknown feature flag", "name", name, "kind", "string")
continue
}
return nil, err
}
@@ -69,10 +60,6 @@ func New(ctx context.Context, ps factory.ProviderSettings, c flagger.Config, reg
for name, value := range c.Config.Float {
feature, _, err := registry.GetByString(name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
settings.Logger().WarnContext(ctx, "skipping unknown feature flag", "name", name, "kind", "float")
continue
}
return nil, err
}
@@ -87,10 +74,6 @@ func New(ctx context.Context, ps factory.ProviderSettings, c flagger.Config, reg
for name, value := range c.Config.Integer {
feature, _, err := registry.GetByString(name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
settings.Logger().WarnContext(ctx, "skipping unknown feature flag", "name", name, "kind", "integer")
continue
}
return nil, err
}
@@ -105,10 +88,6 @@ func New(ctx context.Context, ps factory.ProviderSettings, c flagger.Config, reg
for name, value := range c.Config.Object {
feature, _, err := registry.GetByString(name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
settings.Logger().WarnContext(ctx, "skipping unknown feature flag", "name", name, "kind", "object")
continue
}
return nil, err
}

View File

@@ -5,7 +5,6 @@ import "github.com/SigNoz/signoz/pkg/types/featuretypes"
var (
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
)
func MustNewRegistry() featuretypes.Registry {
@@ -26,14 +25,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureHideRootUser,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageStable,
Description: "Controls whether root admin user is hidden or not",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -120,8 +120,6 @@ func FilterResponse(results []*qbtypes.QueryRangeResponse) []*qbtypes.QueryRange
}
}
resultData.Rows = filteredRows
case *qbtypes.ScalarData:
resultData.Data = filterScalarDataIPs(resultData.Columns, resultData.Data)
}
filteredData = append(filteredData, result)
@@ -147,39 +145,6 @@ func shouldIncludeSeries(series *qbtypes.TimeSeries) bool {
return true
}
func filterScalarDataIPs(columns []*qbtypes.ColumnDescriptor, data [][]any) [][]any {
// Find column indices for server address fields
serverColIndices := make([]int, 0)
for i, col := range columns {
if col.Name == derivedKeyHTTPHost {
serverColIndices = append(serverColIndices, i)
}
}
if len(serverColIndices) == 0 {
return data
}
filtered := make([][]any, 0, len(data))
for _, row := range data {
includeRow := true
for _, colIdx := range serverColIndices {
if colIdx < len(row) {
if strVal, ok := row[colIdx].(string); ok {
if net.ParseIP(strVal) != nil {
includeRow = false
break
}
}
}
}
if includeRow {
filtered = append(filtered, row)
}
}
return filtered
}
func shouldIncludeRow(row *qbtypes.RawRow) bool {
if row.Data != nil {
if domainVal, ok := row.Data[derivedKeyHTTPHost]; ok {

View File

@@ -117,59 +117,6 @@ func TestFilterResponse(t *testing.T) {
},
},
},
{
name: "should filter out IP addresses from scalar data",
input: []*qbtypes.QueryRangeResponse{
{
Data: qbtypes.QueryData{
Results: []any{
&qbtypes.ScalarData{
QueryName: "endpoints",
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: [][]any{
{"192.168.1.1", 10},
{"example.com", 20},
{"10.0.0.1", 5},
},
},
},
},
},
},
expected: []*qbtypes.QueryRangeResponse{
{
Data: qbtypes.QueryData{
Results: []any{
&qbtypes.ScalarData{
QueryName: "endpoints",
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: derivedKeyHTTPHost},
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "endpoints"},
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: [][]any{
{"example.com", 20},
},
},
},
},
},
},
},
}
for _, tt := range tests {

View File

@@ -2,22 +2,18 @@ package impluser
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type getter struct {
store types.UserStore
flagger flagger.Flagger
store types.UserStore
}
func NewGetter(store types.UserStore, flagger flagger.Flagger) user.Getter {
return &getter{store: store, flagger: flagger}
func NewGetter(store types.UserStore) user.Getter {
return &getter{store: store}
}
func (module *getter) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
@@ -30,14 +26,6 @@ func (module *getter) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*ty
return nil, err
}
// filter root users if feature flag `hide_root_users` is true
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
hideRootUsers := module.flagger.BooleanOrEmpty(ctx, flagger.FeatureHideRootUser, evalCtx)
if hideRootUsers {
users = slices.DeleteFunc(users, func(user *types.User) bool { return user.IsRoot })
}
return users, nil
}

View File

@@ -11,11 +11,8 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
@@ -44,13 +41,7 @@ func TestNewHandlers(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil)

View File

@@ -85,11 +85,11 @@ func NewModules(
queryParser queryparser.QueryParser,
config Config,
dashboard dashboard.Module,
userGetter user.Getter,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
user := impluser.NewModule(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, authz, analytics, config.User)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
return Modules{

View File

@@ -11,11 +11,8 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
@@ -43,13 +40,7 @@ func TestNewModules(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -169,7 +169,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddAnonymousPublicDashboardTransactionFactory(sqlstore),
sqlmigration.NewAddRootUserFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserEmailOrgIDIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRulesV4ToV5Factory(sqlstore, telemetryStore),
)
}

View File

@@ -1,13 +1,11 @@
package signoz
import (
"context"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -77,12 +75,7 @@ func TestNewProviderFactories(t *testing.T) {
})
assert.NotPanics(t, func() {
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
if err != nil {
panic(err)
}
userGetter := impluser.NewGetter(impluser.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), instrumentationtest.New().ToProviderSettings()), flagger)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), instrumentationtest.New().ToProviderSettings()))
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
telemetryStore := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherEqual)
NewStatsReporterProviderFactories(telemetryStore, []statsreporter.StatsCollector{}, orgGetter, userGetter, tokenizertest.NewMockTokenizer(t), version.Build{}, analytics.Config{Enabled: true})

View File

@@ -280,7 +280,7 @@ func New(
}
// Initialize user getter
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), flagger)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
licensingProviderFactory := licenseProviderFactory(sqlstore, zeus, orgGetter, analytics)
licensing, err := licensingProviderFactory.New(
@@ -388,7 +388,7 @@ func New(
}
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard)
userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.User, orgGetter, authz, config.User.Root)

View File

@@ -1,209 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migrateRulesV4ToV5 struct {
store sqlstore.SQLStore
telemetryStore telemetrystore.TelemetryStore
logger *slog.Logger
}
func NewMigrateRulesV4ToV5Factory(
store sqlstore.SQLStore,
telemetryStore telemetrystore.TelemetryStore,
) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_rules_post_deprecation"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRulesV4ToV5{
store: store,
telemetryStore: telemetryStore,
logger: ps.Logger,
}, nil
})
}
func (migration *migrateRulesV4ToV5) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *migrateRulesV4ToV5) getLogDuplicateKeys(ctx context.Context) ([]string, error) {
query := `
SELECT name
FROM (
SELECT DISTINCT name FROM signoz_logs.distributed_logs_attribute_keys
INTERSECT
SELECT DISTINCT name FROM signoz_logs.distributed_logs_resource_keys
)
ORDER BY name
`
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
if err != nil {
migration.logger.WarnContext(ctx, "failed to query log duplicate keys", "error", err)
return nil, nil
}
defer rows.Close()
var keys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
migration.logger.WarnContext(ctx, "failed to scan log duplicate key", "error", err)
continue
}
keys = append(keys, key)
}
return keys, nil
}
func (migration *migrateRulesV4ToV5) getTraceDuplicateKeys(ctx context.Context) ([]string, error) {
query := `
SELECT tagKey
FROM signoz_traces.distributed_span_attributes_keys
WHERE tagType IN ('tag', 'resource')
GROUP BY tagKey
HAVING COUNT(DISTINCT tagType) > 1
ORDER BY tagKey
`
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
if err != nil {
migration.logger.WarnContext(ctx, "failed to query trace duplicate keys", "error", err)
return nil, nil
}
defer rows.Close()
var keys []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
migration.logger.WarnContext(ctx, "failed to scan trace duplicate key", "error", err)
continue
}
keys = append(keys, key)
}
return keys, nil
}
func (migration *migrateRulesV4ToV5) Up(ctx context.Context, db *bun.DB) error {
logsKeys, err := migration.getLogDuplicateKeys(ctx)
if err != nil {
return err
}
tracesKeys, err := migration.getTraceDuplicateKeys(ctx)
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var rules []struct {
ID string `bun:"id"`
Data map[string]any `bun:"data"`
}
err = tx.NewSelect().
Table("rule").
Column("id", "data").
Scan(ctx, &rules)
if err != nil {
if err == sql.ErrNoRows {
return nil
}
return err
}
alertsMigrator := transition.NewAlertMigrateV5(migration.logger, logsKeys, tracesKeys)
count := 0
for _, rule := range rules {
version, _ := rule.Data["version"].(string)
if version == "v5" {
continue
}
if version == "" {
migration.logger.WarnContext(ctx, "unexpected empty version for rule", "rule_id", rule.ID)
}
migration.logger.InfoContext(ctx, "migrating rule v4 to v5", "rule_id", rule.ID, "current_version", version)
// Check if the queries envelope already exists and is non-empty
hasQueriesEnvelope := false
if condition, ok := rule.Data["condition"].(map[string]any); ok {
if compositeQuery, ok := condition["compositeQuery"].(map[string]any); ok {
if queries, ok := compositeQuery["queries"].([]any); ok && len(queries) > 0 {
hasQueriesEnvelope = true
}
}
}
if hasQueriesEnvelope {
// already has queries envelope, just bump version
// this is because user made a mistake of choosing version
migration.logger.InfoContext(ctx, "rule already has queries envelope, bumping version", "rule_id", rule.ID)
rule.Data["version"] = "v5"
} else {
// old format, run full migration
migration.logger.InfoContext(ctx, "rule has old format, running full migration", "rule_id", rule.ID)
updated := alertsMigrator.Migrate(ctx, rule.Data)
if !updated {
migration.logger.WarnContext(ctx, "expected updated to be true but got false", "rule_id", rule.ID)
continue
}
rule.Data["version"] = "v5"
}
dataJSON, err := json.Marshal(rule.Data)
if err != nil {
return err
}
_, err = tx.NewUpdate().
Table("rule").
Set("data = ?", string(dataJSON)).
Where("id = ?", rule.ID).
Exec(ctx)
if err != nil {
return err
}
count++
}
if count != 0 {
migration.logger.InfoContext(ctx, "migrate v4 alerts", "count", count)
}
return tx.Commit()
}
func (migration *migrateRulesV4ToV5) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@@ -355,10 +355,6 @@ func (r *PostableRule) validate() error {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "composite query is required"))
}
if r.Version != "v5" {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "only version v5 is supported, got %q", r.Version))
}
if isAllQueriesDisabled(r.RuleCondition.CompositeQuery) {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "all queries are disabled in rule condition"))
}

View File

@@ -108,7 +108,6 @@ func TestParseIntoRule(t *testing.T) {
"ruleType": "threshold_rule",
"evalWindow": "5m",
"frequency": "1m",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -151,7 +150,6 @@ func TestParseIntoRule(t *testing.T) {
content: []byte(`{
"alert": "DefaultsRule",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -189,7 +187,6 @@ func TestParseIntoRule(t *testing.T) {
initRule: PostableRule{},
content: []byte(`{
"alert": "PromQLRule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "promql",
@@ -259,7 +256,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
content: []byte(`{
"alert": "SeverityLabelTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -348,7 +344,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
content: []byte(`{
"alert": "NoLabelsTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -389,7 +384,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
content: []byte(`{
"alert": "OverwriteTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -480,7 +474,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
content: []byte(`{
"alert": "V2Test",
"schemaVersion": "v2",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -524,7 +517,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultSchemaTest",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -577,7 +569,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
func TestParseIntoRuleThresholdGeneration(t *testing.T) {
content := []byte(`{
"alert": "TestThresholds",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -648,7 +639,6 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
"schemaVersion": "v2",
"alert": "MultiThresholdAlert",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -742,7 +732,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -777,7 +766,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -811,7 +799,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -846,7 +833,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -880,7 +866,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -916,7 +901,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -951,7 +935,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "AnomalyOutOfBoundsTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -986,7 +969,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
@@ -1021,7 +1003,6 @@ func TestAnomalyNegationEval(t *testing.T) {
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",