Compare commits

...

31 Commits

Author SHA1 Message Date
Ishan Uniyal
5320138eb9 fix: added event propogation on enter press 2026-03-13 13:01:21 +05:30
Ashwin Bhatkal
0271be11e6 chore: remove dashboard provider from the root (#10526)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove dashboard provider from the root

* chore: fix tests

* chore: fix tests

* chore: remove dashboardId from provider

* chore: remove old instances of dashboard provider

* chore: separate dashboard widget fully

* chore: fix tests

* chore: resolve self comments
2026-03-12 14:51:49 +00:00
Vikrant Gupta
92d220c4d9 feat(serviceaccount): domain changes for service account (#10568)
* feat(serviceaccount): domain type changes

* feat(serviceaccount): domain type changes

* feat(serviceaccount): domain type changes
2026-03-12 11:06:04 +00:00
Vikrant Gupta
0ed8169bad feat(authz): add service account authz changes (#10567) 2026-03-12 09:42:50 +00:00
SagarRajput-7
ed553fb02e feat: removed plan name and added copiable license info in custom domain card (#10558)
* feat: removed plan name and added copiable license info in custom domain card

* feat: added condition on the license row in custom domain card

* feat: code refactor and making license row a common component

* feat: added test case and addressed feedback

* feat: style improvement

* feat: added maskedkey util and refactored code

* feat: updated test case
2026-03-12 09:24:41 +00:00
Ashwin Bhatkal
47daba3c17 chore: link session url with sentry alert (#10566) 2026-03-12 09:19:31 +00:00
Srikanth Chekuri
2b3310809a fix: newServer uses the stored config hash for mismatch (#10563) 2026-03-12 08:26:22 +00:00
Ashwin Bhatkal
542a648cc3 chore: remove toScrollWidgetId from dashboard provider (#10562)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove toScrollWidgetId from dashboard provider

* chore: remove dead files

* chore: fix tests
2026-03-12 06:03:02 +00:00
Naman Verma
61df12d126 test: integration tests for percentile aggregation (#10555)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: make histogramQuantile function work for devenv setup

* fix: make histogramQuantile function work for integration tests

* test: histogram percentile integration tests

* chore: explicitly mention user_scripts_path

* chore: fail tests if download of UDF fails
2026-03-11 14:27:01 +00:00
Vinicius Lourenço
b846faa1fa fix(app-routes): do not render old route, redirect instead (#10553) 2026-03-11 12:53:32 +00:00
Ishan
557451ed81 feat: Option to zoom out OR reset zoom in the explorer pages (#10464)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: zoom out func ladder added

* feat: zoom out feature with testcases

* fix: comments resolved moved to signoz btn added testcase for querydelete

* feat: updated btn compoent to use prefix icon

* feat: historical enddate preset as null to preserve custom

* fix: cursor bot callback and localstorage

* feat: common util for local storage

* feat: rename and testcase

* feat: avoid persist for non preset
2026-03-11 07:23:30 +00:00
Ishan
25c513ec2f fix: updated fallback color (#10525)
* fix: updated fallback color

* fix: updated testcase
2026-03-11 07:23:21 +00:00
primus-bot[bot]
ae71f2608a chore(release): bump to v0.115.0 (#10556)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-03-11 07:11:28 +00:00
Ishan
f7140832d0 Fix - Handling for resource. prefix in quick filters (#10497)
* fix: resource added in checkbox

* fix: resource name handling

* fix: added testcases

* fix: updated for other types as well

* fix: updated testcases

* fix: pr comments
2026-03-11 06:31:53 +00:00
Yunus M
8c5ff10b42 feat: update url with y-axis unit (#10530)
* feat: update url with y-axis unit

* feat: implement useUrlYAxisUnit hook for y-axis unit management

* feat: use nuqs for handling query param updates

* chore: whitelist nuqs in jest

* chore: add nuqs to test utils
2026-03-11 05:10:47 +00:00
Vishal Sharma
12c0df8410 feat(onboarding): add configs and SVGs for 9 new datasources (#10552)
* chore: add new integration logos and update onboarding configurations and related files

* fix(logos): add viewBox attribute to huggingface svg for proper scaling
2026-03-11 04:58:18 +00:00
Ashwin Bhatkal
a139915f4e fix: guard against undefined spread in useGetQueryLabels (#10550)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: guard against undefined spread in useGetQueryLabels

* chore: add tests
2026-03-10 18:30:31 +00:00
Abhi kumar
b69bcd63ba fix: added fix for apiresponse being undefined in panel config creation (#10549)
* fix: added fix for apiresponse being undefined in panel config creation

* chore: pr review changes
2026-03-10 17:35:34 +00:00
Ashwin Bhatkal
f576a86dd1 fix: avoid read-only variables mutation (#10548) 2026-03-10 17:22:47 +00:00
Ashwin Bhatkal
996c9a891f chore: remove selectedRowWidgetId from provider (#10547)
* chore: remove selectedRowWidgetId from provider

* chore: rename helper

* chore: add comment
2026-03-10 16:57:38 +00:00
Ashwin Bhatkal
d1a872dadc chore: remove dashboardId from provider (#10546) 2026-03-10 16:42:25 +00:00
Naman Verma
51967c527f Upgrade prometheus/common and prometheus/prometheus to latest available version (#10467)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: upgrade prometheus/common to latest available version

* chore: upgrade prometheus/prometheus to latest available version

* chore: easy changes first

* chore: slightly unsure changes

* fix: correct imported version of semconv in sdk.go

* test: ut fix, just matched expected and actual nothing else

* test: ut fix, just matched expected and actual nothing else

* test: ut fix, just matched expected and actual nothing else

* test: ut fix, just matched expected and actual nothing else

* test: ut fix, pass no nil prometheus registry

* chore: upgrade go version in dockerfile to 1.25

* chore: no need for our own alert store callback

* chore: 1.25 bullseye is still an rc so shifting to bookworm

* fix: parallel calls for each query in readmultiple method

* chore: remove unused var

* Sync PagerDuty frontend defaults with Alertmanager v0.31

Applied via @cursor push command

* chore: make ctx the first param

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-03-10 05:09:05 +00:00
Karan Balani
6f8da2edeb feat: deprecate user invite table and add user status lifecycle (#10445)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: deprecate user invite table

* fix: handle soft deleted users flow

* fix: handle edge cases for authentication and reset password flow

* feat: integration tests with fixes for new flow

* fix: array for grants

* fix: edge cases for reset token and context api

* chore: remove all code related to old invite flow

* fix: openapi specs

* fix: integration tests and minor naming change

* fix: integration tests fmtlint

* feat: improve invitation email template

* fix: role tests

* fix: context api

* fix: openapi frontend

* chore: rename countbyorgid to activecountbyorgid

* fix: a deleted user cannot recycled, creating a new one

* feat: migrate existing invites to user as pending invite status

* fix: error from GetUsersByEmailAndOrgID

* feat: add backward compatibility to existing apis using new invite flow

* chore: change ordering of apis in server

* chore: change ordering of apis in server

* fix: filter active users in role and org id check

* fix: check deleted user in reset password flow

* chore: address some review comments, add back countbyorgid method

* chore: move to bulk inserts for migrating existing invites

* fix: wrap funcs to transactions, and fix openapi specs

* fix: move reset link method to types, also move authz grants outside transation

* fix: transaction issues

* feat: helper method ErrIfDeleted for user

* fix: error code for errifdeleted in user

* fix: soft delete store method

* fix: password authn tests also add old invite flow test

* fix: callbackauthn tests

* fix: remove extra oidc tests

* fix: callback authn tests oidc

* chore: address review comments and optimise bulk invite api

* fix: use db ctx in various places

* fix: fix duplicate email invite issue and add partial invite

* fix: openapi specs

* fix: errifpending

* fix: user status persistence

* fix: edge cases

* chore: add tests for partial index too

* feat: use composite unique index on users table instead of partial one

* chore: move duplicate email check to unmarshaljson and query user again in accept invite

* fix: make 068 migratin idempotent

* chore: remove unused emails var

* chore: add a temp filter to show only active users in frontend until next frontend fix

* chore: remove one check from register flow testing until temp code is removed

* chore: remove commented code from tests

* chore: address frontend review comments

* chore: address frontend review comments
2026-03-09 18:16:04 +00:00
Vikrant Gupta
ec543eb89c feat(authz): register role and assignee relationships (#10538) 2026-03-09 17:03:27 +00:00
Srikanth Chekuri
48acc297a8 chore: add initial version of query range design principles doc (#10415)
Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-03-09 17:01:13 +00:00
Vinicius Lourenço
1430e5fa99 perf(grid-card): set cache time zero when auto-refresh is enabled (#10225) 2026-03-09 14:54:14 +00:00
Ashwin Bhatkal
1f3f611e9a chore: remove tsc2 check from jsci (#10527)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-03-09 19:26:36 +05:30
Aditya Singh
3c59f45a2f feat: set list height in trace details page for filters (#10534) 2026-03-09 11:23:46 +00:00
Abhi kumar
6fb92880cc feat: legend auto generation based on group by (#10529)
* feat: legend auto generation based on group by

* chore: removed redundent check from util
2026-03-09 11:22:28 +00:00
SagarRajput-7
089a8ee323 feat: removed members and invited user tables from sso page (#10517)
* feat: added components and styles

* feat: added password modal and other functionality

* feat: removed members and invited user tables from sso page

* feat: refactored and addressed the comments

* feat: rebase with main fix
2026-03-09 11:16:23 +00:00
Ashwin Bhatkal
dab7f506f7 fix(frontend): refresh generated api on 401 errors (#10532) 2026-03-09 11:16:13 +00:00
194 changed files with 5646 additions and 3427 deletions

View File

@@ -1,4 +1,22 @@
services: services:
init-clickhouse:
image: clickhouse/clickhouse-server:25.5.6
container_name: init-clickhouse
command:
- bash
- -c
- |
version="v0.0.1"
node_os=$$(uname -s | tr '[:upper:]' '[:lower:]')
node_arch=$$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/)
echo "Fetching histogram-binary for $${node_os}/$${node_arch}"
cd /tmp
wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F$${version}/histogram-quantile_$${node_os}_$${node_arch}.tar.gz"
tar -xvzf histogram-quantile.tar.gz
mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile
restart: on-failure
volumes:
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
clickhouse: clickhouse:
image: clickhouse/clickhouse-server:25.5.6 image: clickhouse/clickhouse-server:25.5.6
container_name: clickhouse container_name: clickhouse
@@ -7,6 +25,7 @@ services:
- ${PWD}/fs/etc/clickhouse-server/users.d/users.xml:/etc/clickhouse-server/users.d/users.xml - ${PWD}/fs/etc/clickhouse-server/users.d/users.xml:/etc/clickhouse-server/users.d/users.xml
- ${PWD}/fs/tmp/var/lib/clickhouse/:/var/lib/clickhouse/ - ${PWD}/fs/tmp/var/lib/clickhouse/:/var/lib/clickhouse/
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/ - ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
- ${PWD}/../../../deploy/common/clickhouse/custom-function.xml:/etc/clickhouse-server/custom-function.xml
ports: ports:
- '127.0.0.1:8123:8123' - '127.0.0.1:8123:8123'
- '127.0.0.1:9000:9000' - '127.0.0.1:9000:9000'
@@ -22,7 +41,10 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
depends_on: depends_on:
- zookeeper init-clickhouse:
condition: service_completed_successfully
zookeeper:
condition: service_healthy
environment: environment:
- CLICKHOUSE_SKIP_USER_SETUP=1 - CLICKHOUSE_SKIP_USER_SETUP=1
zookeeper: zookeeper:

View File

@@ -44,4 +44,6 @@
<shard>01</shard> <shard>01</shard>
<replica>01</replica> <replica>01</replica>
</macros> </macros>
<user_defined_executable_functions_config>*function.xml</user_defined_executable_functions_config>
<user_scripts_path>/var/lib/clickhouse/user_scripts/</user_scripts_path>
</clickhouse> </clickhouse>

View File

@@ -13,23 +13,6 @@ on:
jobs: jobs:
tsc: 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
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install
run: cd frontend && yarn install
- name: tsc
run: cd frontend && yarn tsc
tsc2:
if: | if: |
github.event_name == 'merge_group' || 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' && ! 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')) ||

View File

@@ -1,4 +1,4 @@
FROM golang:1.24-bullseye FROM golang:1.25-bookworm
ARG OS="linux" ARG OS="linux"
ARG TARGETARCH ARG TARGETARCH

View File

@@ -1,4 +1,4 @@
FROM node:18-bullseye AS build FROM node:22-bookworm AS build
WORKDIR /opt/ WORKDIR /opt/
COPY ./frontend/ ./ COPY ./frontend/ ./
@@ -6,7 +6,7 @@ ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 yarn install RUN CI=1 yarn install
RUN CI=1 yarn build RUN CI=1 yarn build
FROM golang:1.24-bullseye FROM golang:1.25-bookworm
ARG OS="linux" ARG OS="linux"
ARG TARGETARCH ARG TARGETARCH

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.114.1 image: signoz/signoz:v0.115.0
ports: ports:
- "8080:8080" # signoz port - "8080:8080" # signoz port
# - "6060:6060" # pprof port # - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.114.1 image: signoz/signoz:v0.115.0
ports: ports:
- "8080:8080" # signoz port - "8080:8080" # signoz port
volumes: volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.114.1} image: signoz/signoz:${VERSION:-v0.115.0}
container_name: signoz container_name: signoz
ports: ports:
- "8080:8080" # signoz port - "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.114.1} image: signoz/signoz:${VERSION:-v0.115.0}
container_name: signoz container_name: signoz
ports: ports:
- "8080:8080" # signoz port - "8080:8080" # signoz port

View File

@@ -1768,19 +1768,19 @@ components:
createdAt: createdAt:
format: date-time format: date-time
type: string type: string
expires_at: expiresAt:
minimum: 0 minimum: 0
type: integer type: integer
id: id:
type: string type: string
key: key:
type: string type: string
last_used: lastObservedAt:
format: date-time format: date-time
type: string type: string
name: name:
type: string type: string
service_account_id: serviceAccountId:
type: string type: string
updatedAt: updatedAt:
format: date-time format: date-time
@@ -1788,9 +1788,9 @@ components:
required: required:
- id - id
- key - key
- expires_at - expiresAt
- last_used - lastObservedAt
- service_account_id - serviceAccountId
type: object type: object
ServiceaccounttypesGettableFactorAPIKeyWithKey: ServiceaccounttypesGettableFactorAPIKeyWithKey:
properties: properties:
@@ -1804,14 +1804,14 @@ components:
type: object type: object
ServiceaccounttypesPostableFactorAPIKey: ServiceaccounttypesPostableFactorAPIKey:
properties: properties:
expires_at: expiresAt:
minimum: 0 minimum: 0
type: integer type: integer
name: name:
type: string type: string
required: required:
- name - name
- expires_at - expiresAt
type: object type: object
ServiceaccounttypesPostableServiceAccount: ServiceaccounttypesPostableServiceAccount:
properties: properties:
@@ -1833,13 +1833,16 @@ components:
createdAt: createdAt:
format: date-time format: date-time
type: string type: string
deletedAt:
format: date-time
type: string
email: email:
type: string type: string
id: id:
type: string type: string
name: name:
type: string type: string
orgID: orgId:
type: string type: string
roles: roles:
items: items:
@@ -1856,18 +1859,19 @@ components:
- email - email
- roles - roles
- status - status
- orgID - orgId
- deletedAt
type: object type: object
ServiceaccounttypesUpdatableFactorAPIKey: ServiceaccounttypesUpdatableFactorAPIKey:
properties: properties:
expires_at: expiresAt:
minimum: 0 minimum: 0
type: integer type: integer
name: name:
type: string type: string
required: required:
- name - name
- expires_at - expiresAt
type: object type: object
ServiceaccounttypesUpdatableServiceAccount: ServiceaccounttypesUpdatableServiceAccount:
properties: properties:
@@ -2108,6 +2112,15 @@ components:
token: token:
type: string type: string
type: object type: object
TypesPostableBulkInviteRequest:
properties:
invites:
items:
$ref: '#/components/schemas/TypesPostableInvite'
type: array
required:
- invites
type: object
TypesPostableForgotPassword: TypesPostableForgotPassword:
properties: properties:
email: email:
@@ -2196,6 +2209,8 @@ components:
type: string type: string
role: role:
type: string type: string
status:
type: string
updatedAt: updatedAt:
format: date-time format: date-time
type: string type: string
@@ -3538,9 +3553,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
items: $ref: '#/components/schemas/TypesPostableBulkInviteRequest'
$ref: '#/components/schemas/TypesPostableInvite'
type: array
responses: responses:
"201": "201":
description: Created description: Created

View File

@@ -100,7 +100,7 @@ This command:
3. Create a `.env` file in this directory: 3. Create a `.env` file in this directory:
```env ```env
FRONTEND_API_ENDPOINT=http://localhost:8080 VITE_FRONTEND_API_ENDPOINT=http://localhost:8080
``` ```
4. Start the development server: 4. Start the development server:

View File

@@ -273,6 +273,7 @@ Options can be simple (direct link) or nested (with another question):
- Place logo files in `public/Logos/` - Place logo files in `public/Logos/`
- Use SVG format - Use SVG format
- Reference as `"/Logos/your-logo.svg"` - Reference as `"/Logos/your-logo.svg"`
- **Optimize new SVGs**: Run any newly downloaded SVGs through an optimizer like [SVGOMG (svgo)](https://svgomg.net/) or use `npx svgo public/Logos/your-logo.svg` to minimise their size before committing.
### 4. Links ### 4. Links

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

View File

@@ -176,6 +176,7 @@ func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource
typeables = append(typeables, register.MustGetTypeables()...) typeables = append(typeables, register.MustGetTypeables()...)
} }
typeables = append(typeables, provider.MustGetTypeables()...)
resources := make([]*authtypes.Resource, 0) resources := make([]*authtypes.Resource, 0)
for _, typeable := range typeables { for _, typeable := range typeables {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()}) resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})

View File

@@ -2,39 +2,45 @@ module base
type organisation type organisation
relations relations
define read: [user, role#assignee] define read: [user, serviceaccount, role#assignee]
define update: [user, role#assignee] define update: [user, serviceaccount, role#assignee]
type user type user
relations relations
define read: [user, role#assignee] define read: [user, serviceaccount, role#assignee]
define update: [user, role#assignee] define update: [user, serviceaccount, role#assignee]
define delete: [user, role#assignee] define delete: [user, serviceaccount, role#assignee]
type serviceaccount
relations
define read: [user, serviceaccount, role#assignee]
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
type anonymous type anonymous
type role type role
relations relations
define assignee: [user, anonymous] define assignee: [user, serviceaccount, anonymous]
define read: [user, role#assignee] define read: [user, serviceaccount, role#assignee]
define update: [user, role#assignee] define update: [user, serviceaccount, role#assignee]
define delete: [user, role#assignee] define delete: [user, serviceaccount, role#assignee]
type metaresources type metaresources
relations relations
define create: [user, role#assignee] define create: [user, serviceaccount, role#assignee]
define list: [user, role#assignee] define list: [user, serviceaccount, role#assignee]
type metaresource type metaresource
relations relations
define read: [user, anonymous, role#assignee] define read: [user, serviceaccount, anonymous, role#assignee]
define update: [user, role#assignee] define update: [user, serviceaccount, role#assignee]
define delete: [user, role#assignee] define delete: [user, serviceaccount, role#assignee]
define block: [user, role#assignee] define block: [user, serviceaccount, role#assignee]
type telemetryresource type telemetryresource
relations relations
define read: [user, role#assignee] define read: [user, serviceaccount, role#assignee]

View File

@@ -170,7 +170,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider) cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName)) email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId)) cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId), types.UserStatusActive)
if err != nil { if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err)) return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
} }

View File

@@ -1,6 +1,6 @@
NODE_ENV="development" NODE_ENV="development"
BUNDLE_ANALYSER="true" BUNDLE_ANALYSER="true"
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080/" VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
VITE_PYLON_APP_ID="pylon-app-id" VITE_PYLON_APP_ID="pylon-app-id"
VITE_APPCUES_APP_ID="appcess-app-id" VITE_APPCUES_APP_ID="appcess-app-id"
VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret" VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret"

View File

@@ -39,7 +39,7 @@ const config: Config.InitialOptions = {
'^.+\\.(js|jsx)$': 'babel-jest', '^.+\\.(js|jsx)$': 'babel-jest',
}, },
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens|@signozhq/table|@signozhq/calendar|@signozhq/input|@signozhq/popover|@signozhq/button|@signozhq/sonner|@signozhq/*|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana)/)', 'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens|@signozhq/table|@signozhq/calendar|@signozhq/input|@signozhq/popover|@signozhq/button|@signozhq/sonner|@signozhq/*|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs)/)',
], ],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'], testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 24 24"><title>Deno</title><path d="M1.105 18.02A11.9 11.9 0 0 1 0 12.985q0-.698.078-1.376a12 12 0 0 1 .231-1.34A12 12 0 0 1 4.025 4.02a12 12 0 0 1 5.46-2.771 12 12 0 0 1 3.428-.23c1.452.112 2.825.477 4.077 1.05a12 12 0 0 1 2.78 1.774 12.02 12.02 0 0 1 4.053 7.078A12 12 0 0 1 24 12.985q0 .454-.036.914a12 12 0 0 1-.728 3.305 12 12 0 0 1-2.38 3.875c-1.33 1.357-3.02 1.962-4.43 1.936a4.4 4.4 0 0 1-2.724-1.024c-.99-.853-1.391-1.83-1.53-2.919a5 5 0 0 1 .128-1.518c.105-.38.37-1.116.76-1.437-.455-.197-1.04-.624-1.226-.829-.045-.05-.04-.13 0-.183a.155.155 0 0 1 .177-.053c.392.134.869.267 1.372.35.66.111 1.484.25 2.317.292 2.03.1 4.153-.813 4.812-2.627s.403-3.609-1.96-4.685-3.454-2.356-5.363-3.128c-1.247-.505-2.636-.205-4.06.582-3.838 2.121-7.277 8.822-5.69 15.032a.191.191 0 0 1-.315.19 12 12 0 0 1-1.25-1.634 12 12 0 0 1-.769-1.404M11.57 6.087c.649-.051 1.214.501 1.31 1.236.13.979-.228 1.99-1.41 2.013-1.01.02-1.315-.997-1.248-1.614.066-.616.574-1.575 1.35-1.635"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 24 24"><title>Haystack</title><path d="M2.008 0C.9 0 0 .9 0 2.008v19.984C0 23.1.9 24 2.008 24h19.984C23.1 24 24 23.1 24 21.992V2.008C24 .9 23.1 0 21.992 0Zm9.963 3.84c3.43 0 6.21 2.763 6.21 6.17v6.488a.27.27 0 0 1-.27.268 2.423 2.423 0 0 1-2.43-2.414V10.01c0-1.927-1.572-3.488-3.51-3.488S8.547 8.085 8.547 10.01v1.608a.263.263 0 0 0 .259.268h1.54a.27.27 0 0 0 .275-.263V9.945c0-.74.604-1.341 1.35-1.341s1.35.6 1.35 1.341V20.03a.275.275 0 0 1-.28.268 2.41 2.41 0 0 1-2.42-2.404v-3.23a.275.275 0 0 0-.276-.269H8.811a.264.264 0 0 0-.264.263v1.08c0 1.333-1.175 2.414-2.517 2.414a.27.27 0 0 1-.27-.268v-7.872c0-3.408 2.78-6.171 6.21-6.171"/></svg>

After

Width:  |  Height:  |  Size: 707 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95 88" fill="none"><path fill="#ffd21e" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5"/><path fill="#ff9d0b" d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0m-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0"/><path fill="#3a3b45" d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32Zm-23.55 0c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32Z"/><path fill="#ff323d" d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"/><path fill="#3a3b45" fill-rule="evenodd" d="M39.43 54a8.7 8.7 0 0 1 5.3-4.49c.4-.12.81.57 1.24 1.28.4.68.82 1.37 1.24 1.37.45 0 .9-.68 1.33-1.35.45-.7.89-1.38 1.32-1.25a8.6 8.6 0 0 1 5 4.17c3.73-2.94 5.1-7.74 5.1-10.7 0-2.34-1.57-1.6-4.09-.36l-.14.07c-2.31 1.15-5.39 2.67-8.77 2.67s-6.45-1.52-8.77-2.67c-2.6-1.29-4.23-2.1-4.23.29 0 3.05 1.46 8.06 5.47 10.97" clip-rule="evenodd"/><path fill="#ff9d0b" d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5m-46.5 0a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5m-6.69 11c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7 7 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.3 44.3 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48m0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85"/><path fill="#ffd21e" d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9-3.24 5.08.68 8.01c3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12"/><path fill="#ff9d0b" d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7 7 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.3 44.3 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48m0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.5 1.5 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85"/><path fill="#ffd21e" d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44-1.58 4.17-6.8 7.33c-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12"/></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#76b900" viewBox="0 0 24 24"><title>NVIDIA</title><path d="M8.948 8.798v-1.43a7 7 0 0 1 .424-.018c3.922-.124 6.493 3.374 6.493 3.374s-2.774 3.851-5.75 3.851a3.7 3.7 0 0 1-1.158-.185v-4.346c1.528.185 1.837.857 2.747 2.385l2.04-1.714s-1.492-1.952-4-1.952a6 6 0 0 0-.796.035m0-4.735v2.138l.424-.027c5.45-.185 9.01 4.47 9.01 4.47s-4.08 4.964-8.33 4.964a6.5 6.5 0 0 1-1.095-.097v1.325c.3.035.61.062.91.062 3.957 0 6.82-2.023 9.593-4.408.459.371 2.34 1.263 2.73 1.652-2.633 2.208-8.772 3.984-12.253 3.984-.335 0-.653-.018-.971-.053v1.864H24V4.063zm0 10.326v1.131c-3.657-.654-4.673-4.46-4.673-4.46s1.758-1.944 4.673-2.262v1.237H8.94c-1.528-.186-2.73 1.245-2.73 1.245s.68 2.412 2.739 3.11M2.456 10.9s2.164-3.197 6.5-3.533V6.201C4.153 6.59 0 10.653 0 10.653s2.35 6.802 8.948 7.42v-1.237c-4.84-.6-6.492-5.936-6.492-5.936"/></svg>

After

Width:  |  Height:  |  Size: 865 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 24 24"><title>Ollama</title><path d="M16.361 10.26a.9.9 0 0 0-.558.47l-.072.148.001.207c0 .193.004.217.059.353.076.193.152.312.291.448.24.238.51.3.872.205a.86.86 0 0 0 .517-.436.75.75 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.1 1.1 0 0 0-.466 0m-9.203.005c-.305.096-.533.32-.65.639a1.2 1.2 0 0 0-.06.52c.057.309.31.59.598.667.362.095.632.033.872-.205.14-.136.215-.255.291-.448.055-.136.059-.16.059-.353l.001-.207-.072-.148a.9.9 0 0 0-.565-.472 1 1 0 0 0-.474.007m4.184 2c-.131.071-.223.25-.195.383.031.143.157.288.353.407.105.063.112.072.117.136.004.038-.01.146-.029.243-.02.094-.036.194-.036.222.002.074.07.195.143.253.064.052.076.054.255.059.164.005.198.001.264-.03.169-.082.212-.234.15-.525-.052-.243-.042-.28.087-.355.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48.4.4 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053-.053-.032c-.219-.13-.259-.145-.391-.143a.4.4 0 0 0-.193.032m.39-2.195c-.373.036-.475.05-.654.086a4.5 4.5 0 0 0-.951.328c-.94.46-1.589 1.226-1.787 2.114-.04.176-.045.234-.045.53 0 .294.005.357.043.524.264 1.16 1.332 2.017 2.714 2.173.3.033 1.596.033 1.896 0 1.11-.125 2.064-.727 2.493-1.571.114-.226.169-.372.22-.602.039-.167.044-.23.044-.523 0-.297-.005-.355-.045-.531-.288-1.29-1.539-2.304-3.072-2.497a7 7 0 0 0-.855-.031zm.645.937a3.3 3.3 0 0 1 1.44.514c.223.148.537.458.671.662.166.251.26.508.303.82.02.143.01.251-.043.482-.08.345-.332.705-.672.957a3 3 0 0 1-.689.348c-.382.122-.632.144-1.525.138-.582-.006-.686-.01-.853-.042q-.856-.16-1.35-.68c-.264-.28-.385-.535-.45-.946-.03-.192.025-.509.137-.776.136-.326.488-.73.836-.963.403-.269.934-.46 1.422-.512.187-.02.586-.02.773-.002m-5.503-11a1.65 1.65 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503 0 .544.064 1.24.155 1.721.02.107.031.202.023.208l-.187.152a5.3 5.3 0 0 0-.949 1.02 5.5 5.5 0 0 0-.94 2.339 6.6 6.6 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195-.037.064c-.269.452-.498 1.105-.605 1.732-.084.496-.095.629-.095 1.294 0 .67.009.803.088 1.266.095.555.288 1.143.503 1.534.071.128.243.393.264.407.007.003-.014.067-.046.141a7.4 7.4 0 0 0-.548 1.873 5 5 0 0 0-.071.991c0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.9.9 0 0 0-.194-.25 1.7 1.7 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451.124-.486.329-.918.544-1.154a.8.8 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.14 3.14 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834.563-.814 1.353-1.336 2.237-1.475.199-.033.57-.028.776.01.226.04.367.028.512-.041.179-.085.268-.19.374-.431.093-.215.165-.333.36-.576.234-.29.46-.489.822-.729.413-.27.884-.467 1.352-.561.17-.035.25-.04.569-.04s.398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602.034.057.095.177.132.267.105.241.195.346.374.43.14.068.286.082.503.045.343-.058.607-.053.943.016 1.144.23 2.14 1.173 2.581 2.437.385 1.108.276 2.267-.296 3.153-.097.15-.193.27-.333.419-.301.322-.301.722-.001 1.053.493.539.801 1.866.708 3.036-.062.772-.26 1.463-.533 1.854a2 2 0 0 1-.224.258.9.9 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295.253 1.008.231 2.01-.059 2.581a1 1 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074.036-.134c.019-.076.057-.3.088-.516a9 9 0 0 0 0-1.258c-.11-.875-.295-1.57-.597-2.226-.032-.074-.053-.138-.046-.141a1.4 1.4 0 0 0 .108-.152c.376-.569.607-1.284.724-2.228.031-.26.031-1.378 0-1.628-.083-.645-.182-1.082-.348-1.525a6 6 0 0 0-.329-.7l-.038-.064.131-.194c.402-.604.636-1.262.727-2.04a6.6 6.6 0 0 0-.024-1.358 5.5 5.5 0 0 0-.939-2.339 5.3 5.3 0 0 0-.95-1.02l-.186-.152a.7.7 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503-.19-.924-.535-1.658-.98-2.082-.354-.338-.716-.482-1.15-.455-.996.059-1.8 1.205-2.116 3.01a7 7 0 0 0-.097.726c0 .036-.007.066-.015.066a1 1 0 0 1-.149-.078A4.86 4.86 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a1 1 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a7 7 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2 2 0 0 0-.585-.041Zm.293 1.402c.248.197.523.759.682 1.388.03.113.06.244.069.292.007.047.026.152.041.233.067.365.098.76.102 1.24l.002.475-.12.175-.118.178h-.278c-.324 0-.646.041-.954.124l-.238.06c-.033.007-.038-.003-.057-.144a8.4 8.4 0 0 1 .016-2.323c.124-.788.413-1.501.696-1.711.067-.05.079-.049.157.013m9.825-.012c.17.126.358.46.498.888.28.854.36 2.028.212 3.145-.019.14-.024.151-.057.144l-.238-.06a3.7 3.7 0 0 0-.954-.124h-.278l-.119-.178-.119-.175.002-.474c.004-.669.066-1.19.214-1.772.157-.623.434-1.185.68-1.382.078-.062.09-.063.159-.012"/></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fff" viewBox="0 0 24 24"><title>OpenRouter</title><path d="M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z"/></svg>

After

Width:  |  Height:  |  Size: 685 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3.94 -1.44 438.62 432.87"><path fill="#e75225" d="M215.926 7.068c115.684.024 210.638 93.784 210.493 207.844-.148 115.793-94.713 208.252-212.912 208.169C97.95 423 4.52 329.143 4.601 213.221 4.68 99.867 99.833 7.044 215.926 7.068m-63.947 73.001c2.652 12.978.076 25.082-3.846 36.988-2.716 8.244-6.47 16.183-8.711 24.539-3.694 13.769-7.885 27.619-9.422 41.701-2.21 20.25 5.795 38.086 19.493 55.822L86.527 225.94c.11 1.978-.007 2.727.21 3.361 5.968 17.43 16.471 32.115 28.243 45.957 1.246 1.465 4.082 2.217 6.182 2.221 62.782.115 125.565.109 188.347.028 1.948-.003 4.546-.369 5.741-1.618 13.456-14.063 23.746-30.079 30.179-50.257l-66.658 12.976c4.397-8.567 9.417-16.1 12.302-24.377 9.869-28.315 5.779-55.69-8.387-81.509-11.368-20.72-21.854-41.349-16.183-66.32-12.005 11.786-16.615 26.79-19.541 42.253-2.882 15.23-4.58 30.684-6.811 46.136-.317-.467-.728-.811-.792-1.212-.258-1.621-.499-3.255-.587-4.893-1.355-25.31-6.328-49.696-16.823-72.987-6.178-13.71-12.99-27.727-6.622-44.081-4.31 2.259-8.205 4.505-10.997 7.711-8.333 9.569-11.779 21.062-12.666 33.645-.757 10.75-1.796 21.552-3.801 32.123-2.107 11.109-5.448 21.998-12.956 32.209-3.033-21.81-3.37-43.38-22.928-57.237m161.877 216.523H116.942v34.007h196.914zm-157.871 51.575c-.163 28.317 28.851 49.414 64.709 47.883 29.716-1.269 56.016-24.51 53.755-47.883z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 122.88 103.53"><path d="M5.47 0H117.4c3.01 0 5.47 2.46 5.47 5.47v92.58c0 3.01-2.46 5.47-5.47 5.47H5.47c-3.01 0-5.47-2.46-5.47-5.47V5.47C0 2.46 2.46 0 5.47 0m26.37 38.55 17.79 18.42 2.14 2.13-2.12 2.16-17.97 19.05-5.07-5 15.85-16.15L26.81 43.6zM94.1 79.41H54.69v-6.84H94.1zM38.19 9.83c3.19 0 5.78 2.59 5.78 5.78s-2.59 5.78-5.78 5.78-5.78-2.59-5.78-5.78S35 9.83 38.19 9.83m-19.24 0c3.19 0 5.78 2.59 5.78 5.78s-2.59 5.78-5.78 5.78-5.78-2.59-5.78-5.78 2.58-5.78 5.78-5.78M7.49 5.41H115.4c1.15 0 2.09.94 2.09 2.09v18.32H5.4V7.5c0-1.15.94-2.09 2.09-2.09" style="fill-rule:evenodd;clip-rule:evenodd;fill:#1668dc"/></svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@@ -1,6 +1,6 @@
import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react'; import { ReactChild, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { matchPath, useLocation } from 'react-router-dom'; import { matchPath, Redirect, useLocation } from 'react-router-dom';
import getLocalStorageApi from 'api/browser/localstorage/get'; import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set'; import setLocalStorageApi from 'api/browser/localstorage/set';
import getAll from 'api/v1/user/get'; import getAll from 'api/v1/user/get';
@@ -128,6 +128,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
isAdmin && isAdmin &&
(path === ROUTES.SETTINGS || (path === ROUTES.SETTINGS ||
path === ROUTES.ORG_SETTINGS || path === ROUTES.ORG_SETTINGS ||
path === ROUTES.MEMBERS_SETTINGS ||
path === ROUTES.BILLING || path === ROUTES.BILLING ||
path === ROUTES.MY_SETTINGS); path === ROUTES.MY_SETTINGS);
@@ -236,13 +237,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
useEffect(() => { useEffect(() => {
// if it is an old route navigate to the new route // if it is an old route navigate to the new route
if (isOldRoute) { if (isOldRoute) {
const redirectUrl = oldNewRoutesMapping[pathname]; // this will be handled by the redirect component below
const newLocation = {
...location,
pathname: redirectUrl,
};
history.replace(newLocation);
return; return;
} }
@@ -296,6 +291,19 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
} }
}, [isLoggedInState, pathname, user, isOldRoute, currentRoute, location]); }, [isLoggedInState, pathname, user, isOldRoute, currentRoute, location]);
if (isOldRoute) {
const redirectUrl = oldNewRoutesMapping[pathname];
return (
<Redirect
to={{
pathname: redirectUrl,
search: location.search,
hash: location.hash,
}}
/>
);
}
// NOTE: disabling this rule as there is no need to have div // NOTE: disabling this rule as there is no need to have div
return <>{children}</>; return <>{children}</>;
} }

View File

@@ -29,7 +29,6 @@ import posthog from 'posthog-js';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types'; import { IUser } from 'providers/App/types';
import { CmdKProvider } from 'providers/cmdKProvider'; import { CmdKProvider } from 'providers/cmdKProvider';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider'; import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider'; import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { QueryBuilderProvider } from 'providers/QueryBuilder';
@@ -321,6 +320,19 @@ function App(): JSX.Element {
// Session Replay // Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
beforeSend(event) {
const sessionReplayUrl = posthog.get_session_replay_url?.({
withTimestamp: true,
});
if (sessionReplayUrl) {
// eslint-disable-next-line no-param-reassign
event.contexts = {
...event.contexts,
posthog: { session_replay_url: sessionReplayUrl },
};
}
return event;
},
}); });
setIsSentryInitialized(true); setIsSentryInitialized(true);
@@ -371,28 +383,26 @@ function App(): JSX.Element {
<PrivateRoute> <PrivateRoute>
<ResourceProvider> <ResourceProvider>
<QueryBuilderProvider> <QueryBuilderProvider>
<DashboardProvider> <KeyboardHotkeysProvider>
<KeyboardHotkeysProvider> <AppLayout>
<AppLayout> <PreferenceContextProvider>
<PreferenceContextProvider> <Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}> <Switch>
<Switch> {routes.map(({ path, component, exact }) => (
{routes.map(({ path, component, exact }) => ( <Route
<Route key={`${path}`}
key={`${path}`} exact={exact}
exact={exact} path={path}
path={path} component={component}
component={component} />
/> ))}
))} <Route exact path="/" component={Home} />
<Route exact path="/" component={Home} /> <Route path="*" component={NotFound} />
<Route path="*" component={NotFound} /> </Switch>
</Switch> </Suspense>
</Suspense> </PreferenceContextProvider>
</PreferenceContextProvider> </AppLayout>
</AppLayout> </KeyboardHotkeysProvider>
</KeyboardHotkeysProvider>
</DashboardProvider>
</QueryBuilderProvider> </QueryBuilderProvider>
</ResourceProvider> </ResourceProvider>
</PrivateRoute> </PrivateRoute>

View File

@@ -2100,7 +2100,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
* @type integer * @type integer
* @minimum 0 * @minimum 0
*/ */
expires_at: number; expiresAt: number;
/** /**
* @type string * @type string
*/ */
@@ -2113,7 +2113,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
* @type string * @type string
* @format date-time * @format date-time
*/ */
last_used: Date; lastObservedAt: Date;
/** /**
* @type string * @type string
*/ */
@@ -2121,7 +2121,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
/** /**
* @type string * @type string
*/ */
service_account_id: string; serviceAccountId: string;
/** /**
* @type string * @type string
* @format date-time * @format date-time
@@ -2145,7 +2145,7 @@ export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
* @type integer * @type integer
* @minimum 0 * @minimum 0
*/ */
expires_at: number; expiresAt: number;
/** /**
* @type string * @type string
*/ */
@@ -2173,6 +2173,11 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @format date-time * @format date-time
*/ */
createdAt?: Date; createdAt?: Date;
/**
* @type string
* @format date-time
*/
deletedAt: Date;
/** /**
* @type string * @type string
*/ */
@@ -2188,7 +2193,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
/** /**
* @type string * @type string
*/ */
orgID: string; orgId: string;
/** /**
* @type array * @type array
*/ */
@@ -2209,7 +2214,7 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
* @type integer * @type integer
* @minimum 0 * @minimum 0
*/ */
expires_at: number; expiresAt: number;
/** /**
* @type string * @type string
*/ */
@@ -2525,6 +2530,13 @@ export interface TypesPostableAcceptInviteDTO {
token?: string; token?: string;
} }
export interface TypesPostableBulkInviteRequestDTO {
/**
* @type array
*/
invites: TypesPostableInviteDTO[];
}
export interface TypesPostableForgotPasswordDTO { export interface TypesPostableForgotPasswordDTO {
/** /**
* @type string * @type string
@@ -2665,6 +2677,10 @@ export interface TypesUserDTO {
* @type string * @type string
*/ */
role?: string; role?: string;
/**
* @type string
*/
status?: string;
/** /**
* @type string * @type string
* @format date-time * @format date-time

View File

@@ -41,6 +41,7 @@ import type {
TypesChangePasswordRequestDTO, TypesChangePasswordRequestDTO,
TypesPostableAcceptInviteDTO, TypesPostableAcceptInviteDTO,
TypesPostableAPIKeyDTO, TypesPostableAPIKeyDTO,
TypesPostableBulkInviteRequestDTO,
TypesPostableForgotPasswordDTO, TypesPostableForgotPasswordDTO,
TypesPostableInviteDTO, TypesPostableInviteDTO,
TypesPostableResetPasswordDTO, TypesPostableResetPasswordDTO,
@@ -671,14 +672,14 @@ export const useAcceptInvite = <
* @summary Create bulk invite * @summary Create bulk invite
*/ */
export const createBulkInvite = ( export const createBulkInvite = (
typesPostableInviteDTO: BodyType<TypesPostableInviteDTO[]>, typesPostableBulkInviteRequestDTO: BodyType<TypesPostableBulkInviteRequestDTO>,
signal?: AbortSignal, signal?: AbortSignal,
) => { ) => {
return GeneratedAPIInstance<void>({ return GeneratedAPIInstance<void>({
url: `/api/v1/invite/bulk`, url: `/api/v1/invite/bulk`,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
data: typesPostableInviteDTO, data: typesPostableBulkInviteRequestDTO,
signal, signal,
}); });
}; };
@@ -690,13 +691,13 @@ export const getCreateBulkInviteMutationOptions = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>, Awaited<ReturnType<typeof createBulkInvite>>,
TError, TError,
{ data: BodyType<TypesPostableInviteDTO[]> }, { data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext TContext
>; >;
}): UseMutationOptions< }): UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>, Awaited<ReturnType<typeof createBulkInvite>>,
TError, TError,
{ data: BodyType<TypesPostableInviteDTO[]> }, { data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext TContext
> => { > => {
const mutationKey = ['createBulkInvite']; const mutationKey = ['createBulkInvite'];
@@ -710,7 +711,7 @@ export const getCreateBulkInviteMutationOptions = <
const mutationFn: MutationFunction< const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createBulkInvite>>, Awaited<ReturnType<typeof createBulkInvite>>,
{ data: BodyType<TypesPostableInviteDTO[]> } { data: BodyType<TypesPostableBulkInviteRequestDTO> }
> = (props) => { > = (props) => {
const { data } = props ?? {}; const { data } = props ?? {};
@@ -723,7 +724,7 @@ export const getCreateBulkInviteMutationOptions = <
export type CreateBulkInviteMutationResult = NonNullable< export type CreateBulkInviteMutationResult = NonNullable<
Awaited<ReturnType<typeof createBulkInvite>> Awaited<ReturnType<typeof createBulkInvite>>
>; >;
export type CreateBulkInviteMutationBody = BodyType<TypesPostableInviteDTO[]>; export type CreateBulkInviteMutationBody = BodyType<TypesPostableBulkInviteRequestDTO>;
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>; export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/** /**
@@ -736,13 +737,13 @@ export const useCreateBulkInvite = <
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>, Awaited<ReturnType<typeof createBulkInvite>>,
TError, TError,
{ data: BodyType<TypesPostableInviteDTO[]> }, { data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext TContext
>; >;
}): UseMutationResult< }): UseMutationResult<
Awaited<ReturnType<typeof createBulkInvite>>, Awaited<ReturnType<typeof createBulkInvite>>,
TError, TError,
{ data: BodyType<TypesPostableInviteDTO[]> }, { data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext TContext
> => { > => {
const mutationOptions = getCreateBulkInviteMutationOptions(options); const mutationOptions = getCreateBulkInviteMutationOptions(options);

View File

@@ -94,19 +94,13 @@ export const interceptorRejected = async (
afterLogin(response.data.accessToken, response.data.refreshToken, true); afterLogin(response.data.accessToken, response.data.refreshToken, true);
try { try {
const reResponse = await axios( const reResponse = await axios({
`${value.config.baseURL}${value.config.url?.substring(1)}`, ...value.config,
{ headers: {
method: value.config.method, ...value.config.headers,
headers: { Authorization: `Bearer ${response.data.accessToken}`,
...value.config.headers,
Authorization: `Bearer ${response.data.accessToken}`,
},
data: {
...JSON.parse(value.config.data || '{}'),
},
}, },
); });
return await Promise.resolve(reResponse); return await Promise.resolve(reResponse);
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,31 @@
.custom-time-picker { .custom-time-picker {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center;
gap: 4px;
.zoom-out-btn {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--foreground);
border: 1px solid var(--border);
border-radius: 2px;
box-shadow: none;
padding: 10px;
height: 33px;
&:hover:not(:disabled) {
color: var(--bg-vanilla-100);
background: var(--primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.timeSelection-input { .timeSelection-input {
&:hover { &:hover {

View File

@@ -16,6 +16,15 @@ jest.mock('react-router-dom', () => {
}; };
}); });
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn(() => jest.fn()),
useSelector: jest.fn(() => ({
minTime: 0,
maxTime: Date.now(),
})),
}));
jest.mock('providers/Timezone', () => { jest.mock('providers/Timezone', () => {
const actual = jest.requireActual('providers/Timezone'); const actual = jest.requireActual('providers/Timezone');

View File

@@ -7,9 +7,11 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Input, InputRef, Popover, Tooltip } from 'antd'; import { Input, InputRef, Popover, Tooltip } from 'antd';
import cx from 'classnames'; import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats'; import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { QueryParams } from 'constants/query';
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal'; import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
import { import {
FixedDurationSuggestionOptions, FixedDurationSuggestionOptions,
@@ -17,9 +19,11 @@ import {
RelativeDurationSuggestionOptions, RelativeDurationSuggestionOptions,
} from 'container/TopNav/DateTimeSelectionV2/constants'; } from 'container/TopNav/DateTimeSelectionV2/constants';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useZoomOut } from 'hooks/useZoomOut';
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax'; import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import { isZoomOutDisabled } from 'lib/zoomOutUtils';
import { defaultTo, isFunction, noop } from 'lodash-es'; import { defaultTo, isFunction, noop } from 'lodash-es';
import { ChevronDown, ChevronUp } from 'lucide-react'; import { ChevronDown, ChevronUp, ZoomOut } from 'lucide-react';
import { useTimezone } from 'providers/Timezone'; import { useTimezone } from 'providers/Timezone';
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils'; import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
import { popupContainer } from 'utils/selectPopupContainer'; import { popupContainer } from 'utils/selectPopupContainer';
@@ -66,6 +70,8 @@ interface CustomTimePickerProps {
showRecentlyUsed?: boolean; showRecentlyUsed?: boolean;
minTime: number; minTime: number;
maxTime: number; maxTime: number;
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
isModalTimeSelection?: boolean;
} }
function CustomTimePicker({ function CustomTimePicker({
@@ -88,6 +94,7 @@ function CustomTimePicker({
showRecentlyUsed = true, showRecentlyUsed = true,
minTime, minTime,
maxTime, maxTime,
isModalTimeSelection = false,
}: CustomTimePickerProps): JSX.Element { }: CustomTimePickerProps): JSX.Element {
const [ const [
selectedTimePlaceholderValue, selectedTimePlaceholderValue,
@@ -116,6 +123,14 @@ function CustomTimePicker({
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false); const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
const durationMs = (maxTime - minTime) / 1e6;
const zoomOutDisabled = showLiveLogs || isZoomOutDisabled(durationMs);
const handleZoomOut = useZoomOut({
isDisabled: zoomOutDisabled,
urlParamsToDelete: [QueryParams.activeLogId],
});
// function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format // function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks // 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
const getSelectedTimeRangeLabelInRelativeFormat = ( const getSelectedTimeRangeLabelInRelativeFormat = (
@@ -282,7 +297,12 @@ function CustomTimePicker({
resetErrorStatus(); resetErrorStatus();
}; };
const handleInputPressEnter = (): void => { const handleInputPressEnter = (
event?: React.KeyboardEvent<HTMLInputElement>,
): void => {
event?.preventDefault();
event?.stopPropagation();
// check if the entered time is in the format of 1m, 2h, 3d, 4w // check if the entered time is in the format of 1m, 2h, 3d, 4w
const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue); const isTimeDurationShortHandFormat = /^(\d+)([mhdw])$/.test(inputValue);
@@ -631,6 +651,23 @@ function CustomTimePicker({
/> />
</Popover> </Popover>
</Tooltip> </Tooltip>
{!showLiveLogs && !isModalTimeSelection && (
<Tooltip
title={
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
}
>
<span>
<Button
className="zoom-out-btn"
onClick={handleZoomOut}
disabled={zoomOutDisabled}
data-testid="zoom-out-btn"
prefixIcon={<ZoomOut size={14} />}
/>
</span>
</Tooltip>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,169 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryParams } from 'constants/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import CustomTimePicker from '../CustomTimePicker';
const MS_PER_MIN = 60 * 1000;
const NOW_MS = 1705312800000;
const mockDispatch = jest.fn();
const mockSafeNavigate = jest.fn();
const mockUrlQueryDelete = jest.fn();
const mockUrlQuerySet = jest.fn();
interface MockAppState {
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
}
jest.mock('react-redux', () => ({
useDispatch: (): jest.Mock => mockDispatch,
useSelector: (selector: (state: MockAppState) => unknown): unknown => {
const mockState: MockAppState = {
globalTime: {
minTime: (NOW_MS - 15 * MS_PER_MIN) * 1e6,
maxTime: NOW_MS * 1e6,
},
};
return selector(mockState);
},
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: mockSafeNavigate,
}),
}));
interface MockUrlQuery {
delete: typeof mockUrlQueryDelete;
set: typeof mockUrlQuerySet;
get: () => null;
toString: () => string;
}
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): MockUrlQuery => ({
delete: mockUrlQueryDelete,
set: mockUrlQuerySet,
get: (): null => null,
toString: (): string => 'relativeTime=45m',
}),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): { timezone: { value: string; offset: string } } => ({
timezone: { value: 'UTC', offset: 'UTC' },
}),
}));
jest.mock('react-router-dom', () => ({
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
}));
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const now = Date.now();
const defaultProps = {
onSelect: jest.fn(),
onError: jest.fn(),
selectedValue: '15m',
selectedTime: '15m',
onValidCustomDateChange: jest.fn(),
open: false,
setOpen: jest.fn(),
items: [
{ value: '15m', label: 'Last 15 minutes' },
{ value: '1h', label: 'Last 1 hour' },
],
minTime: (now - 15 * 60 * 1000) * 1e6,
maxTime: now * 1e6,
};
describe('CustomTimePicker - zoom out button', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should render zoom out button when showLiveLogs is false', () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
expect(screen.getByTestId('zoom-out-btn')).toBeInTheDocument();
});
it('should not render zoom out button when showLiveLogs is true', () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={true} />);
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
});
it('should not render zoom out button when isModalTimeSelection is true', () => {
render(
<CustomTimePicker
{...defaultProps}
showLiveLogs={false}
isModalTimeSelection={true}
/>,
);
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
});
it('should call handleZoomOut when zoom out button is clicked', async () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
await userEvent.click(zoomOutBtn);
expect(mockDispatch).toHaveBeenCalled();
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
expect(mockSafeNavigate).toHaveBeenCalledWith(
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
);
});
it('should use real ladder logic: 15m range zooms to 45m preset and updates URL', async () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
await userEvent.click(zoomOutBtn);
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
expect(mockSafeNavigate).toHaveBeenCalledWith(
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
);
expect(mockDispatch).toHaveBeenCalled();
});
it('should delete activeLogId when zoom out is clicked', async () => {
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
await userEvent.click(zoomOutBtn);
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
});
it('should disable zoom button when time range is >= 1 month', () => {
const now = Date.now();
render(
<CustomTimePicker
{...defaultProps}
minTime={(now - 31 * MS_PER_DAY) * 1e6}
maxTime={now * 1e6}
showLiveLogs={false}
/>,
);
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
expect(zoomOutBtn).toBeDisabled();
});
});

View File

@@ -14,6 +14,7 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder'; import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import HavingFilter from './HavingFilter/HavingFilter'; import HavingFilter from './HavingFilter/HavingFilter';
import { buildDefaultLegendFromGroupBy } from './utils';
import './QueryAddOns.styles.scss'; import './QueryAddOns.styles.scss';
@@ -250,12 +251,33 @@ function QueryAddOns({
}, [panelType, isListViewPanel, query, showReduceTo]); }, [panelType, isListViewPanel, query, showReduceTo]);
const handleOptionClick = (e: RadioChangeEvent): void => { const handleOptionClick = (e: RadioChangeEvent): void => {
if (selectedViews.find((view) => view.key === e.target.value.key)) { const clickedAddOn = e.target.value as AddOn;
setSelectedViews( const isAlreadySelected = selectedViews.some(
selectedViews.filter((view) => view.key !== e.target.value.key), (view) => view.key === clickedAddOn.key,
);
if (isAlreadySelected) {
setSelectedViews((prev) =>
prev.filter((view) => view.key !== clickedAddOn.key),
); );
} else { } else {
setSelectedViews([...selectedViews, e.target.value]); // When enabling Legend format for the first time with an empty legend
// and existing group-by keys, prefill the legend using all group-by keys.
// This keeps existing custom legends intact and only helps seed a sensible default.
if (
clickedAddOn.key === ADD_ONS_KEYS.LEGEND_FORMAT &&
isEmpty(query?.legend) &&
Array.isArray(query.groupBy) &&
query.groupBy.length > 0
) {
const defaultLegend = buildDefaultLegendFromGroupBy(query.groupBy);
if (defaultLegend) {
handleChangeQueryLegend(defaultLegend);
}
}
setSelectedViews((prev) => [...prev, clickedAddOn]);
} }
}; };
@@ -288,12 +310,9 @@ function QueryAddOns({
[handleSetQueryData, index, query], [handleSetQueryData, index, query],
); );
const handleRemoveView = useCallback( const handleRemoveView = useCallback((key: string): void => {
(key: string): void => { setSelectedViews((prev) => prev.filter((view) => view.key !== key));
setSelectedViews(selectedViews.filter((view) => view.key !== key)); }, []);
},
[selectedViews],
);
const handleChangeQueryLegend = useCallback( const handleChangeQueryLegend = useCallback(
(value: string) => { (value: string) => {
@@ -379,8 +398,8 @@ function QueryAddOns({
<div className="input"> <div className="input">
<HavingFilter <HavingFilter
onClose={(): void => { onClose={(): void => {
setSelectedViews( setSelectedViews((prev) =>
selectedViews.filter((view) => view.key !== 'having'), prev.filter((view) => view.key !== 'having'),
); );
}} }}
onChange={handleChangeHaving} onChange={handleChangeHaving}
@@ -399,7 +418,9 @@ function QueryAddOns({
initialValue={query?.limit ?? undefined} initialValue={query?.limit ?? undefined}
placeholder="Enter limit" placeholder="Enter limit"
onClose={(): void => { onClose={(): void => {
setSelectedViews(selectedViews.filter((view) => view.key !== 'limit')); setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'limit'),
);
}} }}
closeIcon={<ChevronUp size={16} />} closeIcon={<ChevronUp size={16} />}
/> />
@@ -482,8 +503,8 @@ function QueryAddOns({
onChange={handleChangeQueryLegend} onChange={handleChangeQueryLegend}
initialValue={isEmpty(query?.legend) ? undefined : query?.legend} initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
onClose={(): void => { onClose={(): void => {
setSelectedViews( setSelectedViews((prev) =>
selectedViews.filter((view) => view.key !== 'legend_format'), prev.filter((view) => view.key !== 'legend_format'),
); );
}} }}
closeIcon={<ChevronUp size={16} />} closeIcon={<ChevronUp size={16} />}

View File

@@ -0,0 +1,16 @@
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export const buildDefaultLegendFromGroupBy = (
groupBy: IBuilderQuery['groupBy'],
): string | null => {
const segments = groupBy
.map((item) => item?.key)
.filter((key): key is string => Boolean(key))
.map((key) => `${key} = {{${key}}}`);
if (segments.length === 0) {
return null;
}
return segments.join(', ');
};

View File

@@ -275,4 +275,59 @@ describe('QueryAddOns', () => {
}); });
}); });
}); });
it('auto-generates legend from all groupBy keys when enabling Legend format with empty legend', async () => {
const user = userEvent.setup();
const query = baseQuery({
groupBy: [{ key: 'service.name' }, { key: 'operation' }],
});
render(
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
const legendTab = screen.getByTestId('query-add-on-legend_format');
await user.click(legendTab);
expect(mockHandleChangeQueryData).toHaveBeenCalledWith(
'legend',
'service.name = {{service.name}}, operation = {{operation}}',
);
});
it('does not override existing legend when enabling Legend format', async () => {
const user = userEvent.setup();
const query = baseQuery({
legend: 'existing legend',
groupBy: [{ key: 'service.name' }],
});
render(
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
const legendTab = screen.getByTestId('query-add-on-legend_format');
await user.click(legendTab);
expect(mockHandleChangeQueryData).not.toHaveBeenCalledWith(
'legend',
expect.anything(),
);
});
}); });

View File

@@ -361,6 +361,58 @@ describe('CheckboxFilter - User Flows', () => {
expect(filtersForServiceName).toHaveLength(0); expect(filtersForServiceName).toHaveLength(0);
}); });
it('should match filter when query uses resource. prefix (resource.service.name matches service.name)', async () => {
// Filter config uses unprefixed key (service.name)
// Query has filter with resource. prefix (resource.service.name)
// Checkbox should recognize the match and show checked state
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: 'resource.service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: [OTEL_DEMO],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData: jest.fn(),
} as any);
const mockFilter = createMockFilter({ defaultOpen: false });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Filter should auto-open because it has active filters (key match via prefix stripping)
await waitFor(() => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
// otel-demo should be checked (filter uses resource.service.name IN [otel-demo])
// Checked items are sorted to the top, so otel-demo is first
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes[0]).toBeChecked();
expect(screen.getByText(OTEL_DEMO)).toBeInTheDocument();
});
it('should extend an existing IN filter when checking an additional value', async () => { it('should extend an existing IN filter when checking an additional value', async () => {
const redirectWithQueryBuilderData = jest.fn(); const redirectWithQueryBuilderData = jest.fn();

View File

@@ -18,7 +18,7 @@ import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues'
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions'; import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
import useDebouncedFn from 'hooks/useDebouncedFunction'; import useDebouncedFn from 'hooks/useDebouncedFunction';
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es'; import { cloneDeep, isArray, isFunction } from 'lodash-es';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData'; import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
@@ -26,6 +26,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState'; import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import { isKeyMatch } from './utils';
import './Checkbox.styles.scss'; import './Checkbox.styles.scss';
@@ -84,7 +85,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
currentQuery.builder.queryData?.[ currentQuery.builder.queryData?.[
activeQueryIndex activeQueryIndex
]?.filters?.items?.some((item) => ]?.filters?.items?.some((item) =>
isEqual(item.key?.key, filter.attributeKey.key), isKeyMatch(item.key?.key, filter.attributeKey.key),
), ),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key], [currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
); );
@@ -189,7 +190,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
const filterSync = currentQuery?.builder.queryData?.[ const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex activeQueryIndex
]?.filters?.items.find((item) => ]?.filters?.items.find((item) =>
isEqual(item.key?.key, filter.attributeKey.key), isKeyMatch(item.key?.key, filter.attributeKey.key),
); );
if (filterSync) { if (filterSync) {
@@ -236,7 +237,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
(currentQuery?.builder?.queryData?.[ (currentQuery?.builder?.queryData?.[
activeQueryIndex activeQueryIndex
]?.filters?.items?.filter((item) => ]?.filters?.items?.filter((item) =>
isEqual(item.key?.key, filter.attributeKey.key), isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1, )?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey], [currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
@@ -280,7 +281,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
items: items:
idx === activeQueryIndex idx === activeQueryIndex
? item.filters?.items?.filter( ? item.filters?.items?.filter(
(fil) => !isEqual(fil.key?.key, filter.attributeKey.key), (fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
) || [] ) || []
: [...(item.filters?.items || [])], : [...(item.filters?.items || [])],
op: item.filters?.op || 'AND', op: item.filters?.op || 'AND',
@@ -313,7 +314,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
: 'Only' : 'Only'
: 'Only'; : 'Only';
query.filters.items = query.filters.items.filter( query.filters.items = query.filters.items.filter(
(q) => !isEqual(q.key?.key, filter.attributeKey.key), (q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
); );
if (query.filter?.expression) { if (query.filter?.expression) {
@@ -335,13 +336,13 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
} else if (query?.filters?.items) { } else if (query?.filters?.items) {
if ( if (
query.filters?.items?.some((item) => query.filters?.items?.some((item) =>
isEqual(item.key?.key, filter.attributeKey.key), isKeyMatch(item.key?.key, filter.attributeKey.key),
) )
) { ) {
// if there is already a running filter for the current attribute key then // if there is already a running filter for the current attribute key then
// we split the cases by which particular operator is present right now! // we split the cases by which particular operator is present right now!
const currentFilter = query.filters?.items?.find((q) => const currentFilter = query.filters?.items?.find((q) =>
isEqual(q.key?.key, filter.attributeKey.key), isKeyMatch(q.key?.key, filter.attributeKey.key),
); );
if (currentFilter) { if (currentFilter) {
const runningOperator = currentFilter?.op; const runningOperator = currentFilter?.op;
@@ -356,7 +357,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
value: [...currentFilter.value, value], value: [...currentFilter.value, value],
}; };
query.filters.items = query.filters.items.map((item) => { query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) { if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter; return newFilter;
} }
return item; return item;
@@ -368,7 +369,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
value: [currentFilter.value as string, value], value: [currentFilter.value as string, value],
}; };
query.filters.items = query.filters.items.map((item) => { query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) { if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter; return newFilter;
} }
return item; return item;
@@ -385,11 +386,11 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
if (newFilter.value.length === 0) { if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter( query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key), (item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
); );
} else { } else {
query.filters.items = query.filters.items.map((item) => { query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) { if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter; return newFilter;
} }
return item; return item;
@@ -398,7 +399,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
} else { } else {
// if not an array remove the whole thing altogether! // if not an array remove the whole thing altogether!
query.filters.items = query.filters.items.filter( query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key), (item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
); );
} }
} }
@@ -414,7 +415,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
value: [...currentFilter.value, value], value: [...currentFilter.value, value],
}; };
query.filters.items = query.filters.items.map((item) => { query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) { if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter; return newFilter;
} }
return item; return item;
@@ -426,7 +427,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
value: [currentFilter.value as string, value], value: [currentFilter.value as string, value],
}; };
query.filters.items = query.filters.items.map((item) => { query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) { if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter; return newFilter;
} }
return item; return item;
@@ -441,7 +442,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
}; };
if (newFilter.value.length === 0) { if (newFilter.value.length === 0) {
query.filters.items = query.filters.items.filter( query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key), (item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
); );
if (query.filter?.expression) { if (query.filter?.expression) {
query.filter.expression = removeKeysFromExpression( query.filter.expression = removeKeysFromExpression(
@@ -451,7 +452,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
} }
} else { } else {
query.filters.items = query.filters.items.map((item) => { query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) { if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter; return newFilter;
} }
return item; return item;
@@ -469,7 +470,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
); );
} }
query.filters.items = query.filters.items.filter( query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key), (item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
); );
} }
} }
@@ -482,14 +483,14 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
value: [currentFilter.value as string, value], value: [currentFilter.value as string, value],
}; };
query.filters.items = query.filters.items.map((item) => { query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) { if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter; return newFilter;
} }
return item; return item;
}); });
} else if (!checked) { } else if (!checked) {
query.filters.items = query.filters.items.filter( query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key), (item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
); );
} }
break; break;
@@ -501,14 +502,14 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
value: [currentFilter.value as string, value], value: [currentFilter.value as string, value],
}; };
query.filters.items = query.filters.items.map((item) => { query.filters.items = query.filters.items.map((item) => {
if (isEqual(item.key?.key, filter.attributeKey.key)) { if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
return newFilter; return newFilter;
} }
return item; return item;
}); });
} else if (checked) { } else if (checked) {
query.filters.items = query.filters.items.filter( query.filters.items = query.filters.items.filter(
(item) => !isEqual(item.key?.key, filter.attributeKey.key), (item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
); );
} }
break; break;

View File

@@ -0,0 +1,41 @@
/**
* These prefixes are added to attribute keys based on their context.
*/
export const FIELD_CONTEXT_PREFIXES = [
'metric',
'log',
'span',
'trace',
'resource',
'scope',
'attribute',
'event',
'body',
];
/**
* Removes the field context prefix from a key to get the base key name.
* Example: 'resource.service.name' -> 'service.name'
* Example: 'attribute.http.method' -> 'http.method'
*/
export function getKeyWithoutPrefix(key: string | undefined): string {
if (!key) {
return '';
}
const prefixPattern = new RegExp(
`^(${FIELD_CONTEXT_PREFIXES.join('|')})\\.`,
'i',
);
return key.replace(prefixPattern, '').trim();
}
/**
* Compares two keys by their base name (without prefix).
* This ensures that 'service.name' matches 'resource.service.name'
*/
export function isKeyMatch(
itemKey: string | undefined,
filterKey: string | undefined,
): boolean {
return getKeyWithoutPrefix(itemKey) === getKeyWithoutPrefix(filterKey);
}

View File

@@ -55,4 +55,5 @@ export enum QueryParams {
source = 'source', source = 'source',
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage', showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
isTestAlert = 'isTestAlert', isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
} }

View File

@@ -0,0 +1,3 @@
export const DASHBOARD_CACHE_TIME = 30_000;
// keep it low or zero, otherwise, when enabled auto-refresh, this causes OOM due to accumulated queries in cache
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;

View File

@@ -96,4 +96,7 @@ export const REACT_QUERY_KEY = {
// Span Percentiles Query Keys // Span Percentiles Query Keys
GET_SPAN_PERCENTILES: 'GET_SPAN_PERCENTILES', GET_SPAN_PERCENTILES: 'GET_SPAN_PERCENTILES',
// Dashboard Grid Card Query Keys
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
} as const; } as const;

View File

@@ -16,8 +16,8 @@ export const PagerInitialConfig: Partial<PagerChannel> = {
client: 'SigNoz Alert Manager', client: 'SigNoz Alert Manager',
client_url: 'https://enter-signoz-host-n-port-here/alerts', client_url: 'https://enter-signoz-host-n-port-here/alerts',
details: JSON.stringify({ details: JSON.stringify({
firing: `{{ template "pagerduty.default.instances" .Alerts.Firing }}`, firing: `{{ .Alerts.Firing | toJson }}`,
resolved: `{{ template "pagerduty.default.instances" .Alerts.Resolved }}`, resolved: `{{ .Alerts.Resolved | toJson }}`,
num_firing: '{{ .Alerts.Firing | len }}', num_firing: '{{ .Alerts.Firing | len }}',
num_resolved: '{{ .Alerts.Resolved | len }}', num_resolved: '{{ .Alerts.Resolved | len }}',
}), }),

View File

@@ -30,14 +30,15 @@ export default function CustomDomainEditModal({
onClearError, onClearError,
onSubmit, onSubmit,
}: CustomDomainEditModalProps): JSX.Element { }: CustomDomainEditModalProps): JSX.Element {
const [value, setValue] = useState(customDomainSubdomain ?? ''); const initialSubdomain = customDomainSubdomain ?? '';
const [value, setValue] = useState(initialSubdomain);
const [validationError, setValidationError] = useState<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setValue(customDomainSubdomain ?? ''); setValue(initialSubdomain);
} }
}, [isOpen, customDomainSubdomain]); }, [isOpen, initialSubdomain]);
const handleClose = (): void => { const handleClose = (): void => {
setValidationError(null); setValidationError(null);
@@ -58,6 +59,11 @@ export default function CustomDomainEditModal({
}; };
const handleSubmit = (): void => { const handleSubmit = (): void => {
if (value === initialSubdomain) {
setValidationError('Input is unchanged');
return;
}
if (!value) { if (!value) {
setValidationError('This field is required'); setValidationError('This field is required');
return; return;
@@ -84,7 +90,7 @@ export default function CustomDomainEditModal({
const hasError = Boolean(errorMessage); const hasError = Boolean(errorMessage);
const statusIcon = ((): JSX.Element => { const statusIcon = ((): JSX.Element | null => {
if (isLoading) { if (isLoading) {
return ( return (
<LoaderCircle size={16} className="animate-spin edit-modal-status-icon" /> <LoaderCircle size={16} className="animate-spin edit-modal-status-icon" />
@@ -95,7 +101,9 @@ export default function CustomDomainEditModal({
return <CircleAlert size={16} color={Color.BG_CHERRY_500} />; return <CircleAlert size={16} color={Color.BG_CHERRY_500} />;
} }
return <CircleCheck size={16} color={Color.BG_FOREST_500} />; return value && value.length >= 3 ? (
<CircleCheck size={16} color={Color.BG_FOREST_500} />
) : null;
})(); })();
return ( return (
@@ -189,7 +197,7 @@ export default function CustomDomainEditModal({
color="primary" color="primary"
className="edit-modal-apply-btn" className="edit-modal-apply-btn"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isLoading} disabled={isLoading || value === initialSubdomain}
loading={isLoading} loading={isLoading}
> >
Apply Changes Apply Changes

View File

@@ -81,6 +81,10 @@
padding-left: 26px; padding-left: 26px;
} }
.custom-domain-card-meta-row.workspace-name-hidden {
padding-left: 0;
}
.custom-domain-card-meta-timezone { .custom-domain-card-meta-timezone {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -117,32 +121,6 @@
background: var(--l2-border); background: var(--l2-border);
margin: 0; margin: 0;
} }
.custom-domain-card-bottom {
display: flex;
align-items: center;
gap: var(--spacing-5);
padding: var(--padding-3);
}
.custom-domain-card-license {
color: var(--l1-foreground);
font-size: var(--paragraph-base-400-font-size);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
.custom-domain-plan-badge {
display: inline-flex;
align-items: center;
padding: 0 2px;
border-radius: 2px;
background: var(--l2-background);
color: var(--l2-foreground);
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: var(--paragraph-base-400-font-size);
line-height: var(--line-height-20);
}
} }
.workspace-url-trigger { .workspace-url-trigger {

View File

@@ -69,8 +69,9 @@ function DomainUpdateToast({
} }
export default function CustomDomainSettings(): JSX.Element { export default function CustomDomainSettings(): JSX.Element {
const { org, activeLicense } = useAppContext(); const { org } = useAppContext();
const { timezone } = useTimezone(); const { timezone } = useTimezone();
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isPollingEnabled, setIsPollingEnabled] = useState(false); const [isPollingEnabled, setIsPollingEnabled] = useState(false);
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null); const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
@@ -175,7 +176,8 @@ export default function CustomDomainSettings(): JSX.Element {
[hosts, activeHost], [hosts, activeHost],
); );
const planName = activeLicense?.plan?.name; const workspaceName =
org?.[0]?.displayName || customDomainSubdomain || activeHost?.name;
if (isLoadingHosts) { if (isLoadingHosts) {
return ( return (
@@ -191,106 +193,98 @@ export default function CustomDomainSettings(): JSX.Element {
return ( return (
<> <>
<div className="custom-domain-card"> <div className="custom-domain-card-top">
<div className="custom-domain-card-top"> <div className="custom-domain-card-info">
<div className="custom-domain-card-info"> {!!workspaceName && (
<div className="custom-domain-card-name-row"> <div className="custom-domain-card-name-row">
<span className="beacon" /> <span className="beacon" />
<span className="custom-domain-card-org-name"> <span className="custom-domain-card-org-name">{workspaceName}</span>
{org?.[0]?.displayName ? org?.[0]?.displayName : customDomainSubdomain}
</span>
</div> </div>
)}
<div className="custom-domain-card-meta-row"> <div
<Dropdown className={`custom-domain-card-meta-row ${
trigger={['click']} !workspaceName ? 'workspace-name-hidden' : ''
dropdownRender={(): JSX.Element => ( }`}
<div className="workspace-url-dropdown">
<span className="workspace-url-dropdown-header">
All Workspace URLs
</span>
<div className="workspace-url-dropdown-divider" />
{sortedHosts.map((host) => {
const isActive = host.name === activeHost?.name;
return (
<a
key={host.name}
href={host.url}
target="_blank"
rel="noopener noreferrer"
className={`workspace-url-dropdown-item${
isActive ? ' workspace-url-dropdown-item--active' : ''
}`}
>
<span className="workspace-url-dropdown-item-label">
{stripProtocol(host.url ?? '')}
</span>
{isActive ? (
<Check size={14} className="workspace-url-dropdown-item-check" />
) : (
<ExternalLink
size={12}
className="workspace-url-dropdown-item-external"
/>
)}
</a>
);
})}
</div>
)}
>
<Button
type="button"
size="xs"
className="workspace-url-trigger"
disabled={isFetchingHosts}
>
<Link2 size={12} />
<span>{stripProtocol(activeHost?.url ?? '')}</span>
<ChevronDown size={12} />
</Button>
</Dropdown>
<span className="custom-domain-card-meta-timezone">
<Clock size={11} />
{timezone.offset}
</span>
</div>
</div>
<Button
variant="solid"
size="sm"
className="custom-domain-edit-button"
prefixIcon={<FilePenLine size={12} />}
disabled={isFetchingHosts || isPollingEnabled}
onClick={(): void => setIsEditModalOpen(true)}
> >
Edit workspace link <Dropdown
</Button> trigger={['click']}
dropdownRender={(): JSX.Element => (
<div className="workspace-url-dropdown">
<span className="workspace-url-dropdown-header">
All Workspace URLs
</span>
<div className="workspace-url-dropdown-divider" />
{sortedHosts.map((host) => {
const isActive = host.name === activeHost?.name;
return (
<a
key={host.name}
href={host.url}
target="_blank"
rel="noopener noreferrer"
className={`workspace-url-dropdown-item${
isActive ? ' workspace-url-dropdown-item--active' : ''
}`}
>
<span className="workspace-url-dropdown-item-label">
{stripProtocol(host.url ?? '')}
</span>
{isActive ? (
<Check size={14} className="workspace-url-dropdown-item-check" />
) : (
<ExternalLink
size={12}
className="workspace-url-dropdown-item-external"
/>
)}
</a>
);
})}
</div>
)}
>
<Button
type="button"
size="xs"
className="workspace-url-trigger"
disabled={isFetchingHosts}
>
<Link2 size={12} />
<span>{stripProtocol(activeHost?.url ?? '')}</span>
<ChevronDown size={12} />
</Button>
</Dropdown>
<span className="custom-domain-card-meta-timezone">
<Clock size={11} />
{timezone.offset}
</span>
</div>
</div> </div>
{isPollingEnabled && ( <Button
<Callout variant="solid"
type="info" size="sm"
showIcon className="custom-domain-edit-button"
className="custom-domain-callout" prefixIcon={<FilePenLine size={12} />}
size="small" disabled={isFetchingHosts || isPollingEnabled}
icon={<SolidAlertCircle size={13} color="primary" />} onClick={(): void => setIsEditModalOpen(true)}
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`} >
/> Edit workspace link
)} </Button>
<div className="custom-domain-card-divider" />
<div className="custom-domain-card-bottom">
<span className="beacon" />
<span className="custom-domain-card-license">
{planName && <code className="custom-domain-plan-badge">{planName}</code>}{' '}
license is currently active
</span>
</div>
</div> </div>
{isPollingEnabled && (
<Callout
type="info"
showIcon
className="custom-domain-callout"
size="small"
icon={<SolidAlertCircle size={13} color="primary" />}
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
/>
)}
<CustomDomainEditModal <CustomDomainEditModal
isOpen={isEditModalOpen} isOpen={isEditModalOpen}
onClose={(): void => setIsEditModalOpen(false)} onClose={(): void => setIsEditModalOpen(false)}

View File

@@ -239,4 +239,87 @@ describe('CustomDomainSettings', () => {
const { container } = render(toastRenderer('test-id')); const { container } = render(toastRenderer('test-id'));
expect(container).toHaveTextContent(/myteam\.test\.cloud/i); expect(container).toHaveTextContent(/myteam\.test\.cloud/i);
}); });
describe('Workspace Name rendering', () => {
it('renders org displayName when available from appContext', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
render(<CustomDomainSettings />, undefined, {
appContextOverrides: {
org: [{ id: 'xyz', displayName: 'My Org Name', createdAt: 0 }],
},
});
expect(await screen.findByText('My Org Name')).toBeInTheDocument();
});
it('falls back to customDomainSubdomain when org displayName is missing', async () => {
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockHostsResponse)),
),
);
render(<CustomDomainSettings />, undefined, {
appContextOverrides: { org: [] },
});
expect(await screen.findByText('custom-host')).toBeInTheDocument();
});
it('falls back to activeHost.name when neither org name nor custom domain exists', async () => {
const onlyDefaultHostResponse = {
...mockHostsResponse,
data: {
...mockHostsResponse.data,
hosts: mockHostsResponse.data.hosts
? [mockHostsResponse.data.hosts[0]]
: [],
},
};
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(onlyDefaultHostResponse)),
),
);
render(<CustomDomainSettings />, undefined, {
appContextOverrides: { org: [] },
});
// 'accepted-starfish' is the default host's name
expect(await screen.findByText('accepted-starfish')).toBeInTheDocument();
});
it('does not render the card name row if workspaceName is totally falsy', async () => {
const emptyHostsResponse = {
...mockHostsResponse,
data: {
...mockHostsResponse.data,
hosts: [],
},
};
server.use(
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(emptyHostsResponse)),
),
);
const { container } = render(<CustomDomainSettings />, undefined, {
appContextOverrides: { org: [] },
});
await screen.findByRole('button', { name: /edit workspace link/i });
expect(
container.querySelector('.custom-domain-card-name-row'),
).not.toBeInTheDocument();
});
});
}); });

View File

@@ -34,11 +34,6 @@ const mockSafeNavigate = jest.fn();
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useLocation: jest.fn(), useLocation: jest.fn(),
useRouteMatch: jest.fn().mockReturnValue({
params: {
dashboardId: 4,
},
}),
})); }));
jest.mock( jest.mock(
@@ -69,7 +64,7 @@ describe('Dashboard landing page actions header tests', () => {
(useLocation as jest.Mock).mockReturnValue(mockLocation); (useLocation as jest.Mock).mockReturnValue(mockLocation);
const { getByTestId } = render( const { getByTestId } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}> <MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardProvider> <DashboardProvider dashboardId="4">
<DashboardDescription <DashboardDescription
handle={{ handle={{
active: false, active: false,
@@ -110,7 +105,7 @@ describe('Dashboard landing page actions header tests', () => {
); );
const { getByTestId } = render( const { getByTestId } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}> <MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardProvider> <DashboardProvider dashboardId="4">
<DashboardDescription <DashboardDescription
handle={{ handle={{
active: false, active: false,
@@ -149,7 +144,7 @@ describe('Dashboard landing page actions header tests', () => {
const { getByText } = render( const { getByText } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}> <MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardProvider> <DashboardProvider dashboardId="4">
<DashboardDescription <DashboardDescription
handle={{ handle={{
active: false, active: false,
@@ -193,20 +188,15 @@ describe('Dashboard landing page actions header tests', () => {
handleDashboardLockToggle: jest.fn(), handleDashboardLockToggle: jest.fn(),
dashboardResponse: {} as IDashboardContext['dashboardResponse'], dashboardResponse: {} as IDashboardContext['dashboardResponse'],
selectedDashboard: (getDashboardById.data as unknown) as Dashboard, selectedDashboard: (getDashboardById.data as unknown) as Dashboard,
dashboardId: '4',
layouts: [], layouts: [],
panelMap: {}, panelMap: {},
setPanelMap: jest.fn(), setPanelMap: jest.fn(),
setLayouts: jest.fn(), setLayouts: jest.fn(),
setSelectedDashboard: jest.fn(), setSelectedDashboard: jest.fn(),
updatedTimeRef: { current: null }, updatedTimeRef: { current: null },
toScrollWidgetId: '',
setToScrollWidgetId: jest.fn(),
updateLocalStorageDashboardVariables: jest.fn(), updateLocalStorageDashboardVariables: jest.fn(),
dashboardQueryRangeCalled: false, dashboardQueryRangeCalled: false,
setDashboardQueryRangeCalled: jest.fn(), setDashboardQueryRangeCalled: jest.fn(),
selectedRowWidgetId: null,
setSelectedRowWidgetId: jest.fn(),
isDashboardFetching: false, isDashboardFetching: false,
columnWidths: {}, columnWidths: {},
setColumnWidths: jest.fn(), setColumnWidths: jest.fn(),

View File

@@ -78,7 +78,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
isDashboardLocked, isDashboardLocked,
setSelectedDashboard, setSelectedDashboard,
handleToggleDashboardSlider, handleToggleDashboardSlider,
setSelectedRowWidgetId,
handleDashboardLockToggle, handleDashboardLockToggle,
} = useDashboard(); } = useDashboard();
@@ -146,7 +145,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const [addPanelPermission] = useComponentPermission(permissions, userRole); const [addPanelPermission] = useComponentPermission(permissions, userRole);
const onEmptyWidgetHandler = useCallback(() => { const onEmptyWidgetHandler = useCallback(() => {
setSelectedRowWidgetId(null);
handleToggleDashboardSlider(true); handleToggleDashboardSlider(true);
logEvent('Dashboard Detail: Add new panel clicked', { logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: selectedDashboard?.id, dashboardId: selectedDashboard?.id,

View File

@@ -13,6 +13,10 @@ import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils'; import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import SelectVariableInput from './SelectVariableInput'; import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper'; import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { import {
@@ -101,9 +105,10 @@ function DynamicVariableInput({
return dynamicVars || 'no_dynamic_variables'; return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]); }, [existingVariables]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>( const { maxTime, minTime, isAutoRefreshDisabled } = useSelector<
(state) => state.globalTime, AppState,
); GlobalReducer
>((state) => state.globalTime);
const { const {
variableFetchCycleId, variableFetchCycleId,
@@ -232,6 +237,9 @@ function DynamicVariableInput({
!!variableData.dynamicVariablesSource && !!variableData.dynamicVariablesSource &&
!!variableData.dynamicVariablesAttribute && !!variableData.dynamicVariablesAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)), (isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
queryFn: ({ signal }) => queryFn: ({ signal }) =>
getFieldValues( getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry' variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'

View File

@@ -11,6 +11,10 @@ import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query'; import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime'; import { GlobalReducer } from 'types/reducer/globalTime';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import { variablePropsToPayloadVariables } from '../utils'; import { variablePropsToPayloadVariables } from '../utils';
import SelectVariableInput from './SelectVariableInput'; import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper'; import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
@@ -33,9 +37,10 @@ function QueryVariableInput({
); );
const [errorMessage, setErrorMessage] = useState<null | string>(null); const [errorMessage, setErrorMessage] = useState<null | string>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>( const { maxTime, minTime, isAutoRefreshDisabled } = useSelector<
(state) => state.globalTime, AppState,
); GlobalReducer
>((state) => state.globalTime);
const { const {
variableFetchCycleId, variableFetchCycleId,
@@ -197,6 +202,9 @@ function QueryVariableInput({
signal, signal,
), ),
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSuccess: (response) => { onSuccess: (response) => {
getOptions(response.payload); getOptions(response.payload);
settleVariableFetch(variableData.name, 'complete'); settleVariableFetch(variableData.name, 'complete');

View File

@@ -67,17 +67,18 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
const oldVariables = prev?.data.variables; const oldVariables = prev?.data.variables;
// this is added to handle case where we have two different // this is added to handle case where we have two different
// schemas for variable response // schemas for variable response
if (oldVariables?.[id]) { const updatedVariables = { ...oldVariables };
oldVariables[id] = { if (updatedVariables?.[id]) {
...oldVariables[id], updatedVariables[id] = {
...updatedVariables[id],
selectedValue: value, selectedValue: value,
allSelected, allSelected,
haveCustomValuesSelected, haveCustomValuesSelected,
}; };
} }
if (oldVariables?.[name]) { if (updatedVariables?.[name]) {
oldVariables[name] = { updatedVariables[name] = {
...oldVariables[name], ...updatedVariables[name],
selectedValue: value, selectedValue: value,
allSelected, allSelected,
haveCustomValuesSelected, haveCustomValuesSelected,
@@ -87,9 +88,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
...prev, ...prev,
data: { data: {
...prev?.data, ...prev?.data,
variables: { variables: updatedVariables,
...oldVariables,
},
}, },
}; };
} }

View File

@@ -1,9 +1,9 @@
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView'; import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView';
jest.mock('providers/Dashboard/Dashboard'); jest.mock('providers/Dashboard/helpers/scrollToWidgetIdHelper');
type MockHTMLElement = { type MockHTMLElement = {
scrollIntoView: jest.Mock; scrollIntoView: jest.Mock;
@@ -18,25 +18,35 @@ function createMockElement(): MockHTMLElement {
} }
describe('useScrollWidgetIntoView', () => { describe('useScrollWidgetIntoView', () => {
const mockedUseDashboard = useDashboard as jest.MockedFunction< const mockedUseScrollToWidgetIdStore = useScrollToWidgetIdStore as jest.MockedFunction<
typeof useDashboard typeof useScrollToWidgetIdStore
>; >;
let mockElement: MockHTMLElement;
let ref: React.RefObject<HTMLDivElement>;
let setToScrollWidgetId: jest.Mock;
function mockStore(toScrollWidgetId: string): void {
const storeState = { toScrollWidgetId, setToScrollWidgetId };
mockedUseScrollToWidgetIdStore.mockImplementation(
(selector) =>
selector(
(storeState as unknown) as Parameters<typeof selector>[0],
) as ReturnType<typeof useScrollToWidgetIdStore>,
);
}
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockElement = createMockElement();
ref = ({
current: mockElement,
} as unknown) as React.RefObject<HTMLDivElement>;
setToScrollWidgetId = jest.fn();
}); });
it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => { it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => {
const setToScrollWidgetId = jest.fn(); mockStore('widget-id');
const mockElement = createMockElement();
const ref = ({
current: mockElement,
} as unknown) as React.RefObject<HTMLDivElement>;
mockedUseDashboard.mockReturnValue(({
toScrollWidgetId: 'widget-id',
setToScrollWidgetId,
} as unknown) as ReturnType<typeof useDashboard>);
renderHook(() => useScrollWidgetIntoView('widget-id', ref)); renderHook(() => useScrollWidgetIntoView('widget-id', ref));
@@ -49,16 +59,7 @@ describe('useScrollWidgetIntoView', () => {
}); });
it('does nothing when toScrollWidgetId does not match widget id', () => { it('does nothing when toScrollWidgetId does not match widget id', () => {
const setToScrollWidgetId = jest.fn(); mockStore('other-widget');
const mockElement = createMockElement();
const ref = ({
current: mockElement,
} as unknown) as React.RefObject<HTMLDivElement>;
mockedUseDashboard.mockReturnValue(({
toScrollWidgetId: 'other-widget',
setToScrollWidgetId,
} as unknown) as ReturnType<typeof useDashboard>);
renderHook(() => useScrollWidgetIntoView('widget-id', ref)); renderHook(() => useScrollWidgetIntoView('widget-id', ref));

View File

@@ -1,5 +1,5 @@
import { RefObject, useEffect } from 'react'; import { RefObject, useEffect } from 'react';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
/** /**
* Scrolls the given widget container into view when the dashboard * Scrolls the given widget container into view when the dashboard
@@ -11,7 +11,10 @@ export function useScrollWidgetIntoView<T extends HTMLElement>(
widgetId: string, widgetId: string,
widgetContainerRef: RefObject<T>, widgetContainerRef: RefObject<T>,
): void { ): void {
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard(); const toScrollWidgetId = useScrollToWidgetIdStore((s) => s.toScrollWidgetId);
const setToScrollWidgetId = useScrollToWidgetIdStore(
(s) => s.setToScrollWidgetId,
);
useEffect(() => { useEffect(() => {
if (toScrollWidgetId === widgetId) { if (toScrollWidgetId === widgetId) {

View File

@@ -1,12 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types'; import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions'; import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types'; import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu'; import ContextMenu from 'periscope/components/ContextMenu';
import { useTimezone } from 'providers/Timezone'; import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange'; import { getTimeRange } from 'utils/getTimeRange';
@@ -35,8 +33,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone(); const { timezone } = useTimezone();
useScrollWidgetIntoView(widget.id, graphRef);
useEffect((): void => { useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse); const { startTime, endTime } = getTimeRange(queryResponse);
@@ -62,7 +58,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
currentQuery: widget.query, currentQuery: widget.query,
onClick: clickHandlerWithContextMenu, onClick: clickHandlerWithContextMenu,
onDragSelect, onDragSelect,
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps, apiResponse: queryResponse?.data?.payload,
timezone, timezone,
panelMode, panelMode,
minTimeScale: minTimeScale, minTimeScale: minTimeScale,

View File

@@ -11,7 +11,6 @@ import { get } from 'lodash-es';
import { Widgets } from 'types/api/dashboard/getAll'; import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData'; import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { AlignedData } from 'uplot'; import { AlignedData } from 'uplot';
import { PanelMode } from '../types'; import { PanelMode } from '../types';
@@ -44,7 +43,7 @@ export function prepareBarPanelConfig({
currentQuery: Query; currentQuery: Query;
onClick: OnClickPluginOpts['onClick']; onClick: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void; onDragSelect: (startTime: number, endTime: number) => void;
apiResponse: MetricRangePayloadProps; apiResponse?: MetricRangePayloadProps;
timezone: Timezone; timezone: Timezone;
panelMode: PanelMode; panelMode: PanelMode;
minTimeScale?: number; minTimeScale?: number;
@@ -76,13 +75,17 @@ export function prepareBarPanelConfig({
stepInterval: minStepInterval, stepInterval: minStepInterval,
}); });
if (!(apiResponse && apiResponse?.data?.result)) {
// if no data, return the builder without adding any series
return builder;
}
if (widget.stackedBarChart) { if (widget.stackedBarChart) {
const seriesCount = (apiResponse?.data?.result?.length ?? 0) + 1; // +1 for 1-based uPlot series indices const seriesCount = (apiResponse.data.result.length ?? 0) + 1; // +1 for 1-based uPlot series indices
builder.setBands(getInitialStackedBands(seriesCount)); builder.setBands(getInitialStackedBands(seriesCount));
} }
const seriesList: QueryData[] = apiResponse?.data?.result || []; apiResponse.data.result.forEach((series) => {
seriesList.forEach((series) => {
const baseLabelName = getLabelName( const baseLabelName = getLabelName(
series.metric, series.metric,
series.queryName || '', // query series.queryName || '', // query

View File

@@ -1,12 +1,10 @@
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types'; import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions'; import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types'; import { LegendPosition } from 'lib/uPlotV2/components/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types'; import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useTimezone } from 'providers/Timezone'; import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot'; import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram'; import Histogram from '../../charts/Histogram/Histogram';
@@ -33,13 +31,11 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone(); const { timezone } = useTimezone();
useScrollWidgetIntoView(widget.id, graphRef);
const config = useMemo(() => { const config = useMemo(() => {
return prepareHistogramPanelConfig({ return prepareHistogramPanelConfig({
widget, widget,
isDarkMode, isDarkMode,
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps, apiResponse: queryResponse?.data?.payload,
panelMode, panelMode,
}); });
}, [widget, isDarkMode, queryResponse?.data?.payload, panelMode]); }, [widget, isDarkMode, queryResponse?.data?.payload, panelMode]);
@@ -49,7 +45,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
return []; return [];
} }
return prepareHistogramPanelData({ return prepareHistogramPanelData({
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps, apiResponse: queryResponse?.data?.payload,
bucketWidth: widget?.bucketWidth, bucketWidth: widget?.bucketWidth,
bucketCount: widget?.bucketCount, bucketCount: widget?.bucketCount,
mergeAllActiveQueries: widget?.mergeAllActiveQueries, mergeAllActiveQueries: widget?.mergeAllActiveQueries,

View File

@@ -149,7 +149,7 @@ export function prepareHistogramPanelConfig({
isDarkMode, isDarkMode,
}: { }: {
widget: Widgets; widget: Widgets;
apiResponse: MetricRangePayloadProps; apiResponse?: MetricRangePayloadProps;
panelMode: PanelMode; panelMode: PanelMode;
isDarkMode: boolean; isDarkMode: boolean;
}): UPlotConfigBuilder { }): UPlotConfigBuilder {
@@ -204,7 +204,7 @@ export function prepareHistogramPanelConfig({
fillColor: '#4E74F8', fillColor: '#4E74F8',
isDarkMode, isDarkMode,
}); });
} else { } else if (apiResponse && apiResponse?.data?.result) {
apiResponse.data.result.forEach((series) => { apiResponse.data.result.forEach((series) => {
const baseLabelName = getLabelName( const baseLabelName = getLabelName(
series.metric, series.metric,

View File

@@ -2,14 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries'; import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager'; import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu'; import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types'; import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions'; import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types'; import { LegendPosition } from 'lib/uPlotV2/components/types';
import { ContextMenu } from 'periscope/components/ContextMenu'; import { ContextMenu } from 'periscope/components/ContextMenu';
import { useTimezone } from 'providers/Timezone'; import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange'; import { getTimeRange } from 'utils/getTimeRange';
@@ -34,8 +32,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone(); const { timezone } = useTimezone();
useScrollWidgetIntoView(widget.id, graphRef);
useEffect((): void => { useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse); const { startTime, endTime } = getTimeRange(queryResponse);
@@ -68,7 +64,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
currentQuery: widget.query, currentQuery: widget.query,
onClick: clickHandlerWithContextMenu, onClick: clickHandlerWithContextMenu,
onDragSelect, onDragSelect,
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps, apiResponse: queryResponse?.data?.payload,
timezone, timezone,
panelMode, panelMode,
minTimeScale: minTimeScale, minTimeScale: minTimeScale,

View File

@@ -68,11 +68,12 @@ export const prepareUPlotConfig = ({
currentQuery: Query; currentQuery: Query;
onClick?: OnClickPluginOpts['onClick']; onClick?: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void; onDragSelect: (startTime: number, endTime: number) => void;
apiResponse: MetricRangePayloadProps; apiResponse?: MetricRangePayloadProps;
timezone: Timezone; timezone: Timezone;
panelMode: PanelMode; panelMode: PanelMode;
minTimeScale?: number; minTimeScale?: number;
maxTimeScale?: number; maxTimeScale?: number;
// eslint-disable-next-line sonarjs/cognitive-complexity
}): UPlotConfigBuilder => { }): UPlotConfigBuilder => {
const stepIntervals: ExecStats['stepIntervals'] = get( const stepIntervals: ExecStats['stepIntervals'] = get(
apiResponse, apiResponse,
@@ -100,7 +101,12 @@ export const prepareUPlotConfig = ({
stepInterval: minStepInterval, stepInterval: minStepInterval,
}); });
apiResponse.data?.result?.forEach((series) => { if (!(apiResponse && apiResponse.data.result)) {
// if no data, return the builder without adding any series
return builder;
}
apiResponse.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePointForSeries(series); const hasSingleValidPoint = hasSingleVisiblePointForSeries(series);
const baseLabelName = getLabelName( const baseLabelName = getLabelName(
series.metric, series.metric,

View File

@@ -18,7 +18,7 @@ import { PanelMode } from '../types';
export interface BaseConfigBuilderProps { export interface BaseConfigBuilderProps {
id: string; id: string;
thresholds?: ThresholdProps[]; thresholds?: ThresholdProps[];
apiResponse: MetricRangePayloadProps; apiResponse?: MetricRangePayloadProps;
isDarkMode: boolean; isDarkMode: boolean;
onClick?: OnClickPluginOpts['onClick']; onClick?: OnClickPluginOpts['onClick'];
onDragSelect?: (startTime: number, endTime: number) => void; onDragSelect?: (startTime: number, endTime: number) => void;

View File

@@ -10,6 +10,7 @@ import setRetentionApi from 'api/settings/setRetention';
import setRetentionApiV2 from 'api/settings/setRetentionV2'; import setRetentionApiV2 from 'api/settings/setRetentionV2';
import TextToolTip from 'components/TextToolTip'; import TextToolTip from 'components/TextToolTip';
import CustomDomainSettings from 'container/CustomDomainSettings'; import CustomDomainSettings from 'container/CustomDomainSettings';
import LicenseKeyRow from 'container/GeneralSettings/LicenseKeyRow/LicenseKeyRow';
import GeneralSettingsCloud from 'container/GeneralSettingsCloud'; import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
import useComponentPermission from 'hooks/useComponentPermission'; import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
@@ -81,7 +82,7 @@ function GeneralSettings({
logsTtlValuesPayload, logsTtlValuesPayload,
); );
const { user } = useAppContext(); const { user, activeLicense } = useAppContext();
const [setRetentionPermission] = useComponentPermission( const [setRetentionPermission] = useComponentPermission(
['set_retention_period'], ['set_retention_period'],
@@ -680,7 +681,15 @@ function GeneralSettings({
</span> </span>
</div> </div>
{showCustomDomainSettings && <CustomDomainSettings />} {(showCustomDomainSettings || activeLicense?.key) && (
<div className="custom-domain-card">
{showCustomDomainSettings && <CustomDomainSettings />}
{showCustomDomainSettings && activeLicense?.key && (
<div className="custom-domain-card-divider" />
)}
{activeLicense?.key && <LicenseKeyRow />}
</div>
)}
<div className="retention-controls-container"> <div className="retention-controls-container">
<div className="retention-controls-header"> <div className="retention-controls-header">

View File

@@ -0,0 +1,65 @@
.license-key-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--padding-2) var(--padding-3);
gap: var(--spacing-5);
&__left {
display: inline-flex;
align-items: center;
gap: 12px;
color: var(--l2-foreground);
svg {
flex-shrink: 0;
}
}
&__label {
color: var(--l2-foreground);
font-size: var(--paragraph-base-400-font-size);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
flex-shrink: 0;
}
&__value {
display: inline-flex;
align-items: stretch;
}
&__code {
display: inline-flex;
align-items: center;
padding: 1px 2px;
border-radius: 2px 0 0 2px;
background: var(--l3-background);
border: 1px solid var(--l2-border);
color: var(--l2-foreground);
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace;
font-size: var(--paragraph-base-400-font-size);
line-height: var(--line-height-20);
white-space: nowrap;
margin-right: -1px;
}
&__copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
padding: 1px 2px;
border-radius: 0 2px 2px 0;
background: var(--l3-background);
border: 1px solid var(--l2-border);
color: var(--l2-foreground);
cursor: pointer;
flex-shrink: 0;
height: 24px;
&:hover {
background: var(--l3-background-hover);
}
}
}

View File

@@ -0,0 +1,48 @@
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/button';
import { Copy, KeyRound } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { useAppContext } from 'providers/App/App';
import { getMaskedKey } from 'utils/maskedKey';
import './LicenseKeyRow.styles.scss';
function LicenseKeyRow(): JSX.Element | null {
const { activeLicense } = useAppContext();
const [, copyToClipboard] = useCopyToClipboard();
if (!activeLicense?.key) {
return null;
}
const handleCopyLicenseKey = (text: string): void => {
copyToClipboard(text);
toast.success('License key copied to clipboard.', { richColors: true });
};
return (
<div className="license-key-row">
<span className="license-key-row__left">
<KeyRound size={14} />
<span className="license-key-row__label">SigNoz License Key</span>
</span>
<span className="license-key-row__value">
<code className="license-key-row__code">
{getMaskedKey(activeLicense.key)}
</code>
<Button
type="button"
size="xs"
aria-label="Copy license key"
data-testid="license-key-row-copy-btn"
className="license-key-row__copy-btn"
onClick={(): void => handleCopyLicenseKey(activeLicense.key)}
>
<Copy size={12} />
</Button>
</span>
</div>
);
}
export default LicenseKeyRow;

View File

@@ -0,0 +1,61 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import LicenseKeyRow from '../LicenseKeyRow';
const mockCopyToClipboard = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
}));
const mockToastSuccess = jest.fn();
jest.mock('@signozhq/sonner', () => ({
toast: {
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
},
}));
describe('LicenseKeyRow', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders nothing when activeLicense key is absent', () => {
const { container } = render(<LicenseKeyRow />, undefined, {
appContextOverrides: { activeLicense: null },
});
expect(container).toBeEmptyDOMElement();
});
it('renders label and masked key when activeLicense key exists', () => {
render(<LicenseKeyRow />, undefined, {
appContextOverrides: {
activeLicense: { key: 'abcdefghij' } as any,
},
});
expect(screen.getByText('SigNoz License Key')).toBeInTheDocument();
expect(screen.getByText('ab·······ij')).toBeInTheDocument();
});
it('calls copyToClipboard and shows success toast when clipboard is available', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LicenseKeyRow />);
await user.click(screen.getByRole('button', { name: /copy license key/i }));
await waitFor(() => {
expect(mockCopyToClipboard).toHaveBeenCalledWith('test-key');
expect(mockToastSuccess).toHaveBeenCalledWith(
'License key copied to clipboard.',
{
richColors: true,
},
);
});
});
});

View File

@@ -19,7 +19,6 @@ export default function DashboardEmptyState(): JSX.Element {
selectedDashboard, selectedDashboard,
isDashboardLocked, isDashboardLocked,
handleToggleDashboardSlider, handleToggleDashboardSlider,
setSelectedRowWidgetId,
} = useDashboard(); } = useDashboard();
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null); const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
@@ -42,7 +41,6 @@ export default function DashboardEmptyState(): JSX.Element {
const [addPanelPermission] = useComponentPermission(permissions, userRole); const [addPanelPermission] = useComponentPermission(permissions, userRole);
const onEmptyWidgetHandler = useCallback(() => { const onEmptyWidgetHandler = useCallback(() => {
setSelectedRowWidgetId(null);
handleToggleDashboardSlider(true); handleToggleDashboardSlider(true);
logEvent('Dashboard Detail: Add new panel clicked', { logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: selectedDashboard?.id, dashboardId: selectedDashboard?.id,

View File

@@ -5,6 +5,7 @@ import logEvent from 'api/common/logEvent';
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app'; import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query'; import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util'; import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types'; import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState'; import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
@@ -25,6 +26,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType'; import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData'; import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import { REACT_QUERY_KEY } from '../../../constants/reactQueryKeys';
import EmptyWidget from '../EmptyWidget'; import EmptyWidget from '../EmptyWidget';
import { MenuItemKeys } from '../WidgetHeader/contants'; import { MenuItemKeys } from '../WidgetHeader/contants';
import { GridCardGraphProps } from './types'; import { GridCardGraphProps } from './types';
@@ -62,16 +68,14 @@ function GridCardGraph({
const [isInternalServerError, setIsInternalServerError] = useState<boolean>( const [isInternalServerError, setIsInternalServerError] = useState<boolean>(
false, false,
); );
const { const { setDashboardQueryRangeCalled } = useDashboard();
toScrollWidgetId,
setToScrollWidgetId,
setDashboardQueryRangeCalled,
} = useDashboard();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector< const {
AppState, minTime,
GlobalReducer maxTime,
>((state) => state.globalTime); selectedTime: globalSelectedInterval,
isAutoRefreshDisabled,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const handleBackNavigation = (): void => { const handleBackNavigation = (): void => {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
@@ -102,20 +106,11 @@ function GridCardGraph({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const graphRef = useRef<HTMLDivElement>(null); const widgetContainerRef = useRef<HTMLDivElement>(null);
const isVisible = useIntersectionObserver(graphRef, undefined, true); const isVisible = useIntersectionObserver(widgetContainerRef, undefined, true);
useEffect(() => { useScrollWidgetIntoView(widget?.id || '', widgetContainerRef);
if (toScrollWidgetId === widget.id) {
graphRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
graphRef.current?.focus();
setToScrollWidgetId('');
}
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
const updatedQuery = widget?.query; const updatedQuery = widget?.query;
@@ -210,8 +205,10 @@ function GridCardGraph({
version || DEFAULT_ENTITY_VERSION, version || DEFAULT_ENTITY_VERSION,
{ {
queryKey: [ queryKey: [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
maxTime, maxTime,
minTime, minTime,
isAutoRefreshDisabled,
globalSelectedInterval, globalSelectedInterval,
widget?.query, widget?.query,
widget?.panelTypes, widget?.panelTypes,
@@ -241,6 +238,9 @@ function GridCardGraph({
return failureCount < 2; return failureCount < 2;
}, },
keepPreviousData: true, keepPreviousData: true,
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
enabled: queryEnabledCondition, enabled: queryEnabledCondition,
refetchOnMount: false, refetchOnMount: false,
onError: (error) => { onError: (error) => {
@@ -294,7 +294,7 @@ function GridCardGraph({
: headerMenuList; : headerMenuList;
return ( return (
<div style={{ height: '100%', width: '100%' }} ref={graphRef}> <div style={{ height: '100%', width: '100%' }} ref={widgetContainerRef}>
{isEmptyLayout ? ( {isEmptyLayout ? (
<EmptyWidget /> <EmptyWidget />
) : ( ) : (

View File

@@ -71,7 +71,6 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
isDashboardLocked, isDashboardLocked,
dashboardQueryRangeCalled, dashboardQueryRangeCalled,
setDashboardQueryRangeCalled, setDashboardQueryRangeCalled,
setSelectedRowWidgetId,
isDashboardFetching, isDashboardFetching,
columnWidths, columnWidths,
} = useDashboard(); } = useDashboard();
@@ -195,7 +194,6 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
updateDashboardMutation.mutate(updatedDashboard, { updateDashboardMutation.mutate(updatedDashboard, {
onSuccess: (updatedDashboard) => { onSuccess: (updatedDashboard) => {
setSelectedRowWidgetId(null);
if (updatedDashboard.data) { if (updatedDashboard.data) {
if (updatedDashboard.data.data.layout) { if (updatedDashboard.data.data.layout) {
setLayouts(sortLayout(updatedDashboard.data.data.layout)); setLayouts(sortLayout(updatedDashboard.data.data.layout));

View File

@@ -5,6 +5,7 @@ import useComponentPermission from 'hooks/useComponentPermission';
import { EllipsisIcon, PenLine, Plus, X } from 'lucide-react'; import { EllipsisIcon, PenLine, Plus, X } from 'lucide-react';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import { useDashboard } from 'providers/Dashboard/Dashboard';
import { setSelectedRowWidgetId } from 'providers/Dashboard/helpers/selectedRowWidgetIdHelper';
import { ROLES, USER_ROLES } from 'types/roles'; import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission'; import { ComponentTypes } from 'utils/permission';
@@ -37,7 +38,6 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
handleToggleDashboardSlider, handleToggleDashboardSlider,
selectedDashboard, selectedDashboard,
isDashboardLocked, isDashboardLocked,
setSelectedRowWidgetId,
} = useDashboard(); } = useDashboard();
const permissions: ComponentTypes[] = ['add_panel']; const permissions: ComponentTypes[] = ['add_panel'];
@@ -81,7 +81,12 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
disabled={!editWidget && addPanelPermission && !isDashboardLocked} disabled={!editWidget && addPanelPermission && !isDashboardLocked}
icon={<Plus size={14} />} icon={<Plus size={14} />}
onClick={(): void => { onClick={(): void => {
setSelectedRowWidgetId(id); // TODO: @AshwinBhatkal Simplify this check in cleanup of https://github.com/SigNoz/engineering-pod/issues/3953
if (!selectedDashboard?.id) {
return;
}
setSelectedRowWidgetId(selectedDashboard.id, id);
handleToggleDashboardSlider(true); handleToggleDashboardSlider(true);
}} }}
> >

View File

@@ -4,8 +4,8 @@ import { getColorsForSeverityLabels, isRedLike } from '../utils';
describe('getColorsForSeverityLabels', () => { describe('getColorsForSeverityLabels', () => {
it('should return slate for blank labels', () => { it('should return slate for blank labels', () => {
expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_SLATE_300); expect(getColorsForSeverityLabels('', 0)).toBe(Color.BG_VANILLA_400);
expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_SLATE_300); expect(getColorsForSeverityLabels(' ', 0)).toBe(Color.BG_VANILLA_400);
}); });
it('should return correct colors for known severity variants', () => { it('should return correct colors for known severity variants', () => {

View File

@@ -79,7 +79,7 @@ export function getColorsForSeverityLabels(
const trimmed = label.trim(); const trimmed = label.trim();
if (!trimmed) { if (!trimmed) {
return Color.BG_SLATE_300; return Color.BG_VANILLA_400; // Default color for empty labels
} }
const variantColor = SEVERITY_VARIANT_COLORS[trimmed]; const variantColor = SEVERITY_VARIANT_COLORS[trimmed];
@@ -119,6 +119,6 @@ export function getColorsForSeverityLabels(
return ( return (
SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] || SAFE_FALLBACK_COLORS[index % SAFE_FALLBACK_COLORS.length] ||
Color.BG_SLATE_400 Color.BG_VANILLA_400
); );
} }

View File

@@ -39,6 +39,7 @@ import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQuery
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate'; import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQueryData from 'hooks/useUrlQueryData'; import useUrlQueryData from 'hooks/useUrlQueryData';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { isEmpty, isUndefined } from 'lodash-es'; import { isEmpty, isUndefined } from 'lodash-es';
import LiveLogs from 'pages/LiveLogs'; import LiveLogs from 'pages/LiveLogs';
import { UpdateTimeInterval } from 'store/actions'; import { UpdateTimeInterval } from 'store/actions';
@@ -59,6 +60,7 @@ import LogsActionsContainer from './LogsActionsContainer';
import './LogsExplorerViews.styles.scss'; import './LogsExplorerViews.styles.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
function LogsExplorerViewsContainer({ function LogsExplorerViewsContainer({
setIsLoadingQueries, setIsLoadingQueries,
listQueryKeyRef, listQueryKeyRef,
@@ -109,7 +111,7 @@ function LogsExplorerViewsContainer({
const [orderBy, setOrderBy] = useState<string>('timestamp:desc'); const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const [yAxisUnit, setYAxisUnit] = useState<string>(''); const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
const listQuery = useMemo(() => getListQuery(stagedQuery) || null, [ const listQuery = useMemo(() => getListQuery(stagedQuery) || null, [
stagedQuery, stagedQuery,
@@ -367,10 +369,6 @@ function LogsExplorerViewsContainer({
orderBy, orderBy,
]); ]);
const onUnitChangeHandler = useCallback((value: string): void => {
setYAxisUnit(value);
}, []);
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!stagedQuery) { if (!stagedQuery) {
return []; return [];
@@ -488,10 +486,7 @@ function LogsExplorerViewsContainer({
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && ( {selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<div className="time-series-view-container"> <div className="time-series-view-container">
<div className="time-series-view-container-header"> <div className="time-series-view-container-header">
<BuilderUnitsFilter <BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
onChange={onUnitChangeHandler}
yAxisUnit={yAxisUnit}
/>
</div> </div>
<TimeSeriesView <TimeSeriesView
isLoading={isLoading || isFetching} isLoading={isLoading || isFetching}

View File

@@ -5,7 +5,6 @@ import NewWidget from 'container/NewWidget';
import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range'; import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range';
import { server } from 'mocks-server/server'; import { server } from 'mocks-server/server';
import { rest } from 'msw'; import { rest } from 'msw';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider'; import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import i18n from 'ReactI18'; import i18n from 'ReactI18';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils'; import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
@@ -104,15 +103,13 @@ describe('LogsPanelComponent', () => {
const renderComponent = async (): Promise<void> => { const renderComponent = async (): Promise<void> => {
render( render(
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<DashboardProvider> <PreferenceContextProvider>
<PreferenceContextProvider> <NewWidget
<NewWidget dashboardId=""
selectedGraph={PANEL_TYPES.LIST} selectedDashboard={undefined}
fillSpans={undefined} selectedGraph={PANEL_TYPES.LIST}
yAxisUnit={undefined} />
/> </PreferenceContextProvider>
</PreferenceContextProvider>
</DashboardProvider>
</I18nextProvider>, </I18nextProvider>,
); );

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { useQueries } from 'react-query'; import { useQueries } from 'react-query';
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
@@ -11,6 +11,7 @@ import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsF
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView'; import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils'; import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useErrorModal } from 'providers/ErrorModalProvider'; import { useErrorModal } from 'providers/ErrorModalProvider';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
@@ -22,14 +23,13 @@ import { GlobalReducer } from 'types/reducer/globalTime';
function TimeSeries(): JSX.Element { function TimeSeries(): JSX.Element {
const { stagedQuery, currentQuery } = useQueryBuilder(); const { stagedQuery, currentQuery } = useQueryBuilder();
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector< const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector<
AppState, AppState,
GlobalReducer GlobalReducer
>((state) => state.globalTime); >((state) => state.globalTime);
const [yAxisUnit, setYAxisUnit] = useState<string>('');
const isValidToConvertToMs = useMemo(() => { const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = []; const isValid: boolean[] = [];
@@ -112,10 +112,6 @@ function TimeSeries(): JSX.Element {
[data, isValidToConvertToMs], [data, isValidToConvertToMs],
); );
const onUnitChangeHandler = (value: string): void => {
setYAxisUnit(value);
};
const hasMetricSelected = useMemo( const hasMetricSelected = useMemo(
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key), () => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
[currentQuery], [currentQuery],
@@ -123,7 +119,7 @@ function TimeSeries(): JSX.Element {
return ( return (
<div className="meter-time-series-container"> <div className="meter-time-series-container">
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} /> <BuilderUnitsFilter onChange={onUnitChange} yAxisUnit={yAxisUnit} />
<div className="time-series-container"> <div className="time-series-container">
{!hasMetricSelected && <EmptyMetricsSearch />} {!hasMetricSelected && <EmptyMetricsSearch />}
{hasMetricSelected && {hasMetricSelected &&

View File

@@ -4,6 +4,7 @@ import { Typography } from 'antd';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
import { Copy } from 'lucide-react'; import { Copy } from 'lucide-react';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';
import { getMaskedKey } from 'utils/maskedKey';
import './LicenseSection.styles.scss'; import './LicenseSection.styles.scss';
@@ -12,15 +13,6 @@ function LicenseSection(): JSX.Element | null {
const { notifications } = useNotifications(); const { notifications } = useNotifications();
const [, handleCopyToClipboard] = useCopyToClipboard(); const [, handleCopyToClipboard] = useCopyToClipboard();
const getMaskedKey = (key: string): string => {
if (!key || key.length < 4) {
return key || 'N/A';
}
return `${key.substring(0, 2)}********${key
.substring(key.length - 2)
.trim()}`;
};
const handleCopyKey = (text: string): void => { const handleCopyKey = (text: string): void => {
handleCopyToClipboard(text); handleCopyToClipboard(text);
notifications.success({ notifications.success({

View File

@@ -271,7 +271,7 @@ describe('MySettings Flows', () => {
}, },
}); });
expect(within(container).getByText('ab********cd')).toBeInTheDocument(); expect(within(container).getByText('ab·······cd')).toBeInTheDocument();
}); });
it('Should not mask license key if it is too short', () => { it('Should not mask license key if it is too short', () => {

View File

@@ -8,28 +8,15 @@ import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import TextToolTip from 'components/TextToolTip'; import TextToolTip from 'components/TextToolTip';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { QBShortcuts } from 'constants/shortcuts/QBShortcuts'; import { QBShortcuts } from 'constants/shortcuts/QBShortcuts';
import { import { PANEL_TYPE_TO_QUERY_TYPES } from 'container/NewWidget/utils';
getDefaultWidgetData,
PANEL_TYPE_TO_QUERY_TYPES,
} from 'container/NewWidget/utils';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn'; import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
// import { QueryBuilder } from 'container/QueryBuilder';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces'; import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl'; import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useIsDarkMode } from 'hooks/useDarkMode'; import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import { defaultTo, isUndefined } from 'lodash-es';
import { Atom, Terminal } from 'lucide-react'; import { Atom, Terminal } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
getNextWidgets,
getPreviousWidgets,
getSelectedWidgetIndex,
} from 'providers/Dashboard/util';
import { Widgets } from 'types/api/dashboard/getAll'; import { Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard'; import { EQueryType } from 'types/common/dashboard';
import ClickHouseQueryContainer from './QueryBuilder/clickHouse'; import ClickHouseQueryContainer from './QueryBuilder/clickHouse';
@@ -40,77 +27,25 @@ function QuerySection({
selectedGraph, selectedGraph,
queryRangeKey, queryRangeKey,
isLoadingQueries, isLoadingQueries,
selectedWidget,
dashboardVersion,
dashboardId,
dashboardName,
isNewPanel,
}: QueryProps): JSX.Element { }: QueryProps): JSX.Element {
const { const {
currentQuery, currentQuery,
handleRunQuery: handleRunQueryFromQueryBuilder, handleRunQuery: handleRunQueryFromQueryBuilder,
redirectWithQueryBuilderData, redirectWithQueryBuilderData,
} = useQueryBuilder(); } = useQueryBuilder();
const urlQuery = useUrlQuery();
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { selectedDashboard, setSelectedDashboard } = useDashboard();
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const { widgets } = selectedDashboard?.data || {};
const getWidget = useCallback(() => {
const widgetId = urlQuery.get('widgetId');
return defaultTo(
widgets?.find((e) => e.id === widgetId),
getDefaultWidgetData(widgetId || '', selectedGraph),
);
}, [urlQuery, widgets, selectedGraph]);
const selectedWidget = getWidget() as Widgets;
const { query } = selectedWidget; const { query } = selectedWidget;
useShareBuilderUrl({ defaultValue: query }); useShareBuilderUrl({ defaultValue: query });
const handleStageQuery = useCallback(
(query: Query): void => {
if (selectedDashboard === undefined) {
return;
}
const selectedWidgetIndex = getSelectedWidgetIndex(
selectedDashboard,
selectedWidget.id,
);
const previousWidgets = getPreviousWidgets(
selectedDashboard,
selectedWidgetIndex,
);
const nextWidgets = getNextWidgets(selectedDashboard, selectedWidgetIndex);
setSelectedDashboard({
...selectedDashboard,
data: {
...selectedDashboard?.data,
widgets: [
...previousWidgets,
{
...selectedWidget,
query,
},
...nextWidgets,
],
},
});
handleRunQueryFromQueryBuilder();
},
[
selectedDashboard,
selectedWidget,
setSelectedDashboard,
handleRunQueryFromQueryBuilder,
],
);
const handleQueryCategoryChange = useCallback( const handleQueryCategoryChange = useCallback(
(qCategory: string): void => { (qCategory: string): void => {
const currentQueryType = qCategory as EQueryType; const currentQueryType = qCategory as EQueryType;
@@ -123,19 +58,16 @@ function QuerySection({
); );
const handleRunQuery = (): void => { const handleRunQuery = (): void => {
const widgetId = urlQuery.get('widgetId');
const isNewPanel = isUndefined(widgets?.find((e) => e.id === widgetId));
logEvent('Panel Edit: Stage and run query', { logEvent('Panel Edit: Stage and run query', {
dataSource: currentQuery.builder?.queryData?.[0]?.dataSource, dataSource: currentQuery.builder?.queryData?.[0]?.dataSource,
panelType: selectedWidget.panelTypes, panelType: selectedWidget.panelTypes,
queryType: currentQuery.queryType, queryType: currentQuery.queryType,
widgetId: selectedWidget.id, widgetId: selectedWidget.id,
dashboardId: selectedDashboard?.id, dashboardId,
dashboardName: selectedDashboard?.data.title, dashboardName,
isNewPanel, isNewPanel,
}); });
handleStageQuery(currentQuery); handleRunQueryFromQueryBuilder();
}; };
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => { const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
@@ -164,7 +96,7 @@ function QuerySection({
panelType={selectedGraph} panelType={selectedGraph}
filterConfigs={filterConfigs} filterConfigs={filterConfigs}
showTraceOperator={selectedGraph !== PANEL_TYPES.LIST} showTraceOperator={selectedGraph !== PANEL_TYPES.LIST}
version={selectedDashboard?.data?.version || 'v3'} version={dashboardVersion || 'v3'}
isListViewPanel={selectedGraph === PANEL_TYPES.LIST} isListViewPanel={selectedGraph === PANEL_TYPES.LIST}
queryComponents={queryComponents} queryComponents={queryComponents}
signalSourceChangeEnabled signalSourceChangeEnabled
@@ -204,7 +136,7 @@ function QuerySection({
queryComponents, queryComponents,
selectedGraph, selectedGraph,
filterConfigs, filterConfigs,
selectedDashboard?.data?.version, dashboardVersion,
isDarkMode, isDarkMode,
]); ]);
@@ -261,6 +193,11 @@ interface QueryProps {
selectedGraph: PANEL_TYPES; selectedGraph: PANEL_TYPES;
queryRangeKey?: QueryKey; queryRangeKey?: QueryKey;
isLoadingQueries?: boolean; isLoadingQueries?: boolean;
selectedWidget: Widgets;
dashboardVersion?: string;
dashboardId?: string;
dashboardName?: string;
isNewPanel?: boolean;
} }
export default QuerySection; export default QuerySection;

View File

@@ -30,6 +30,8 @@ function LeftContainer({
setRequestData, setRequestData,
setQueryResponse, setQueryResponse,
enableDrillDown = false, enableDrillDown = false,
selectedDashboard,
isNewPanel = false,
}: WidgetGraphProps): JSX.Element { }: WidgetGraphProps): JSX.Element {
const { stagedQuery } = useQueryBuilder(); const { stagedQuery } = useQueryBuilder();
@@ -75,6 +77,11 @@ function LeftContainer({
selectedGraph={selectedGraph} selectedGraph={selectedGraph}
queryRangeKey={queryRangeKey} queryRangeKey={queryRangeKey}
isLoadingQueries={queryResponse.isFetching} isLoadingQueries={queryResponse.isFetching}
selectedWidget={selectedWidget}
dashboardVersion={ENTITY_VERSION_V5}
dashboardId={selectedDashboard?.id}
dashboardName={selectedDashboard?.data.title}
isNewPanel={isNewPanel}
/> />
{selectedGraph === PANEL_TYPES.LIST && ( {selectedGraph === PANEL_TYPES.LIST && (
<ExplorerColumnsRenderer <ExplorerColumnsRenderer

View File

@@ -8,7 +8,6 @@ import userEvent from '@testing-library/user-event';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { AppContext } from 'providers/App/App'; import { AppContext } from 'providers/App/App';
import { IAppContext } from 'providers/App/types'; import { IAppContext } from 'providers/App/types';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider'; import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder'; import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store'; import configureStore from 'redux-mock-store';
@@ -96,9 +95,7 @@ const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
<Provider store={createMockStore()}> <Provider store={createMockStore()}>
<AppContext.Provider value={createMockAppContext() as IAppContext}> <AppContext.Provider value={createMockAppContext() as IAppContext}>
<ErrorModalProvider> <ErrorModalProvider>
<DashboardProvider> <QueryBuilderProvider>{ui}</QueryBuilderProvider>
<QueryBuilderProvider>{ui}</QueryBuilderProvider>
</DashboardProvider>
</ErrorModalProvider> </ErrorModalProvider>
</AppContext.Provider> </AppContext.Provider>
</Provider> </Provider>

View File

@@ -310,12 +310,12 @@ describe('Stacking bar in new panel', () => {
const { container, getByText } = render( const { container, getByText } = render(
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<DashboardProvider> <DashboardProvider dashboardId="">
<PreferenceContextProvider> <PreferenceContextProvider>
<NewWidget <NewWidget
dashboardId=""
selectedDashboard={undefined}
selectedGraph={PANEL_TYPES.BAR} selectedGraph={PANEL_TYPES.BAR}
fillSpans={undefined}
yAxisUnit={undefined}
/> />
</PreferenceContextProvider> </PreferenceContextProvider>
</DashboardProvider> </DashboardProvider>
@@ -356,11 +356,11 @@ describe('when switching to BAR panel type', () => {
it('should preserve saved stacking value of true', async () => { it('should preserve saved stacking value of true', async () => {
const { getByTestId, getByText, container } = render( const { getByTestId, getByText, container } = render(
<DashboardProvider> <DashboardProvider dashboardId="">
<NewWidget <NewWidget
dashboardId=""
selectedDashboard={undefined}
selectedGraph={PANEL_TYPES.BAR} selectedGraph={PANEL_TYPES.BAR}
fillSpans={undefined}
yAxisUnit={undefined}
/> />
</DashboardProvider>, </DashboardProvider>,
); );

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query'; import { UseQueryResult } from 'react-query';
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { generatePath, useParams } from 'react-router-dom'; import { generatePath } from 'react-router-dom';
import { WarningOutlined } from '@ant-design/icons'; import { WarningOutlined } from '@ant-design/icons';
import { Button, Flex, Modal, Space, Typography } from 'antd'; import { Button, Flex, Modal, Space, Typography } from 'antd';
import logEvent from 'api/common/logEvent'; import logEvent from 'api/common/logEvent';
@@ -32,8 +32,11 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables'; import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es'; import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
import { Check, X } from 'lucide-react'; import { Check, X } from 'lucide-react';
import { DashboardWidgetPageParams } from 'pages/DashboardWidget'; import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
import { useDashboard } from 'providers/Dashboard/Dashboard'; import {
clearSelectedRowWidgetId,
getSelectedRowWidgetId,
} from 'providers/Dashboard/helpers/selectedRowWidgetIdHelper';
import { import {
getNextWidgets, getNextWidgets,
getPreviousWidgets, getPreviousWidgets,
@@ -78,18 +81,15 @@ import {
import './NewWidget.styles.scss'; import './NewWidget.styles.scss';
function NewWidget({ function NewWidget({
selectedDashboard,
dashboardId,
selectedGraph, selectedGraph,
enableDrillDown = false, enableDrillDown = false,
}: NewWidgetProps): JSX.Element { }: NewWidgetProps): JSX.Element {
const { safeNavigate } = useSafeNavigate(); const { safeNavigate } = useSafeNavigate();
const { const setToScrollWidgetId = useScrollToWidgetIdStore(
selectedDashboard, (s) => s.setToScrollWidgetId,
setSelectedDashboard, );
setToScrollWidgetId,
selectedRowWidgetId,
setSelectedRowWidgetId,
columnWidths,
} = useDashboard();
const { dashboardVariables } = useDashboardVariables(); const { dashboardVariables } = useDashboardVariables();
@@ -134,8 +134,6 @@ function NewWidget({
const query = useUrlQuery(); const query = useUrlQuery();
const { dashboardId } = useParams<DashboardWidgetPageParams>();
const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false); const [isNewDashboard, setIsNewDashboard] = useState<boolean>(false);
const logEventCalledRef = useRef(false); const logEventCalledRef = useRef(false);
@@ -281,11 +279,10 @@ function NewWidget({
isLogScale, isLogScale,
legendPosition, legendPosition,
customLegendColors, customLegendColors,
columnWidths: columnWidths?.[selectedWidget?.id], columnWidths: selectedWidget.columnWidths,
contextLinks, contextLinks,
}; };
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
columnUnits, columnUnits,
currentQuery, currentQuery,
@@ -308,8 +305,8 @@ function NewWidget({
isLogScale, isLogScale,
legendPosition, legendPosition,
customLegendColors, customLegendColors,
columnWidths,
contextLinks, contextLinks,
selectedWidget.columnWidths,
]); ]);
const closeModal = (): void => { const closeModal = (): void => {
@@ -450,6 +447,8 @@ function NewWidget({
const widgetId = query.get('widgetId') || ''; const widgetId = query.get('widgetId') || '';
let updatedLayout = selectedDashboard.data.layout || []; let updatedLayout = selectedDashboard.data.layout || [];
const selectedRowWidgetId = getSelectedRowWidgetId(dashboardId);
if (isNewDashboard && isEmpty(selectedRowWidgetId)) { if (isNewDashboard && isEmpty(selectedRowWidgetId)) {
const newLayoutItem = placeWidgetAtBottom(widgetId, updatedLayout); const newLayoutItem = placeWidgetAtBottom(widgetId, updatedLayout);
updatedLayout = [...updatedLayout, newLayoutItem]; updatedLayout = [...updatedLayout, newLayoutItem];
@@ -553,9 +552,7 @@ function NewWidget({
}; };
updateDashboardMutation.mutateAsync(dashboard, { updateDashboardMutation.mutateAsync(dashboard, {
onSuccess: (updatedDashboard) => { onSuccess: () => {
setSelectedRowWidgetId(null);
setSelectedDashboard(updatedDashboard.data);
setToScrollWidgetId(selectedWidget?.id || ''); setToScrollWidgetId(selectedWidget?.id || '');
safeNavigate({ safeNavigate({
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }), pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
@@ -566,7 +563,6 @@ function NewWidget({
selectedDashboard, selectedDashboard,
query, query,
isNewDashboard, isNewDashboard,
selectedRowWidgetId,
afterWidgets, afterWidgets,
selectedWidget, selectedWidget,
selectedTime.enum, selectedTime.enum,
@@ -575,9 +571,7 @@ function NewWidget({
preWidgets, preWidgets,
updateDashboardMutation, updateDashboardMutation,
widgets, widgets,
setSelectedDashboard,
setToScrollWidgetId, setToScrollWidgetId,
setSelectedRowWidgetId,
safeNavigate, safeNavigate,
dashboardId, dashboardId,
]); ]);
@@ -626,22 +620,25 @@ function NewWidget({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]); }, [query]);
const onSaveDashboard = useCallback((): void => { const isNewPanel = useMemo(() => {
const widgetId = query.get('widgetId'); const widgetId = query.get('widgetId');
const selectWidget = widgets?.find((e) => e.id === widgetId); const selectedWidget = widgets?.find((e) => e.id === widgetId);
return isUndefined(selectedWidget);
}, [query, widgets]);
const onSaveDashboard = useCallback((): void => {
logEvent('Panel Edit: Save changes', { logEvent('Panel Edit: Save changes', {
panelType: selectedWidget.panelTypes, panelType: selectedWidget.panelTypes,
dashboardId: selectedDashboard?.id, dashboardId: selectedDashboard?.id,
widgetId: selectedWidget.id, widgetId: selectedWidget.id,
dashboardName: selectedDashboard?.data.title, dashboardName: selectedDashboard?.data.title,
queryType: currentQuery.queryType, queryType: currentQuery.queryType,
isNewPanel: isUndefined(selectWidget), isNewPanel,
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource, dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
}); });
setSaveModal(true); setSaveModal(true);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [isNewPanel]);
const isNewTraceLogsAvailable = const isNewTraceLogsAvailable =
currentQuery.queryType === EQueryType.QUERY_BUILDER && currentQuery.queryType === EQueryType.QUERY_BUILDER &&
@@ -681,6 +678,10 @@ function NewWidget({
* on mount here with the currentQuery in the begining itself * on mount here with the currentQuery in the begining itself
*/ */
setSupersetQuery(currentQuery); setSupersetQuery(currentQuery);
return (): void => {
clearSelectedRowWidgetId(dashboardId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -813,6 +814,8 @@ function NewWidget({
isLoadingPanelData={isLoadingPanelData} isLoadingPanelData={isLoadingPanelData}
setQueryResponse={setQueryResponse} setQueryResponse={setQueryResponse}
enableDrillDown={enableDrillDown} enableDrillDown={enableDrillDown}
selectedDashboard={selectedDashboard}
isNewPanel={isNewPanel}
/> />
)} )}
</OverlayScrollbar> </OverlayScrollbar>

View File

@@ -2,6 +2,7 @@ import { Dispatch, SetStateAction } from 'react';
import { UseQueryResult } from 'react-query'; import { UseQueryResult } from 'react-query';
import { PANEL_TYPES } from 'constants/queryBuilder'; import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { IDashboardContext } from 'providers/Dashboard/types';
import { SuccessResponse, Warning } from 'types/api'; import { SuccessResponse, Warning } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll'; import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
@@ -9,9 +10,9 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { timePreferance } from './RightContainer/timeItems'; import { timePreferance } from './RightContainer/timeItems';
export interface NewWidgetProps { export interface NewWidgetProps {
dashboardId: string;
selectedDashboard: IDashboardContext['selectedDashboard'];
selectedGraph: PANEL_TYPES; selectedGraph: PANEL_TYPES;
yAxisUnit: Widgets['yAxisUnit'];
fillSpans: Widgets['fillSpans'];
enableDrillDown?: boolean; enableDrillDown?: boolean;
} }
@@ -34,6 +35,8 @@ export interface WidgetGraphProps {
> >
>; >;
enableDrillDown?: boolean; enableDrillDown?: boolean;
selectedDashboard: IDashboardContext['selectedDashboard'];
isNewPanel?: boolean;
} }
export type WidgetGraphContainerProps = { export type WidgetGraphContainerProps = {

View File

@@ -11,7 +11,7 @@ import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader'; import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader';
import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal'; import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal';
import { InviteMemberFormValues } from 'container/OrganizationSettings/PendingInvitesContainer'; import { InviteMemberFormValues } from 'container/OrganizationSettings/utils';
import history from 'lib/history'; import history from 'lib/history';
import { UserPlus } from 'lucide-react'; import { UserPlus } from 'lucide-react';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';

View File

@@ -12,7 +12,7 @@ import { SOMETHING_WENT_WRONG } from 'constants/api';
import { FeatureKeys } from 'constants/features'; import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences'; import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import { InviteTeamMembersProps } from 'container/OrganizationSettings/PendingInvitesContainer'; import { InviteTeamMembersProps } from 'container/OrganizationSettings/utils';
import { useNotifications } from 'hooks/useNotifications'; import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history'; import history from 'lib/history';
import { useAppContext } from 'providers/App/App'; import { useAppContext } from 'providers/App/App';

View File

@@ -619,6 +619,11 @@ function OnboardingAddDataSource(): JSX.Element {
); );
}; };
const progressText = `Get Started (${Math.min(
currentStep + 1,
setupStepItems.length,
)}/${setupStepItems.length})`;
return ( return (
<div className="onboarding-v2"> <div className="onboarding-v2">
<Layout> <Layout>
@@ -639,7 +644,7 @@ function OnboardingAddDataSource(): JSX.Element {
history.push(ROUTES.HOME); history.push(ROUTES.HOME);
}} }}
/> />
<Typography.Text>Get Started (2/4)</Typography.Text> <Typography.Text>{progressText}</Typography.Text>
</div> </div>
<div className="header-right-section"> <div className="header-right-section">

View File

@@ -611,6 +611,34 @@
] ]
} }
}, },
{
"dataSource": "deno",
"label": "Deno",
"imgUrl": "/Logos/deno.svg",
"tags": [
"apm/traces",
"logs",
"metrics"
],
"module": "apm",
"relatedSearchKeywords": [
"deno",
"deno monitoring",
"deno observability",
"javascript",
"js",
"ts",
"typescript",
"opentelemetry deno",
"observability",
"metrics",
"logs",
"traces",
"tracing"
],
"id": "deno",
"link": "/docs/instrumentation/opentelemetry-deno/"
},
{ {
"dataSource": "javascript", "dataSource": "javascript",
"label": "JavaScript", "label": "JavaScript",
@@ -1828,15 +1856,33 @@
], ],
"module": "logs", "module": "logs",
"relatedSearchKeywords": [ "relatedSearchKeywords": [
"apache",
"application log file collection", "application log file collection",
"c#",
"c++",
"collect", "collect",
"collect logs from file", "collect logs from file",
"collect logs from log file", "collect logs from log file",
"cpp",
"csharp",
"custom log file monitoring", "custom log file monitoring",
"deno",
"django",
"dotnet",
"elixir",
"express",
"file", "file",
"file based logging", "file based logging",
"filebeat alternative", "filebeat alternative",
"flask",
"from-log-file", "from-log-file",
"go",
"golang",
"java",
"javascript",
"js",
"kotlin",
"linux",
"log", "log",
"log file ingestion", "log file ingestion",
"log file to signoz", "log file to signoz",
@@ -1844,7 +1890,29 @@
"log tailing with otel", "log tailing with otel",
"logging", "logging",
"logs", "logs",
"otel file logs" "macos",
"mongodb",
"mysql",
"nestjs",
"nextjs",
"nginx",
"otel file logs",
"php",
"postgres",
"postgresql",
"python",
"rails",
"react",
"redis",
"ruby",
"rust",
"scala",
"spring",
"swift",
"ts",
"typescript",
"vue",
"windows"
], ],
"link": "/docs/userguide/collect_logs_from_file/" "link": "/docs/userguide/collect_logs_from_file/"
}, },
@@ -5208,7 +5276,7 @@
{ {
"dataSource": "prometheus-metrics", "dataSource": "prometheus-metrics",
"label": "Prometheus Metrics", "label": "Prometheus Metrics",
"imgUrl": "/Logos/other-metrics.svg", "imgUrl": "/Logos/prometheus.svg",
"tags": [ "tags": [
"metrics", "metrics",
"infrastructure monitoring" "infrastructure monitoring"
@@ -5809,5 +5877,250 @@
} }
] ]
} }
},
{
"dataSource": "rust-metrics",
"label": "Rust Metrics",
"imgUrl": "/Logos/rust.svg",
"tags": [
"metrics"
],
"module": "metrics",
"relatedSearchKeywords": [
"metrics",
"opentelemetry rust",
"otel rust",
"rust",
"rust metrics",
"rust monitoring",
"rust observability"
],
"id": "rust-metrics",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-rust/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-rust/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-rust/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-rust/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-rust/"
}
]
}
},
{
"dataSource": "ruby-metrics",
"label": "Ruby Metrics",
"imgUrl": "/Logos/ruby-on-rails.svg",
"tags": [
"metrics"
],
"module": "metrics",
"relatedSearchKeywords": [
"metrics",
"opentelemetry ruby",
"otel ruby",
"ruby",
"ruby metrics",
"ruby monitoring",
"ruby observability",
"ruby on rails metrics"
],
"id": "ruby-metrics",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-ruby/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-ruby/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-ruby/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-ruby/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-ruby/"
}
]
}
},
{
"dataSource": "statsd",
"label": "StatsD",
"imgUrl": "/Logos/statsd.svg",
"tags": [
"metrics"
],
"module": "metrics",
"relatedSearchKeywords": [
"metrics",
"statsd",
"statsd metrics",
"statsd monitoring",
"statsd observability"
],
"id": "statsd",
"link": "/docs/userguide/opentelemetry-statsd/"
},
{
"dataSource": "nvidia-dcgm",
"label": "Nvidia DCGM",
"imgUrl": "/Logos/nvidia-dcgm.svg",
"tags": [
"infrastructure monitoring"
],
"module": "infra-monitoring-hosts",
"relatedSearchKeywords": [
"dcgm",
"gpu monitoring",
"infrastructure monitoring",
"nvidia",
"nvidia dcgm",
"nvidia metrics"
],
"id": "nvidia-dcgm",
"link": "/docs/metrics-management/nvidia-dcgm-metrics/"
},
{
"dataSource": "slurm",
"label": "Slurm",
"imgUrl": "/Logos/slurm.svg",
"tags": [
"infrastructure monitoring"
],
"module": "infra-monitoring-hosts",
"relatedSearchKeywords": [
"infrastructure monitoring",
"slurm",
"slurm cluster monitoring",
"slurm metrics"
],
"id": "slurm",
"link": "/docs/metrics-management/slurm-metrics/"
},
{
"dataSource": "ollama-monitoring",
"label": "Ollama",
"imgUrl": "/Logos/ollama.svg",
"tags": [
"LLM Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
"llm",
"llm monitoring",
"monitoring",
"observability",
"ollama",
"ollama monitoring",
"otel ollama",
"traces",
"tracing"
],
"id": "ollama-monitoring",
"link": "/docs/ollama-monitoring/"
},
{
"dataSource": "haystack-monitoring",
"label": "Haystack",
"imgUrl": "/Logos/haystack.svg",
"tags": [
"LLM Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
"haystack",
"haystack monitoring",
"llm",
"llm monitoring",
"monitoring",
"observability",
"otel haystack",
"traces",
"tracing"
],
"id": "haystack-monitoring",
"link": "/docs/haystack-monitoring/"
},
{
"dataSource": "openrouter-observability",
"label": "OpenRouter",
"imgUrl": "/Logos/openrouter.svg",
"tags": [
"LLM Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
"llm",
"llm monitoring",
"monitoring",
"observability",
"openrouter",
"openrouter monitoring",
"otel openrouter",
"traces",
"tracing"
],
"id": "openrouter-observability",
"link": "/docs/openrouter-observability/"
},
{
"dataSource": "huggingface-observability",
"label": "Hugging Face",
"imgUrl": "/Logos/huggingface.svg",
"tags": [
"LLM Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
"hugging face monitoring",
"huggingface",
"llm",
"llm monitoring",
"monitoring",
"observability",
"otel huggingface",
"traces",
"tracing"
],
"id": "huggingface-observability",
"link": "/docs/huggingface-observability/"
} }
] ]

View File

@@ -1,32 +0,0 @@
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;

View File

@@ -1,167 +0,0 @@
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;

View File

@@ -1,16 +0,0 @@
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;
`;

View File

@@ -11,7 +11,7 @@ import {
} from 'antd'; } from 'antd';
import { requireErrorMessage } from 'utils/form/requireErrorMessage'; import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { InviteMemberFormValues } from '../PendingInvitesContainer/index'; import { InviteMemberFormValues } from '../utils';
import { SelectDrawer, SpaceContainer, TitleWrapper } from './styles'; import { SelectDrawer, SpaceContainer, TitleWrapper } from './styles';
function InviteTeamMembers({ form, onFinish }: Props): JSX.Element { function InviteTeamMembers({ form, onFinish }: Props): JSX.Element {

View File

@@ -6,7 +6,7 @@ import { useNotifications } from 'hooks/useNotifications';
import APIError from 'types/api/error'; import APIError from 'types/api/error';
import InviteTeamMembers from '../InviteTeamMembers'; import InviteTeamMembers from '../InviteTeamMembers';
import { InviteMemberFormValues } from '../PendingInvitesContainer'; import { InviteMemberFormValues } from '../utils';
export interface InviteUserModalProps { export interface InviteUserModalProps {
isInviteTeamMemberModalOpen: boolean; isInviteTeamMemberModalOpen: boolean;

View File

@@ -1,324 +0,0 @@
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;

View File

@@ -1,248 +0,0 @@
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;

View File

@@ -1,8 +0,0 @@
import styled from 'styled-components';
export const TitleWrapper = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`;

View File

@@ -3,8 +3,6 @@ import { useAppContext } from 'providers/App/App';
import AuthDomain from './AuthDomain'; import AuthDomain from './AuthDomain';
import DisplayName from './DisplayName'; import DisplayName from './DisplayName';
import Members from './Members';
import PendingInvitesContainer from './PendingInvitesContainer';
import './OrganizationSettings.styles.scss'; import './OrganizationSettings.styles.scss';
@@ -23,9 +21,6 @@ function OrganizationSettings(): JSX.Element {
))} ))}
</Space> </Space>
<PendingInvitesContainer />
<Members />
<AuthDomain /> <AuthDomain />
</div> </div>
); );

View File

@@ -1,37 +0,0 @@
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();
});
});
});

View File

@@ -0,0 +1,17 @@
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;
}[];
}

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