mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-07 06:12:05 +00:00
Compare commits
2 Commits
ssopage-cl
...
query-rang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd5b09415f | ||
|
|
f05c574606 |
4
.github/workflows/commitci.yaml
vendored
4
.github/workflows/commitci.yaml
vendored
@@ -7,14 +7,10 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
jobs:
|
||||
refcheck:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
9
.github/workflows/goci.yaml
vendored
9
.github/workflows/goci.yaml
vendored
@@ -7,14 +7,10 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/go-test.yaml@main
|
||||
@@ -25,7 +21,6 @@ jobs:
|
||||
GO_VERSION: 1.24
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/go-fmt.yaml@main
|
||||
@@ -35,7 +30,6 @@ jobs:
|
||||
GO_VERSION: 1.24
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/go-lint.yaml@main
|
||||
@@ -45,7 +39,6 @@ jobs:
|
||||
GO_VERSION: 1.24
|
||||
deps:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/go-deps.yaml@main
|
||||
@@ -55,7 +48,6 @@ jobs:
|
||||
GO_VERSION: 1.24
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
@@ -87,7 +79,6 @@ jobs:
|
||||
make docker-build-enterprise
|
||||
openapi:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
10
.github/workflows/jsci.yaml
vendored
10
.github/workflows/jsci.yaml
vendored
@@ -7,14 +7,10 @@ on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
jobs:
|
||||
tsc:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,7 +27,6 @@ jobs:
|
||||
run: cd frontend && yarn tsc
|
||||
tsc2:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/js-tsc.yaml@main
|
||||
@@ -41,7 +36,6 @@ jobs:
|
||||
JS_SRC: frontend
|
||||
test:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/js-test.yaml@main
|
||||
@@ -51,7 +45,6 @@ jobs:
|
||||
JS_SRC: frontend
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/js-fmt.yaml@main
|
||||
@@ -61,7 +54,6 @@ jobs:
|
||||
JS_SRC: frontend
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
uses: signoz/primus.workflows/.github/workflows/js-lint.yaml@main
|
||||
@@ -71,7 +63,6 @@ jobs:
|
||||
JS_SRC: frontend
|
||||
md-languages:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
@@ -82,7 +73,6 @@ jobs:
|
||||
run: bash frontend/scripts/validate-md-languages.sh
|
||||
authz:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.1
|
||||
image: signoz/signoz:v0.114.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.1
|
||||
image: signoz/signoz:v0.114.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
image: signoz/signoz:${VERSION:-v0.114.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.1}
|
||||
image: signoz/signoz:${VERSION:-v0.114.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
216
docs/contributing/query-range.md
Normal file
216
docs/contributing/query-range.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 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.**
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
@@ -69,10 +67,6 @@ func (p *BaseSeasonalProvider) toTSResults(ctx context.Context, resp *qbtypes.Qu
|
||||
}
|
||||
|
||||
func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID, params *anomalyQueryParams) (*anomalyQueryResults, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "anomaly",
|
||||
instrumentationtypes.CodeFunctionName: "getResults",
|
||||
})
|
||||
// TODO(srikanthccv): parallelize this?
|
||||
p.logger.InfoContext(ctx, "fetching results for current period", "anomaly_current_period_query", params.CurrentPeriodQuery)
|
||||
currentPeriodResults, err := p.querier.QueryRange(ctx, orgID, ¶ms.CurrentPeriodQuery)
|
||||
|
||||
@@ -15,9 +15,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -107,10 +105,6 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.UUID, widgetIdx, startTime, endTime uint64) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "dashboard",
|
||||
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRange",
|
||||
})
|
||||
dashboard, err := module.GetDashboardByPublicID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -63,10 +61,6 @@ func (p *BaseSeasonalProvider) getQueryParams(req *GetAnomaliesRequest) *anomaly
|
||||
}
|
||||
|
||||
func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID, params *anomalyQueryParams) (*anomalyQueryResults, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "anomaly",
|
||||
instrumentationtypes.CodeFunctionName: "getResults",
|
||||
})
|
||||
zap.L().Info("fetching results for current period", zap.Any("currentPeriodQuery", params.CurrentPeriodQuery))
|
||||
currentPeriodResults, _, err := p.querierV2.QueryRange(ctx, orgID, params.CurrentPeriodQuery)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,7 +23,29 @@ const config: Config.InitialOptions = {
|
||||
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
'^@signozhq/([^/]+)$': '<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
|
||||
'^@signozhq/sonner$':
|
||||
'<rootDir>/node_modules/@signozhq/sonner/dist/sonner.js',
|
||||
'^@signozhq/button$':
|
||||
'<rootDir>/node_modules/@signozhq/button/dist/button.js',
|
||||
'^@signozhq/calendar$':
|
||||
'<rootDir>/node_modules/@signozhq/calendar/dist/calendar.js',
|
||||
'^@signozhq/badge': '<rootDir>/node_modules/@signozhq/badge/dist/badge.js',
|
||||
'^@signozhq/checkbox':
|
||||
'<rootDir>/node_modules/@signozhq/checkbox/dist/checkbox.js',
|
||||
'^@signozhq/switch': '<rootDir>/node_modules/@signozhq/switch/dist/switch.js',
|
||||
'^@signozhq/callout':
|
||||
'<rootDir>/node_modules/@signozhq/callout/dist/callout.js',
|
||||
'^@signozhq/combobox':
|
||||
'<rootDir>/node_modules/@signozhq/combobox/dist/combobox.js',
|
||||
'^@signozhq/input': '<rootDir>/node_modules/@signozhq/input/dist/input.js',
|
||||
'^@signozhq/command':
|
||||
'<rootDir>/node_modules/@signozhq/command/dist/command.js',
|
||||
'^@signozhq/radio-group':
|
||||
'<rootDir>/node_modules/@signozhq/radio-group/dist/radio-group.js',
|
||||
'^@signozhq/toggle-group$':
|
||||
'<rootDir>/node_modules/@signozhq/toggle-group/dist/toggle-group.js',
|
||||
'^@signozhq/dialog$':
|
||||
'<rootDir>/node_modules/@signozhq/dialog/dist/dialog.js',
|
||||
},
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testMatch: ['<rootDir>/src/**/*?(*.)(test).(ts|js)?(x)'],
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
import 'jest-styled-components';
|
||||
import './src/styles.scss';
|
||||
|
||||
import { server } from './src/mocks-server/server';
|
||||
|
||||
import './src/styles.scss';
|
||||
// Establish API mocking before all tests.
|
||||
|
||||
// Mock window.matchMedia
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "2.1.1",
|
||||
"@signozhq/dialog": "^0.0.2",
|
||||
"@signozhq/drawer": "0.0.4",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
|
||||
@@ -14,6 +14,5 @@
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members"
|
||||
"role_details": "Role Details"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,5 @@
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members"
|
||||
"role_details": "Role Details"
|
||||
}
|
||||
|
||||
@@ -74,6 +74,5 @@
|
||||
"METER_EXPLORER": "SigNoz | Meter Explorer",
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members"
|
||||
"ROLES_SETTINGS": "SigNoz | Roles"
|
||||
}
|
||||
|
||||
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -19,7 +19,6 @@ import '@signozhq/combobox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/dialog';
|
||||
import '@signozhq/drawer';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
.edit-member-drawer {
|
||||
&__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-5) var(--padding-4);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__email-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__role-select {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 0 var(--padding-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
line-height: 32px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--margin-1);
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 0 var(--padding-4);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
&__footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
&__footer-divider {
|
||||
width: 1px;
|
||||
height: 21px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__footer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
line-height: var(--label-small-400-line-height);
|
||||
letter-spacing: var(--label-small-400-letter-spacing);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
&--warning {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
margin: 0;
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--margin-6);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-link-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
border-color: var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
width: 510px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
margin: 0;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&__link-text-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__link-text {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 var(--padding-2);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-left: 1px solid var(--border);
|
||||
min-width: 64px;
|
||||
}
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Link,
|
||||
LockKeyhole,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Select } from 'antd';
|
||||
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import cancelInvite from 'api/v1/invite/id/delete';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { INVITE_PREFIX, MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import './EditMemberDrawer.styles.scss';
|
||||
|
||||
export interface EditMemberDrawerProps {
|
||||
member: MemberRow | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
onRefetch?: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function EditMemberDrawer({
|
||||
member,
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
onRefetch,
|
||||
}: EditMemberDrawerProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [resetLink, setResetLink] = useState<string | null>(null);
|
||||
const [showResetLinkDialog, setShowResetLinkDialog] = useState(false);
|
||||
const [hasCopiedResetLink, setHasCopiedResetLink] = useState(false);
|
||||
|
||||
const isInvited = member?.status === MemberStatus.Invited;
|
||||
// Invited member IDs are prefixed with 'invite-'; strip it to get the real invite ID
|
||||
const inviteId =
|
||||
isInvited && member ? member.id.slice(INVITE_PREFIX.length) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
setDisplayName(member.name ?? '');
|
||||
setSelectedRole(member.role);
|
||||
}
|
||||
}, [member]);
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
(displayName !== member.name || selectedRole !== member.role);
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const saveInvitedMember = useCallback(async (): Promise<void> => {
|
||||
if (!member || !inviteId) {
|
||||
return;
|
||||
}
|
||||
await cancelInvite({ id: inviteId });
|
||||
try {
|
||||
await sendInvite({
|
||||
email: member.email,
|
||||
name: displayName,
|
||||
role: selectedRole,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
});
|
||||
toast.success('Invite updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch {
|
||||
onRefetch?.();
|
||||
onClose();
|
||||
toast.error(
|
||||
'Failed to send the updated invite. Please re-invite this member.',
|
||||
{ richColors: true },
|
||||
);
|
||||
}
|
||||
}, [
|
||||
member,
|
||||
inviteId,
|
||||
displayName,
|
||||
selectedRole,
|
||||
onComplete,
|
||||
onClose,
|
||||
onRefetch,
|
||||
]);
|
||||
|
||||
const saveActiveMember = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
await update({
|
||||
userId: member.id,
|
||||
displayName,
|
||||
role: selectedRole,
|
||||
});
|
||||
toast.success('Member details updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
}, [member, displayName, selectedRole, onComplete, onClose]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!member || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (isInvited && inviteId) {
|
||||
await saveInvitedMember();
|
||||
} else {
|
||||
await saveActiveMember();
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
isInvited ? 'Failed to update invite' : 'Failed to update member details',
|
||||
{ richColors: true },
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
member,
|
||||
isDirty,
|
||||
isInvited,
|
||||
inviteId,
|
||||
saveInvitedMember,
|
||||
saveActiveMember,
|
||||
]);
|
||||
|
||||
const handleDelete = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
if (isInvited && inviteId) {
|
||||
await cancelInvite({ id: inviteId });
|
||||
toast.success('Invitation cancelled successfully', { richColors: true });
|
||||
} else {
|
||||
await deleteUser({ userId: member.id });
|
||||
toast.success('Member deleted successfully', { richColors: true });
|
||||
}
|
||||
setShowDeleteConfirm(false);
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(
|
||||
isInvited ? 'Failed to cancel invitation' : 'Failed to delete member',
|
||||
{ richColors: true },
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [member, isInvited, inviteId, onComplete, onClose]);
|
||||
|
||||
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
setIsGeneratingLink(true);
|
||||
try {
|
||||
const response = await getResetPasswordToken({ userId: member.id });
|
||||
if (response?.data?.token) {
|
||||
const link = `${window.location.origin}/password-reset?token=${response.data.token}`;
|
||||
setResetLink(link);
|
||||
setHasCopiedResetLink(false);
|
||||
setShowResetLinkDialog(true);
|
||||
onClose();
|
||||
} else {
|
||||
toast.error('Failed to generate password reset link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to generate password reset link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
} finally {
|
||||
setIsGeneratingLink(false);
|
||||
}
|
||||
}, [member, onClose]);
|
||||
|
||||
const handleCopyResetLink = useCallback(async (): Promise<void> => {
|
||||
if (!resetLink) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(resetLink);
|
||||
setHasCopiedResetLink(true);
|
||||
setTimeout(() => setHasCopiedResetLink(false), 2000);
|
||||
toast.success('Reset link copied to clipboard', { richColors: true });
|
||||
} catch {
|
||||
toast.error('Failed to copy link', {
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
}, [resetLink]);
|
||||
|
||||
const handleCopyInviteLink = useCallback(async (): Promise<void> => {
|
||||
if (!member?.token) {
|
||||
toast.error('Invite link is not available', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const inviteLink = `${window.location.origin}${ROUTES.SIGN_UP}?token=${member.token}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
toast.success('Invite link copied to clipboard', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to copy invite link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
}, [member]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setShowDeleteConfirm(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
|
||||
|
||||
const drawerContent = (
|
||||
<div className="edit-member-drawer__layout">
|
||||
<div className="edit-member-drawer__body">
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="member-name"
|
||||
value={displayName}
|
||||
onChange={(e): void => setDisplayName(e.target.value)}
|
||||
className="edit-member-drawer__input"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
|
||||
<span className="edit-member-drawer__email-text">
|
||||
{member?.email || '—'}
|
||||
</span>
|
||||
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__field">
|
||||
<label className="edit-member-drawer__label" htmlFor="member-role">
|
||||
Roles
|
||||
</label>
|
||||
<Select
|
||||
id="member-role"
|
||||
value={selectedRole}
|
||||
onChange={(role): void => setSelectedRole(role as ROLES)}
|
||||
className="edit-member-drawer__role-select"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.edit-member-drawer') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
>
|
||||
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
|
||||
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
|
||||
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__meta">
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Status</span>
|
||||
{member?.status === MemberStatus.Active ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
|
||||
</div>
|
||||
{!isInvited && (
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Last Modified</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer">
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Cancel Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
|
||||
{isInvited ? (
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleCopyInviteLink}
|
||||
disabled={!member?.token}
|
||||
>
|
||||
<Link size={12} />
|
||||
Copy Invite Link
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink ? 'Generating...' : 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const deleteDialogTitle = isInvited ? 'Cancel Invitation' : 'Delete Member';
|
||||
const deleteDialogBody = isInvited ? (
|
||||
<>
|
||||
Are you sure you want to cancel the invitation for{' '}
|
||||
<strong>{member?.email}</strong>? They will no longer be able to join the
|
||||
workspace using this invite.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<strong>{member?.name || member?.email}</strong>? This will permanently
|
||||
remove their access to the workspace.
|
||||
</>
|
||||
);
|
||||
const deleteConfirmLabel = isInvited ? 'Cancel Invite' : 'Delete Member';
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
header={{ title: 'Member Details' }}
|
||||
content={drawerContent}
|
||||
className="edit-member-drawer"
|
||||
/>
|
||||
|
||||
<DialogWrapper
|
||||
open={showResetLinkDialog}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setShowResetLinkDialog(false);
|
||||
}
|
||||
}}
|
||||
title="Password Reset Link"
|
||||
showCloseButton
|
||||
width="base"
|
||||
className="reset-link-dialog"
|
||||
>
|
||||
<div className="reset-link-dialog__content">
|
||||
<p className="reset-link-dialog__description">
|
||||
This creates a one-time link the team member can use to set a new password
|
||||
for their SigNoz account.
|
||||
</p>
|
||||
<div className="reset-link-dialog__link-row">
|
||||
<div className="reset-link-dialog__link-text-wrap">
|
||||
<span className="reset-link-dialog__link-text">{resetLink}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopyResetLink}
|
||||
prefixIcon={
|
||||
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
|
||||
}
|
||||
className="reset-link-dialog__copy-btn"
|
||||
>
|
||||
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
|
||||
<DialogWrapper
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
}}
|
||||
title={deleteDialogTitle}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="delete-dialog__body">{deleteDialogBody}</p>
|
||||
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setShowDeleteConfirm(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDeleting ? 'Processing...' : deleteConfirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditMemberDrawer;
|
||||
@@ -1,277 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
|
||||
import cancelInvite from 'api/v1/invite/id/delete';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
|
||||
|
||||
jest.mock('@signozhq/drawer', () => ({
|
||||
DrawerWrapper: ({
|
||||
content,
|
||||
open,
|
||||
}: {
|
||||
content?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null => (open ? <div>{content}</div> : null),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/dialog', () => ({
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('api/v1/user/id/update');
|
||||
jest.mock('api/v1/user/id/delete');
|
||||
jest.mock('api/v1/invite/id/delete');
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/factor_password/getResetPasswordToken');
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUpdate = jest.mocked(update);
|
||||
const mockDeleteUser = jest.mocked(deleteUser);
|
||||
const mockCancelInvite = jest.mocked(cancelInvite);
|
||||
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
|
||||
|
||||
const activeMember = {
|
||||
id: 'user-1',
|
||||
name: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
role: 'ADMIN' as ROLES,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: '1700000000000',
|
||||
updatedAt: '1710000000000',
|
||||
};
|
||||
|
||||
const invitedMember = {
|
||||
id: 'invite-abc123',
|
||||
name: '',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: '1700000000000',
|
||||
token: 'tok-xyz',
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
props: Partial<EditMemberDrawerProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<EditMemberDrawer
|
||||
member={activeMember}
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
onComplete={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('EditMemberDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUpdate.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
mockDeleteUser.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
mockCancelInvite.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
});
|
||||
|
||||
it('renders active member details and disables Save when form is not dirty', () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(screen.getByDisplayValue('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save member details/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Save after editing name and calls update API on confirm', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Alice Smith');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Alice Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
displayName: 'Alice Updated',
|
||||
}),
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows delete confirm dialog and calls deleteUser for active members', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ onComplete });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete member/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/are you sure you want to delete/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /delete member/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith({ userId: 'user-1' });
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Cancel Invite and Copy Invite Link for invited members; hides Last Modified', () => {
|
||||
renderDrawer({ member: invitedMember });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel invite/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy invite link/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Invited On')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Last Modified')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls cancelInvite after confirming Cancel Invite for invited members', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ member: invitedMember, onComplete });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel invite/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/are you sure you want to cancel the invitation/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /cancel invite/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelInvite).toHaveBeenCalledWith({ id: 'abc123' });
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generate Password Reset Link', () => {
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined);
|
||||
let clipboardSpy: jest.SpyInstance | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: (): Promise<void> => Promise.resolve() },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockWriteText.mockClear();
|
||||
clipboardSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockImplementation(mockWriteText);
|
||||
mockGetResetPasswordToken.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { token: 'reset-tok-abc', userId: 'user-1' },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clipboardSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it('calls getResetPasswordToken and opens the reset link dialog with the generated link', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /password reset link/i,
|
||||
});
|
||||
expect(mockGetResetPasswordToken).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(dialog).toHaveTextContent('reset-tok-abc');
|
||||
});
|
||||
|
||||
it('copies the link to clipboard and shows "Copied!" on the button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /password reset link/i,
|
||||
});
|
||||
expect(dialog).toHaveTextContent('reset-tok-abc');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^copy$/i }));
|
||||
|
||||
// Verify success path: writeText called with the correct link
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Reset link copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('reset-tok-abc'),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
.invite-members-modal {
|
||||
max-width: 700px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--bg-base-white);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.invite-members-modal__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-members-modal__table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
.email-header {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
.role-header {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-header {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
.table-header-cell {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.team-member-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
> .email-cell {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
> .role-cell {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
> .action-cell {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
&.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-email-input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.team-member-role-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
padding: 0 var(--padding-2) !important;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.4;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--bg-base-white);
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.remove-team-member-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--destructive);
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.email-error-message {
|
||||
display: block;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.invite-team-members-error-callout {
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
border: 1px solid rgba(229, 72, 77, 0.2);
|
||||
border-radius: 4px;
|
||||
animation: horizontal-shaking 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes horizontal-shaking {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invite-members-modal__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.add-another-member-button {
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
border-style: dashed;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.invite-members-modal {
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-role-select {
|
||||
.ant-select-selector {
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { ChevronDown, CircleAlert, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Select } from 'antd';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './InviteMembersModal.styles.scss';
|
||||
|
||||
interface InviteRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: ROLES | '';
|
||||
}
|
||||
|
||||
export interface InviteMembersModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
|
||||
|
||||
const isRowTouched = (row: InviteRow): boolean =>
|
||||
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
function InviteMembersModal({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const [rows, setRows] = useState<InviteRow[]>(() => [
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
|
||||
|
||||
const resetAndClose = useCallback((): void => {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
setEmailValidity({});
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const getValidationErrorMessage = (): string => {
|
||||
if (hasInvalidEmails && hasInvalidRoles) {
|
||||
return 'Please enter valid emails and select roles for team members';
|
||||
}
|
||||
if (hasInvalidEmails) {
|
||||
return 'Please enter valid emails for team members';
|
||||
}
|
||||
return 'Please select roles for team members';
|
||||
};
|
||||
|
||||
const validateAllUsers = useCallback((): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
|
||||
touchedRows.forEach((row) => {
|
||||
const emailValid = EMAIL_REGEX.test(row.email);
|
||||
const roleValid = Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
if (!emailValid || !row.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
if (row.id) {
|
||||
updatedEmailValidity[row.id] = emailValid;
|
||||
}
|
||||
});
|
||||
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
return isValid;
|
||||
}, [rows]);
|
||||
|
||||
const debouncedValidateEmail = useMemo(
|
||||
() =>
|
||||
debounce((email: string, rowId: string) => {
|
||||
const isValid = EMAIL_REGEX.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
debouncedValidateEmail.cancel();
|
||||
}
|
||||
return (): void => {
|
||||
debouncedValidateEmail.cancel();
|
||||
};
|
||||
}, [open, debouncedValidateEmail]);
|
||||
|
||||
const updateEmail = (id: string, email: string): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.email = email;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidEmails) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
if (emailValidity[id] === false) {
|
||||
setEmailValidity((prev) => ({ ...prev, [id]: true }));
|
||||
}
|
||||
|
||||
debouncedValidateEmail(email, id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRole = (id: string, role: ROLES): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.role = role;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidRoles) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addRow = (): void => {
|
||||
setRows((prev) => [...prev, EMPTY_ROW()]);
|
||||
};
|
||||
|
||||
const removeRow = (id: string): void => {
|
||||
setRows((prev) => prev.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
if (!validateAllUsers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
if (touchedRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (touchedRows.length === 1) {
|
||||
const row = touchedRows[0];
|
||||
await sendInvite({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role as ROLES,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
});
|
||||
} else {
|
||||
await inviteUsers({
|
||||
invites: touchedRows.map((row) => ({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
})),
|
||||
});
|
||||
}
|
||||
toast.success('Invites sent successfully', { richColors: true });
|
||||
resetAndClose();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
const apiErr = err as APIError;
|
||||
if (apiErr?.getHttpStatusCode() === 409) {
|
||||
toast.error(
|
||||
touchedRows.length === 1
|
||||
? `${touchedRows[0].email} is already a member`
|
||||
: 'Invite for one or more users already exists',
|
||||
{ richColors: true },
|
||||
);
|
||||
} else {
|
||||
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
|
||||
toast.error(`Failed to send invites: ${errorMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [rows, onComplete, resetAndClose, validateAllUsers]);
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="Invite Team Members"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
resetAndClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<div className="invite-members-modal__content">
|
||||
<div className="invite-members-modal__table">
|
||||
<div className="invite-members-modal__table-header">
|
||||
<div className="table-header-cell email-header">Email address</div>
|
||||
<div className="table-header-cell role-header">Roles</div>
|
||||
<div className="table-header-cell action-header" />
|
||||
</div>
|
||||
<div className="invite-members-modal__container">
|
||||
{rows.map(
|
||||
(row): JSX.Element => (
|
||||
<div key={row.id} className="team-member-row">
|
||||
<div className="team-member-cell email-cell">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="john@signoz.io"
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
className="team-member-email-input"
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<span className="email-error-message">Invalid email address</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team-member-cell role-cell">
|
||||
<Select
|
||||
value={row.role || undefined}
|
||||
onChange={(role): void => updateRole(row.id, role as ROLES)}
|
||||
className="team-member-role-select"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.invite-members-modal') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
>
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="team-member-cell action-cell">
|
||||
{rows.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="remove-team-member-button"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className="invite-team-members-error-callout"
|
||||
description={getValidationErrorMessage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="invite-members-modal__footer">
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="add-another-member-button"
|
||||
prefixIcon={<Plus size={12} color={Style.L1_FOREGROUND} />}
|
||||
onClick={addRow}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
|
||||
<div className="invite-members-modal__footer-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={resetAndClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembersModal;
|
||||
@@ -1,177 +0,0 @@
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import InviteMembersModal from '../InviteMembersModal';
|
||||
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/invite/bulk/create');
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSendInvite = jest.mocked(sendInvite);
|
||||
const mockInviteUsers = jest.mocked(inviteUsers);
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: jest.fn(),
|
||||
onComplete: jest.fn(),
|
||||
};
|
||||
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSendInvite.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { data: 'test', status: 'success' },
|
||||
});
|
||||
mockInviteUsers.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
});
|
||||
|
||||
it('renders 3 initial empty rows and disables the submit button', () => {
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds a row when "Add another" is clicked and removes a row via trash button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(4);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[0]);
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(3);
|
||||
});
|
||||
|
||||
describe('validation callout messages', () => {
|
||||
it('shows combined message when email is invalid and role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'not-an-email',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Please enter valid emails and select roles for team members',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows email-only message when email is invalid but role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'not-an-email');
|
||||
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Please enter valid emails for team members'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows role-only message when email is valid but role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'valid@signoz.io',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('Please select roles for team members'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses sendInvite (single) when only one row is filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'single@signoz.io');
|
||||
|
||||
const roleSelects = screen.getAllByText('Select roles');
|
||||
await user.click(roleSelects[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'single@signoz.io', role: 'VIEWER' }),
|
||||
);
|
||||
expect(mockInviteUsers).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInviteUsers).toHaveBeenCalledWith({
|
||||
invites: expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'alice@signoz.io', role: 'VIEWER' }),
|
||||
expect.objectContaining({ email: 'bob@signoz.io', role: 'EDITOR' }),
|
||||
]),
|
||||
});
|
||||
expect(mockSendInvite).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
.members-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.members-table {
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-table-thead {
|
||||
> tr > th,
|
||||
> tr > td {
|
||||
background: var(--background);
|
||||
font-size: var(--paragraph-small-600-font-size);
|
||||
font-weight: var(--paragraph-small-600-font-weight);
|
||||
line-height: var(--paragraph-small-600-line-height);
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
border-bottom: none !important;
|
||||
border-top: none !important;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-table-column-sorters {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ant-table-column-title {
|
||||
flex: unset;
|
||||
}
|
||||
|
||||
.ant-table-column-sorter {
|
||||
color: var(--foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ant-table-column-sorter-up.active,
|
||||
.ant-table-column-sorter-down.active {
|
||||
color: var(--bg-base-white);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
> tr > td {
|
||||
border-bottom: none !important;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
background: transparent;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
> tr.members-table-row--tinted > td {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
}
|
||||
> tr:hover > td {
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-wrapper,
|
||||
.ant-table-container,
|
||||
.ant-spin-nested-loading,
|
||||
.ant-spin-container {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.member-status-cell {
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
.member-name-email-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
|
||||
.member-name {
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-email {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l3-foreground-hover);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.member-joined-date {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-joined-dash {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
color: var(--l3-foreground-hover);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.members-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-12) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
color: var(--foreground);
|
||||
|
||||
&__emoji {
|
||||
font-size: var(--font-size-2xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: var(--paragraph-base-400-font-height);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--bg-base-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.members-table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.members-pagination-range {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.members-pagination-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.members-table {
|
||||
.ant-table-tbody {
|
||||
> tr.members-table-row--tinted > td {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(0, 0, 0, 0.03) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.members-empty-state {
|
||||
&__text {
|
||||
strong {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import type React from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Pagination, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import './MembersTable.styles.scss';
|
||||
|
||||
export interface MemberRow {
|
||||
id: string;
|
||||
name?: string;
|
||||
email: string;
|
||||
role: ROLES;
|
||||
status: MemberStatus;
|
||||
joinedOn: string | null;
|
||||
updatedAt?: string | null;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
interface MembersTableProps {
|
||||
data: MemberRow[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
searchQuery: string;
|
||||
onPageChange: (page: number) => void;
|
||||
onRowClick?: (member: MemberRow) => void;
|
||||
onSortChange?: (
|
||||
sorter: SorterResult<MemberRow> | SorterResult<MemberRow>[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
function NameEmailCell({
|
||||
name,
|
||||
email,
|
||||
}: {
|
||||
name?: string;
|
||||
email: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="member-name-email-cell">
|
||||
{name && (
|
||||
<span className="member-name" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip title={email} overlayClassName="member-tooltip">
|
||||
<span className="member-email">{email}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
|
||||
if (status === MemberStatus.Active) {
|
||||
return (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersEmptyState({
|
||||
searchQuery,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="members-empty-state">
|
||||
<span
|
||||
className="members-empty-state__emoji"
|
||||
role="img"
|
||||
aria-label="monocle face"
|
||||
>
|
||||
🧐
|
||||
</span>
|
||||
{searchQuery ? (
|
||||
<p className="members-empty-state__text">
|
||||
No results for <strong>{searchQuery}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="members-empty-state__text">No members found</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersTable({
|
||||
data,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
searchQuery,
|
||||
onPageChange,
|
||||
onRowClick,
|
||||
onSortChange,
|
||||
}: MembersTableProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const formatJoinedOn = (date: string | null): string => {
|
||||
if (!date) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(date);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<MemberRow> = [
|
||||
{
|
||||
title: 'Name / Email',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
sorter: (a, b): number => a.email.localeCompare(b.email),
|
||||
render: (_, record): JSX.Element => (
|
||||
<NameEmailCell name={record.name} email={record.email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 180,
|
||||
sorter: (a, b): number => a.role.localeCompare(b.role),
|
||||
render: (role: ROLES): JSX.Element => (
|
||||
<Badge color="vanilla">{capitalize(role)}</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
className: 'member-status-cell',
|
||||
sorter: (a, b): number => a.status.localeCompare(b.status),
|
||||
render: (status: MemberRow['status']): JSX.Element => (
|
||||
<StatusBadge status={status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Joined On',
|
||||
dataIndex: 'joinedOn',
|
||||
key: 'joinedOn',
|
||||
width: 250,
|
||||
align: 'right' as const,
|
||||
sorter: (a, b): number => {
|
||||
if (!a.joinedOn && !b.joinedOn) {
|
||||
return 0;
|
||||
}
|
||||
if (!a.joinedOn) {
|
||||
return 1;
|
||||
}
|
||||
if (!b.joinedOn) {
|
||||
return -1;
|
||||
}
|
||||
return new Date(a.joinedOn).getTime() - new Date(b.joinedOn).getTime();
|
||||
},
|
||||
render: (joinedOn: string | null): JSX.Element => {
|
||||
const formatted = formatJoinedOn(joinedOn);
|
||||
const isDash = formatted === '—';
|
||||
return (
|
||||
<span className={isDash ? 'member-joined-dash' : 'member-joined-date'}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const showPaginationTotal = (_total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="members-pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="members-pagination-total"> of {_total}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="members-table-wrapper">
|
||||
<Table<MemberRow>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'members-table-row--tinted' : ''
|
||||
}
|
||||
onRow={(record): React.HTMLAttributes<HTMLElement> => ({
|
||||
onClick: (): void => onRowClick?.(record),
|
||||
style: onRowClick ? { cursor: 'pointer' } : undefined,
|
||||
})}
|
||||
onChange={(_, __, sorter): void => {
|
||||
if (onSortChange) {
|
||||
onSortChange(
|
||||
sorter as SorterResult<MemberRow> | SorterResult<MemberRow>[],
|
||||
);
|
||||
}
|
||||
}}
|
||||
showSorterTooltip={false}
|
||||
locale={{
|
||||
emptyText: <MembersEmptyState searchQuery={searchQuery} />,
|
||||
}}
|
||||
className="members-table"
|
||||
/>
|
||||
{total > pageSize && (
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showTotal={showPaginationTotal}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
className="members-table-pagination"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MembersTable;
|
||||
@@ -1,143 +0,0 @@
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import MembersTable, { MemberRow } from '../MembersTable';
|
||||
|
||||
const mockActiveMembers: MemberRow[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
name: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
role: 'ADMIN' as ROLES,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: '1700000000000',
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
name: 'Bob Jones',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockInvitedMember: MemberRow = {
|
||||
id: 'invite-abc',
|
||||
name: '',
|
||||
email: 'charlie@signoz.io',
|
||||
role: 'EDITOR' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: null,
|
||||
token: 'tok-123',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
loading: false,
|
||||
total: 2,
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
searchQuery: '',
|
||||
onPageChange: jest.fn(),
|
||||
onRowClick: jest.fn(),
|
||||
};
|
||||
|
||||
describe('MembersTable', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders member rows with name, email, role badge, and ACTIVE status', () => {
|
||||
render(<MembersTable {...defaultProps} data={mockActiveMembers} />);
|
||||
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders INVITED badge for pending invite members', () => {
|
||||
render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={[...mockActiveMembers, mockInvitedMember]}
|
||||
total={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('INVITED')).toBeInTheDocument();
|
||||
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('Editor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRowClick with the member data when a row is clicked', async () => {
|
||||
const onRowClick = jest.fn() as jest.MockedFunction<
|
||||
(member: MemberRow) => void
|
||||
>;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={mockActiveMembers}
|
||||
onRowClick={onRowClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Alice Smith'));
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'user-1', email: 'alice@signoz.io' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "No members found" empty state when no data and no search query', () => {
|
||||
render(<MembersTable {...defaultProps} data={[]} total={0} searchQuery="" />);
|
||||
|
||||
expect(screen.getByText('No members found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No results for X" when no data and a search query is set', () => {
|
||||
render(
|
||||
<MembersTable {...defaultProps} data={[]} total={0} searchQuery="unknown" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No results for/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides pagination when total does not exceed pageSize', () => {
|
||||
const { container } = render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={mockActiveMembers}
|
||||
total={2}
|
||||
pageSize={20}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('.members-table-pagination'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pagination when total exceeds pageSize', () => {
|
||||
const { container } = render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={mockActiveMembers}
|
||||
total={25}
|
||||
pageSize={20}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('.members-table-pagination'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('.members-pagination-total'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import cx from 'classnames';
|
||||
import { dragColumnParams } from 'hooks/useDragColumns/configs';
|
||||
import { getColumnWidth, RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { debounce, set } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
import ResizableHeader from './ResizableHeader';
|
||||
import { DragSpanStyle } from './styles';
|
||||
@@ -24,26 +26,31 @@ function ResizeTable({
|
||||
columns,
|
||||
onDragColumn,
|
||||
pagination,
|
||||
columnWidths,
|
||||
onColumnWidthsChange,
|
||||
widgetId,
|
||||
shouldPersistColumnWidths = false,
|
||||
...restProps
|
||||
}: ResizeTableProps): JSX.Element {
|
||||
const [columnsData, setColumns] = useState<ColumnsType>([]);
|
||||
const onColumnWidthsChangeRef = useRef(onColumnWidthsChange);
|
||||
const { setColumnWidths, selectedDashboard } = useDashboard();
|
||||
|
||||
const columnWidths = shouldPersistColumnWidths
|
||||
? (selectedDashboard?.data?.widgets?.find(
|
||||
(widget) => widget.id === widgetId,
|
||||
) as Widgets)?.columnWidths
|
||||
: undefined;
|
||||
|
||||
const updateAllColumnWidths = useRef(
|
||||
debounce((widthsConfig: Record<string, number>) => {
|
||||
if (!onColumnWidthsChangeRef.current) {
|
||||
if (!widgetId || !shouldPersistColumnWidths) {
|
||||
return;
|
||||
}
|
||||
onColumnWidthsChangeRef.current(widthsConfig);
|
||||
setColumnWidths?.((prev) => ({
|
||||
...prev,
|
||||
[widgetId]: widthsConfig,
|
||||
}));
|
||||
}, 1000),
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
onColumnWidthsChangeRef.current = onColumnWidthsChange;
|
||||
}, [onColumnWidthsChange]);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(index: number) => (
|
||||
e: SyntheticEvent<Element>,
|
||||
@@ -68,7 +75,7 @@ function ResizeTable({
|
||||
...col,
|
||||
...(onDragColumn && {
|
||||
title: (
|
||||
<DragSpanStyle className="dragHandler" data-testid="drag-column-title">
|
||||
<DragSpanStyle className="dragHandler">
|
||||
{col?.title?.toString() || ''}
|
||||
</DragSpanStyle>
|
||||
),
|
||||
@@ -99,31 +106,31 @@ function ResizeTable({
|
||||
}, [mergedColumns, pagination, restProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!columns) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnsWithStoredWidths = columns.map((col) => {
|
||||
const dataIndex = (col as RowData).dataIndex as string;
|
||||
if (dataIndex && columnWidths) {
|
||||
const width = getColumnWidth(dataIndex, columnWidths);
|
||||
if (width) {
|
||||
return { ...col, width };
|
||||
if (columns) {
|
||||
// Apply stored column widths from widget configuration
|
||||
const columnsWithStoredWidths = columns.map((col) => {
|
||||
const dataIndex = (col as RowData).dataIndex as string;
|
||||
if (dataIndex && columnWidths) {
|
||||
const width = getColumnWidth(dataIndex, columnWidths);
|
||||
if (width) {
|
||||
return {
|
||||
...col,
|
||||
width, // Apply stored width
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return col;
|
||||
});
|
||||
return col;
|
||||
});
|
||||
|
||||
setColumns(columnsWithStoredWidths);
|
||||
setColumns(columnsWithStoredWidths);
|
||||
}
|
||||
}, [columns, columnWidths]);
|
||||
|
||||
const lastReportedWidthsRef = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!onColumnWidthsChange) {
|
||||
if (!shouldPersistColumnWidths) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all column widths in a single object
|
||||
const newColumnWidths: Record<string, number> = {};
|
||||
|
||||
mergedColumns.forEach((col) => {
|
||||
@@ -133,20 +140,11 @@ function ResizeTable({
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(newColumnWidths).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const last = lastReportedWidthsRef.current;
|
||||
const hasChange =
|
||||
Object.keys(newColumnWidths).length !== Object.keys(last).length ||
|
||||
Object.keys(newColumnWidths).some((k) => newColumnWidths[k] !== last[k]);
|
||||
|
||||
if (hasChange) {
|
||||
lastReportedWidthsRef.current = newColumnWidths;
|
||||
// Only update if there are actual widths to set
|
||||
if (Object.keys(newColumnWidths).length > 0) {
|
||||
updateAllColumnWidths(newColumnWidths);
|
||||
}
|
||||
}, [mergedColumns, updateAllColumnWidths, onColumnWidthsChange]);
|
||||
}, [mergedColumns, updateAllColumnWidths, shouldPersistColumnWidths]);
|
||||
|
||||
return onDragColumn ? (
|
||||
<ReactDragListView.DragColumn {...dragColumnParams} onDragEnd={onDragColumn}>
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ResizeTable from '../ResizeTable';
|
||||
|
||||
jest.mock('react-resizable', () => ({
|
||||
Resizable: ({
|
||||
children,
|
||||
onResize,
|
||||
width,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onResize: (
|
||||
e: React.SyntheticEvent,
|
||||
data: { size: { width: number } },
|
||||
) => void;
|
||||
width: number;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{children}
|
||||
<button
|
||||
data-testid="resize-trigger"
|
||||
type="button"
|
||||
onClick={(e): void => onResize(e, { size: { width: width + 50 } })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Make debounce synchronous so onColumnWidthsChange fires immediately
|
||||
jest.mock('lodash-es', () => ({
|
||||
...jest.requireActual('lodash-es'),
|
||||
debounce: (fn: (...args: any[]) => any): ((...args: any[]) => any) => fn,
|
||||
}));
|
||||
|
||||
const baseColumns = [
|
||||
{ dataIndex: 'name', title: 'Name', width: 100 },
|
||||
{ dataIndex: 'value', title: 'Value', width: 100 },
|
||||
];
|
||||
|
||||
const baseDataSource = [
|
||||
{ key: '1', name: 'Alice', value: 42 },
|
||||
{ key: '2', name: 'Bob', value: 99 },
|
||||
];
|
||||
|
||||
describe('ResizeTable', () => {
|
||||
it('renders column headers and data rows', () => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('overrides column widths from columnWidths prop and reports them via onColumnWidthsChange', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
columnWidths={{ name: 250, value: 180 }}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 250, value: 180 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports original column widths via onColumnWidthsChange when columnWidths prop is not provided', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 100, value: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call onColumnWidthsChange when it is not provided', () => {
|
||||
// Should render without errors and without attempting to call an undefined callback
|
||||
expect(() => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('only overrides the column that has a stored width, leaving others at their original width', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
columnWidths={{ name: 250 }}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 250, value: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call onColumnWidthsChange on re-render when widths have not changed', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledTimes(1);
|
||||
onColumnWidthsChange.mockClear();
|
||||
|
||||
rerender(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onColumnWidthsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onColumnWidthsChange when no column has a defined width', () => {
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={[
|
||||
{ dataIndex: 'name', title: 'Name' },
|
||||
{ dataIndex: 'value', title: 'Value' },
|
||||
]}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onColumnWidthsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onColumnWidthsChange with the new width after a column is resized', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
onColumnWidthsChange.mockClear();
|
||||
|
||||
// Click the first column's resize trigger — mock adds 50px to the current width (100 → 150)
|
||||
const [firstResizeTrigger] = screen.getAllByTestId('resize-trigger');
|
||||
await user.click(firstResizeTrigger);
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 150, value: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not affect other columns when only one column is resized', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onColumnWidthsChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
onColumnWidthsChange.mockClear();
|
||||
|
||||
// Resize only the second column (value: 100 → 150), name should stay at 100
|
||||
const resizeTriggers = screen.getAllByTestId('resize-trigger');
|
||||
await user.click(resizeTriggers[1]);
|
||||
|
||||
expect(onColumnWidthsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 100, value: 150 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps column titles in drag handler spans when onDragColumn is provided', () => {
|
||||
const onDragColumn = jest.fn();
|
||||
|
||||
render(
|
||||
<ResizeTable
|
||||
columns={baseColumns}
|
||||
dataSource={baseDataSource}
|
||||
rowKey="key"
|
||||
onDragColumn={onDragColumn}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dragTitles = screen.getAllByTestId('drag-column-title');
|
||||
expect(dragTitles).toHaveLength(baseColumns.length);
|
||||
expect(dragTitles[0]).toHaveTextContent('Name');
|
||||
expect(dragTitles[1]).toHaveTextContent('Value');
|
||||
});
|
||||
});
|
||||
@@ -6,25 +6,10 @@ import { LaunchChatSupportProps } from 'components/LaunchChatSupport/LaunchChatS
|
||||
|
||||
import { TableDataSource } from './contants';
|
||||
|
||||
type ColumnWidths = Record<string, number>;
|
||||
|
||||
export interface ResizeTableProps extends TableProps<any> {
|
||||
onDragColumn?: (fromIndex: number, toIndex: number) => void;
|
||||
/**
|
||||
* Pre-resolved column widths for this table, keyed by column dataIndex.
|
||||
* Use this to apply persisted widths on mount (e.g. from widget.columnWidths).
|
||||
* Do NOT pass a value that updates reactively on every resize — that creates a
|
||||
* feedback loop. Pass only stable / persisted values.
|
||||
*/
|
||||
columnWidths?: ColumnWidths;
|
||||
/**
|
||||
* Called (debounced) whenever the user finishes resizing a column.
|
||||
* The widths object contains all current column widths keyed by dataIndex.
|
||||
* Intended for persisting widths to an external store (e.g. dashboard context
|
||||
* staging buffer). The caller owns the storage; ResizeTable does not read back
|
||||
* whatever is written here.
|
||||
*/
|
||||
onColumnWidthsChange?: (widths: ColumnWidths) => void;
|
||||
widgetId?: string;
|
||||
shouldPersistColumnWidths?: boolean;
|
||||
}
|
||||
export interface DynamicColumnTableProps extends TableProps<any> {
|
||||
tablesource: typeof TableDataSource[keyof typeof TableDataSource];
|
||||
|
||||
@@ -56,7 +56,6 @@ const ROUTES = {
|
||||
BILLING: '/settings/billing',
|
||||
ROLES_SETTINGS: '/settings/roles',
|
||||
ROLE_DETAILS: '/settings/roles/:roleId',
|
||||
MEMBERS_SETTINGS: '/settings/members',
|
||||
SUPPORT: '/support',
|
||||
LOGS_SAVE_VIEWS: '/logs/saved-views',
|
||||
TRACES_SAVE_VIEWS: '/traces/saved-views',
|
||||
|
||||
@@ -674,7 +674,7 @@ function GeneralSettings({
|
||||
return (
|
||||
<div className="general-settings-page">
|
||||
<div className="general-settings-header">
|
||||
<span className="general-settings-title">Workspace</span>
|
||||
<span className="general-settings-title">General</span>
|
||||
<span className="general-settings-subtitle">
|
||||
Manage your workspace settings.
|
||||
</span>
|
||||
|
||||
@@ -81,18 +81,7 @@ function FullView({
|
||||
setCurrentGraphRef(fullViewRef);
|
||||
}, [setCurrentGraphRef]);
|
||||
|
||||
const {
|
||||
selectedDashboard,
|
||||
isDashboardLocked,
|
||||
setColumnWidths,
|
||||
} = useDashboard();
|
||||
|
||||
const onColumnWidthsChange = useCallback(
|
||||
(widths: Record<string, number>) => {
|
||||
setColumnWidths((prev) => ({ ...prev, [widget.id]: widths }));
|
||||
},
|
||||
[setColumnWidths, widget.id],
|
||||
);
|
||||
const { selectedDashboard, isDashboardLocked } = useDashboard();
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const { user } = useAppContext();
|
||||
|
||||
@@ -392,7 +381,6 @@ function FullView({
|
||||
onClickHandler={onClickHandler}
|
||||
enableDrillDown={enableDrillDown}
|
||||
selectedGraph={selectedPanelType}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</div>
|
||||
|
||||
@@ -168,9 +168,6 @@ jest.mock('providers/Dashboard/Dashboard', () => ({
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
setLayouts: jest.fn(),
|
||||
setSelectedDashboard: jest.fn(),
|
||||
setColumnWidths: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -101,19 +101,7 @@ function WidgetGraphComponent({
|
||||
|
||||
const navigateToExplorerPages = useNavigateToExplorerPages();
|
||||
|
||||
const {
|
||||
setLayouts,
|
||||
selectedDashboard,
|
||||
setSelectedDashboard,
|
||||
setColumnWidths,
|
||||
} = useDashboard();
|
||||
|
||||
const onColumnWidthsChange = useCallback(
|
||||
(widths: Record<string, number>) => {
|
||||
setColumnWidths((prev) => ({ ...prev, [widget.id]: widths }));
|
||||
},
|
||||
[setColumnWidths, widget.id],
|
||||
);
|
||||
const { setLayouts, selectedDashboard, setSelectedDashboard } = useDashboard();
|
||||
|
||||
const onToggleModal = useCallback(
|
||||
(func: Dispatch<SetStateAction<boolean>>) => {
|
||||
@@ -436,7 +424,6 @@ function WidgetGraphComponent({
|
||||
customSeries={customSeries}
|
||||
customOnRowClick={customOnRowClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -45,8 +45,6 @@ function GridTableComponent({
|
||||
onOpenTraceBtnClick,
|
||||
customOnRowClick,
|
||||
widgetId,
|
||||
columnWidths,
|
||||
onColumnWidthsChange,
|
||||
panelType,
|
||||
queryRangeRequest,
|
||||
decimalPrecision,
|
||||
@@ -286,8 +284,6 @@ function GridTableComponent({
|
||||
dataSource={dataSource}
|
||||
sticky={sticky}
|
||||
widgetId={widgetId}
|
||||
columnWidths={columnWidths}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
panelType={panelType}
|
||||
queryRangeRequest={queryRangeRequest}
|
||||
onRow={
|
||||
|
||||
@@ -24,8 +24,6 @@ export type GridTableComponentProps = {
|
||||
onOpenTraceBtnClick?: (record: RowData) => void;
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
widgetId?: string;
|
||||
columnWidths?: Record<string, number>;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
||||
customColTitles?: Record<string, string>;
|
||||
enableDrillDown?: boolean;
|
||||
|
||||
@@ -33,7 +33,6 @@ function LogsPanelComponent({
|
||||
widget,
|
||||
setRequestData,
|
||||
queryResponse,
|
||||
onColumnWidthsChange,
|
||||
}: LogsPanelComponentProps): JSX.Element {
|
||||
const [pageSize, setPageSize] = useState<number>(10);
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
@@ -146,8 +145,8 @@ function LogsPanelComponent({
|
||||
columns={columns}
|
||||
onRow={handleRow}
|
||||
rowKey={(record): string => record.id}
|
||||
columnWidths={widget.columnWidths}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
widgetId={widget.id}
|
||||
shouldPersistColumnWidths
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
@@ -190,7 +189,6 @@ export type LogsPanelComponentProps = {
|
||||
Error
|
||||
>;
|
||||
widget: Widgets;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
};
|
||||
|
||||
export default LogsPanelComponent;
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
.members-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
|
||||
height: 100%;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--label-large-500-font-size);
|
||||
font-weight: var(--label-large-500-font-weight);
|
||||
color: var(--text-base-white);
|
||||
letter-spacing: -0.09px;
|
||||
line-height: var(--line-height-normal);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.members-filter-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background);
|
||||
|
||||
> span {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.members-filter-dropdown {
|
||||
.ant-dropdown-menu {
|
||||
padding: var(--padding-3) 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--l2-background);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
background: transparent !important;
|
||||
padding: var(--padding-1) 0 !important;
|
||||
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.members-filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.14px;
|
||||
min-width: 170px;
|
||||
|
||||
&:hover {
|
||||
color: var(--card-foreground);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.members-search-input {
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.members-settings {
|
||||
&__title {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.members-filter-option {
|
||||
&:hover {
|
||||
color: var(--bg-neutral-light-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import getPendingInvites from 'api/v1/invite/get';
|
||||
import getAll from 'api/v1/user/get';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { FilterMode, INVITE_PREFIX, MemberStatus } from './utils';
|
||||
|
||||
import './MembersSettings.styles.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function MembersSettings(): JSX.Element {
|
||||
const { org } = useAppContext();
|
||||
const history = useHistory();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
|
||||
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
|
||||
|
||||
// TODO(nuqs): Replace with nuqs once the nuqs setup and integration is done - for search
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>(FilterMode.All);
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
|
||||
|
||||
const {
|
||||
data: usersData,
|
||||
isLoading: isUsersLoading,
|
||||
refetch: refetchUsers,
|
||||
} = useQuery({
|
||||
queryFn: getAll,
|
||||
queryKey: ['getOrgUser', org?.[0]?.id],
|
||||
});
|
||||
|
||||
const {
|
||||
data: invitesData,
|
||||
isLoading: isInvitesLoading,
|
||||
refetch: refetchInvites,
|
||||
} = useQuery({
|
||||
queryFn: getPendingInvites,
|
||||
queryKey: ['getPendingInvites'],
|
||||
});
|
||||
|
||||
const isLoading = isUsersLoading || isInvitesLoading;
|
||||
|
||||
const allMembers = useMemo((): MemberRow[] => {
|
||||
const activeMembers: MemberRow[] = (usersData?.data ?? []).map((user) => ({
|
||||
id: user.id,
|
||||
name: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: user.createdAt ? String(user.createdAt) : null,
|
||||
updatedAt: user?.updatedAt ? String(user.updatedAt) : null,
|
||||
}));
|
||||
|
||||
const pendingInvites: MemberRow[] = (invitesData?.data ?? []).map(
|
||||
(invite) => ({
|
||||
id: `${INVITE_PREFIX}${invite.id}`,
|
||||
name: invite.name ?? '',
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: invite.createdAt ? String(invite.createdAt) : null,
|
||||
token: invite.token ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return [...activeMembers, ...pendingInvites];
|
||||
}, [usersData, invitesData]);
|
||||
|
||||
const filteredMembers = useMemo((): MemberRow[] => {
|
||||
let result = allMembers;
|
||||
|
||||
if (filterMode === FilterMode.Invited) {
|
||||
result = result.filter((m) => m.status === MemberStatus.Invited);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(m) =>
|
||||
m?.name?.toLowerCase().includes(q) ||
|
||||
m.email.toLowerCase().includes(q) ||
|
||||
m.role.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [allMembers, filterMode, searchQuery]);
|
||||
|
||||
const paginatedMembers = useMemo((): MemberRow[] => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredMembers.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredMembers, currentPage]);
|
||||
|
||||
// TODO(nuqs): Replace with nuqs once the nuqs setup and integration is done
|
||||
const setPage = useCallback(
|
||||
(page: number): void => {
|
||||
urlQuery.set('page', String(page));
|
||||
history.replace({ search: urlQuery.toString() });
|
||||
},
|
||||
[history, urlQuery],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredMembers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.ceil(filteredMembers.length / PAGE_SIZE);
|
||||
if (currentPage > maxPage) {
|
||||
setPage(maxPage);
|
||||
}
|
||||
}, [filteredMembers.length, currentPage, setPage]);
|
||||
|
||||
const pendingCount = invitesData?.data?.length ?? 0;
|
||||
const totalCount = allMembers.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: FilterMode.All,
|
||||
label: (
|
||||
<div className="members-filter-option">
|
||||
<span>All members ⎯ {totalCount}</span>
|
||||
{filterMode === FilterMode.All && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.All);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Invited,
|
||||
label: (
|
||||
<div className="members-filter-option">
|
||||
<span>Pending invites ⎯ {pendingCount}</span>
|
||||
{filterMode === FilterMode.Invited && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Invited);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterLabel =
|
||||
filterMode === FilterMode.All
|
||||
? `All members ⎯ ${totalCount}`
|
||||
: `Pending invites ⎯ ${pendingCount}`;
|
||||
|
||||
const handleInviteComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
refetchInvites();
|
||||
}, [refetchUsers, refetchInvites]);
|
||||
|
||||
const handleRowClick = useCallback((member: MemberRow): void => {
|
||||
setSelectedMember(member);
|
||||
}, []);
|
||||
|
||||
const handleDrawerClose = useCallback((): void => {
|
||||
setSelectedMember(null);
|
||||
}, []);
|
||||
|
||||
const handleMemberEditComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
refetchInvites();
|
||||
setSelectedMember(null);
|
||||
}, [refetchUsers, refetchInvites]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="members-settings">
|
||||
<div className="members-settings__header">
|
||||
<h1 className="members-settings__title">Members</h1>
|
||||
<p className="members-settings__subtitle">
|
||||
Overview of people added to this workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="members-settings__controls">
|
||||
<Dropdown
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="members-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="members-filter-trigger"
|
||||
>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown size={12} className="members-filter-trigger__chevron" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<div className="members-settings__search">
|
||||
<Input
|
||||
placeholder="Search by name, email, or role..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="members-search-input"
|
||||
color="secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={(): void => setIsInviteModalOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Invite member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<MembersTable
|
||||
data={paginatedMembers}
|
||||
loading={isLoading}
|
||||
total={filteredMembers.length}
|
||||
currentPage={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
searchQuery={searchQuery}
|
||||
onPageChange={setPage}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
|
||||
<InviteMembersModal
|
||||
open={isInviteModalOpen}
|
||||
onClose={(): void => setIsInviteModalOpen(false)}
|
||||
onComplete={handleInviteComplete}
|
||||
/>
|
||||
|
||||
<EditMemberDrawer
|
||||
member={selectedMember}
|
||||
open={selectedMember !== null}
|
||||
onClose={handleDrawerClose}
|
||||
onComplete={handleMemberEditComplete}
|
||||
onRefetch={handleInviteComplete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MembersSettings;
|
||||
@@ -1,131 +0,0 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { PendingInvite } from 'types/api/user/getPendingInvites';
|
||||
import { UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
import MembersSettings from '../MembersSettings';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const USERS_ENDPOINT = '*/api/v1/user';
|
||||
const INVITES_ENDPOINT = '*/api/v1/invite';
|
||||
|
||||
const mockUsers: UserResponse[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
displayName: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
role: 'ADMIN',
|
||||
createdAt: 1700000000,
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
displayName: 'Bob Jones',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER',
|
||||
createdAt: 1700000001,
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
];
|
||||
|
||||
const mockInvites: PendingInvite[] = [
|
||||
{
|
||||
id: 'inv-1',
|
||||
email: 'charlie@signoz.io',
|
||||
name: 'Charlie',
|
||||
role: 'EDITOR',
|
||||
createdAt: 1700000002,
|
||||
token: 'tok-abc',
|
||||
},
|
||||
];
|
||||
|
||||
describe('MembersSettings (integration)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockUsers })),
|
||||
),
|
||||
rest.get(INVITES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockInvites })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('loads and displays active users and pending invites', async () => {
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
|
||||
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
|
||||
expect(screen.getByText('INVITED')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters to pending invites via the filter dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /all members/i }));
|
||||
|
||||
const pendingOption = await screen.findByText(/pending invites/i);
|
||||
await user.click(pendingOption);
|
||||
|
||||
await screen.findByText('charlie@signoz.io');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters members by name using the search input', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Search by name, email, or role/i),
|
||||
'bob',
|
||||
);
|
||||
|
||||
await screen.findByText('Bob Jones');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('charlie@signoz.io')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(await screen.findByText('Alice Smith'));
|
||||
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
|
||||
expect(await screen.findAllByPlaceholderText('john@signoz.io')).toHaveLength(
|
||||
3,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
export const INVITE_PREFIX = 'invite-';
|
||||
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Invited = 'invited',
|
||||
}
|
||||
|
||||
export enum MemberStatus {
|
||||
Active = 'Active',
|
||||
Invited = 'Invited',
|
||||
}
|
||||
@@ -161,7 +161,7 @@ function MySettings(): JSX.Element {
|
||||
<div className="my-settings-container">
|
||||
<div className="user-info-section">
|
||||
<div className="user-info-section-header">
|
||||
<div className="user-info-section-title">Account </div>
|
||||
<div className="user-info-section-title">General </div>
|
||||
|
||||
<div className="user-info-section-subtitle">
|
||||
Manage your account settings.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FeatureKeys } from 'constants/features';
|
||||
import ROUTES from 'constants/routes';
|
||||
import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader';
|
||||
import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal';
|
||||
import { InviteMemberFormValues } from 'container/OrganizationSettings/utils';
|
||||
import { InviteMemberFormValues } from 'container/OrganizationSettings/PendingInvitesContainer';
|
||||
import history from 'lib/history';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { InviteTeamMembersProps } from 'container/OrganizationSettings/utils';
|
||||
import { InviteTeamMembersProps } from 'container/OrganizationSettings/PendingInvitesContainer';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { gold } from '@ant-design/colors';
|
||||
import { ExclamationCircleTwoTone } from '@ant-design/icons';
|
||||
import { Space, Typography } from 'antd';
|
||||
|
||||
function DeleteMembersDetails({
|
||||
name,
|
||||
}: DeleteMembersDetailsProps): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Space direction="horizontal" size="middle" align="start">
|
||||
<ExclamationCircleTwoTone
|
||||
twoToneColor={[gold[6], '#1f1f1f']}
|
||||
style={{
|
||||
fontSize: '1.4rem',
|
||||
}}
|
||||
/>
|
||||
<Space direction="vertical">
|
||||
<Typography>Are you sure you want to delete {name}</Typography>
|
||||
<Typography>
|
||||
This will remove all access from dashboards and other features in SigNoz
|
||||
</Typography>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteMembersDetailsProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default DeleteMembersDetails;
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Select, Space, Tooltip } from 'antd';
|
||||
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import { InputGroup, SelectDrawer, Title } from './styles';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
function EditMembersDetails({
|
||||
emailAddress,
|
||||
name,
|
||||
role,
|
||||
setEmailAddress,
|
||||
setName,
|
||||
setRole,
|
||||
id,
|
||||
}: EditMembersDetailsProps): JSX.Element {
|
||||
const [passwordLink, setPasswordLink] = useState<string>('');
|
||||
|
||||
const { t } = useTranslation(['common']);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [state, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const getPasswordLink = (token: string): string =>
|
||||
`${window.location.origin}${ROUTES.PASSWORD_RESET}?token=${token}`;
|
||||
|
||||
const onChangeHandler = useCallback(
|
||||
(setFunc: Dispatch<SetStateAction<string>>, value: string) => {
|
||||
setFunc(value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
notifications.error({
|
||||
message: t('something_went_wrong'),
|
||||
});
|
||||
}
|
||||
|
||||
if (state.value) {
|
||||
notifications.success({
|
||||
message: t('success'),
|
||||
});
|
||||
}
|
||||
}, [state.error, state.value, t, notifications]);
|
||||
|
||||
const onPasswordChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
setPasswordLink(event.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onGeneratePasswordHandler = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getResetPasswordToken({
|
||||
userId: id || '',
|
||||
});
|
||||
setPasswordLink(getPasswordLink(response.data.token));
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large">
|
||||
<Space direction="horizontal">
|
||||
<Title>Email address</Title>
|
||||
<Input
|
||||
placeholder="john@signoz.io"
|
||||
readOnly
|
||||
onChange={(event): void =>
|
||||
onChangeHandler(setEmailAddress, event.target.value)
|
||||
}
|
||||
disabled={isLoading}
|
||||
value={emailAddress}
|
||||
/>
|
||||
</Space>
|
||||
<Space direction="horizontal">
|
||||
<Title>Name (optional)</Title>
|
||||
<Input
|
||||
placeholder="John"
|
||||
onChange={(event): void => onChangeHandler(setName, event.target.value)}
|
||||
value={name}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Space>
|
||||
<Space direction="horizontal">
|
||||
<Title>Role</Title>
|
||||
<SelectDrawer
|
||||
value={role}
|
||||
onSelect={(value: unknown): void => {
|
||||
if (typeof value === 'string') {
|
||||
setRole(value as ROLES);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Option value="ADMIN">ADMIN</Option>
|
||||
<Option value="VIEWER">VIEWER</Option>
|
||||
<Option value="EDITOR">EDITOR</Option>
|
||||
</SelectDrawer>
|
||||
</Space>
|
||||
|
||||
<Button
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={onGeneratePasswordHandler}
|
||||
type="primary"
|
||||
>
|
||||
Generate Reset Password link
|
||||
</Button>
|
||||
{passwordLink && (
|
||||
<InputGroup>
|
||||
<Input
|
||||
style={{ width: '100%' }}
|
||||
defaultValue="git@github.com:ant-design/ant-design.git"
|
||||
onChange={onPasswordChangeHandler}
|
||||
value={passwordLink}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Tooltip title="COPY LINK">
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(): void => copyToClipboard(passwordLink)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</InputGroup>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditMembersDetailsProps {
|
||||
emailAddress: string;
|
||||
name: string;
|
||||
role: ROLES;
|
||||
setEmailAddress: Dispatch<SetStateAction<string>>;
|
||||
setName: Dispatch<SetStateAction<string>>;
|
||||
setRole: Dispatch<SetStateAction<ROLES>>;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default EditMembersDetails;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Select, Typography } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const SelectDrawer = styled(Select)`
|
||||
width: 120px;
|
||||
`;
|
||||
|
||||
export const Title = styled(Typography)`
|
||||
width: 7rem;
|
||||
`;
|
||||
|
||||
export const InputGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'antd';
|
||||
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
|
||||
|
||||
import { InviteMemberFormValues } from '../utils';
|
||||
import { InviteMemberFormValues } from '../PendingInvitesContainer/index';
|
||||
import { SelectDrawer, SpaceContainer, TitleWrapper } from './styles';
|
||||
|
||||
function InviteTeamMembers({ form, onFinish }: Props): JSX.Element {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useNotifications } from 'hooks/useNotifications';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import InviteTeamMembers from '../InviteTeamMembers';
|
||||
import { InviteMemberFormValues } from '../utils';
|
||||
import { InviteMemberFormValues } from '../PendingInvitesContainer';
|
||||
|
||||
export interface InviteUserModalProps {
|
||||
isInviteTeamMemberModalOpen: boolean;
|
||||
|
||||
324
frontend/src/container/OrganizationSettings/Members/index.tsx
Normal file
324
frontend/src/container/OrganizationSettings/Members/index.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Space,
|
||||
TableColumnsType as ColumnsType,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import getAll from 'api/v1/user/get';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import DeleteMembersDetails from '../DeleteMembersDetails';
|
||||
import EditMembersDetails from '../EditMembersDetails';
|
||||
|
||||
function UserFunction({
|
||||
setDataSource,
|
||||
accessLevel,
|
||||
name,
|
||||
email,
|
||||
id,
|
||||
}: UserFunctionProps): JSX.Element {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
|
||||
const onModalToggleHandler = (
|
||||
func: Dispatch<SetStateAction<boolean>>,
|
||||
value: boolean,
|
||||
): void => {
|
||||
func(value);
|
||||
};
|
||||
|
||||
const [emailAddress, setEmailAddress] = useState(email);
|
||||
const [updatedName, setUpdatedName] = useState(name);
|
||||
const [role, setRole] = useState<ROLES>(accessLevel);
|
||||
const { t } = useTranslation(['common']);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState<boolean>(false);
|
||||
const [isUpdateLoading, setIsUpdateLoading] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const onUpdateDetailsHandler = (): void => {
|
||||
setDataSource((data) => {
|
||||
const index = data.findIndex((e) => e.id === id);
|
||||
if (index !== -1) {
|
||||
const current = data[index];
|
||||
|
||||
const updatedData: DataType[] = [
|
||||
...data.slice(0, index),
|
||||
{
|
||||
...current,
|
||||
name: updatedName,
|
||||
accessLevel: role,
|
||||
email: emailAddress,
|
||||
},
|
||||
...data.slice(index + 1, data.length),
|
||||
];
|
||||
|
||||
return updatedData;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = (): void => {
|
||||
setDataSource((source) => {
|
||||
const index = source.findIndex((e) => e.id === id);
|
||||
|
||||
if (index !== -1) {
|
||||
const updatedData: DataType[] = [
|
||||
...source.slice(0, index),
|
||||
...source.slice(index + 1, source.length),
|
||||
];
|
||||
|
||||
return updatedData;
|
||||
}
|
||||
return source;
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteHandler = async (): Promise<void> => {
|
||||
try {
|
||||
setIsDeleteLoading(true);
|
||||
await deleteUser({
|
||||
userId: id,
|
||||
});
|
||||
onDelete();
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
setIsDeleteModalVisible(false);
|
||||
setIsDeleteLoading(false);
|
||||
} catch (error) {
|
||||
setIsDeleteLoading(false);
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onEditMemberDetails = async (): Promise<void> => {
|
||||
try {
|
||||
setIsUpdateLoading(true);
|
||||
await update({
|
||||
userId: id,
|
||||
displayName: updatedName,
|
||||
role,
|
||||
});
|
||||
onUpdateDetailsHandler();
|
||||
|
||||
if (role !== accessLevel) {
|
||||
notifications.success({
|
||||
message: 'User details updated successfully',
|
||||
description: 'The user details have been updated successfully.',
|
||||
});
|
||||
} else {
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setIsUpdateLoading(false);
|
||||
setIsModalVisible(false);
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
setIsUpdateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space direction="horizontal">
|
||||
<Typography.Link
|
||||
onClick={(): void => onModalToggleHandler(setIsModalVisible, true)}
|
||||
>
|
||||
Edit
|
||||
</Typography.Link>
|
||||
<Typography.Link
|
||||
onClick={(): void => onModalToggleHandler(setIsDeleteModalVisible, true)}
|
||||
>
|
||||
Delete
|
||||
</Typography.Link>
|
||||
</Space>
|
||||
<Modal
|
||||
title="Edit member details"
|
||||
className="edit-member-details-modal"
|
||||
open={isModalVisible}
|
||||
onOk={(): void => onModalToggleHandler(setIsModalVisible, false)}
|
||||
onCancel={(): void => onModalToggleHandler(setIsModalVisible, false)}
|
||||
centered
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="back"
|
||||
onClick={(): void => onModalToggleHandler(setIsModalVisible, false)}
|
||||
type="default"
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="Invite_team_members"
|
||||
onClick={onEditMemberDetails}
|
||||
type="primary"
|
||||
disabled={isUpdateLoading}
|
||||
loading={isUpdateLoading}
|
||||
>
|
||||
Update Details
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<EditMembersDetails
|
||||
{...{
|
||||
emailAddress,
|
||||
name: updatedName,
|
||||
role,
|
||||
setEmailAddress,
|
||||
setName: setUpdatedName,
|
||||
setRole,
|
||||
id,
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Edit member details"
|
||||
open={isDeleteModalVisible}
|
||||
onOk={onDeleteHandler}
|
||||
onCancel={(): void => onModalToggleHandler(setIsDeleteModalVisible, false)}
|
||||
centered
|
||||
confirmLoading={isDeleteLoading}
|
||||
>
|
||||
<DeleteMembersDetails name={name} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Members(): JSX.Element {
|
||||
const { org } = useAppContext();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryFn: () => getAll(),
|
||||
queryKey: ['getOrgUser', org?.[0].id],
|
||||
});
|
||||
|
||||
const [dataSource, setDataSource] = useState<DataType[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.data && Array.isArray(data.data)) {
|
||||
const updatedData: DataType[] = data?.data?.map((e) => ({
|
||||
accessLevel: e.role,
|
||||
email: e.email,
|
||||
id: String(e.id),
|
||||
joinedOn: String(e.createdAt),
|
||||
name: e.displayName,
|
||||
}));
|
||||
setDataSource(updatedData);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const columns: ColumnsType<DataType> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Emails',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Access Level',
|
||||
dataIndex: 'accessLevel',
|
||||
key: 'accessLevel',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
title: 'Joined On',
|
||||
dataIndex: 'joinedOn',
|
||||
key: 'joinedOn',
|
||||
width: 60,
|
||||
render: (_, record): JSX.Element => {
|
||||
const { joinedOn } = record;
|
||||
return (
|
||||
<Typography>
|
||||
{dayjs(joinedOn).format(DATE_TIME_FORMATS.MONTH_DATE_FULL)}
|
||||
</Typography>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
dataIndex: 'action',
|
||||
width: 80,
|
||||
render: (_, record): JSX.Element => (
|
||||
<UserFunction
|
||||
{...{
|
||||
accessLevel: record.accessLevel,
|
||||
email: record.email,
|
||||
joinedOn: record.joinedOn,
|
||||
name: record.name,
|
||||
id: record.id,
|
||||
setDataSource,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="members-container">
|
||||
<Typography.Title level={3}>
|
||||
Members{' '}
|
||||
{!isLoading && dataSource && (
|
||||
<div className="members-count"> ({dataSource.length}) </div>
|
||||
)}
|
||||
</Typography.Title>
|
||||
{!(error as APIError) && (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={dataSource}
|
||||
pagination={false}
|
||||
loading={isLoading}
|
||||
bordered
|
||||
/>
|
||||
)}
|
||||
{(error as APIError) && <ErrorContent error={error as APIError} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataType {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
accessLevel: ROLES;
|
||||
joinedOn: string;
|
||||
}
|
||||
|
||||
interface UserFunctionProps extends DataType {
|
||||
setDataSource: Dispatch<SetStateAction<DataType[]>>;
|
||||
}
|
||||
|
||||
export default Members;
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Space,
|
||||
TableColumnsType as ColumnsType,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import get from 'api/v1/invite/get';
|
||||
import deleteInvite from 'api/v1/invite/id/delete';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { INVITE_MEMBERS_HASH } from 'constants/app';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import APIError from 'types/api/error';
|
||||
import { PendingInvite } from 'types/api/user/getPendingInvites';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import InviteUserModal from '../InviteUserModal/InviteUserModal';
|
||||
import { TitleWrapper } from './styles';
|
||||
|
||||
function PendingInvitesContainer(): JSX.Element {
|
||||
const [
|
||||
isInviteTeamMemberModalOpen,
|
||||
setIsInviteTeamMemberModalOpen,
|
||||
] = useState<boolean>(false);
|
||||
const [form] = Form.useForm<InviteMemberFormValues>();
|
||||
const { t } = useTranslation(['organizationsettings', 'common']);
|
||||
const [state, setText] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
const { user } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
notifications.error({
|
||||
message: state.error.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.value) {
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, [state.error, state.value, t, notifications]);
|
||||
|
||||
const { data, isLoading, error, isError, refetch } = useQuery({
|
||||
queryFn: get,
|
||||
queryKey: ['getPendingInvites', user?.accessJwt],
|
||||
});
|
||||
|
||||
const [dataSource, setDataSource] = useState<DataProps[]>([]);
|
||||
|
||||
const toggleModal = useCallback(
|
||||
(value: boolean): void => {
|
||||
setIsInviteTeamMemberModalOpen(value);
|
||||
if (!value) {
|
||||
form.resetFields();
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const { hash } = useLocation();
|
||||
|
||||
const getParsedInviteData = useCallback(
|
||||
(payload: PendingInvite[] = []) =>
|
||||
payload?.map((data) => ({
|
||||
key: data.createdAt,
|
||||
name: data.name,
|
||||
id: data.id,
|
||||
email: data.email,
|
||||
accessLevel: data.role,
|
||||
inviteLink: `${window.location.origin}${ROUTES.SIGN_UP}?token=${data.token}`,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hash === INVITE_MEMBERS_HASH) {
|
||||
toggleModal(true);
|
||||
}
|
||||
}, [hash, toggleModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.data) {
|
||||
const parsedData = getParsedInviteData(data?.data || []);
|
||||
setDataSource(parsedData);
|
||||
}
|
||||
}, [data, getParsedInviteData]);
|
||||
|
||||
const onRevokeHandler = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await deleteInvite({
|
||||
id,
|
||||
});
|
||||
// remove from the client data
|
||||
const index = dataSource.findIndex((e) => e.id === id);
|
||||
if (index !== -1) {
|
||||
setDataSource([
|
||||
...dataSource.slice(0, index),
|
||||
...dataSource.slice(index + 1, dataSource.length),
|
||||
]);
|
||||
}
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<DataProps> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Emails',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: 'Access Level',
|
||||
dataIndex: 'accessLevel',
|
||||
key: 'accessLevel',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
title: 'Invite Link',
|
||||
dataIndex: 'inviteLink',
|
||||
key: 'Invite Link',
|
||||
ellipsis: true,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
dataIndex: 'action',
|
||||
width: 80,
|
||||
key: 'Action',
|
||||
render: (_, record): JSX.Element => (
|
||||
<Space direction="horizontal">
|
||||
<Typography.Link onClick={(): Promise<void> => onRevokeHandler(record.id)}>
|
||||
Revoke
|
||||
</Typography.Link>
|
||||
<Typography.Link
|
||||
onClick={(): void => {
|
||||
setText(record.inviteLink);
|
||||
}}
|
||||
>
|
||||
Copy Invite Link
|
||||
</Typography.Link>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="pending-invites-container-wrapper">
|
||||
<InviteUserModal
|
||||
form={form}
|
||||
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
|
||||
toggleModal={toggleModal}
|
||||
onClose={refetch}
|
||||
/>
|
||||
|
||||
<div className="pending-invites-container">
|
||||
<TitleWrapper>
|
||||
<Typography.Title level={3}>
|
||||
{t('pending_invites')}
|
||||
{dataSource && (
|
||||
<div className="members-count"> ({dataSource.length})</div>
|
||||
)}
|
||||
</Typography.Title>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
toggleModal(true);
|
||||
}}
|
||||
>
|
||||
{t('invite_members')}
|
||||
</Button>
|
||||
</Space>
|
||||
</TitleWrapper>
|
||||
{!isError && (
|
||||
<ResizeTable
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={dataSource}
|
||||
pagination={false}
|
||||
loading={isLoading}
|
||||
bordered
|
||||
/>
|
||||
)}
|
||||
{isError && <ErrorContent error={error as APIError} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface InviteTeamMembersProps {
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
id: string;
|
||||
frontendBaseUrl: string;
|
||||
}
|
||||
|
||||
interface DataProps {
|
||||
key: number;
|
||||
name: string;
|
||||
id: string;
|
||||
email: string;
|
||||
accessLevel: ROLES;
|
||||
inviteLink: string;
|
||||
}
|
||||
|
||||
type Role = 'ADMIN' | 'VIEWER' | 'EDITOR';
|
||||
|
||||
export interface InviteMemberFormValues {
|
||||
members: {
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default PendingInvitesContainer;
|
||||
@@ -0,0 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TitleWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
@@ -3,6 +3,8 @@ import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import AuthDomain from './AuthDomain';
|
||||
import DisplayName from './DisplayName';
|
||||
import Members from './Members';
|
||||
import PendingInvitesContainer from './PendingInvitesContainer';
|
||||
|
||||
import './OrganizationSettings.styles.scss';
|
||||
|
||||
@@ -21,6 +23,9 @@ function OrganizationSettings(): JSX.Element {
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<PendingInvitesContainer />
|
||||
|
||||
<Members />
|
||||
<AuthDomain />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { act, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import Members from '../Members';
|
||||
|
||||
describe('Organization Settings Page', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('render list of members', async () => {
|
||||
act(() => {
|
||||
render(<Members />);
|
||||
});
|
||||
|
||||
const title = await screen.findByText(/Members/i);
|
||||
expect(title).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('firstUser@test.io')).toBeInTheDocument(); // first item
|
||||
expect(screen.getByText('lastUser@test.io')).toBeInTheDocument(); // last item
|
||||
});
|
||||
});
|
||||
|
||||
// this is required as our edit/delete logic is dependent on the index and it will break with pagination enabled
|
||||
it('render list of members without pagination', async () => {
|
||||
render(<Members />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('firstUser@test.io')).toBeInTheDocument(); // first item
|
||||
expect(screen.getByText('lastUser@test.io')).toBeInTheDocument(); // last item
|
||||
|
||||
expect(
|
||||
document.querySelector('.ant-table-pagination'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
export interface InviteTeamMembersProps {
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
id: string;
|
||||
frontendBaseUrl: string;
|
||||
}
|
||||
|
||||
type Role = 'ADMIN' | 'VIEWER' | 'EDITOR';
|
||||
|
||||
export interface InviteMemberFormValues {
|
||||
members: {
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
}[];
|
||||
}
|
||||
@@ -8,7 +8,6 @@ function ListPanelWrapper({
|
||||
widget,
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
onColumnWidthsChange,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const dataSource = widget.query.builder?.queryData[0]?.dataSource;
|
||||
|
||||
@@ -22,7 +21,6 @@ function ListPanelWrapper({
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +29,6 @@ function ListPanelWrapper({
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ function PanelWrapper({
|
||||
customOnRowClick,
|
||||
panelMode,
|
||||
enableDrillDown = false,
|
||||
onColumnWidthsChange,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const Component = PanelTypeVsPanelWrapper[
|
||||
selectedGraph || widget.panelTypes
|
||||
@@ -59,7 +58,6 @@ function PanelWrapper({
|
||||
customOnRowClick={customOnRowClick}
|
||||
customSeries={customSeries}
|
||||
enableDrillDown={enableDrillDown}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ function TablePanelWrapper({
|
||||
onOpenTraceBtnClick,
|
||||
customOnRowClick,
|
||||
enableDrillDown = false,
|
||||
onColumnWidthsChange,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const panelData =
|
||||
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
|
||||
@@ -35,8 +34,6 @@ function TablePanelWrapper({
|
||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||
customOnRowClick={customOnRowClick}
|
||||
widgetId={widget.id}
|
||||
columnWidths={widget.columnWidths}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
renderColumnCell={widget.renderColumnCell}
|
||||
customColTitles={widget.customColTitles}
|
||||
contextLinks={widget.contextLinks}
|
||||
|
||||
@@ -29,7 +29,6 @@ export type PanelWrapperProps = {
|
||||
customSeries?: (data: QueryData[]) => uPlot.Series[];
|
||||
enableDrillDown?: boolean;
|
||||
panelMode: PanelMode;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -24,8 +24,6 @@ export type QueryTableProps = Omit<
|
||||
sticky?: TableProps<RowData>['sticky'];
|
||||
searchTerm?: string;
|
||||
widgetId?: string;
|
||||
columnWidths?: Record<string, number>;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
enableDrillDown?: boolean;
|
||||
contextLinks?: ContextLinksData;
|
||||
panelType?: PANEL_TYPES;
|
||||
|
||||
@@ -28,8 +28,6 @@ export function QueryTable({
|
||||
sticky,
|
||||
searchTerm,
|
||||
widgetId,
|
||||
columnWidths,
|
||||
onColumnWidthsChange,
|
||||
panelType,
|
||||
...props
|
||||
}: QueryTableProps): JSX.Element {
|
||||
@@ -177,8 +175,8 @@ export function QueryTable({
|
||||
dataSource={filterTable === null ? newDataSource : filterTable}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={paginationConfig}
|
||||
columnWidths={columnWidths}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
widgetId={widgetId}
|
||||
shouldPersistColumnWidths
|
||||
sticky={sticky}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1057,20 +1057,21 @@
|
||||
gap: 8px;
|
||||
|
||||
.user-settings-dropdown-label-text {
|
||||
color: var(--l3-foreground);
|
||||
color: var(--bg-slate-50, #62687c);
|
||||
font-family: Inter;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
font-size: 10px;
|
||||
font-family: Inter;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
line-height: 18px;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-settings-dropdown-label-email {
|
||||
color: var(--l1-foreground);
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
@@ -1078,7 +1079,7 @@
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--secondary) !important;
|
||||
background-color: var(--bg-slate-500, #161922) !important;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-disabled {
|
||||
@@ -1094,14 +1095,6 @@
|
||||
.help-support-dropdown {
|
||||
.ant-dropdown-menu-item {
|
||||
min-height: 32px;
|
||||
|
||||
.ant-dropdown-menu-title-content {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
.user-settings-dropdown-logout-section {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1278,7 +1271,7 @@
|
||||
}
|
||||
|
||||
.help-support-dropdown li.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--secondary) !important;
|
||||
background-color: var(--bg-slate-500, #161922) !important;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
@@ -1438,6 +1431,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings-dropdown {
|
||||
.user-settings-dropdown-logged-in-section {
|
||||
.user-settings-dropdown-label-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.user-settings-dropdown-label-email {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.reorder-shortcut-nav-items-modal {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
@@ -1494,6 +1503,10 @@
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.help-support-dropdown li.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.version-tooltip-overlay {
|
||||
|
||||
@@ -69,7 +69,6 @@ import { routeConfig } from './config';
|
||||
import { getQueryString } from './helper';
|
||||
import {
|
||||
defaultMoreMenuItems,
|
||||
getUserSettingsDropdownMenuItems,
|
||||
helpSupportDropdownMenuItems as DefaultHelpSupportDropdownMenuItems,
|
||||
helpSupportMenuItem,
|
||||
primaryMenuItems,
|
||||
@@ -486,12 +485,48 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
|
||||
const userSettingsDropdownMenuItems: MenuProps['items'] = useMemo(
|
||||
() =>
|
||||
getUserSettingsDropdownMenuItems({
|
||||
userEmail: user.email,
|
||||
isWorkspaceBlocked,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
}),
|
||||
[
|
||||
{
|
||||
key: 'label',
|
||||
label: (
|
||||
<div className="user-settings-dropdown-logged-in-section">
|
||||
<span className="user-settings-dropdown-label-text">LOGGED IN AS</span>
|
||||
<span className="user-settings-dropdown-label-email">{user.email}</span>
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
dataTestId: 'logged-in-as-nav-item',
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account Settings',
|
||||
dataTestId: 'account-settings-nav-item',
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace Settings',
|
||||
disabled: isWorkspaceBlocked,
|
||||
dataTestId: 'workspace-settings-nav-item',
|
||||
},
|
||||
...(isEnterpriseSelfHostedUser || isCommunityEnterpriseUser
|
||||
? [
|
||||
{
|
||||
key: 'license',
|
||||
label: 'Manage License',
|
||||
dataTestId: 'manage-license-nav-item',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
label: (
|
||||
<span className="user-settings-dropdown-logout-section">Sign out</span>
|
||||
),
|
||||
dataTestId: 'logout-nav-item',
|
||||
},
|
||||
].filter(Boolean),
|
||||
[
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
@@ -821,6 +856,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
});
|
||||
|
||||
switch (item.key) {
|
||||
case ROUTES.SHORTCUTS:
|
||||
history.push(ROUTES.SHORTCUTS);
|
||||
break;
|
||||
case 'invite-collaborators':
|
||||
history.push(`${ROUTES.ORG_SETTINGS}#invite-team-members`);
|
||||
break;
|
||||
@@ -840,7 +878,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
};
|
||||
|
||||
const handleSettingsMenuItemClick = (info: SidebarItem): void => {
|
||||
const item = (userSettingsDropdownMenuItems ?? []).find(
|
||||
const item = userSettingsDropdownMenuItems.find(
|
||||
(item) => item?.key === info.key,
|
||||
);
|
||||
let menuLabel = '';
|
||||
@@ -866,9 +904,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
case 'license':
|
||||
history.push(ROUTES.LIST_LICENSES);
|
||||
break;
|
||||
case 'keyboard-shortcuts':
|
||||
history.push(ROUTES.SHORTCUTS);
|
||||
break;
|
||||
case 'logout':
|
||||
Logout();
|
||||
break;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { getUserSettingsDropdownMenuItems } from 'container/SideNav/menuItems';
|
||||
|
||||
const BASE_PARAMS = {
|
||||
userEmail: 'test@signoz.io',
|
||||
isWorkspaceBlocked: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
};
|
||||
|
||||
describe('getUserSettingsDropdownMenuItems', () => {
|
||||
it('always includes logged-in-as label, workspace, account, keyboard shortcuts, and sign out', () => {
|
||||
const items = getUserSettingsDropdownMenuItems(BASE_PARAMS);
|
||||
const keys = items?.map((item) => item?.key);
|
||||
|
||||
expect(keys).toContain('label');
|
||||
expect(keys).toContain('workspace');
|
||||
expect(keys).toContain('account');
|
||||
expect(keys).toContain('keyboard-shortcuts');
|
||||
expect(keys).toContain('logout');
|
||||
|
||||
// workspace item is enabled when workspace is not blocked
|
||||
const workspaceItem = items?.find(
|
||||
(item: any) => item.key === 'workspace',
|
||||
) as any;
|
||||
|
||||
expect(workspaceItem?.disabled).toBe(false);
|
||||
|
||||
// does not include license item for regular cloud user
|
||||
expect(keys).not.toContain('license');
|
||||
});
|
||||
|
||||
it('includes manage license item for enterprise self-hosted users', () => {
|
||||
const items = getUserSettingsDropdownMenuItems({
|
||||
...BASE_PARAMS,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
const keys = items?.map((item) => item?.key);
|
||||
|
||||
expect(keys).toContain('license');
|
||||
});
|
||||
|
||||
it('includes manage license item for community enterprise users', () => {
|
||||
const items = getUserSettingsDropdownMenuItems({
|
||||
...BASE_PARAMS,
|
||||
isCommunityEnterpriseUser: true,
|
||||
});
|
||||
const keys = items?.map((item) => item?.key);
|
||||
|
||||
expect(keys).toContain('license');
|
||||
});
|
||||
|
||||
it('workspace item is disabled when workspace is blocked', () => {
|
||||
const items = getUserSettingsDropdownMenuItems({
|
||||
...BASE_PARAMS,
|
||||
isWorkspaceBlocked: true,
|
||||
});
|
||||
const workspaceItem = items?.find(
|
||||
(item: any) => item.key === 'workspace',
|
||||
) as any;
|
||||
|
||||
expect(workspaceItem?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns items in correct order: label, divider, workspace, account, ..., shortcuts, divider, logout', () => {
|
||||
const items = getUserSettingsDropdownMenuItems(BASE_PARAMS) ?? [];
|
||||
const keys = items.map((item: any) => item.key ?? item.type);
|
||||
|
||||
expect(keys[0]).toBe('label');
|
||||
expect(keys[1]).toBe('divider');
|
||||
expect(keys[2]).toBe('workspace');
|
||||
expect(keys[3]).toBe('account');
|
||||
expect(keys[keys.length - 1]).toBe('logout');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import { RocketOutlined } from '@ant-design/icons';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { MenuProps } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -10,7 +8,6 @@ import {
|
||||
Book,
|
||||
Boxes,
|
||||
BugIcon,
|
||||
Building2,
|
||||
ChartArea,
|
||||
Cloudy,
|
||||
DraftingCompass,
|
||||
@@ -23,26 +20,21 @@ import {
|
||||
Layers2,
|
||||
LayoutGrid,
|
||||
ListMinus,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Plus,
|
||||
Receipt,
|
||||
Route,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
Slack,
|
||||
Unplug,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
SecondaryMenuItemKey,
|
||||
SettingsNavSection,
|
||||
SidebarItem,
|
||||
} from './sideNav.types';
|
||||
import { SecondaryMenuItemKey, SidebarItem } from './sideNav.types';
|
||||
|
||||
export const getStartedMenuItem = {
|
||||
key: ROUTES.GET_STARTED,
|
||||
@@ -196,6 +188,12 @@ export const primaryMenuItems: SidebarItem[] = [
|
||||
icon: <Home size={16} />,
|
||||
itemKey: 'home',
|
||||
},
|
||||
{
|
||||
key: 'quick-search',
|
||||
label: 'Search',
|
||||
icon: <Search size={16} />,
|
||||
itemKey: 'quick-search',
|
||||
},
|
||||
{
|
||||
key: ROUTES.LIST_ALL_ALERT,
|
||||
label: 'Alerts',
|
||||
@@ -298,107 +296,77 @@ export const defaultMoreMenuItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const settingsNavSections: SettingsNavSection[] = [
|
||||
export const settingsMenuItems: SidebarItem[] = [
|
||||
{
|
||||
key: 'general',
|
||||
items: [
|
||||
{
|
||||
key: ROUTES.SETTINGS,
|
||||
label: 'Workspace',
|
||||
icon: <Settings size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'workspace',
|
||||
},
|
||||
{
|
||||
key: ROUTES.MY_SETTINGS,
|
||||
label: 'Account',
|
||||
icon: <User size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'account',
|
||||
},
|
||||
{
|
||||
key: ROUTES.ALL_CHANNELS,
|
||||
label: 'Notification Channels',
|
||||
icon: <FileKey2 size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'notification-channels',
|
||||
},
|
||||
{
|
||||
key: ROUTES.BILLING,
|
||||
label: 'Billing',
|
||||
icon: <Receipt size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'billing',
|
||||
},
|
||||
{
|
||||
key: ROUTES.INTEGRATIONS,
|
||||
label: 'Integrations',
|
||||
icon: <Unplug size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'integrations',
|
||||
},
|
||||
],
|
||||
key: ROUTES.SETTINGS,
|
||||
label: 'General',
|
||||
icon: <Settings size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'general',
|
||||
},
|
||||
{
|
||||
key: ROUTES.BILLING,
|
||||
label: 'Billing',
|
||||
icon: <Receipt size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'billing',
|
||||
},
|
||||
{
|
||||
key: ROUTES.ROLES_SETTINGS,
|
||||
label: 'Roles',
|
||||
icon: <Shield size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'roles',
|
||||
},
|
||||
{
|
||||
key: ROUTES.ORG_SETTINGS,
|
||||
label: 'Members & SSO',
|
||||
icon: <User size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'members-sso',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'identity-access',
|
||||
title: 'Identity & Access',
|
||||
items: [
|
||||
{
|
||||
key: ROUTES.ROLES_SETTINGS,
|
||||
label: 'Roles',
|
||||
icon: <Shield size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'roles',
|
||||
},
|
||||
{
|
||||
key: ROUTES.MEMBERS_SETTINGS,
|
||||
label: 'Members',
|
||||
icon: <Users size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'members',
|
||||
},
|
||||
{
|
||||
key: ROUTES.API_KEYS,
|
||||
label: 'API Keys',
|
||||
icon: <Key size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'api-keys',
|
||||
},
|
||||
{
|
||||
key: ROUTES.INGESTION_SETTINGS,
|
||||
label: 'Ingestion',
|
||||
icon: <RocketOutlined rotate={45} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'ingestion',
|
||||
},
|
||||
],
|
||||
key: ROUTES.INTEGRATIONS,
|
||||
label: 'Integrations',
|
||||
icon: <Unplug size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'integrations',
|
||||
},
|
||||
{
|
||||
key: 'authentication',
|
||||
title: 'Authentication',
|
||||
items: [
|
||||
{
|
||||
key: ROUTES.ORG_SETTINGS,
|
||||
label: 'Single Sign-on',
|
||||
icon: <User size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'sso',
|
||||
},
|
||||
],
|
||||
key: ROUTES.ALL_CHANNELS,
|
||||
label: 'Notification Channels',
|
||||
icon: <FileKey2 size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'notification-channels',
|
||||
},
|
||||
{
|
||||
key: 'shortcuts',
|
||||
hasDivider: true,
|
||||
items: [
|
||||
{
|
||||
key: ROUTES.SHORTCUTS,
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: <Keyboard size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'keyboard-shortcuts',
|
||||
},
|
||||
],
|
||||
key: ROUTES.API_KEYS,
|
||||
label: 'API Keys',
|
||||
icon: <Key size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'api-keys',
|
||||
},
|
||||
{
|
||||
key: ROUTES.INGESTION_SETTINGS,
|
||||
label: 'Ingestion',
|
||||
icon: <RocketOutlined rotate={45} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'ingestion',
|
||||
},
|
||||
{
|
||||
key: ROUTES.MY_SETTINGS,
|
||||
label: 'Account Settings',
|
||||
icon: <User size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'account-settings',
|
||||
},
|
||||
{
|
||||
key: ROUTES.SHORTCUTS,
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: <Layers2 size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'keyboard-shortcuts',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -449,6 +417,12 @@ export const helpSupportDropdownMenuItems: SidebarItem[] = [
|
||||
icon: <MessageSquareText size={14} />,
|
||||
itemKey: 'chat-support',
|
||||
},
|
||||
{
|
||||
key: ROUTES.SHORTCUTS,
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: <Keyboard size={14} />,
|
||||
itemKey: 'keyboard-shortcuts',
|
||||
},
|
||||
{
|
||||
key: 'invite-collaborators',
|
||||
label: 'Invite a Team Member',
|
||||
@@ -457,78 +431,6 @@ export const helpSupportDropdownMenuItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export interface UserSettingsMenuItemsParams {
|
||||
userEmail: string;
|
||||
isWorkspaceBlocked: boolean;
|
||||
isEnterpriseSelfHostedUser: boolean;
|
||||
isCommunityEnterpriseUser: boolean;
|
||||
}
|
||||
|
||||
export const getUserSettingsDropdownMenuItems = ({
|
||||
userEmail,
|
||||
isWorkspaceBlocked,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
}: UserSettingsMenuItemsParams): MenuProps['items'] =>
|
||||
[
|
||||
{
|
||||
key: 'label',
|
||||
label: (
|
||||
<div className="user-settings-dropdown-logged-in-section">
|
||||
<span className="user-settings-dropdown-label-text">LOGGED IN AS</span>
|
||||
<span className="user-settings-dropdown-label-email">{userEmail}</span>
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
dataTestId: 'logged-in-as-nav-item',
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace Settings',
|
||||
icon: <Building2 size={14} color={Style.L1_FOREGROUND} />,
|
||||
disabled: isWorkspaceBlocked,
|
||||
dataTestId: 'workspace-settings-nav-item',
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account Settings',
|
||||
icon: <User size={14} color={Style.L1_FOREGROUND} />,
|
||||
dataTestId: 'account-settings-nav-item',
|
||||
},
|
||||
...(isEnterpriseSelfHostedUser || isCommunityEnterpriseUser
|
||||
? [
|
||||
{
|
||||
key: 'license',
|
||||
label: 'Manage License',
|
||||
icon: <Shield size={14} color={Style.L1_FOREGROUND} />,
|
||||
dataTestId: 'manage-license-nav-item',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'keyboard-shortcuts',
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: <Keyboard size={14} color={Style.L1_FOREGROUND} />,
|
||||
dataTestId: 'keyboard-shortcuts-nav-item',
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
label: (
|
||||
<span className="user-settings-dropdown-logout-section">Sign out</span>
|
||||
),
|
||||
icon: (
|
||||
<LogOut
|
||||
size={14}
|
||||
className="user-settings-dropdown-logout-section"
|
||||
color={Style.DANGER_BACKGROUND}
|
||||
/>
|
||||
),
|
||||
dataTestId: 'logout-nav-item',
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
/** Mapping of some newly added routes and their corresponding active sidebar menu key */
|
||||
export const NEW_ROUTES_MENU_ITEM_KEY_MAP: Record<string, string> = {
|
||||
[ROUTES.TRACE]: ROUTES.TRACES_EXPLORER,
|
||||
|
||||
@@ -24,13 +24,6 @@ export interface SidebarItem {
|
||||
|
||||
export const CHANGELOG_LABEL = 'Full Changelog';
|
||||
|
||||
export interface SettingsNavSection {
|
||||
title?: string;
|
||||
items: SidebarItem[];
|
||||
key: string;
|
||||
hasDivider?: boolean;
|
||||
}
|
||||
|
||||
export interface DropdownSeparator {
|
||||
type: 'divider' | 'group';
|
||||
label?: ReactNode;
|
||||
|
||||
@@ -153,7 +153,6 @@ export const routesToSkip = [
|
||||
ROUTES.VERSION,
|
||||
ROUTES.ALL_DASHBOARD,
|
||||
ROUTES.ORG_SETTINGS,
|
||||
ROUTES.MEMBERS_SETTINGS,
|
||||
ROUTES.INGESTION_SETTINGS,
|
||||
ROUTES.API_KEYS,
|
||||
ROUTES.ERROR_DETAIL,
|
||||
|
||||
@@ -35,7 +35,6 @@ function TracesTableComponent({
|
||||
widget,
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
onColumnWidthsChange,
|
||||
}: TracesTableComponentProps): JSX.Element {
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
offset: 0,
|
||||
@@ -132,8 +131,8 @@ function TracesTableComponent({
|
||||
columns={columns}
|
||||
onRow={handleRow}
|
||||
sticky
|
||||
columnWidths={widget.columnWidths}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
widgetId={widget.id}
|
||||
shouldPersistColumnWidths
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
@@ -176,7 +175,6 @@ export type TracesTableComponentProps = {
|
||||
>;
|
||||
widget: Widgets;
|
||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
};
|
||||
|
||||
export default TracesTableComponent;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import MembersSettingsContainer from 'container/MembersSettings/MembersSettings';
|
||||
|
||||
function MembersSettings(): JSX.Element {
|
||||
return <MembersSettingsContainer />;
|
||||
}
|
||||
|
||||
export default MembersSettings;
|
||||
@@ -31,33 +31,9 @@
|
||||
.settings-page-sidenav {
|
||||
width: 240px;
|
||||
height: calc(100vh - 48px);
|
||||
border-right: 1px solid var(--secondary);
|
||||
background: var(--sidebar-primary-foreground);
|
||||
padding-top: var(--padding-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
overflow-y: auto;
|
||||
|
||||
.settings-nav-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-nav-section--with-divider {
|
||||
border-top: 1px solid var(--secondary);
|
||||
padding-top: var(--padding-4);
|
||||
}
|
||||
|
||||
.settings-nav-section-title {
|
||||
font-size: var(--uppercase-small-600-font-size);
|
||||
font-weight: var(--uppercase-small-600-font-weight);
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
color: var(--l3-foreground);
|
||||
margin-bottom: var(--margin-2);
|
||||
padding: var(--padding-1) var(--padding-3);
|
||||
}
|
||||
border-right: 1px solid var(--Slate-500, #161922);
|
||||
background: var(--Ink-500, #0b0c0e);
|
||||
margin-top: 4px;
|
||||
|
||||
.nav-item {
|
||||
.nav-item-data {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FeatureKeys } from 'constants/features';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { routeConfig } from 'container/SideNav/config';
|
||||
import { getQueryString } from 'container/SideNav/helper';
|
||||
import { settingsNavSections } from 'container/SideNav/menuItems';
|
||||
import { settingsMenuItems as defaultSettingsMenuItems } from 'container/SideNav/menuItems';
|
||||
import NavItem from 'container/SideNav/NavItem/NavItem';
|
||||
import { SidebarItem } from 'container/SideNav/sideNav.types';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
@@ -33,7 +33,7 @@ function SettingsPage(): JSX.Element {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const [settingsMenuItems, setSettingsMenuItems] = useState<SidebarItem[]>(
|
||||
settingsNavSections.flatMap((section) => section.items),
|
||||
defaultSettingsMenuItems,
|
||||
);
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
@@ -83,7 +83,6 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SHORTCUTS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
@@ -114,7 +113,6 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
@@ -138,9 +136,7 @@ function SettingsPage(): JSX.Element {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS
|
||||
item.key === ROUTES.API_KEYS || item.key === ROUTES.ORG_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
@@ -256,45 +252,25 @@ function SettingsPage(): JSX.Element {
|
||||
|
||||
<div className="settings-page-content-container">
|
||||
<div className="settings-page-sidenav" data-testid="settings-page-sidenav">
|
||||
{settingsNavSections.map((section) => {
|
||||
const enabledItems = section.items.filter((sectionItem) =>
|
||||
settingsMenuItems.some(
|
||||
(item) => item.key === sectionItem.key && item.isEnabled,
|
||||
),
|
||||
);
|
||||
if (enabledItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={section.key}
|
||||
className={`settings-nav-section${
|
||||
section.hasDivider ? ' settings-nav-section--with-divider' : ''
|
||||
}`}
|
||||
>
|
||||
{section.title && (
|
||||
<div className="settings-nav-section-title">{section.title}</div>
|
||||
)}
|
||||
{enabledItems.map((item) => (
|
||||
<NavItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isActive={isActiveNavItem(item.key as string)}
|
||||
isDisabled={false}
|
||||
showIcon={false}
|
||||
onClick={(event): void => {
|
||||
logEvent('Settings V2: Menu clicked', {
|
||||
menuLabel: item.label,
|
||||
menuRoute: item.key,
|
||||
});
|
||||
handleMenuItemClick((event as unknown) as MouseEvent, item);
|
||||
}}
|
||||
dataTestId={item.itemKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{settingsMenuItems
|
||||
.filter((item) => item.isEnabled)
|
||||
.map((item) => (
|
||||
<NavItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isActive={isActiveNavItem(item.key as string)}
|
||||
isDisabled={false}
|
||||
showIcon={false}
|
||||
onClick={(event): void => {
|
||||
logEvent('Settings V2: Menu clicked', {
|
||||
menuLabel: item.label,
|
||||
menuRoute: item.key,
|
||||
});
|
||||
handleMenuItemClick((event as unknown) as MouseEvent, item);
|
||||
}}
|
||||
dataTestId={item.itemKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="settings-page-content">
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import React from 'react';
|
||||
import SettingsPage from 'pages/Settings/Settings';
|
||||
import { render, screen, within } from 'tests/test-utils';
|
||||
import { LicensePlatform } from 'types/api/licensesV3/getActive';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }): React.ReactNode =>
|
||||
children,
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
push: jest.fn(),
|
||||
listen: jest.fn(() => jest.fn()),
|
||||
location: { pathname: '/settings', search: '' },
|
||||
}));
|
||||
|
||||
const getCloudAdminOverrides = (): any => ({
|
||||
activeLicense: {
|
||||
key: 'test-key',
|
||||
platform: LicensePlatform.CLOUD,
|
||||
},
|
||||
});
|
||||
|
||||
const getSelfHostedAdminOverrides = (): any => ({
|
||||
activeLicense: {
|
||||
key: 'test-key',
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
},
|
||||
});
|
||||
|
||||
describe('SettingsPage nav sections', () => {
|
||||
describe('Cloud Admin', () => {
|
||||
beforeEach(() => {
|
||||
render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.ADMIN,
|
||||
appContextOverrides: getCloudAdminOverrides(),
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'settings-page-sidenav',
|
||||
'workspace',
|
||||
'account',
|
||||
'notification-channels',
|
||||
'billing',
|
||||
'roles',
|
||||
'members',
|
||||
'api-keys',
|
||||
'sso',
|
||||
'integrations',
|
||||
'ingestion',
|
||||
])('renders "%s" element', (id) => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['Identity & Access', 'Authentication'])(
|
||||
'renders "%s" section title',
|
||||
(text) => {
|
||||
expect(screen.getByText(text)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Cloud Viewer', () => {
|
||||
beforeEach(() => {
|
||||
render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.VIEWER,
|
||||
appContextOverrides: getCloudAdminOverrides(),
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['workspace', 'account'])('renders "%s" element', (id) => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['billing', 'roles', 'api-keys'])(
|
||||
'does not render "%s" element',
|
||||
(id) => {
|
||||
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Self-hosted Admin', () => {
|
||||
beforeEach(() => {
|
||||
render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.ADMIN,
|
||||
appContextOverrides: getSelfHostedAdminOverrides(),
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['roles', 'members', 'api-keys', 'integrations', 'sso', 'ingestion'])(
|
||||
'renders "%s" element',
|
||||
(id) => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('section structure', () => {
|
||||
it('renders items in correct sections for cloud admin', () => {
|
||||
const { container } = render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.ADMIN,
|
||||
appContextOverrides: getCloudAdminOverrides(),
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
|
||||
const sidenav = within(container).getByTestId('settings-page-sidenav');
|
||||
const sections = sidenav.querySelectorAll('.settings-nav-section');
|
||||
|
||||
// Should have at least 2 sections (general + identity-access) for cloud admin
|
||||
expect(sections.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('hides section entirely when all items in it are disabled', () => {
|
||||
// Community user has very limited access — identity section should be hidden
|
||||
render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.VIEWER,
|
||||
appContextOverrides: {
|
||||
activeLicense: null,
|
||||
},
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
|
||||
expect(screen.queryByText('IDENTITY & ACCESS')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,8 @@ import {
|
||||
Plus,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import ChannelsEdit from 'pages/ChannelsEdit';
|
||||
import MembersSettings from 'pages/MembersSettings';
|
||||
import Shortcuts from 'pages/Shortcuts';
|
||||
|
||||
export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
@@ -138,19 +136,6 @@ export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const membersSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: MembersSettings,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<Users size={16} /> {t('routes:members').toString()}
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.MEMBERS_SETTINGS,
|
||||
key: ROUTES.MEMBERS_SETTINGS,
|
||||
},
|
||||
];
|
||||
|
||||
export const rolesSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: RolesSettings,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
generalSettings,
|
||||
ingestionSettings,
|
||||
keyboardShortcuts,
|
||||
membersSettings,
|
||||
multiIngestionSettings,
|
||||
mySettings,
|
||||
organizationSettings,
|
||||
@@ -61,7 +60,7 @@ export const getRoutes = (
|
||||
settings.push(...alertChannels(t));
|
||||
|
||||
if (isAdmin) {
|
||||
settings.push(...apiKeys(t), ...membersSettings(t));
|
||||
settings.push(...apiKeys(t));
|
||||
}
|
||||
|
||||
// todo: Sagar - check the condition for role list and details page, to whom we want to serve
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface UserResponse {
|
||||
orgId: string;
|
||||
organization: string;
|
||||
role: ROLES;
|
||||
updatedAt?: number;
|
||||
}
|
||||
export interface PayloadProps {
|
||||
data: UserResponse;
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface UserResponse {
|
||||
orgId: string;
|
||||
organization: string;
|
||||
role: ROLES;
|
||||
updatedAt?: number;
|
||||
}
|
||||
export interface PayloadProps {
|
||||
data: UserResponse[];
|
||||
|
||||
@@ -71,5 +71,3 @@ export function buildAbsolutePath({
|
||||
|
||||
return urlQueryString ? `${absolutePath}?${urlQueryString}` : absolutePath;
|
||||
}
|
||||
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
@@ -99,7 +99,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLES_SETTINGS: ['ADMIN'],
|
||||
ROLE_DETAILS: ['ADMIN'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
@@ -4439,7 +4439,7 @@
|
||||
aria-hidden "^1.1.1"
|
||||
react-remove-scroll "2.5.4"
|
||||
|
||||
"@radix-ui/react-dialog@^1.1.1", "@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
|
||||
"@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
|
||||
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
|
||||
@@ -5519,21 +5519,6 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/drawer@0.0.4":
|
||||
version "0.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/drawer/-/drawer-0.0.4.tgz#7c6e6779602113f55df8a55076e68b9cc13c7d79"
|
||||
integrity sha512-m/shStl5yVPjHjrhDAh3EeKqqTtMmZUBVlgJPUGgoNV3sFsuN6JNaaAtEJI8cQBWkbEEiHLWKVkL/vhbQ7YrUg==
|
||||
dependencies:
|
||||
"@radix-ui/react-dialog" "^1.1.11"
|
||||
"@radix-ui/react-icons" "^1.3.0"
|
||||
"@radix-ui/react-slot" "^1.1.0"
|
||||
class-variance-authority "^0.7.0"
|
||||
clsx "^2.1.1"
|
||||
lucide-react "^0.445.0"
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
vaul "^1.1.2"
|
||||
|
||||
"@signozhq/icons@0.1.0", "@signozhq/icons@^0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/icons/-/icons-0.1.0.tgz#00dfb430dbac423bfff715876f91a7b8a72509e4"
|
||||
@@ -19675,13 +19660,6 @@ value-equal@^1.0.1:
|
||||
resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz"
|
||||
integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==
|
||||
|
||||
vaul@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.2.tgz#c959f8b9dc2ed4f7d99366caee433fbef91f5ba9"
|
||||
integrity sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==
|
||||
dependencies:
|
||||
"@radix-ui/react-dialog" "^1.1.1"
|
||||
|
||||
vfile-location@^4.0.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-4.1.0.tgz#69df82fb9ef0a38d0d02b90dd84620e120050dd0"
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
@@ -60,8 +59,6 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
|
||||
|
||||
// TODO(srikanthccv): use metadata store to fetch metric metadata
|
||||
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "ListMetrics")
|
||||
|
||||
if err := params.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -291,7 +288,6 @@ func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metrics
|
||||
}
|
||||
|
||||
func (m *module) GetMetricMetadataMulti(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error) {
|
||||
|
||||
if len(metricNames) == 0 {
|
||||
return map[string]*metricsexplorertypes.MetricMetadata{}, nil
|
||||
}
|
||||
@@ -480,8 +476,6 @@ func (m *module) GetMetricAttributes(ctx context.Context, orgID valuer.UUID, req
|
||||
}
|
||||
|
||||
func (m *module) CheckMetricExists(ctx context.Context, orgID valuer.UUID, metricName string) (bool, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "CheckMetricExists")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0 as metricExists")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
@@ -518,8 +512,6 @@ func (m *module) fetchMetadataFromCache(ctx context.Context, orgID valuer.UUID,
|
||||
}
|
||||
|
||||
func (m *module) fetchUpdatedMetadata(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "fetchUpdatedMetadata")
|
||||
|
||||
if len(metricNames) == 0 {
|
||||
return map[string]*metricsexplorertypes.MetricMetadata{}, nil
|
||||
}
|
||||
@@ -578,8 +570,6 @@ func (m *module) fetchUpdatedMetadata(ctx context.Context, orgID valuer.UUID, me
|
||||
}
|
||||
|
||||
func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "fetchTimeseriesMetadata")
|
||||
|
||||
if len(metricNames) == 0 {
|
||||
return map[string]*metricsexplorertypes.MetricMetadata{}, nil
|
||||
}
|
||||
@@ -708,8 +698,6 @@ func (m *module) validateMetricLabels(ctx context.Context, req *metricsexplorert
|
||||
}
|
||||
|
||||
func (m *module) checkForLabelInMetric(ctx context.Context, metricName string, label string) (bool, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "checkForLabelInMetric")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("count(*) > 0 AS has_label")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
@@ -731,7 +719,6 @@ func (m *module) checkForLabelInMetric(ctx context.Context, metricName string, l
|
||||
}
|
||||
|
||||
func (m *module) insertMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.UpdateMetricMetadataRequest) error {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "insertMetricsMetadata")
|
||||
createdAt := time.Now().UnixMilli()
|
||||
|
||||
ib := sqlbuilder.NewInsertBuilder()
|
||||
@@ -825,7 +812,6 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
normalized bool,
|
||||
orderBy *qbtypes.OrderBy,
|
||||
) ([]metricsexplorertypes.Stat, uint64, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "fetchMetricsStatsWithSamples")
|
||||
|
||||
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
|
||||
samplesTable := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
|
||||
@@ -933,8 +919,6 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
}
|
||||
|
||||
func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest, filterWhereClause *sqlbuilder.WhereClause) ([]metricsexplorertypes.TreemapEntry, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "computeTimeseriesTreemap")
|
||||
|
||||
start, end, distributedTsTable, _ := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
|
||||
|
||||
totalTSBuilder := sqlbuilder.NewSelectBuilder()
|
||||
@@ -999,8 +983,6 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplo
|
||||
}
|
||||
|
||||
func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorertypes.TreemapRequest, filterWhereClause *sqlbuilder.WhereClause) ([]metricsexplorertypes.TreemapEntry, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "computeSamplesTreemap")
|
||||
|
||||
start, end, distributedTsTable, localTsTable := telemetrymetrics.WhichTSTableToUse(uint64(req.Start), uint64(req.End), nil)
|
||||
samplesTable := telemetrymetrics.WhichSamplesTableToUse(uint64(req.Start), uint64(req.End), metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil)
|
||||
countExp := telemetrymetrics.CountExpressionForSamplesTable(samplesTable)
|
||||
@@ -1102,8 +1084,6 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
|
||||
|
||||
// getMetricDataPoints returns the total number of data points (samples) for a metric.
|
||||
func (m *module) getMetricDataPoints(ctx context.Context, metricName string) (uint64, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "getMetricDataPoints")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("sum(count) AS data_points")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.SamplesV4Agg30mTableName))
|
||||
@@ -1124,8 +1104,6 @@ func (m *module) getMetricDataPoints(ctx context.Context, metricName string) (ui
|
||||
|
||||
// getMetricLastReceived returns the last received timestamp for a metric.
|
||||
func (m *module) getMetricLastReceived(ctx context.Context, metricName string) (uint64, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "getMetricLastReceived")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("MAX(last_reported_unix_milli) AS last_received_time")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.AttributesMetadataTableName))
|
||||
@@ -1149,8 +1127,6 @@ func (m *module) getMetricLastReceived(ctx context.Context, metricName string) (
|
||||
|
||||
// getTotalTimeSeriesForMetricName returns the total number of unique time series for a metric.
|
||||
func (m *module) getTotalTimeSeriesForMetricName(ctx context.Context, metricName string) (uint64, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "getTotalTimeSeriesForMetricName")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("uniq(fingerprint) AS time_series_count")
|
||||
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
|
||||
@@ -1171,8 +1147,6 @@ func (m *module) getTotalTimeSeriesForMetricName(ctx context.Context, metricName
|
||||
|
||||
// getActiveTimeSeriesForMetricName returns the number of active time series for a metric within the given duration.
|
||||
func (m *module) getActiveTimeSeriesForMetricName(ctx context.Context, metricName string, duration time.Duration) (uint64, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "getActiveTimeSeriesForMetricName")
|
||||
|
||||
milli := time.Now().Add(-duration).UnixMilli()
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
@@ -1194,8 +1168,6 @@ func (m *module) getActiveTimeSeriesForMetricName(ctx context.Context, metricNam
|
||||
}
|
||||
|
||||
func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, start, end *int64) ([]metricsexplorertypes.MetricAttribute, error) {
|
||||
ctx = m.withMetricsExplorerContext(ctx, "fetchMetricAttributes")
|
||||
|
||||
// Build query using sqlbuilder
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
@@ -1244,12 +1216,3 @@ func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, s
|
||||
|
||||
return attributes, nil
|
||||
}
|
||||
|
||||
func (m *module) withMetricsExplorerContext(ctx context.Context, functionName string) context.Context {
|
||||
comments := map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "metrics-explorer",
|
||||
instrumentationtypes.CodeFunctionName: functionName,
|
||||
}
|
||||
return ctxtypes.NewContextWithCommentVals(ctx, comments)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/promotetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
@@ -107,11 +105,6 @@ func (m *module) PromotePaths(ctx context.Context, paths []string) error {
|
||||
|
||||
// createIndexes creates string ngram + token filter indexes on JSON path subcolumns for LIKE queries.
|
||||
func (m *module) createIndexes(ctx context.Context, indexes []schemamigrator.Index) error {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "promote",
|
||||
instrumentationtypes.CodeFunctionName: "createIndexes",
|
||||
})
|
||||
if len(indexes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -23,10 +22,6 @@ func NewModule(querier querier.Querier) rawdataexport.Module {
|
||||
}
|
||||
|
||||
func (m *Module) ExportRawData(ctx context.Context, orgID valuer.UUID, rangeRequest *qbtypes.QueryRangeRequest, doneChan chan any) (chan *qbtypes.RawRow, chan error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "rawdataexport",
|
||||
instrumentationtypes.CodeFunctionName: "ExportRawData",
|
||||
})
|
||||
|
||||
spec := rangeRequest.CompositeQuery.Queries[0].Spec.(qbtypes.QueryBuilderQuery[qbtypes.LogAggregation])
|
||||
rowCountLimit := spec.Limit
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/servicetypes/servicetypesv1"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
@@ -36,8 +34,6 @@ func NewModule(q querier.Querier, ts telemetrystore.TelemetryStore) services.Mod
|
||||
|
||||
// FetchTopLevelOperations returns top-level operations per service using db query
|
||||
func (m *module) FetchTopLevelOperations(ctx context.Context, start time.Time, services []string) (map[string][]string, error) {
|
||||
ctx = m.withServicesContext(ctx, "FetchTopLevelOperations")
|
||||
|
||||
db := m.TelemetryStore.ClickhouseDB()
|
||||
query := fmt.Sprintf("SELECT name, serviceName, max(time) as ts FROM %s.%s WHERE time >= @start", telemetrytraces.DBName, telemetrytraces.TopLevelOperationsTableName)
|
||||
args := []any{clickhouse.Named("start", start)}
|
||||
@@ -74,7 +70,6 @@ func (m *module) FetchTopLevelOperations(ctx context.Context, start time.Time, s
|
||||
// Get implements services.Module
|
||||
// Builds a QBv5 traces aggregation grouped by service.name and maps results to ResponseItem.
|
||||
func (m *module) Get(ctx context.Context, orgUUID valuer.UUID, req *servicetypesv1.Request) ([]*servicetypesv1.ResponseItem, error) {
|
||||
ctx = m.withServicesContext(ctx, "Get")
|
||||
if req == nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
@@ -109,7 +104,6 @@ func (m *module) Get(ctx context.Context, orgUUID valuer.UUID, req *servicetypes
|
||||
|
||||
// GetTopOperations implements services.Module for QBV5 based top ops
|
||||
func (m *module) GetTopOperations(ctx context.Context, orgUUID valuer.UUID, req *servicetypesv1.OperationsRequest) ([]servicetypesv1.OperationItem, error) {
|
||||
ctx = m.withServicesContext(ctx, "GetTopOperations")
|
||||
if req == nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
@@ -130,7 +124,6 @@ func (m *module) GetTopOperations(ctx context.Context, orgUUID valuer.UUID, req
|
||||
|
||||
// GetEntryPointOperations implements services.Module for QBV5 based entry point ops
|
||||
func (m *module) GetEntryPointOperations(ctx context.Context, orgUUID valuer.UUID, req *servicetypesv1.OperationsRequest) ([]servicetypesv1.OperationItem, error) {
|
||||
ctx = m.withServicesContext(ctx, "GetEntryPointOperations")
|
||||
if req == nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
@@ -524,12 +517,3 @@ func (m *module) mapEntryPointOpsQueryRangeResp(resp *qbtypes.QueryRangeResponse
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *module) withServicesContext(ctx context.Context, functionName string) context.Context {
|
||||
comments := map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "services",
|
||||
instrumentationtypes.CodeFunctionName: functionName,
|
||||
}
|
||||
return ctxtypes.NewContextWithCommentVals(ctx, comments)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -29,10 +27,6 @@ func NewModule(
|
||||
}
|
||||
|
||||
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "spanpercentile",
|
||||
instrumentationtypes.CodeFunctionName: "GetSpanPercentile",
|
||||
})
|
||||
queryRangeRequest, err := buildSpanPercentileQuery(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,9 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
promValue "github.com/prometheus/prometheus/model/value"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
@@ -140,7 +137,6 @@ func (client *client) queryToClickhouseQuery(_ context.Context, query *prompb.Qu
|
||||
}
|
||||
|
||||
func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, query string, args []any) (map[uint64][]prompb.Label, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "getFingerprintsFromClickhouseQuery")
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -172,7 +168,6 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
|
||||
}
|
||||
|
||||
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
|
||||
argCount := len(args)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
@@ -249,8 +244,6 @@ func (client *client) querySamples(ctx context.Context, start int64, end int64,
|
||||
}
|
||||
|
||||
func (client *client) queryRaw(ctx context.Context, query string, ts int64) (*prompb.QueryResult, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "queryRaw")
|
||||
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -298,12 +291,3 @@ func (client *client) queryRaw(ctx context.Context, query string, ts int64) (*pr
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (client *client) withClickhousePrometheusContext(ctx context.Context, functionName string) context.Context {
|
||||
comments := map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-prometheus",
|
||||
instrumentationtypes.CodeFunctionName: functionName,
|
||||
}
|
||||
return ctxtypes.NewContextWithCommentVals(ctx, comments)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -34,10 +33,6 @@ func NewHandler(set factory.ProviderSettings, querier Querier, analytics analyti
|
||||
|
||||
func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "querier",
|
||||
instrumentationtypes.CodeFunctionName: "QueryRange",
|
||||
})
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,8 +12,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/bytedance/sonic"
|
||||
@@ -214,11 +212,6 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
|
||||
|
||||
// executeWithContext executes the query with query window and step context for partial value detection
|
||||
func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: q.spec.Signal.StringValue(),
|
||||
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),
|
||||
})
|
||||
|
||||
totalRows := uint64(0)
|
||||
totalBytes := uint64(0)
|
||||
elapsed := time.Duration(0)
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
@@ -100,9 +98,6 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
|
||||
}
|
||||
|
||||
func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),
|
||||
})
|
||||
|
||||
totalRows := uint64(0)
|
||||
totalBytes := uint64(0)
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
@@ -189,11 +187,6 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
|
||||
|
||||
func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.tr.From, q.tr.To),
|
||||
})
|
||||
|
||||
start := int64(querybuilder.ToNanoSecs(q.tr.From))
|
||||
end := int64(querybuilder.ToNanoSecs(q.tr.To))
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -528,11 +526,6 @@ func (q *querier) run(
|
||||
steps map[string]qbtypes.Step,
|
||||
qbEvent *qbtypes.QBEvent,
|
||||
) (*qbtypes.QueryRangeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.PanelType: qbEvent.PanelType,
|
||||
instrumentationtypes.QueryType: qbEvent.QueryType,
|
||||
})
|
||||
|
||||
results := make(map[string]any)
|
||||
warnings := make([]string, 0)
|
||||
warningsDocURL := ""
|
||||
|
||||
@@ -6,10 +6,7 @@ import (
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type traceOperatorQuery struct {
|
||||
@@ -55,11 +52,6 @@ func (q *traceOperatorQuery) Execute(ctx context.Context) (*qbtypes.Result, erro
|
||||
}
|
||||
|
||||
func (q *traceOperatorQuery) executeWithContext(ctx context.Context, query string, args []any) (*qbtypes.Result, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),
|
||||
})
|
||||
|
||||
totalRows := uint64(0)
|
||||
totalBytes := uint64(0)
|
||||
elapsed := time.Duration(0)
|
||||
|
||||
@@ -20,9 +20,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
@@ -272,12 +269,6 @@ func (r *ClickHouseReader) GetQueryRangeResult(ctx context.Context, query *model
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetServicesList(ctx context.Context) (*[]string, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetServicesList",
|
||||
})
|
||||
|
||||
services := []string{}
|
||||
rows, err := r.db.Query(ctx, fmt.Sprintf(`SELECT DISTINCT resource_string_service$$name FROM %s.%s WHERE ts_bucket_start > (toUnixTimestamp(now() - INTERVAL 1 DAY) - 1800) AND toDate(timestamp) > now() - INTERVAL 1 DAY`, r.TraceDB, r.traceTableName))
|
||||
if err != nil {
|
||||
@@ -297,12 +288,6 @@ func (r *ClickHouseReader) GetServicesList(ctx context.Context) (*[]string, erro
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetTopLevelOperations(ctx context.Context, start, end time.Time, services []string) (*map[string][]string, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTopLevelOperations",
|
||||
})
|
||||
|
||||
start = start.In(time.UTC)
|
||||
|
||||
// The `top_level_operations` that have `time` >= start
|
||||
@@ -398,12 +383,6 @@ func (r *ClickHouseReader) buildResourceSubQuery(tags []model.TagQueryParam, svc
|
||||
|
||||
func (r *ClickHouseReader) GetServices(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceItem, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetServices",
|
||||
})
|
||||
|
||||
if r.indexTable == "" {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: ErrNoIndexTable}
|
||||
}
|
||||
@@ -760,11 +739,6 @@ func (r *ClickHouseReader) GetEntryPointOperations(ctx context.Context, queryPar
|
||||
|
||||
func (r *ClickHouseReader) GetTopOperations(ctx context.Context, queryParams *model.GetTopOperationsParams) (*[]model.TopOperationsItem, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTopOperations",
|
||||
})
|
||||
namedArgs := []interface{}{
|
||||
clickhouse.Named("start", strconv.FormatInt(queryParams.Start.UnixNano(), 10)),
|
||||
clickhouse.Named("end", strconv.FormatInt(queryParams.End.UnixNano(), 10)),
|
||||
@@ -820,11 +794,6 @@ func (r *ClickHouseReader) GetTopOperations(ctx context.Context, queryParams *mo
|
||||
|
||||
func (r *ClickHouseReader) GetUsage(ctx context.Context, queryParams *model.GetUsageParams) (*[]model.UsageItem, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetUsage",
|
||||
})
|
||||
var usageItems []model.UsageItem
|
||||
namedArgs := []interface{}{
|
||||
clickhouse.Named("interval", queryParams.StepHour),
|
||||
@@ -860,13 +829,6 @@ func (r *ClickHouseReader) GetUsage(ctx context.Context, queryParams *model.GetU
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetSpansForTrace(ctx context.Context, traceID string, traceDetailsQuery string) ([]model.SpanItemV2, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetSpansForTrace",
|
||||
})
|
||||
|
||||
var traceSummary model.TraceSummary
|
||||
summaryQuery := fmt.Sprintf("SELECT trace_id, min(start) AS start, max(end) AS end, sum(num_spans) AS num_spans FROM %s.%s WHERE trace_id=$1 GROUP BY trace_id", r.TraceDB, r.traceSummaryTable)
|
||||
err := r.db.QueryRow(ctx, summaryQuery, traceID).Scan(&traceSummary.TraceID, &traceSummary.Start, &traceSummary.End, &traceSummary.NumSpans)
|
||||
@@ -1265,11 +1227,6 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
|
||||
|
||||
func (r *ClickHouseReader) GetDependencyGraph(ctx context.Context, queryParams *model.GetServicesParams) (*[]model.ServiceMapDependencyResponseItem, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetDependencyGraph",
|
||||
})
|
||||
response := []model.ServiceMapDependencyResponseItem{}
|
||||
|
||||
args := []interface{}{}
|
||||
@@ -1324,11 +1281,6 @@ func getLocalTableName(tableName string) string {
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "setTTLLogs",
|
||||
})
|
||||
hasCustomRetention, err := r.hasCustomRetentionColumn(ctx)
|
||||
if hasCustomRetention {
|
||||
return nil, &model.ApiError{Typ: model.ErrorExec, Err: fmt.Errorf("SetTTLV2 only supported")}
|
||||
@@ -1492,11 +1444,6 @@ func (r *ClickHouseReader) setTTLLogs(ctx context.Context, orgID string, params
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "setTTLTraces",
|
||||
})
|
||||
// uuid is used as transaction id
|
||||
uuidWithHyphen := uuid.New()
|
||||
uuid := strings.Replace(uuidWithHyphen.String(), "-", "", -1)
|
||||
@@ -1642,12 +1589,6 @@ func (r *ClickHouseReader) setTTLTraces(ctx context.Context, orgID string, param
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) hasCustomRetentionColumn(ctx context.Context) (bool, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "hasCustomRetentionColumn",
|
||||
})
|
||||
|
||||
// Directly query for the _retention_days column existence
|
||||
query := fmt.Sprintf("SELECT 1 FROM system.columns WHERE database = '%s' AND table = '%s' AND name = '_retention_days' LIMIT 1", r.logsDB, r.logsLocalTableV2)
|
||||
|
||||
@@ -1669,11 +1610,6 @@ func (r *ClickHouseReader) hasCustomRetentionColumn(ctx context.Context) (bool,
|
||||
|
||||
func (r *ClickHouseReader) SetTTLV2(ctx context.Context, orgID string, params *model.CustomRetentionTTLParams) (*model.CustomRetentionTTLResponse, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "SetTTLV2",
|
||||
})
|
||||
hasCustomRetention, err := r.hasCustomRetentionColumn(ctx)
|
||||
if err != nil {
|
||||
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "custom retention not supported")
|
||||
@@ -2063,10 +1999,6 @@ func (r *ClickHouseReader) updateCustomRetentionTTLStatus(ctx context.Context, o
|
||||
|
||||
// Enhanced validation function with duplicate detection and efficient key validation
|
||||
func (r *ClickHouseReader) validateTTLConditions(ctx context.Context, ttlConditions []model.CustomRetentionRule) error {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "validateTTLConditions",
|
||||
})
|
||||
if len(ttlConditions) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -2184,11 +2116,6 @@ func (r *ClickHouseReader) SetTTL(ctx context.Context, orgID string, params *mod
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) setTTLMetrics(ctx context.Context, orgID string, params *model.TTLParams) (*model.SetTTLResponseItem, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "setTTLMetrics",
|
||||
})
|
||||
// uuid is used as transaction id
|
||||
uuidWithHyphen := uuid.New()
|
||||
uuid := strings.Replace(uuidWithHyphen.String(), "-", "", -1)
|
||||
@@ -2397,10 +2324,6 @@ func (r *ClickHouseReader) getTTLQueryStatus(ctx context.Context, orgID string,
|
||||
|
||||
func (r *ClickHouseReader) setColdStorage(ctx context.Context, tableName string, coldStorageVolume string) *model.ApiError {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "setColdStorage",
|
||||
})
|
||||
// Set the storage policy for the required table. If it is already set, then setting it again
|
||||
// will not a problem.
|
||||
if len(coldStorageVolume) > 0 {
|
||||
@@ -2417,10 +2340,6 @@ func (r *ClickHouseReader) setColdStorage(ctx context.Context, tableName string,
|
||||
|
||||
// GetDisks returns a list of disks {name, type} configured in clickhouse DB.
|
||||
func (r *ClickHouseReader) GetDisks(ctx context.Context) (*[]model.DiskItem, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetDisks",
|
||||
})
|
||||
diskItems := []model.DiskItem{}
|
||||
|
||||
query := "SELECT name,type FROM system.disks"
|
||||
@@ -2444,10 +2363,6 @@ func getLocalTableNameArray(tableNames []string) []string {
|
||||
// GetTTL returns current ttl, expected ttl and past setTTL status for metrics/traces.
|
||||
func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *model.GetTTLParams) (*model.GetTTLResponseItem, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTTL",
|
||||
})
|
||||
parseTTL := func(queryResp string) (int, int) {
|
||||
|
||||
zap.L().Info("Parsing TTL from: ", zap.String("queryResp", queryResp))
|
||||
@@ -2617,11 +2532,6 @@ func (r *ClickHouseReader) GetTTL(ctx context.Context, orgID string, ttlParams *
|
||||
|
||||
func (r *ClickHouseReader) ListErrors(ctx context.Context, queryParams *model.ListErrorsParams) (*[]model.Error, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "ListErrors",
|
||||
})
|
||||
var getErrorResponses []model.Error
|
||||
|
||||
query := "SELECT any(exceptionMessage) as exceptionMessage, count() AS exceptionCount, min(timestamp) as firstSeen, max(timestamp) as lastSeen, groupID"
|
||||
@@ -2694,12 +2604,6 @@ func (r *ClickHouseReader) ListErrors(ctx context.Context, queryParams *model.Li
|
||||
|
||||
func (r *ClickHouseReader) CountErrors(ctx context.Context, queryParams *model.CountErrorsParams) (uint64, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "CountErrors",
|
||||
})
|
||||
|
||||
var errorCount uint64
|
||||
|
||||
query := fmt.Sprintf("SELECT count(distinct(groupID)) FROM %s.%s WHERE timestamp >= @timestampL AND timestamp <= @timestampU", r.TraceDB, r.errorTable)
|
||||
@@ -2737,11 +2641,6 @@ func (r *ClickHouseReader) CountErrors(ctx context.Context, queryParams *model.C
|
||||
|
||||
func (r *ClickHouseReader) GetErrorFromErrorID(ctx context.Context, queryParams *model.GetErrorParams) (*model.ErrorWithSpan, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetErrorFromErrorID",
|
||||
})
|
||||
if queryParams.ErrorID == "" {
|
||||
zap.L().Error("errorId missing from params")
|
||||
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: fmt.Errorf("ErrorID missing from params")}
|
||||
@@ -2769,11 +2668,6 @@ func (r *ClickHouseReader) GetErrorFromErrorID(ctx context.Context, queryParams
|
||||
|
||||
func (r *ClickHouseReader) GetErrorFromGroupID(ctx context.Context, queryParams *model.GetErrorParams) (*model.ErrorWithSpan, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetErrorFromGroupID",
|
||||
})
|
||||
var getErrorWithSpanReponse []model.ErrorWithSpan
|
||||
|
||||
query := fmt.Sprintf("SELECT errorID, exceptionType, exceptionStacktrace, exceptionEscaped, exceptionMessage, timestamp, spanID, traceID, serviceName, groupID FROM %s.%s WHERE timestamp = @timestamp AND groupID = @groupID LIMIT 1", r.TraceDB, r.errorTable)
|
||||
@@ -2822,11 +2716,6 @@ func (r *ClickHouseReader) GetNextPrevErrorIDs(ctx context.Context, queryParams
|
||||
|
||||
func (r *ClickHouseReader) getNextErrorID(ctx context.Context, queryParams *model.GetErrorParams) (string, time.Time, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "getNextErrorID",
|
||||
})
|
||||
var getNextErrorIDReponse []model.NextPrevErrorIDsDBResponse
|
||||
|
||||
query := fmt.Sprintf("SELECT errorID as nextErrorID, timestamp as nextTimestamp FROM %s.%s WHERE groupID = @groupID AND timestamp >= @timestamp AND errorID != @errorID ORDER BY timestamp ASC LIMIT 2", r.TraceDB, r.errorTable)
|
||||
@@ -2896,11 +2785,6 @@ func (r *ClickHouseReader) getNextErrorID(ctx context.Context, queryParams *mode
|
||||
|
||||
func (r *ClickHouseReader) getPrevErrorID(ctx context.Context, queryParams *model.GetErrorParams) (string, time.Time, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "getPrevErrorID",
|
||||
})
|
||||
var getPrevErrorIDReponse []model.NextPrevErrorIDsDBResponse
|
||||
|
||||
query := fmt.Sprintf("SELECT errorID as prevErrorID, timestamp as prevTimestamp FROM %s.%s WHERE groupID = @groupID AND timestamp <= @timestamp AND errorID != @errorID ORDER BY timestamp DESC LIMIT 2", r.TraceDB, r.errorTable)
|
||||
@@ -2992,11 +2876,6 @@ func (r *ClickHouseReader) FetchTemporality(ctx context.Context, orgID valuer.UU
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetLogFields(ctx context.Context) (*model.GetFieldsResponse, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetLogFields",
|
||||
})
|
||||
// response will contain top level fields from the otel log model
|
||||
response := model.GetFieldsResponse{
|
||||
Selected: constants.StaticSelectedLogFields,
|
||||
@@ -3033,11 +2912,6 @@ func (r *ClickHouseReader) GetLogFields(ctx context.Context) (*model.GetFieldsRe
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetLogFieldsFromNames(ctx context.Context, fieldNames []string) (*model.GetFieldsResponse, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetLogFieldsFromNames",
|
||||
})
|
||||
// response will contain top level fields from the otel log model
|
||||
response := model.GetFieldsResponse{
|
||||
Selected: constants.StaticSelectedLogFields,
|
||||
@@ -3088,10 +2962,6 @@ func (r *ClickHouseReader) extractSelectedAndInterestingFields(tableStatement st
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) UpdateLogField(ctx context.Context, field *model.UpdateField) *model.ApiError {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "UpdateLogField",
|
||||
})
|
||||
if !field.Selected {
|
||||
return model.ForbiddenError(errors.New("removing a selected field is not allowed, please reach out to support."))
|
||||
}
|
||||
@@ -3158,10 +3028,6 @@ func (r *ClickHouseReader) UpdateLogField(ctx context.Context, field *model.Upda
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetTraceFields(ctx context.Context) (*model.GetFieldsResponse, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTraceFields",
|
||||
})
|
||||
// response will contain top level fields from the otel trace model
|
||||
response := model.GetFieldsResponse{
|
||||
Selected: []model.Field{},
|
||||
@@ -3217,11 +3083,6 @@ func (r *ClickHouseReader) GetTraceFields(ctx context.Context) (*model.GetFields
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) UpdateTraceField(ctx context.Context, field *model.UpdateField) *model.ApiError {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "UpdateTraceField",
|
||||
})
|
||||
if !field.Selected {
|
||||
return model.ForbiddenError(errors.New("removing a selected field is not allowed, please reach out to support."))
|
||||
}
|
||||
@@ -3313,10 +3174,6 @@ func (r *ClickHouseReader) UpdateTraceField(ctx context.Context, field *model.Up
|
||||
return nil
|
||||
}
|
||||
func (r *ClickHouseReader) QueryDashboardVars(ctx context.Context, query string) (*model.DashboardVar, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "QueryDashboardVars",
|
||||
})
|
||||
var result = model.DashboardVar{VariableValues: make([]interface{}, 0)}
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
|
||||
@@ -3353,11 +3210,6 @@ func (r *ClickHouseReader) QueryDashboardVars(ctx context.Context, query string)
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetMetricAggregateAttributes(ctx context.Context, orgID valuer.UUID, req *v3.AggregateAttributeRequest, skipSignozMetrics bool) (*v3.AggregateAttributeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricAggregateAttributes",
|
||||
})
|
||||
var response v3.AggregateAttributeResponse
|
||||
normalized := true
|
||||
if constants.IsDotMetricsEnabled {
|
||||
@@ -3436,11 +3288,6 @@ func (r *ClickHouseReader) GetMetricAggregateAttributes(ctx context.Context, org
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetMeterAggregateAttributes(ctx context.Context, orgID valuer.UUID, req *v3.AggregateAttributeRequest) (*v3.AggregateAttributeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMeterAggregateAttributes",
|
||||
})
|
||||
var response v3.AggregateAttributeResponse
|
||||
// Query all relevant metric names from time_series_v4, but leave metadata retrieval to cache/db
|
||||
query := fmt.Sprintf(
|
||||
@@ -3489,11 +3336,6 @@ func (r *ClickHouseReader) GetMeterAggregateAttributes(ctx context.Context, orgI
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetMetricAttributeKeys(ctx context.Context, req *v3.FilterAttributeKeyRequest) (*v3.FilterAttributeKeyResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricAttributeKeys",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
@@ -3534,11 +3376,6 @@ func (r *ClickHouseReader) GetMetricAttributeKeys(ctx context.Context, req *v3.F
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetMeterAttributeKeys(ctx context.Context, req *v3.FilterAttributeKeyRequest) (*v3.FilterAttributeKeyResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMeterAttributeKeys",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
@@ -3575,11 +3412,6 @@ func (r *ClickHouseReader) GetMeterAttributeKeys(ctx context.Context, req *v3.Fi
|
||||
|
||||
func (r *ClickHouseReader) GetMetricAttributeValues(ctx context.Context, req *v3.FilterAttributeValueRequest) (*v3.FilterAttributeValueResponse, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricAttributeValues",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
@@ -3620,11 +3452,6 @@ func (r *ClickHouseReader) GetMetricAttributeValues(ctx context.Context, req *v3
|
||||
|
||||
func (r *ClickHouseReader) GetMetricMetadata(ctx context.Context, orgID valuer.UUID, metricName, serviceName string) (*v3.MetricMetadataResponse, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricMetadata",
|
||||
})
|
||||
unixMilli := common.PastDayRoundOff()
|
||||
|
||||
// 1. Fetch metadata from cache/db using unified function
|
||||
@@ -3706,10 +3533,6 @@ func (r *ClickHouseReader) GetMetricMetadata(ctx context.Context, orgID valuer.U
|
||||
// GetCountOfThings returns the count of things in the query
|
||||
// This is a generic function that can be used to check if any data exists for a given query
|
||||
func (r *ClickHouseReader) GetCountOfThings(ctx context.Context, query string) (uint64, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetCountOfThings",
|
||||
})
|
||||
var count uint64
|
||||
err := r.db.QueryRow(ctx, query).Scan(&count)
|
||||
if err != nil {
|
||||
@@ -3760,11 +3583,6 @@ func (r *ClickHouseReader) GetActiveHostsFromMetricMetadata(ctx context.Context,
|
||||
func (r *ClickHouseReader) GetLatestReceivedMetric(
|
||||
ctx context.Context, metricNames []string, labelValues map[string]string,
|
||||
) (*model.MetricStatus, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetLatestReceivedMetric",
|
||||
})
|
||||
// at least 1 metric name must be specified.
|
||||
// this query can be too slow otherwise.
|
||||
if len(metricNames) < 1 {
|
||||
@@ -3849,11 +3667,6 @@ func isColumn(tableStatement, attrType, field, datType string) bool {
|
||||
|
||||
func (r *ClickHouseReader) GetLogAggregateAttributes(ctx context.Context, req *v3.AggregateAttributeRequest) (*v3.AggregateAttributeResponse, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetLogAggregateAttributes",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
@@ -3938,11 +3751,6 @@ func (r *ClickHouseReader) GetLogAggregateAttributes(ctx context.Context, req *v
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetLogAttributeKeys(ctx context.Context, req *v3.FilterAttributeKeyRequest) (*v3.FilterAttributeKeyResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetLogAttributeKeys",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
@@ -4009,10 +3817,6 @@ func (r *ClickHouseReader) GetLogAttributeKeys(ctx context.Context, req *v3.Filt
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) FetchRelatedValues(ctx context.Context, req *v3.FilterAttributeValueRequest) ([]string, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "FetchRelatedValues",
|
||||
})
|
||||
var andConditions []string
|
||||
|
||||
andConditions = append(andConditions, fmt.Sprintf("unix_milli >= %d", req.StartTimeMillis))
|
||||
@@ -4104,11 +3908,6 @@ func (r *ClickHouseReader) FetchRelatedValues(ctx context.Context, req *v3.Filte
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetLogAttributeValues(ctx context.Context, req *v3.FilterAttributeValueRequest) (*v3.FilterAttributeValueResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetLogAttributeValues",
|
||||
})
|
||||
var err error
|
||||
var filterValueColumn string
|
||||
var rows driver.Rows
|
||||
@@ -4426,10 +4225,6 @@ func readRowsForTimeSeriesResult(rows driver.Rows, vars []interface{}, columnNam
|
||||
|
||||
// GetTimeSeriesResultV3 runs the query and returns list of time series
|
||||
func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query string) ([]*v3.Series, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTimeSeriesResultV3",
|
||||
})
|
||||
// Hook up query progress reporting if requested.
|
||||
queryId := ctx.Value("queryId")
|
||||
if queryId != nil {
|
||||
@@ -4493,10 +4288,6 @@ func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query stri
|
||||
|
||||
// GetListResultV3 runs the query and returns list of rows
|
||||
func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([]*v3.Row, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetListResultV3",
|
||||
})
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
if err != nil {
|
||||
zap.L().Error("error while reading time series result", zap.Error(err))
|
||||
@@ -4559,11 +4350,6 @@ func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([
|
||||
// GetHostMetricsExistenceAndEarliestTime returns (count, minFirstReportedUnixMilli, error) for the given host metric names
|
||||
// from distributed_metadata. When count is 0, minFirstReportedUnixMilli is 0.
|
||||
func (r *ClickHouseReader) GetMetricsExistenceAndEarliestTime(ctx context.Context, metricNames []string) (uint64, uint64, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricsExistenceAndEarliestTime",
|
||||
})
|
||||
if len(metricNames) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
@@ -4599,10 +4385,6 @@ func getPersonalisedError(err error) error {
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) CheckClickHouse(ctx context.Context) error {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "CheckClickHouse",
|
||||
})
|
||||
rows, err := r.db.Query(ctx, "SELECT 1")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -4613,11 +4395,6 @@ func (r *ClickHouseReader) CheckClickHouse(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetTraceAggregateAttributes(ctx context.Context, req *v3.AggregateAttributeRequest) (*v3.AggregateAttributeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTraceAggregateAttributes",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
@@ -4711,11 +4488,6 @@ func (r *ClickHouseReader) GetTraceAggregateAttributes(ctx context.Context, req
|
||||
|
||||
func (r *ClickHouseReader) GetTraceAttributeKeys(ctx context.Context, req *v3.FilterAttributeKeyRequest) (*v3.FilterAttributeKeyResponse, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTraceAttributeKeys",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
@@ -4784,11 +4556,6 @@ func (r *ClickHouseReader) GetTraceAttributeKeys(ctx context.Context, req *v3.Fi
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetTraceAttributeValues(ctx context.Context, req *v3.FilterAttributeValueRequest) (*v3.FilterAttributeValueResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTraceAttributeValues",
|
||||
})
|
||||
var query string
|
||||
var filterValueColumn string
|
||||
var err error
|
||||
@@ -4882,11 +4649,6 @@ func (r *ClickHouseReader) GetTraceAttributeValues(ctx context.Context, req *v3.
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetSpanAttributeKeysByNames(ctx context.Context, names []string) (map[string]v3.AttributeKey, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetSpanAttributeKeysByNames",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
@@ -4935,10 +4697,6 @@ func (r *ClickHouseReader) GetSpanAttributeKeysByNames(ctx context.Context, name
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) AddRuleStateHistory(ctx context.Context, ruleStateHistory []model.RuleStateHistory) error {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "AddRuleStateHistory",
|
||||
})
|
||||
var statement driver.Batch
|
||||
var err error
|
||||
|
||||
@@ -4970,10 +4728,6 @@ func (r *ClickHouseReader) AddRuleStateHistory(ctx context.Context, ruleStateHis
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]model.RuleStateHistory, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetLastSavedRuleStateHistory",
|
||||
})
|
||||
query := fmt.Sprintf("SELECT * FROM %s.%s WHERE rule_id = '%s' AND state_changed = true ORDER BY unix_milli DESC LIMIT 1 BY fingerprint",
|
||||
signozHistoryDBName, ruleStateHistoryTableName, ruleID)
|
||||
|
||||
@@ -4988,10 +4742,6 @@ func (r *ClickHouseReader) GetLastSavedRuleStateHistory(ctx context.Context, rul
|
||||
func (r *ClickHouseReader) ReadRuleStateHistoryByRuleID(
|
||||
ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*model.RuleStateTimeline, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "ReadRuleStateHistoryByRuleID",
|
||||
})
|
||||
var conditions []string
|
||||
|
||||
conditions = append(conditions, fmt.Sprintf("rule_id = '%s'", ruleID))
|
||||
@@ -5106,10 +4856,6 @@ func (r *ClickHouseReader) ReadRuleStateHistoryByRuleID(
|
||||
|
||||
func (r *ClickHouseReader) ReadRuleStateHistoryTopContributorsByRuleID(
|
||||
ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.RuleStateHistoryContributor, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "ReadRuleStateHistoryTopContributorsByRuleID",
|
||||
})
|
||||
query := fmt.Sprintf(`SELECT
|
||||
fingerprint,
|
||||
any(labels) as labels,
|
||||
@@ -5134,10 +4880,6 @@ func (r *ClickHouseReader) ReadRuleStateHistoryTopContributorsByRuleID(
|
||||
|
||||
func (r *ClickHouseReader) GetOverallStateTransitions(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.ReleStateItem, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetOverallStateTransitions",
|
||||
})
|
||||
tmpl := `WITH firing_events AS (
|
||||
SELECT
|
||||
rule_id,
|
||||
@@ -5265,10 +5007,6 @@ ORDER BY firing_time ASC;`
|
||||
|
||||
func (r *ClickHouseReader) GetAvgResolutionTime(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (float64, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetAvgResolutionTime",
|
||||
})
|
||||
tmpl := `
|
||||
WITH firing_events AS (
|
||||
SELECT
|
||||
@@ -5380,10 +5118,6 @@ ORDER BY ts ASC;`
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetTotalTriggers(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (uint64, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTotalTriggers",
|
||||
})
|
||||
query := fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d",
|
||||
signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End)
|
||||
|
||||
@@ -5412,11 +5146,6 @@ func (r *ClickHouseReader) GetTriggersByInterval(ctx context.Context, ruleID str
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMinAndMaxTimestampForTraceID",
|
||||
})
|
||||
var minTime, maxTime time.Time
|
||||
|
||||
query := fmt.Sprintf("SELECT min(timestamp), max(timestamp) FROM %s.%s WHERE traceID IN ('%s')",
|
||||
@@ -5454,11 +5183,6 @@ func (r *ClickHouseReader) SubscribeToQueryProgress(
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetAllMetricFilterAttributeKeys(ctx context.Context, req *metrics_explorer.FilterKeyRequest) (*[]v3.AttributeKey, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetAllMetricFilterAttributeKeys",
|
||||
})
|
||||
var rows driver.Rows
|
||||
var response []v3.AttributeKey
|
||||
normalized := true
|
||||
@@ -5496,11 +5220,6 @@ func (r *ClickHouseReader) GetAllMetricFilterAttributeKeys(ctx context.Context,
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetAllMetricFilterAttributeValues(ctx context.Context, req *metrics_explorer.FilterValueRequest) ([]string, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetAllMetricFilterAttributeValues",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
@@ -5537,11 +5256,6 @@ func (r *ClickHouseReader) GetAllMetricFilterAttributeValues(ctx context.Context
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetAllMetricFilterUnits(ctx context.Context, req *metrics_explorer.FilterValueRequest) ([]string, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetAllMetricFilterUnits",
|
||||
})
|
||||
var rows driver.Rows
|
||||
var response []string
|
||||
query := fmt.Sprintf("SELECT DISTINCT unit FROM %s.%s WHERE unit ILIKE $1 AND unit IS NOT NULL ORDER BY unit", signozMetricDBName, signozTSTableNameV41Day)
|
||||
@@ -5569,11 +5283,6 @@ func (r *ClickHouseReader) GetAllMetricFilterUnits(ctx context.Context, req *met
|
||||
return response, nil
|
||||
}
|
||||
func (r *ClickHouseReader) GetAllMetricFilterTypes(ctx context.Context, req *metrics_explorer.FilterValueRequest) ([]string, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetAllMetricFilterTypes",
|
||||
})
|
||||
var rows driver.Rows
|
||||
var response []string
|
||||
query := fmt.Sprintf("SELECT DISTINCT type FROM %s.%s WHERE type ILIKE $1 AND type IS NOT NULL ORDER BY type", signozMetricDBName, signozTSTableNameV41Day)
|
||||
@@ -5601,11 +5310,6 @@ func (r *ClickHouseReader) GetAllMetricFilterTypes(ctx context.Context, req *met
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetMetricsDataPoints(ctx context.Context, metricName string) (uint64, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricsDataPoints",
|
||||
})
|
||||
query := fmt.Sprintf(`SELECT
|
||||
sum(count) as data_points
|
||||
FROM %s.%s
|
||||
@@ -5621,11 +5325,6 @@ WHERE metric_name = ?
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetMetricsLastReceived(ctx context.Context, metricName string) (int64, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricsLastReceived",
|
||||
})
|
||||
query := fmt.Sprintf(`SELECT
|
||||
MAX(unix_milli) AS last_received_time
|
||||
FROM %s.%s
|
||||
@@ -5651,11 +5350,6 @@ WHERE metric_name = ? and unix_milli > ?
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetTotalTimeSeriesForMetricName(ctx context.Context, metricName string) (uint64, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTotalTimeSeriesForMetricName",
|
||||
})
|
||||
query := fmt.Sprintf(`SELECT
|
||||
uniq(fingerprint) AS timeSeriesCount
|
||||
FROM %s.%s
|
||||
@@ -5670,11 +5364,6 @@ WHERE metric_name = ?;`, signozMetricDBName, signozTSTableNameV41Week)
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetAttributesForMetricName(ctx context.Context, metricName string, start, end *int64, filters *v3.FilterSet) (*[]metrics_explorer.Attribute, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetAttributesForMetricName",
|
||||
})
|
||||
whereClause := ""
|
||||
if filters != nil {
|
||||
conditions, _ := utils.BuildFilterConditions(filters, "t")
|
||||
@@ -5742,11 +5431,6 @@ WHERE metric_name = ? AND __normalized=? %s`
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetActiveTimeSeriesForMetricName(ctx context.Context, metricName string, duration time.Duration) (uint64, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetActiveTimeSeriesForMetricName",
|
||||
})
|
||||
milli := time.Now().Add(-duration).UnixMilli()
|
||||
query := fmt.Sprintf("SELECT uniq(fingerprint) FROM %s.%s WHERE metric_name = '%s' and unix_milli >= ?", signozMetricDBName, signozTSTableNameV4, metricName)
|
||||
var timeSeries uint64
|
||||
@@ -5760,11 +5444,6 @@ func (r *ClickHouseReader) GetActiveTimeSeriesForMetricName(ctx context.Context,
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.UUID, req *metrics_explorer.SummaryListMetricsRequest) (*metrics_explorer.SummaryListMetricsResponse, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "ListSummaryMetrics",
|
||||
})
|
||||
var args []interface{}
|
||||
|
||||
// Build filter conditions (if any)
|
||||
@@ -5983,11 +5662,6 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetMetricsTimeSeriesPercentage(ctx context.Context, req *metrics_explorer.TreeMapMetricsRequest) (*[]metrics_explorer.TreeMapResponseItem, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricsTimeSeriesPercentage",
|
||||
})
|
||||
var args []interface{}
|
||||
|
||||
normalized := true
|
||||
@@ -6068,11 +5742,6 @@ func (r *ClickHouseReader) GetMetricsTimeSeriesPercentage(ctx context.Context, r
|
||||
|
||||
func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req *metrics_explorer.TreeMapMetricsRequest) (*[]metrics_explorer.TreeMapResponseItem, *model.ApiError) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricsSamplesPercentage",
|
||||
})
|
||||
conditions, _ := utils.BuildFilterConditions(&req.Filters, "ts")
|
||||
whereClause := ""
|
||||
if conditions != nil {
|
||||
@@ -6232,11 +5901,6 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetNameSimilarity(ctx context.Context, req *metrics_explorer.RelatedMetricsRequest) (map[string]metrics_explorer.RelatedMetricsScore, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetNameSimilarity",
|
||||
})
|
||||
start, end, tsTable, _ := utils.WhichTSTableToUse(req.Start, req.End)
|
||||
|
||||
normalized := true
|
||||
@@ -6290,11 +5954,6 @@ func (r *ClickHouseReader) GetNameSimilarity(ctx context.Context, req *metrics_e
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetAttributeSimilarity(ctx context.Context, req *metrics_explorer.RelatedMetricsRequest) (map[string]metrics_explorer.RelatedMetricsScore, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetAttributeSimilarity",
|
||||
})
|
||||
start, end, tsTable, _ := utils.WhichTSTableToUse(req.Start, req.End)
|
||||
|
||||
normalized := true
|
||||
@@ -6453,11 +6112,6 @@ func (r *ClickHouseReader) GetAttributeSimilarity(ctx context.Context, req *metr
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetMetricsAllResourceAttributes(ctx context.Context, start int64, end int64) (map[string]uint64, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetMetricsAllResourceAttributes",
|
||||
})
|
||||
start, end, attTable, _ := utils.WhichAttributesTableToUse(start, end)
|
||||
query := fmt.Sprintf(`SELECT
|
||||
key,
|
||||
@@ -6494,11 +6148,6 @@ ORDER BY distinct_value_count DESC;`, signozMetadataDbName, attTable)
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetInspectMetrics(ctx context.Context, req *metrics_explorer.InspectMetricsRequest, fingerprints []string) (*metrics_explorer.InspectMetricsResponse, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetInspectMetrics",
|
||||
})
|
||||
start, end, _, localTsTable := utils.WhichTSTableToUse(req.Start, req.End)
|
||||
fingerprintsString := strings.Join(fingerprints, ",")
|
||||
query := fmt.Sprintf(`SELECT
|
||||
@@ -6593,11 +6242,6 @@ func (r *ClickHouseReader) GetInspectMetrics(ctx context.Context, req *metrics_e
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetInspectMetricsFingerprints(ctx context.Context, attributes []string, req *metrics_explorer.InspectMetricsRequest) ([]string, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetInspectMetricsFingerprints",
|
||||
})
|
||||
// Build dynamic key selections and JSON extracts
|
||||
var jsonExtracts []string
|
||||
var groupBys []string
|
||||
@@ -6679,11 +6323,6 @@ LIMIT 40`, // added rand to get diff value every time we run this query
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) UpdateMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *model.UpdateMetricsMetadata) *model.ApiError {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "UpdateMetricsMetadata",
|
||||
})
|
||||
if req.MetricType == v3.MetricTypeHistogram {
|
||||
labels := []string{"le"}
|
||||
hasLabels, apiError := r.CheckForLabelsInMetric(ctx, req.MetricName, labels)
|
||||
@@ -6728,11 +6367,6 @@ VALUES ( ?, ?, ?, ?, ?, ?, ?);`, signozMetricDBName, signozUpdatedMetricsMetadat
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) CheckForLabelsInMetric(ctx context.Context, metricName string, labels []string) (bool, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "CheckForLabelsInMetric",
|
||||
})
|
||||
if len(labels) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
@@ -6767,11 +6401,6 @@ func (r *ClickHouseReader) CheckForLabelsInMetric(ctx context.Context, metricNam
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID valuer.UUID, metricNames ...string) (map[string]*model.UpdateMetricsMetadata, *model.ApiError) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetUpdatedMetricsMetadata",
|
||||
})
|
||||
cachedMetadata := make(map[string]*model.UpdateMetricsMetadata)
|
||||
var missingMetrics []string
|
||||
|
||||
@@ -6881,11 +6510,6 @@ func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) SearchTraces(ctx context.Context, params *model.SearchTracesParams) (*[]model.SearchSpansResult, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "SearchTraces",
|
||||
})
|
||||
searchSpansResult := []model.SearchSpansResult{
|
||||
{
|
||||
Columns: []string{"__time", "SpanId", "TraceId", "ServiceName", "Name", "Kind", "DurationNano", "TagsKeys", "TagsValues", "References", "Events", "HasError", "StatusMessage", "StatusCodeString", "SpanKind"},
|
||||
@@ -6997,11 +6621,6 @@ func (r *ClickHouseReader) GetNormalizedStatus(
|
||||
metricNames []string,
|
||||
) (map[string]bool, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetNormalizedStatus",
|
||||
})
|
||||
if len(metricNames) == 0 {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
@@ -62,10 +62,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/licensetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
|
||||
@@ -2414,12 +2412,7 @@ func (aH *APIHandler) onboardKafka(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "onboardKafka",
|
||||
})
|
||||
|
||||
results, errQueriesByName, err := aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
results, errQueriesByName, err := aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -2531,12 +2524,7 @@ func (aH *APIHandler) getNetworkData(w http.ResponseWriter, r *http.Request) {
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getNetworkData",
|
||||
})
|
||||
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -2572,7 +2560,7 @@ func (aH *APIHandler) getNetworkData(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resultFetchLatency, errQueriesByNameFetchLatency, err := aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
resultFetchLatency, errQueriesByNameFetchLatency, err := aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByNameFetchLatency)
|
||||
@@ -2628,11 +2616,7 @@ func (aH *APIHandler) getProducerData(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getProducerData",
|
||||
})
|
||||
kafkaSpanEval := aH.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureKafkaSpanEval, evalCtx)
|
||||
kafkaSpanEval := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureKafkaSpanEval, evalCtx)
|
||||
|
||||
queryRangeParams, err := kafka.BuildQueryRangeParams(messagingQueue, "producer", kafkaSpanEval)
|
||||
if err != nil {
|
||||
@@ -2650,7 +2634,7 @@ func (aH *APIHandler) getProducerData(w http.ResponseWriter, r *http.Request) {
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -2703,11 +2687,7 @@ func (aH *APIHandler) getConsumerData(w http.ResponseWriter, r *http.Request) {
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getConsumerData",
|
||||
})
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -2761,11 +2741,7 @@ func (aH *APIHandler) getPartitionOverviewLatencyData(w http.ResponseWriter, r *
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getPartitionOverviewLatencyData",
|
||||
})
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -2819,11 +2795,7 @@ func (aH *APIHandler) getConsumerPartitionLatencyData(w http.ResponseWriter, r *
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getConsumerPartitionLatencyData",
|
||||
})
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -2880,11 +2852,7 @@ func (aH *APIHandler) getProducerThroughputOverview(w http.ResponseWriter, r *ht
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getProducerThroughputOverview",
|
||||
})
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, producerQueryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, producerQueryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -2918,7 +2886,7 @@ func (aH *APIHandler) getProducerThroughputOverview(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
resultFetchLatency, errQueriesByNameFetchLatency, err := aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
resultFetchLatency, errQueriesByNameFetchLatency, err := aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByNameFetchLatency)
|
||||
@@ -2995,11 +2963,7 @@ func (aH *APIHandler) getProducerThroughputDetails(w http.ResponseWriter, r *htt
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getProducerThroughputDetails",
|
||||
})
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -3053,11 +3017,7 @@ func (aH *APIHandler) getConsumerThroughputOverview(w http.ResponseWriter, r *ht
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getConsumerThroughputOverview",
|
||||
})
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -3111,11 +3071,7 @@ func (aH *APIHandler) getConsumerThroughputDetails(w http.ResponseWriter, r *htt
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getConsumerThroughputDetails",
|
||||
})
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -3175,11 +3131,7 @@ func (aH *APIHandler) getProducerConsumerEval(w http.ResponseWriter, r *http.Req
|
||||
var result []*v3.Result
|
||||
var errQueriesByName map[string]error
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getProducerConsumerEval",
|
||||
})
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(ctx, orgID, queryRangeParams)
|
||||
result, errQueriesByName, err = aH.querierV2.QueryRange(r.Context(), orgID, queryRangeParams)
|
||||
if err != nil {
|
||||
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
RespondError(w, apiErrObj, errQueriesByName)
|
||||
@@ -3440,10 +3392,6 @@ func (aH *APIHandler) calculateLogsConnectionStatus(ctx context.Context, orgID v
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "calculateLogsConnectionStatus",
|
||||
})
|
||||
queryRes, _, err := aH.querier.QueryRange(ctx, orgID, qrParams)
|
||||
if err != nil {
|
||||
return nil, model.InternalError(fmt.Errorf(
|
||||
@@ -3996,10 +3944,6 @@ func (aH *APIHandler) calculateAWSIntegrationSvcLogsConnectionStatus(
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "calculateLogsConnectionStatus",
|
||||
})
|
||||
queryRes, _, err := aH.querier.QueryRange(
|
||||
ctx, orgID, qrParams,
|
||||
)
|
||||
@@ -4548,10 +4492,6 @@ func (aH *APIHandler) queryRangeV3(ctx context.Context, queryRangeParams *v3.Que
|
||||
}
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "QueryRange",
|
||||
})
|
||||
result, errQueriesByName, err = aH.querier.QueryRange(ctx, orgID, queryRangeParams)
|
||||
|
||||
if err != nil {
|
||||
@@ -4946,11 +4886,7 @@ func (aH *APIHandler) QueryRangeV4(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "QueryRangeV4",
|
||||
})
|
||||
aH.queryRangeV4(ctx, queryRangeParams, w, r)
|
||||
aH.queryRangeV4(r.Context(), queryRangeParams, w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) traceFields(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -5043,12 +4979,8 @@ func (aH *APIHandler) getDomainList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getDomainList",
|
||||
})
|
||||
// Execute the query using the v5 querier
|
||||
result, err := aH.Signoz.Querier.QueryRange(ctx, orgID, queryRangeRequest)
|
||||
result, err := aH.Signoz.Querier.QueryRange(r.Context(), orgID, queryRangeRequest)
|
||||
if err != nil {
|
||||
zap.L().Error("Query execution failed", zap.Error(err))
|
||||
apiErrObj := errorsV2.New(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error())
|
||||
@@ -5103,12 +5035,8 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := ctxtypes.NewContextWithCommentVals(r.Context(), map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "getDomainInfo",
|
||||
})
|
||||
// Execute the query using the v5 querier
|
||||
result, err := aH.Signoz.Querier.QueryRange(ctx, orgID, queryRangeRequest)
|
||||
result, err := aH.Signoz.Querier.QueryRange(r.Context(), orgID, queryRangeRequest)
|
||||
if err != nil {
|
||||
zap.L().Error("Query execution failed", zap.Error(err))
|
||||
apiErrObj := errorsV2.New(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, err.Error())
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
@@ -165,10 +163,6 @@ func (p *ClustersRepo) getTopClusterGroups(ctx context.Context, orgID valuer.UUI
|
||||
topClusterGroupsQueryRangeParams.CompositeQuery.BuilderQueries[queryName] = query
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "getTopClusterGroups",
|
||||
})
|
||||
queryResponse, _, err := p.querierV2.QueryRange(ctx, orgID, topClusterGroupsQueryRangeParams)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -284,10 +278,6 @@ func (p *ClustersRepo) GetClusterList(ctx context.Context, orgID valuer.UUID, re
|
||||
}
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "GetClusterList",
|
||||
})
|
||||
queryResponse, _, err := p.querierV2.QueryRange(ctx, orgID, query)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
@@ -232,10 +230,6 @@ func (d *DaemonSetsRepo) getTopDaemonSetGroups(ctx context.Context, orgID valuer
|
||||
topDaemonSetGroupsQueryRangeParams.CompositeQuery.BuilderQueries[queryName] = query
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "getTopDaemonSetGroups",
|
||||
})
|
||||
queryResponse, _, err := d.querierV2.QueryRange(ctx, orgID, topDaemonSetGroupsQueryRangeParams)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -361,10 +355,6 @@ func (d *DaemonSetsRepo) GetDaemonSetList(ctx context.Context, orgID valuer.UUID
|
||||
}
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "GetDaemonSetList",
|
||||
})
|
||||
queryResponse, _, err := d.querierV2.QueryRange(ctx, orgID, query)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
@@ -232,10 +230,6 @@ func (d *DeploymentsRepo) getTopDeploymentGroups(ctx context.Context, orgID valu
|
||||
topDeploymentGroupsQueryRangeParams.CompositeQuery.BuilderQueries[queryName] = query
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "getTopDeploymentGroups",
|
||||
})
|
||||
queryResponse, _, err := d.querierV2.QueryRange(ctx, orgID, topDeploymentGroupsQueryRangeParams)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/exp/maps"
|
||||
@@ -274,10 +272,6 @@ func (h *HostsRepo) getTopHostGroups(ctx context.Context, orgID valuer.UUID, req
|
||||
topHostGroupsQueryRangeParams.CompositeQuery.BuilderQueries[queryName] = query
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "getTopHostGroups",
|
||||
})
|
||||
queryResponse, _, err := h.querierV2.QueryRange(ctx, orgID, topHostGroupsQueryRangeParams)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -488,10 +482,6 @@ func (h *HostsRepo) GetHostList(ctx context.Context, orgID valuer.UUID, req mode
|
||||
}
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "GetHostList",
|
||||
})
|
||||
queryResponse, _, err := h.querierV2.QueryRange(ctx, orgID, query)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
@@ -276,10 +274,6 @@ func (d *JobsRepo) getTopJobGroups(ctx context.Context, orgID valuer.UUID, req m
|
||||
topJobGroupsQueryRangeParams.CompositeQuery.BuilderQueries[queryName] = query
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "getTopJobGroups",
|
||||
})
|
||||
queryResponse, _, err := d.querierV2.QueryRange(ctx, orgID, topJobGroupsQueryRangeParams)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -405,10 +399,6 @@ func (d *JobsRepo) GetJobList(ctx context.Context, orgID valuer.UUID, req model.
|
||||
}
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "GetJobList",
|
||||
})
|
||||
queryResponse, _, err := d.querierV2.QueryRange(ctx, orgID, query)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
@@ -159,10 +157,6 @@ func (p *NamespacesRepo) getTopNamespaceGroups(ctx context.Context, orgID valuer
|
||||
topNamespaceGroupsQueryRangeParams.CompositeQuery.BuilderQueries[queryName] = query
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "getTopNamespaceGroups",
|
||||
})
|
||||
queryResponse, _, err := p.querierV2.QueryRange(ctx, orgID, topNamespaceGroupsQueryRangeParams)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -283,10 +277,6 @@ func (p *NamespacesRepo) GetNamespaceList(ctx context.Context, orgID valuer.UUID
|
||||
}
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "inframetrics",
|
||||
instrumentationtypes.CodeFunctionName: "GetNamespaceList",
|
||||
})
|
||||
queryResponse, _, err := p.querierV2.QueryRange(ctx, orgID, query)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user