Compare commits

..

8 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
97e1be83f9 Merge branch 'main' into chore/remove_eval_delay_from_safe_rules 2026-01-15 14:52:46 +05:30
Abhishek Kumar Singh
b53b40121d fix: removed increase time agg from safe aggregations 2026-01-15 14:52:28 +05:30
Abhishek Kumar Singh
70cd4a6e58 fix: openapi ci fail 2026-01-08 13:33:34 +05:30
Abhishek Kumar Singh
4af551b483 Merge branch 'main' into chore/remove_eval_delay_from_safe_rules 2026-01-08 11:59:33 +05:30
Abhishek Kumar Singh
ceee42616a refactor: added handling for space agg when calculating the eval delay 2026-01-07 14:37:32 +05:30
Abhishek Kumar Singh
e10c1b9723 feat: added handling for log and traces based aggregation for QB in CalculateEvalDelay 2025-12-18 13:43:25 +05:30
Abhishek Kumar Singh
06ae762070 fix: use matchType and compare operator from threshold rather than ruleCondition 2025-12-17 21:03:54 +05:30
Abhishek Kumar Singh
3cc34baf74 chore: remove eval delay for safe eval rules 2025-12-17 20:39:32 +05:30
169 changed files with 3762 additions and 13141 deletions

59
.github/CODEOWNERS vendored
View File

@@ -16,13 +16,13 @@
# Scaffold Owners
/pkg/config/ @vikrantgupta25
/pkg/errors/ @vikrantgupta25
/pkg/factory/ @vikrantgupta25
/pkg/types/ @vikrantgupta25
/pkg/valuer/ @vikrantgupta25
/cmd/ @vikrantgupta25
.golangci.yml @vikrantgupta25
/pkg/config/ @therealpandey
/pkg/errors/ @therealpandey
/pkg/factory/ @therealpandey
/pkg/types/ @therealpandey
/pkg/valuer/ @therealpandey
/cmd/ @therealpandey
.golangci.yml @therealpandey
# Zeus Owners
@@ -48,60 +48,19 @@
/pkg/querier/ @srikanthccv
/pkg/variables/ @srikanthccv
/pkg/types/querybuildertypes/ @srikanthccv
/pkg/types/telemetrytypes/ @srikanthccv
/pkg/querybuilder/ @srikanthccv
/pkg/telemetrylogs/ @srikanthccv
/pkg/telemetrymetadata/ @srikanthccv
/pkg/telemetrymetrics/ @srikanthccv
/pkg/telemetrytraces/ @srikanthccv
# Metrics
/pkg/types/metrictypes/ @srikanthccv
/pkg/types/metricsexplorertypes/ @srikanthccv
/pkg/modules/metricsexplorer/ @srikanthccv
/pkg/prometheus/ @srikanthccv
# APM
/pkg/types/servicetypes/ @srikanthccv
/pkg/types/apdextypes/ @srikanthccv
/pkg/modules/apdex/ @srikanthccv
/pkg/modules/services/ @srikanthccv
# Dashboard
/pkg/types/dashboardtypes/ @srikanthccv
/pkg/modules/dashboard/ @srikanthccv
# Rule/Alertmanager
/pkg/types/ruletypes/ @srikanthccv
/pkg/types/alertmanagertypes @srikanthccv
/pkg/alertmanager/ @srikanthccv
/pkg/ruler/ @srikanthccv
# Correlation-adjacent
/pkg/contextlinks/ @srikanthccv
/pkg/types/parsertypes/ @srikanthccv
/pkg/queryparser/ @srikanthccv
# AuthN / AuthZ Owners
/pkg/authz/ @vikrantgupta25
/ee/authz/ @vikrantgupta25
/pkg/authn/ @vikrantgupta25
/ee/authn/ @vikrantgupta25
/pkg/modules/user/ @vikrantgupta25
/pkg/modules/session/ @vikrantgupta25
/pkg/modules/organization/ @vikrantgupta25
/pkg/modules/authdomain/ @vikrantgupta25
/pkg/modules/role/ @vikrantgupta25
/pkg/authz/ @vikrantgupta25 @therealpandey
# Integration tests
/tests/integration/ @vikrantgupta25
/tests/integration/ @therealpandey
# Dashboard Owners

View File

@@ -1,76 +1,86 @@
## Pull Request
## 📄 Summary
<!-- Describe the purpose of the PR in a few sentences. What does it fix/add/update? -->
---
### 📄 Summary
> Why does this change exist?
> What problem does it solve, and why is this the right approach?
## ✅ Changes
- [ ] Feature: Brief description
- [ ] Bug fix: Brief description
---
### ✅ Change Type
_Select all that apply_
- [ ] ✨ Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only
## 📝 Changelog
> Fill this only if the change affects users, APIs, UI, or documented behavior.
Mention as N/A for internal refactors or non-user-visible changes.
**Deployment Type:** Cloud / OSS / Enterprise
**Type:** Feature / Bug Fix / Maintenance
**Description:** Short, user-facing summary of the change
---
### 🐛 Bug Context
> Required if this PR fixes a bug
## 🏷️ Required: Add Relevant Labels
#### Root Cause
> What caused the issue?
> Regression, faulty assumption, edge case, refactor, etc.
> ⚠️ **Manually add appropriate labels in the PR sidebar**
Please select one or more labels (as applicable):
#### Fix Strategy
> How does this PR address the root cause?
ex:
- `frontend`
- `backend`
- `devops`
- `bug`
- `enhancement`
- `ui`
- `test`
---
### 🧪 Testing Strategy
> How was this change validated?
## 👥 Reviewers
- Tests added/updated:
- Manual verification:
- Edge cases covered:
> Tag the relevant teams for review:
- frontend / backend / devops
---
### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?
## 🧪 How to Test
- Blast radius:
- Potential regressions:
- Rollback plan:
<!-- Describe how reviewers can test this PR -->
1. ...
2. ...
3. ...
---
### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior
> Use **N/A** for internal or non-user-facing changes
## 🔍 Related Issues
| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Feature / Bug Fix / Maintenance |
| Description | User-facing summary |
<!-- Reference any related issues (e.g. Fixes #123, Closes #456) -->
Closes #
---
### 📋 Checklist
- [ ] Tests added or explicitly not required
- [ ] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
## 📸 Screenshots / Screen Recording (if applicable / mandatory for UI related changes)
<!-- Add screenshots or GIFs to help visualize changes -->
---
## 📋 Checklist
- [ ] Dev Review
- [ ] Test cases added (Unit/ Integration / E2E)
- [ ] Manually tested the changes
---
## 👀 Notes for Reviewers
<!-- Anything reviewers should keep in mind while reviewing -->
---

View File

@@ -25,10 +25,3 @@ jobs:
else
echo "No references to 'ee' packages found in 'pkg' directory"
fi
if grep -R --include="*.go" '.*/ee/.*' cmd/community/; then
echo "Error: Found references to 'ee' packages in 'cmd/community' directory"
exit 1
else
echo "No references to 'ee' packages found in 'cmd/community' directory"
fi

6
.gitignore vendored
View File

@@ -1,9 +1,6 @@
node_modules
.vscode
!.vscode/settings.json
deploy/docker/environment_tiny/common_test
frontend/node_modules
frontend/.pnp
@@ -15,6 +12,7 @@ frontend/coverage
# production
frontend/build
frontend/.vscode
frontend/.yarnclean
frontend/.temp_cache
frontend/test-results
@@ -33,6 +31,7 @@ frontend/src/constants/env.ts
.idea
**/.vscode
**/build
**/storage
**/locust-scripts/__pycache__/
@@ -107,6 +106,7 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/

View File

@@ -1,9 +0,0 @@
{
"eslint.workingDirectories": ["./frontend"],
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"prettier.requireConfig": true
}

View File

@@ -86,7 +86,7 @@ go-run-enterprise: ## Runs the enterprise go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
@@ -103,7 +103,7 @@ go-run-community: ## Runs the community go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \

View File

@@ -5,14 +5,13 @@ import (
"log/slog"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
"github.com/SigNoz/signoz/pkg/authz/openfgaschema"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -25,6 +24,7 @@ import (
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -57,6 +57,13 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
// print the version
version.Info.PrettyPrint(config.Version)
// add enterprise sqlstore factories to the community sqlstore factories
sqlstoreFactories := signoz.NewSQLStoreProviderFactories()
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil {
logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err)
return err
}
signoz, err := signoz.New(
ctx,
config,
@@ -83,9 +90,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, _ role.Module, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -10,7 +10,6 @@ import (
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
@@ -23,7 +22,6 @@ import (
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
@@ -122,9 +120,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, role role.Module, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, role, queryParser, querier, licensing)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -1,4 +0,0 @@
{
"url": "https://context7.com/signoz/signoz",
"public_key": "pk_6g9GfjdkuPEIDuTGAxnol"
}

View File

@@ -195,7 +195,7 @@ services:
- GODEBUG=netdns=go
- TELEMETRY_ENABLED=true
- DEPLOYMENT_TYPE=docker-swarm
- SIGNOZ_TOKENIZER_JWT_SECRET=secret
- SIGNOZ_JWT_SECRET=secret
- DOT_METRICS_ENABLED=true
healthcheck:
test:

View File

@@ -2067,361 +2067,6 @@ paths:
summary: Get features
tags:
- features
/api/v2/gateway/ingestion_keys:
get:
deprecated: false
description: This endpoint returns the ingestion keys for a workspace
operationId: GetIngestionKeys
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableIngestionKeys'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get ingestion keys for workspace
tags:
- gateway
post:
deprecated: false
description: This endpoint creates an ingestion key for the workspace
operationId: CreateIngestionKey
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesPostableIngestionKey'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableCreatedIngestionKey'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create ingestion key for workspace
tags:
- gateway
/api/v2/gateway/ingestion_keys/{keyId}:
delete:
deprecated: false
description: This endpoint deletes an ingestion key for the workspace
operationId: DeleteIngestionKey
parameters:
- in: path
name: keyId
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete ingestion key for workspace
tags:
- gateway
patch:
deprecated: false
description: This endpoint updates an ingestion key for the workspace
operationId: UpdateIngestionKey
parameters:
- in: path
name: keyId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesPostableIngestionKey'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update ingestion key for workspace
tags:
- gateway
/api/v2/gateway/ingestion_keys/{keyId}/limits:
post:
deprecated: false
description: This endpoint creates an ingestion key limit
operationId: CreateIngestionKeyLimit
parameters:
- in: path
name: keyId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesPostableIngestionKeyLimit'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableCreatedIngestionKeyLimit'
status:
type: string
type: object
description: Created
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create limit for the ingestion key
tags:
- gateway
/api/v2/gateway/ingestion_keys/limits/{limitId}:
delete:
deprecated: false
description: This endpoint deletes an ingestion key limit
operationId: DeleteIngestionKeyLimit
parameters:
- in: path
name: limitId
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete limit for the ingestion key
tags:
- gateway
patch:
deprecated: false
description: This endpoint updates an ingestion key limit
operationId: UpdateIngestionKeyLimit
parameters:
- in: path
name: limitId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesUpdatableIngestionKeyLimit'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update limit for the ingestion key
tags:
- gateway
/api/v2/gateway/ingestion_keys/search:
get:
deprecated: false
description: This endpoint returns the ingestion keys for a workspace
operationId: SearchIngestionKeys
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableIngestionKeys'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Search ingestion keys for workspace
tags:
- gateway
/api/v2/metric/alerts:
get:
deprecated: false
@@ -3091,25 +2736,12 @@ paths:
- sessions
components:
schemas:
AuthtypesAttributeMapping:
properties:
email:
type: string
groups:
type: string
name:
type: string
role:
type: string
type: object
AuthtypesAuthDomainConfig:
properties:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
@@ -3143,6 +2775,11 @@ components:
url:
type: string
type: object
AuthtypesClaimMapping:
properties:
email:
type: string
type: object
AuthtypesDeprecatedGettableLogin:
properties:
accessJwt:
@@ -3174,8 +2811,6 @@ components:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
orgId:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
@@ -3199,33 +2834,17 @@ components:
type: object
AuthtypesGoogleConfig:
properties:
allowedGroups:
items:
type: string
type: array
clientId:
type: string
clientSecret:
type: string
domainToAdminEmail:
additionalProperties:
type: string
type: object
fetchGroups:
type: boolean
fetchTransitiveGroupMembership:
type: boolean
insecureSkipEmailVerified:
type: boolean
redirectURI:
type: string
serviceAccountJson:
type: string
type: object
AuthtypesOIDCConfig:
properties:
claimMapping:
$ref: '#/components/schemas/AuthtypesAttributeMapping'
$ref: '#/components/schemas/AuthtypesClaimMapping'
clientId:
type: string
clientSecret:
@@ -3276,22 +2895,8 @@ components:
refreshToken:
type: string
type: object
AuthtypesRoleMapping:
properties:
defaultRole:
type: string
groupMappings:
additionalProperties:
type: string
nullable: true
type: object
useRoleAttribute:
type: boolean
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
$ref: '#/components/schemas/AuthtypesAttributeMapping'
insecureSkipAuthNRequestsSigned:
type: boolean
samlCert:
@@ -3406,160 +3011,6 @@ components:
nullable: true
type: object
type: object
GatewaytypesGettableCreatedIngestionKey:
properties:
id:
type: string
value:
type: string
type: object
GatewaytypesGettableCreatedIngestionKeyLimit:
properties:
id:
type: string
type: object
GatewaytypesGettableIngestionKeys:
properties:
_pagination:
$ref: '#/components/schemas/GatewaytypesPagination'
keys:
items:
$ref: '#/components/schemas/GatewaytypesIngestionKey'
nullable: true
type: array
type: object
GatewaytypesIngestionKey:
properties:
created_at:
format: date-time
type: string
expires_at:
format: date-time
type: string
id:
type: string
limits:
items:
$ref: '#/components/schemas/GatewaytypesLimit'
nullable: true
type: array
name:
type: string
tags:
items:
type: string
nullable: true
type: array
updated_at:
format: date-time
type: string
value:
type: string
workspace_id:
type: string
type: object
GatewaytypesLimit:
properties:
config:
$ref: '#/components/schemas/GatewaytypesLimitConfig'
created_at:
format: date-time
type: string
id:
type: string
key_id:
type: string
metric:
$ref: '#/components/schemas/GatewaytypesLimitMetric'
signal:
type: string
tags:
items:
type: string
nullable: true
type: array
updated_at:
format: date-time
type: string
type: object
GatewaytypesLimitConfig:
properties:
day:
$ref: '#/components/schemas/GatewaytypesLimitValue'
second:
$ref: '#/components/schemas/GatewaytypesLimitValue'
type: object
GatewaytypesLimitMetric:
properties:
day:
$ref: '#/components/schemas/GatewaytypesLimitMetricValue'
second:
$ref: '#/components/schemas/GatewaytypesLimitMetricValue'
type: object
GatewaytypesLimitMetricValue:
properties:
count:
format: int64
type: integer
size:
format: int64
type: integer
type: object
GatewaytypesLimitValue:
properties:
count:
format: int64
type: integer
size:
format: int64
type: integer
type: object
GatewaytypesPagination:
properties:
page:
type: integer
pages:
type: integer
per_page:
type: integer
total:
type: integer
type: object
GatewaytypesPostableIngestionKey:
properties:
expires_at:
format: date-time
type: string
name:
type: string
tags:
items:
type: string
nullable: true
type: array
type: object
GatewaytypesPostableIngestionKeyLimit:
properties:
config:
$ref: '#/components/schemas/GatewaytypesLimitConfig'
signal:
type: string
tags:
items:
type: string
nullable: true
type: array
type: object
GatewaytypesUpdatableIngestionKeyLimit:
properties:
config:
$ref: '#/components/schemas/GatewaytypesLimitConfig'
tags:
items:
type: string
nullable: true
type: array
type: object
MetricsexplorertypesMetricAlert:
properties:
alertId:

View File

@@ -2,7 +2,6 @@ package oidccallbackauthn
import (
"context"
"fmt"
"net/url"
"github.com/SigNoz/signoz/pkg/authn"
@@ -20,27 +19,25 @@ const (
redirectPath string = "/api/v1/complete/oidc"
)
var defaultScopes []string = []string{"email", "profile", oidc.ScopeOpenID}
var (
scopes []string = []string{"email", oidc.ScopeOpenID}
)
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
}
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn")
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
if err != nil {
return nil, err
}
return &AuthN{
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
@@ -129,40 +126,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
}
name := ""
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
if n, ok := claims[nameClaim].(string); ok {
name = n
}
}
var groups []string
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
if claimValue, exists := claims[groupsClaim]; exists {
switch g := claimValue.(type) {
case []any:
for _, group := range g {
if gs, ok := group.(string); ok {
groups = append(groups, gs)
}
}
case string:
// Some IDPs return a single group as a string instead of an array
groups = append(groups, g)
default:
a.settings.Logger().WarnContext(ctx, "oidc: unsupported groups type", "type", fmt.Sprintf("%T", claimValue))
}
}
}
role := ""
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
if r, ok := claims[roleClaim].(string); ok {
role = r
}
}
return authtypes.NewCallbackIdentity(name, email, authDomain.StorableAuthDomain().OrgID, state, groups, role), nil
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
@@ -181,13 +145,6 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
return nil, nil, err
}
scopes := make([]string, len(defaultScopes))
copy(scopes, defaultScopes)
if authDomain.AuthDomainConfig().RoleMapping != nil && len(authDomain.AuthDomainConfig().RoleMapping.GroupMappings) > 0 {
scopes = append(scopes, "groups")
}
return oidcProvider, &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,

View File

@@ -96,26 +96,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "saml: invalid email").WithAdditional("The nameID assertion is used to retrieve the email address, please check your IDP configuration and try again.")
}
name := ""
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
name = val
}
}
var groups []string
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
groups = assertionInfo.Values.GetAll(groupAttribute)
}
role := ""
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
role = val
}
}
return authtypes.NewCallbackIdentity(name, email, authDomain.StorableAuthDomain().OrgID, state, groups, role), nil
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {

View File

@@ -1,282 +0,0 @@
package httpgateway
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/gatewaytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/tidwall/gjson"
)
type Provider struct {
settings factory.ScopedProviderSettings
config gateway.Config
httpClient *client.Client
licensing licensing.Licensing
}
func NewProviderFactory(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return factory.NewProviderFactory(factory.MustNewName("http"), func(ctx context.Context, ps factory.ProviderSettings, c gateway.Config) (gateway.Gateway, error) {
return New(ctx, ps, c, licensing)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config gateway.Config, licensing licensing.Licensing) (gateway.Gateway, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/gateway/httpgateway")
httpClient, err := client.New(
settings.Logger(),
providerSettings.TracerProvider,
providerSettings.MeterProvider,
client.WithRequestResponseLog(true),
client.WithRetryCount(3),
)
if err != nil {
return nil, err
}
return &Provider{
settings: settings,
config: config,
httpClient: httpClient,
licensing: licensing,
}, nil
}
func (provider *Provider) GetIngestionKeys(ctx context.Context, orgID valuer.UUID, page, perPage int) (*gatewaytypes.GettableIngestionKeys, error) {
qParams := url.Values{}
qParams.Add("page", strconv.Itoa(page))
qParams.Add("per_page", strconv.Itoa(perPage))
responseBody, err := provider.do(ctx, orgID, http.MethodGet, "/v1/workspaces/me/keys", qParams, nil)
if err != nil {
return nil, err
}
var ingestionKeys []gatewaytypes.IngestionKey
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &ingestionKeys); err != nil {
return nil, err
}
var pagination gatewaytypes.Pagination
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "_pagination").String()), &pagination); err != nil {
return nil, err
}
return &gatewaytypes.GettableIngestionKeys{
Keys: ingestionKeys,
Pagination: pagination,
}, nil
}
func (provider *Provider) SearchIngestionKeysByName(ctx context.Context, orgID valuer.UUID, name string, page, perPage int) (*gatewaytypes.GettableIngestionKeys, error) {
qParams := url.Values{}
qParams.Add("name", name)
qParams.Add("page", strconv.Itoa(page))
qParams.Add("per_page", strconv.Itoa(perPage))
responseBody, err := provider.do(ctx, orgID, http.MethodGet, "/v1/workspaces/me/keys/search", qParams, nil)
if err != nil {
return nil, err
}
var ingestionKeys []gatewaytypes.IngestionKey
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &ingestionKeys); err != nil {
return nil, err
}
var pagination gatewaytypes.Pagination
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "_pagination").String()), &pagination); err != nil {
return nil, err
}
return &gatewaytypes.GettableIngestionKeys{
Keys: ingestionKeys,
Pagination: pagination,
}, nil
}
func (provider *Provider) CreateIngestionKey(ctx context.Context, orgID valuer.UUID, name string, tags []string, expiresAt time.Time) (*gatewaytypes.GettableCreatedIngestionKey, error) {
requestBody := gatewaytypes.PostableIngestionKey{
Name: name,
Tags: tags,
ExpiresAt: expiresAt,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, err
}
responseBody, err := provider.do(ctx, orgID, http.MethodPost, "/v1/workspaces/me/keys", nil, requestBodyBytes)
if err != nil {
return nil, err
}
var createdKeyResponse gatewaytypes.GettableCreatedIngestionKey
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &createdKeyResponse); err != nil {
return nil, err
}
return &createdKeyResponse, nil
}
func (provider *Provider) UpdateIngestionKey(ctx context.Context, orgID valuer.UUID, keyID string, name string, tags []string, expiresAt time.Time) error {
requestBody := gatewaytypes.PostableIngestionKey{
Name: name,
Tags: tags,
ExpiresAt: expiresAt,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return err
}
_, err = provider.do(ctx, orgID, http.MethodPatch, "/v1/workspaces/me/keys/"+keyID, nil, requestBodyBytes)
if err != nil {
return err
}
return nil
}
func (provider *Provider) DeleteIngestionKey(ctx context.Context, orgID valuer.UUID, keyID string) error {
_, err := provider.do(ctx, orgID, http.MethodDelete, "/v1/workspaces/me/keys/"+keyID, nil, nil)
if err != nil {
return err
}
return nil
}
func (provider *Provider) CreateIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, keyID string, signal string, limitConfig gatewaytypes.LimitConfig, tags []string) (*gatewaytypes.GettableCreatedIngestionKeyLimit, error) {
requestBody := gatewaytypes.PostableIngestionKeyLimit{
Signal: signal,
Config: limitConfig,
Tags: tags,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, err
}
responseBody, err := provider.do(ctx, orgID, http.MethodPost, "/v1/workspaces/me/keys/"+keyID+"/limits", nil, requestBodyBytes)
if err != nil {
return nil, err
}
var createdIngestionKeyLimitResponse gatewaytypes.GettableCreatedIngestionKeyLimit
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &createdIngestionKeyLimitResponse); err != nil {
return nil, err
}
return &createdIngestionKeyLimitResponse, nil
}
func (provider *Provider) UpdateIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, limitID string, limitConfig gatewaytypes.LimitConfig, tags []string) error {
requestBody := gatewaytypes.UpdatableIngestionKeyLimit{
Config: limitConfig,
Tags: tags,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return err
}
_, err = provider.do(ctx, orgID, http.MethodPatch, "/v1/workspaces/me/limits/"+limitID, nil, requestBodyBytes)
if err != nil {
return err
}
return nil
}
func (provider *Provider) DeleteIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, limitID string) error {
_, err := provider.do(ctx, orgID, http.MethodDelete, "/v1/workspaces/me/limits/"+limitID, nil, nil)
if err != nil {
return err
}
return nil
}
func (provider *Provider) do(ctx context.Context, orgID valuer.UUID, method string, path string, queryParams url.Values, body []byte) ([]byte, error) {
license, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "no valid license found").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
// build url
requestURL := provider.config.URL.JoinPath(path)
// add query params to the url
if queryParams != nil {
requestURL.RawQuery = queryParams.Encode()
}
// build request
request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), bytes.NewBuffer(body))
if err != nil {
return nil, err
}
// add headers needed to call gateway
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Signoz-Cloud-Api-Key", license.Key)
request.Header.Set("X-Consumer-Username", "lid:00000000-0000-0000-0000-000000000000")
request.Header.Set("X-Consumer-Groups", "ns:default")
// execute request
response, err := provider.httpClient.Do(request)
if err != nil {
return nil, err
}
// read response
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
// only 2XX
if response.StatusCode/100 == 2 {
return responseBody, nil
}
errorMessage := gjson.GetBytes(responseBody, "error").String()
if errorMessage == "" {
errorMessage = "an unknown error occurred"
}
// return error for non 2XX
return nil, provider.errFromStatusCode(response.StatusCode, errorMessage)
}
func (provider *Provider) errFromStatusCode(code int, errorMessage string) error {
switch code {
case http.StatusBadRequest:
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, errorMessage)
case http.StatusUnauthorized:
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, errorMessage)
case http.StatusForbidden:
return errors.New(errors.TypeForbidden, errors.CodeForbidden, errorMessage)
case http.StatusNotFound:
return errors.New(errors.TypeNotFound, errors.CodeNotFound, errorMessage)
case http.StatusConflict:
return errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, errorMessage)
}
return errors.New(errors.TypeInternal, errors.CodeInternal, errorMessage)
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
querierAPI "github.com/SigNoz/signoz/pkg/querier"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
@@ -37,7 +36,6 @@ type APIHandlerOptions struct {
GatewayUrl string
// Querier Influx Interval
FluxInterval time.Duration
GlobalConfig global.Config
}
type APIHandler struct {

View File

@@ -76,7 +76,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
ingestionUrl, signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't deduce ingestion url and signoz api url",
@@ -84,7 +84,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
result.IngestionUrl = ah.opts.GlobalConfig.IngestionURL.String()
result.IngestionUrl = ingestionUrl
result.SigNozAPIUrl = signozApiUrl
gatewayUrl := ah.opts.GatewayUrl
@@ -186,7 +186,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
}
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
string, *basemodel.ApiError,
string, string, *basemodel.ApiError,
) {
// TODO: remove this struct from here
type deploymentResponse struct {
@@ -200,7 +200,7 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
return "", "", basemodel.InternalError(fmt.Errorf(
"couldn't query for deployment info: error: %w", err,
))
}
@@ -209,7 +209,7 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
err = json.Unmarshal(respBytes, resp)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
return "", "", basemodel.InternalError(fmt.Errorf(
"couldn't unmarshal deployment info response: error: %w", err,
))
}
@@ -219,14 +219,16 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
if len(regionDns) < 1 || len(deploymentName) < 1 {
// Fail early if actual response structure and expectation here ever diverge
return "", basemodel.InternalError(fmt.Errorf(
return "", "", basemodel.InternalError(fmt.Errorf(
"deployment info response not in expected shape. couldn't determine region dns and deployment name",
))
}
ingestionUrl := fmt.Sprintf("https://ingest.%s", regionDns)
signozApiUrl := fmt.Sprintf("https://%s.%s", deploymentName, regionDns)
return signozApiUrl, nil
return ingestionUrl, signozApiUrl, nil
}
type ingestionKey struct {

View File

@@ -172,7 +172,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
FluxInterval: config.Querier.FluxInterval,
Gateway: gatewayProxy,
GatewayUrl: config.Gateway.URL.String(),
GlobalConfig: config.Global,
}
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)

View File

@@ -26,6 +26,10 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
}
// calculate eval delay based on rule config
evalDelay := baserules.CalculateEvalDelay(opts.Rule, opts.ManagerOpts.EvalDelay)
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(
@@ -35,7 +39,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
opts.Reader,
opts.Querier,
opts.SLogger,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
baserules.WithEvalDelay(evalDelay),
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
baserules.WithMetadataStore(opts.ManagerOpts.MetadataStore),
@@ -84,7 +88,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
opts.Querier,
opts.SLogger,
opts.Cache,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
baserules.WithEvalDelay(evalDelay),
baserules.WithSQLStore(opts.SQLStore),
baserules.WithQueryParser(opts.ManagerOpts.QueryParser),
baserules.WithMetadataStore(opts.ManagerOpts.MetadataStore),

View File

@@ -7,6 +7,8 @@ module.exports = {
'jest/globals': true,
},
extends: [
'airbnb',
'airbnb-typescript',
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
@@ -33,7 +35,6 @@ module.exports = {
'react-hooks',
'prettier',
'jest',
'jsx-a11y',
],
settings: {
react: {
@@ -71,6 +72,9 @@ module.exports = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// airbnb
'no-underscore-dangle': 'off',
'no-console': 'off',
'import/prefer-default-export': 'off',
'import/extensions': [
'error',
@@ -83,9 +87,6 @@ module.exports = {
},
],
'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
// Disabled because TypeScript already handles this check more accurately,
// and the rule has false positives with type-only imports (e.g., TooltipProps from antd)
'import/named': 'off',
'no-plusplus': 'off',
'jsx-a11y/label-has-associated-control': [
'error',
@@ -103,10 +104,7 @@ module.exports = {
},
},
],
// Allow empty functions for mocks, default context values, and noop callbacks
'@typescript-eslint/no-empty-function': 'off',
// Allow underscore prefix for intentionally unused variables (e.g., const { id: _id, ...rest } = props)
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-unused-vars': 'error',
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'arrow-body-style': ['error', 'as-needed'],

View File

@@ -4,14 +4,5 @@
"tabWidth": 1,
"singleQuote": true,
"jsxSingleQuote": false,
"semi": true,
"printWidth": 80,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"endOfLine": "lf",
"quoteProps": "as-needed",
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css",
"embeddedLanguageFormatting": "auto"
"semi": true
}

View File

@@ -1,8 +0,0 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"prettier.requireConfig": true
}

View File

@@ -34,18 +34,12 @@ Embrace the spirit of collaboration and contribute to the success of our open-so
### Linting and Setup
- It is crucial to refrain from disabling ESLint and TypeScript errors within the project. If there is a specific rule that needs to be disabled, provide a clear and justified explanation for doing so. Maintaining the integrity of the linting and type-checking processes ensures code quality and consistency throughout the codebase.
- In our project, we rely on several essential ESLint plugins and configurations:
- In our project, we rely on several essential ESLint plugins, namely:
- [plugin:@typescript-eslint](https://typescript-eslint.io/rules/)
- [airbnb styleguide](https://github.com/airbnb/javascript)
- [plugin:sonarjs](https://github.com/SonarSource/eslint-plugin-sonarjs)
- [eslint:recommended](https://eslint.org/docs/latest/rules/) - Core ESLint rules for JavaScript best practices
- [plugin:@typescript-eslint](https://typescript-eslint.io/rules/) - TypeScript-specific linting rules
- [plugin:react](https://github.com/jsx-eslint/eslint-plugin-react) - React best practices and patterns
- [plugin:react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) - Rules of Hooks enforcement
- [plugin:sonarjs](https://github.com/SonarSource/eslint-plugin-sonarjs) - Code quality and complexity analysis
- [plugin:prettier](https://github.com/prettier/eslint-plugin-prettier) - Code formatting via Prettier
- [simple-import-sort](https://github.com/lydell/eslint-plugin-simple-import-sort) - Automatic import organization
- [plugin:jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y) - Accessibility rules for JSX elements
To ensure compliance with our coding standards and best practices, we encourage you to refer to the documentation of these plugins. Familiarizing yourself with the ESLint rules they provide will help maintain code quality and consistency throughout the project.
To ensure compliance with our coding standards and best practices, we encourage you to refer to the documentation of these plugins. Familiarizing yourself with the ESLint rules they provide will help maintain code quality and consistency throughout the project.
### Naming Conventions

View File

@@ -219,11 +219,16 @@
"compression-webpack-plugin": "9.0.0",
"copy-webpack-plugin": "^11.0.0",
"eslint": "^7.32.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^16.1.4",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard": "^16.0.3",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jest": "^26.9.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-simple-import-sort": "^7.0.0",

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path fill="#fff" d="m9.05 20.616 13.968-.178-.06-9.283-14.417.178z"/><path fill="#d80d1a" d="m10.165 20.736-1.302-2.253.127-5.209 5.133 7.491z"/><path fill="#1d86fa" d="m16.72 20.616-6.416-9.401 4.298.06 6.536 9.372z"/><path fill="#d80d1a" d="m22.958 19.125-5.58-7.88 4.118.12 2.12 2.956z"/><path fill="#a1d2d6" d="m8.75 11.722 14.208-.09-.098-.86-14.14.114zM8.99 21.214l14.237-.18-.329-.864-14.237.177z"/><path fill="#c8c8c8" d="M22.772 21.163c.122.147.882.175.882.175s.444.825 1.41.707c.865-.106 1.2-.829 1.2-.829s1.463-.338 2.259-1.464c.846-1.2.882-2.383.882-4.076 0-1.836-.143-3.088-1.076-4.076-1.073-1.134-2.186-1.218-2.186-1.218s-.39-.654-1.271-.636c-.883.018-1.218.74-1.218.74s-.704.151-.882.282c-.091.067-.067 2.703-.054 5.294.012 2.5-.042 4.988.054 5.1"/><path fill="#858585" d="M28.541 16.538c.353.018.345-1.851.036-3.229-.354-1.57-1.694-2.082-1.8-1.993s-.071 1.675.017 1.764c.09.09.918.494 1.2 1.182.283.69.278 2.262.547 2.276M25.299 21.99l-.056-12.213s.258.04.509.207c.242.162.389.395.389.395l.127 10.828s-.176.325-.35.465c-.381.309-.62.318-.62.318"/><path fill="#e1e0e0" d="m24.692 22.043-.157-12.255s-.323.04-.565.258c-.204.182-.318.44-.318.44l.107 11.01s.164.223.353.354c.23.157.58.193.58.193"/><path fill="#c8c8c8" d="M9.13 21.32c.158-.142.036-10.216-.053-10.392-.089-.175-.829-1.069-2.01-1-1.167.069-1.536.876-1.536.876s-1.169.155-2.065 1.5c-.635.954-.82 2.19-.806 3.74.015 1.712.12 3.319 1.142 4.341.982.982 1.94 1.022 1.94 1.022s.267.87 1.573.87c1.289.002 1.816-.956 1.816-.956"/><path fill="#858585" d="M7.548 22.274s-.373-1.907-.427-6.158.256-6.186.256-6.186.258-.017.504.067c.276.093.458.24.458.24s-.244 3.656-.227 5.879c.018 2.222.37 5.85.37 5.85s-.265.153-.423.206c-.164.051-.51.102-.51.102"/><path fill="#e1e0e0" d="M6.858 22.234s-.48-2.45-.533-6.032.157-6.163.157-6.163-.422.127-.633.322c-.21.196-.318.44-.318.44s-.122 3.359-.14 5.39c-.02 2.454.351 5.216.351 5.216s.085.296.345.496c.367.285.771.331.771.331"/><path fill="#fff" d="M6.1 16.763c.19 0 .3 1.251.38 2.58.045.744.411 2.058-.08 2.058-.49 0-.584-.476-.617-2.09-.034-1.612.064-2.548.317-2.548M3.173 16.667c.12-.042.35.634.903 1.156.39.369.76.49.855.744.096.254.222 1.613-.062 1.631-.396.023-1.022-.262-1.393-.933-.59-1.062-.525-2.518-.303-2.598"/></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path fill="#455a64" d="m15.255 10.911-4.82-6.048a.204.204 0 0 1 .025-.278.2.2 0 0 1 .284.018l5.127 5.775a.2.2 0 0 1-.018.284l-.307.271a.2.2 0 0 1-.291-.022M16.494 10.878l-.276-.245a.22.22 0 0 1-.017-.313l5.097-5.746a.22.22 0 0 1 .313-.018c.09.078.1.213.027.307l-4.822 5.99a.227.227 0 0 1-.322.025"/><path fill="#455a64" d="M12.389 11.214c0-.775 1.72-1.406 3.842-1.406s3.842.629 3.842 1.406z"/><path fill="#82aec0" d="M28.353 29.337H3.652c-.544 0-.985-.458-.985-1.02V11.8c0-.564.44-1.02.985-1.02h24.703c.542 0 .985.458.985 1.02v16.516c-.003.562-.443 1.02-.987 1.02"/><path fill="#212121" d="M27.637 28.368H4.438a.79.79 0 0 1-.789-.79V12.456a.79.79 0 0 1 .79-.79h23.198c.436 0 .789.354.789.79v15.124a.79.79 0 0 1-.79.789"/><path fill="#b9e4ea" d="m5.2 25.995-1.068 1.677c-.186.293.014.691.36.694h18.414a.536.536 0 0 0 .478-.77l-.667-1.373z"/><path fill="#455a64" d="M27.312 27.662h-2.53a.464.464 0 0 1-.465-.464V21.92c0-.258.209-.464.464-.464h2.531c.258 0 .465.209.465.464v5.278a.464.464 0 0 1-.465.464M26.048 20.226a1.531 1.531 0 1 0 0-3.062 1.531 1.531 0 0 0 0 3.062"/><path fill="#82aec0" d="M24.966 17.798a.254.254 0 0 0-.009.313l.504.678c.158.21.363.382.6.497l.76.37a.254.254 0 0 0 .316-.38l-.505-.678a1.65 1.65 0 0 0-.6-.498l-.76-.369a.26.26 0 0 0-.306.067"/><path fill="#455a64" d="M26.048 16.268a1.531 1.531 0 1 0 0-3.062 1.531 1.531 0 0 0 0 3.062"/><path fill="#82aec0" d="M26.049 13.33c-.118 0-.22.08-.247.194l-.2.822a1.65 1.65 0 0 0 0 .78l.2.822a.254.254 0 0 0 .493 0l.2-.822a1.65 1.65 0 0 0 0-.78l-.2-.822a.256.256 0 0 0-.247-.194"/><path fill="#2f7889" d="M22.992 25.219c-.17.77-.742 1.366-1.475 1.529-1.205.269-3.45.604-7.148.604-3.883 0-6.487-.369-7.89-.642-.775-.151-1.384-.787-1.536-1.607-.348-1.885-.786-5.652.018-10.357.15-.871.814-1.54 1.643-1.653 1.495-.207 4.167-.483 7.765-.483 3.414 0 5.7.25 6.997.45.797.124 1.435.77 1.598 1.608.962 4.957.43 8.733.028 10.55"/><path fill="#212121" fill-rule="evenodd" d="M14.37 13.055c-3.576 0-6.227.274-7.705.478-.623.085-1.147.593-1.265 1.288-.794 4.64-.361 8.353-.02 10.201.121.653.6 1.138 1.184 1.252 1.375.267 3.951.634 7.805.634 3.672 0 5.884-.334 7.051-.594.553-.122 1.002-.576 1.139-1.192.392-1.772.917-5.485-.032-10.369-.13-.67-.633-1.161-1.23-1.254-1.272-.197-3.536-.444-6.928-.444m-7.827-.403c1.514-.209 4.206-.486 7.826-.486 3.436 0 5.746.25 7.064.454h.001c1 .156 1.771.959 1.966 1.964.976 5.028.439 8.867.027 10.73-.206.928-.9 1.665-1.814 1.868-1.242.277-3.52.615-7.244.615-3.91 0-6.544-.372-7.975-.65-.967-.19-1.705-.976-1.887-1.963m2.036-12.532c-1.035.142-1.84.972-2.02 2.02-.815 4.768-.372 8.59-.016 10.512" clip-rule="evenodd"/><path fill="url(#a)" d="M8.686 14.175c.373-.045 1.08-.013 1.329.744.248.758-.298.963-.591 1.187-.843.642-1.26.887-1.767 1.587-.404.56-1.111.384-1.322.015-.169-.298-.293-1.253.064-1.884.771-1.351 1.914-1.605 2.287-1.649"/><defs><linearGradient id="a" x1="8.303" x2="8.07" y1="9.59" y2="18.014" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,145 +0,0 @@
.auth-error-container {
margin-top: 24px;
width: 100%;
animation: horizontal-shaking 300ms ease-out;
.error-content {
background: rgba(229, 72, 77, 0.1);
border: 1px solid rgba(229, 72, 77, 0.2);
border-radius: 4px;
&__summary-section {
border-bottom: 1px solid rgba(229, 72, 77, 0.2);
}
&__summary {
padding: 16px;
}
&__summary-left {
gap: 10px;
}
&__icon-wrapper {
width: 12px;
height: 12px;
flex-shrink: 0;
}
&__summary-text {
gap: 6px;
}
&__error-code {
color: #fadadb;
font-size: 13px;
font-weight: 500;
line-height: 1;
letter-spacing: -0.065px;
}
&__error-message {
color: #f5b6b8;
font-size: 13px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
}
&__message-badge {
padding: 0px 16px 16px;
}
&__message-badge-label-text {
color: #fadadb;
}
&__message-badge-line {
background-image: radial-gradient(
circle,
rgba(229, 72, 77, 0.3) 1px,
transparent 2px
);
}
&__messages-section {
padding: 0;
}
&__message-list {
max-height: 200px;
}
&__message-item {
color: #f5b6b8;
font-size: 13px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
&::before {
background: #f5b6b8;
}
}
&__scroll-hint {
background: rgba(229, 72, 77, 0.2);
}
&__scroll-hint-text {
color: #fadadb;
}
}
.auth-error-icon {
color: var(--bg-cherry-300);
padding-top: 1px;
}
}
.lightMode {
.auth-error-container {
.error-content {
background: rgba(229, 72, 77, 0.1);
border-color: rgba(229, 72, 77, 0.2);
&__error-code {
color: var(--bg-ink-100);
}
&__error-message {
color: var(--bg-ink-400);
}
&__message-item {
color: var(--bg-ink-400);
&::before {
background: var(--bg-ink-400);
}
}
&__scroll-hint-text {
color: var(--bg-ink-100);
}
}
}
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -1,22 +0,0 @@
import './AuthError.styles.scss';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { CircleAlert } from 'lucide-react';
import APIError from 'types/api/error';
interface AuthErrorProps {
error: APIError;
}
function AuthError({ error }: AuthErrorProps): JSX.Element {
return (
<div className="auth-error-container">
<ErrorContent
error={error}
icon={<CircleAlert size={12} className="auth-error-icon" />}
/>
</div>
);
}
export default AuthError;

View File

@@ -1,115 +0,0 @@
@import '@signozhq/design-tokens/dist/style.css';
.auth-footer {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 0;
position: relative;
z-index: 10;
}
.auth-footer-content {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 12px;
background: var(--bg-ink-400, #121317);
border: 1px solid var(--bg-ink-200, #23262e);
border-radius: 4px;
}
.auth-footer-item {
display: flex;
align-items: center;
gap: 6px;
height: 12px;
}
.auth-footer-status-indicator {
width: 6px;
height: 6px;
border-radius: 9999px;
background: #25e192;
flex-shrink: 0;
}
.auth-footer-icon {
aspect-ratio: 1.93;
width: 29px;
flex-shrink: 0;
object-fit: contain;
opacity: 1;
}
.auth-footer-text {
font-family: var(--font-family-inter, Inter, sans-serif);
font-size: 11px;
font-weight: 400;
line-height: 1;
color: var(--text-neutral-dark-100, #adb4c2);
text-align: center;
}
.auth-footer-link {
display: flex;
align-items: center;
gap: 6px;
text-decoration: none;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
}
.auth-footer-link-icon {
flex-shrink: 0;
color: var(--text-neutral-dark-50, #eceef2);
}
.auth-footer-link-status {
.auth-footer-text {
color: #25e192;
}
.auth-footer-link-icon {
color: #25e192;
}
}
.auth-footer-separator {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--bg-ink-200, #23262e);
flex-shrink: 0;
}
.lightMode {
.auth-footer-content {
background: var(--bg-base-white, #ffffff);
border-color: var(--bg-vanilla-300, #e9e9e9);
box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.08);
}
.auth-footer-icon {
filter: brightness(0) saturate(100%) invert(25%) sepia(8%) saturate(518%)
hue-rotate(192deg) brightness(80%) contrast(95%);
opacity: 0.9;
}
.auth-footer-text {
color: var(--text-neutral-light-200, #80828d);
}
.auth-footer-link-icon {
color: var(--text-neutral-light-100, #62636c);
}
.auth-footer-separator {
background: var(--bg-vanilla-300, #e9e9e9);
}
}

View File

@@ -1,75 +0,0 @@
import './AuthFooter.styles.scss';
import { ArrowUpRight } from 'lucide-react';
import React from 'react';
interface FooterItem {
icon?: string;
text: string;
url?: string;
statusIndicator?: boolean;
}
const footerItems: FooterItem[] = [
{
text: 'All systems operational',
url: 'https://status.signoz.io/',
statusIndicator: true,
},
{
text: 'Privacy',
url: 'https://www.signoz.io/privacy',
},
{
text: 'Security',
url: 'https://www.signoz.io/security',
},
];
function AuthFooter(): JSX.Element {
return (
<footer className="auth-footer">
<div className="auth-footer-content">
{footerItems.map((item, index) => (
<React.Fragment key={item.text}>
<div className="auth-footer-item">
{item.statusIndicator && (
<div className="auth-footer-status-indicator" />
)}
{item.icon && (
<img
loading="lazy"
src={item.icon}
alt=""
className="auth-footer-icon"
/>
)}
{item.url ? (
<a
href={item.url}
className={`auth-footer-link ${
item.statusIndicator ? 'auth-footer-link-status' : ''
}`}
target="_blank"
rel="noopener noreferrer"
>
<span className="auth-footer-text">{item.text}</span>
{!item.statusIndicator && (
<ArrowUpRight size={12} className="auth-footer-link-icon" />
)}
</a>
) : (
<span className="auth-footer-text">{item.text}</span>
)}
</div>
{index < footerItems.length - 1 && (
<div className="auth-footer-separator" />
)}
</React.Fragment>
))}
</div>
</footer>
);
}
export default AuthFooter;

View File

@@ -1,82 +0,0 @@
@import '@signozhq/design-tokens/dist/style.css';
.auth-header {
width: 100%;
max-width: 1176px;
margin: 0 auto;
padding: 12px 0;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
z-index: 10;
}
.auth-header-logo {
display: flex;
align-items: center;
gap: 4.9px;
text-decoration: none;
}
.auth-header-logo-icon {
width: 17.5px;
height: 17.5px;
flex-shrink: 0;
}
.auth-header-logo-text {
font-family: Satoshi, var(--font-family-inter, Inter), sans-serif;
font-size: 15.4px;
font-weight: 500;
line-height: 17.5px;
color: var(--text-neutral-dark-50, #eceef2);
white-space: nowrap;
}
.auth-header-help-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 32px;
padding: 10px 16px;
background: var(--bg-ink-400, #121317);
border: none;
border-radius: 2px;
cursor: pointer;
transition: opacity 0.2s ease;
span {
font-family: var(--font-family-inter, Inter, sans-serif);
font-size: 11px;
font-weight: 500;
line-height: 1;
color: var(--text-neutral-dark-100, #adb4c2);
text-align: center;
}
svg {
flex-shrink: 0;
color: var(--text-neutral-dark-100, #adb4c2);
}
&:hover {
opacity: 0.8;
}
}
.lightMode {
.auth-header-logo-text {
color: var(--text-neutral-light-100, #62636c);
}
.auth-header-help-button {
background: var(--bg-vanilla-200, #f5f5f5);
span,
svg {
color: var(--text-neutral-light-200, #80828d);
}
}
}

View File

@@ -1,33 +0,0 @@
import './AuthHeader.styles.scss';
import { Button } from '@signozhq/button';
import { LifeBuoy } from 'lucide-react';
import { useCallback } from 'react';
function AuthHeader(): JSX.Element {
const handleGetHelp = useCallback((): void => {
window.open('https://signoz.io/support/', '_blank');
}, []);
return (
<header className="auth-header">
<div className="auth-header-logo">
<img
src="/Logos/signoz-brand-logo.svg"
alt="SigNoz"
className="auth-header-logo-icon"
/>
<span className="auth-header-logo-text">SigNoz</span>
</div>
<Button
className="auth-header-help-button"
prefixIcon={<LifeBuoy size={12} />}
onClick={handleGetHelp}
>
Get Help
</Button>
</header>
);
}
export default AuthHeader;

View File

@@ -1,181 +0,0 @@
@import '@signozhq/design-tokens/dist/style.css';
.auth-page-wrapper {
position: relative;
min-height: 100vh;
width: 100%;
background: var(--bg-neutral-dark-1000, #0a0c10);
display: flex;
flex-direction: column;
}
.auth-page-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
}
.auth-page-dots {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.bg-dot-pattern {
background: radial-gradient(
circle,
var(--bg-neutral-dark-50, #eceef2) 1px,
transparent 1px
);
background-size: 12px 12px;
opacity: 1;
}
.masked-dots {
mask-image: radial-gradient(
circle at 50% 0%,
rgba(11, 12, 14, 0.1) 0%,
rgba(11, 12, 14, 0) 56.77%
);
-webkit-mask-image: radial-gradient(
circle at 50% 0%,
rgba(11, 12, 14, 0.1) 0%,
rgba(11, 12, 14, 0) 56.77%
);
}
.auth-page-gradient {
position: absolute;
left: 0;
right: 0;
top: 0;
margin: 0 auto;
height: 450px;
width: 100%;
flex-shrink: 0;
border-radius: 956px;
background: radial-gradient(
ellipse at center -500px,
rgba(78, 116, 248, 0.3) 0%,
transparent 70%
);
opacity: 0.3;
filter: blur(150px);
@media (min-width: 768px) {
height: 956px;
filter: blur(300px);
}
}
.auth-page-line-left,
.auth-page-line-right {
position: absolute;
top: 0;
width: 1px;
height: 100%;
background-image: repeating-linear-gradient(
to bottom,
var(--bg-ink-200, #23262e) 0px,
var(--bg-ink-200, #23262e) 4px,
transparent 4px,
transparent 8px
);
pointer-events: none;
@media (max-width: 1440px) {
display: none;
}
}
.auth-page-line-left {
left: calc(50% - 600px);
}
.auth-page-line-right {
left: calc(50% + 600px);
}
.auth-page-layout {
position: relative;
z-index: 1;
width: 100%;
max-width: 1440px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
@media (max-width: 1440px) {
padding: 0 24px;
}
}
.auth-page-content {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 8vh;
padding-bottom: 24px;
width: 100%;
position: relative;
@media (max-width: 768px) {
padding-top: 15vh;
align-items: center;
}
&.onboarding-flow {
padding-top: 0;
@media (max-width: 768px) {
padding-top: 0;
}
}
}
.lightMode {
.auth-page-wrapper {
background: var(--bg-base-white, #ffffff);
}
.bg-dot-pattern {
background: radial-gradient(circle, rgba(35, 38, 46, 1) 1px, transparent 1px);
background-size: 12px 12px;
}
.auth-page-gradient {
background: radial-gradient(
ellipse at center top,
rgba(78, 116, 248, 0.12) 0%,
transparent 60%
);
opacity: 0.8;
filter: blur(200px);
@media (min-width: 768px) {
filter: blur(300px);
}
}
.auth-page-line-left,
.auth-page-line-right {
background-image: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300, #e9e9e9) 0px,
var(--bg-vanilla-300, #e9e9e9) 4px,
transparent 4px,
transparent 8px
);
}
}

View File

@@ -1,41 +0,0 @@
import './AuthPageContainer.styles.scss';
import { PropsWithChildren } from 'react';
import AuthFooter from './AuthFooter';
import AuthHeader from './AuthHeader';
type AuthPageContainerProps = PropsWithChildren<{
isOnboarding?: boolean;
}>;
function AuthPageContainer({
children,
isOnboarding = false,
}: AuthPageContainerProps): JSX.Element {
return (
<div className="auth-page-wrapper">
<div className="auth-page-background">
<div className="auth-page-dots bg-dot-pattern masked-dots" />
<div className="auth-page-gradient" />
<div className="auth-page-line-left" />
<div className="auth-page-line-right" />
</div>
<div className="auth-page-layout">
<AuthHeader />
<main
className={`auth-page-content ${isOnboarding ? 'onboarding-flow' : ''}`}
>
{children}
</main>
<AuthFooter />
</div>
</div>
);
}
AuthPageContainer.defaultProps = {
isOnboarding: false,
};
export default AuthPageContainer;

View File

@@ -58,7 +58,6 @@
flex-direction: column;
gap: 16px;
padding-left: 30px;
margin-bottom: 1rem;
li {
position: relative;

View File

@@ -6,15 +6,13 @@ import ErrorIcon from 'assets/Error';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { BookOpenText, ChevronsDown } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { ReactNode } from 'react';
import APIError from 'types/api/error';
interface ErrorContentProps {
error: APIError;
icon?: ReactNode;
}
function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
function ErrorContent({ error }: ErrorContentProps): JSX.Element {
const {
url: errorUrl,
errors: errorMessages,
@@ -27,7 +25,9 @@ function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
<section className="error-content__summary-section">
<header className="error-content__summary">
<div className="error-content__summary-left">
<div className="error-content__icon-wrapper">{icon || <ErrorIcon />}</div>
<div className="error-content__icon-wrapper">
<ErrorIcon />
</div>
<div className="error-content__summary-text">
<h2 className="error-content__error-code">{errorCode}</h2>
@@ -95,8 +95,4 @@ function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
);
}
ErrorContent.defaultProps = {
icon: undefined,
};
export default ErrorContent;

View File

@@ -1,5 +1,4 @@
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
// eslint-disable-next-line import/namespace -- side-effect import that registers Chart.js date adapter
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';

View File

@@ -211,10 +211,7 @@ describe('VariableItem Integration Tests', () => {
await user.clear(textInput);
await user.type(textInput, 'new-text-value');
// Blur the input to trigger the value update
await user.tab();
// Should call onValueUpdate after blur
// Should call onValueUpdate after debounce
await waitFor(
() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(

View File

@@ -4,7 +4,7 @@ import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { memo, useEffect, useMemo, useRef } from 'react';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -33,7 +33,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
addTraceOperator,
panelType,
initialDataSource,
handleRunQuery,
} = useQueryBuilder();
const containerRef = useRef(null);
@@ -158,29 +157,10 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>): void => {
const target = e.target as HTMLElement | null;
const tagName = target?.tagName || '';
const isInputElement =
['INPUT', 'TEXTAREA', 'SELECT'].includes(tagName) ||
(target?.getAttribute('contenteditable') || '').toLowerCase() === 'true';
// Allow input elements in qb to run the query when Cmd/Ctrl + Enter is pressed
if (isInputElement && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleRunQuery();
}
},
[handleRunQuery],
);
return (
<QueryBuilderV2Provider>
<div className="query-builder-v2">
<div className="qb-content-container" onKeyDownCapture={handleKeyDown}>
<div className="qb-content-container">
{!isMultiQueryAllowed ? (
<QueryV2
ref={containerRef}

View File

@@ -11,7 +11,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { BarChart2, ChevronUp, ExternalLink, ScrollText } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -171,9 +171,6 @@ function QueryAddOns({
const [selectedViews, setSelectedViews] = useState<AddOn[]>([]);
const initializedRef = useRef(false);
const prevAvailableKeysRef = useRef<Set<string> | null>(null);
const { handleChangeQueryData } = useQueryOperations({
index,
query,
@@ -216,41 +213,23 @@ function QueryAddOns({
}
setAddOns(filteredAddOns);
const availableAddOnKeys = new Set(filteredAddOns.map((a) => a.key));
const previousKeys = prevAvailableKeysRef.current;
const hasAvailabilityItemsChanged =
previousKeys !== null &&
(previousKeys.size !== availableAddOnKeys.size ||
[...availableAddOnKeys].some((key) => !previousKeys.has(key)));
prevAvailableKeysRef.current = availableAddOnKeys;
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key),
);
if (!initializedRef.current || hasAvailabilityItemsChanged) {
initializedRef.current = true;
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key),
);
// Initial seeding from query values on mount
setSelectedViews(
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
);
return;
}
setSelectedViews((prev) =>
prev.filter((view) =>
filteredAddOns.some((addOn) => addOn.key === view.key),
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
// Filter and set selected views: add-ons that are both active and available
setSelectedViews(
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panelType, isListViewPanel, query, showReduceTo]);
}, [panelType, isListViewPanel, query]);
const handleOptionClick = (e: RadioChangeEvent): void => {
if (selectedViews.find((view) => view.key === e.target.value.key)) {

View File

@@ -1379,6 +1379,8 @@ function QuerySearch({
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(getCurrentExpression());
} else {
handleRunQuery();
}
return true;
},

View File

@@ -410,6 +410,8 @@ function TraceOperatorEditor({
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(value);
} else {
handleRunQuery();
}
return true;
},

View File

@@ -270,6 +270,44 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
});
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
const mockedHandleRunQuery = handleRunQueryMock as jest.MockedFunction<
() => void
>;
mockedHandleRunQuery.mockClear();
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
// Wait for CodeMirror to initialize
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
fireEvent.keyDown(editor, {
key: 'Enter',
code: 'Enter',
[modKey]: true,
keyCode: 13,
});
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled(), {
timeout: 2000,
});
});
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
const testExpression =
"http.status_code >= 500 AND service.name = 'frontend'";

View File

@@ -3,21 +3,14 @@
import '@testing-library/jest-dom';
import { jest } from '@jest/globals';
import { fireEvent, waitFor } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { render, screen, userEvent } from 'tests/test-utils';
import {
Having,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { render, screen } from 'tests/test-utils';
import { Having, IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { UseQueryOperations } from 'types/common/operations.types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../../QueryBuilderV2';
import { QueryV2 } from '../QueryV2';
// Local mocks for domain-specific heavy child components
jest.mock(
@@ -43,87 +36,16 @@ const mockedUseQueryOperations = jest.mocked(
useQueryOperations,
) as jest.MockedFunction<UseQueryOperations>;
describe('QueryBuilderV2 + QueryV2 - base render', () => {
let handleRunQueryMock: jest.MockedFunction<() => void>;
describe('QueryV2 - base render', () => {
beforeEach(() => {
const mockCloneQuery = jest.fn() as jest.MockedFunction<
(type: string, q: IBuilderQuery) => void
>;
handleRunQueryMock = jest.fn() as jest.MockedFunction<() => void>;
const baseQuery: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: '',
aggregations: [],
timeAggregation: '',
spaceAggregation: '',
temporality: '',
functions: [],
filter: undefined,
filters: { items: [], op: 'AND' },
groupBy: [],
expression: '',
disabled: false,
having: [] as Having[],
limit: 10,
stepInterval: null,
orderBy: [],
legend: 'A',
};
const currentQueryObj: Query = {
id: 'test',
unit: undefined,
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [baseQuery],
queryFormulas: [],
queryTraceOperator: [],
},
};
const updateAllQueriesOperators: QueryBuilderContextType['updateAllQueriesOperators'] = (
q,
) => q;
const updateQueriesData: QueryBuilderContextType['updateQueriesData'] = (q) =>
q;
mockedUseQueryBuilder.mockReturnValue(({
currentQuery: currentQueryObj,
stagedQuery: null,
lastUsedQuery: null,
setLastUsedQuery: jest.fn(),
supersetQuery: currentQueryObj,
setSupersetQuery: jest.fn(),
initialDataSource: null,
panelType: PANEL_TYPES.TABLE,
isEnabledQuery: true,
handleSetQueryData: jest.fn(),
handleSetTraceOperatorData: jest.fn(),
handleSetFormulaData: jest.fn(),
handleSetQueryItemData: jest.fn(),
handleSetConfig: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
removeAllQueryBuilderEntities: jest.fn(),
removeQueryTypeItemByIndex: jest.fn(),
addNewBuilderQuery: jest.fn(),
addNewFormula: jest.fn(),
removeTraceOperator: jest.fn(),
addTraceOperator: jest.fn(),
// Only fields used by QueryV2
cloneQuery: mockCloneQuery,
addNewQueryItem: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
handleRunQuery: handleRunQueryMock,
resetQuery: jest.fn(),
handleOnUnitsChange: jest.fn(),
updateAllQueriesOperators,
updateQueriesData,
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
panelType: null,
} as unknown) as QueryBuilderContextType);
mockedUseQueryOperations.mockReturnValue({
@@ -149,7 +71,40 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
});
it('renders limit input when dataSource is logs', () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
const baseQuery: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: '',
aggregations: [],
timeAggregation: '',
spaceAggregation: '',
temporality: '',
functions: [],
filter: undefined,
filters: { items: [], op: 'AND' },
groupBy: [],
expression: '',
disabled: false,
having: [] as Having[],
limit: 10,
stepInterval: null,
orderBy: [],
legend: 'A',
};
render(
<QueryV2
index={0}
isAvailableToDisable
query={baseQuery}
version="v4"
onSignalSourceChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
signalSourceChangeEnabled={false}
queriesCount={1}
showTraceOperator={false}
hasTraceOperator={false}
/>,
);
// Ensure the Limit add-on input is present and is of type number
const limitInput = screen.getByPlaceholderText(
@@ -160,43 +115,4 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
expect(limitInput).toHaveAttribute('name', 'limit');
expect(limitInput).toHaveAttribute('data-testid', 'input-Limit');
});
it('Cmd+Enter on an input triggers handleRunQuery via container handler', async () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
const limitInput = screen.getByPlaceholderText('Enter limit');
fireEvent.keyDown(limitInput, {
key: 'Enter',
code: 'Enter',
metaKey: true,
});
expect(handleRunQueryMock).toHaveBeenCalled();
const legendInput = screen.getByPlaceholderText('Write legend format');
fireEvent.keyDown(legendInput, {
key: 'Enter',
code: 'Enter',
metaKey: true,
});
expect(handleRunQueryMock).toHaveBeenCalled();
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
// Wait for CodeMirror to initialize
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
fireEvent.keyDown(editor, {
key: 'Enter',
code: 'Enter',
metaKey: true,
});
expect(handleRunQueryMock).toHaveBeenCalled();
});
});

View File

@@ -53,5 +53,4 @@ export enum QueryParams {
variables = 'variables',
version = 'version',
showNewCreateAlertsPage = 'showNewCreateAlertsPage',
source = 'source',
}

View File

@@ -301,7 +301,6 @@ export const initialQueryState: QueryState = {
builder: initialQueryBuilderData,
clickhouse_sql: [initialClickHouseData],
promql: [initialQueryPromQLData],
unit: '',
};
const initialQueryWithType: Query = {

View File

@@ -37,7 +37,10 @@ function ThresholdItem({
);
if (units.length === 0) {
component = (
<Tooltip trigger="hover" title="No compatible units available">
<Tooltip
trigger="hover"
title="Please select a Y-axis unit for the query first"
>
<Select
placeholder="Unit"
value={threshold.unit ? threshold.unit : null}

View File

@@ -47,17 +47,9 @@ export function getCategoryByOptionId(id: string): string | undefined {
}
export function getCategorySelectOptionByName(
name: string | undefined,
name: string,
): DefaultOptionType[] {
if (!name) {
return [];
}
const categories = getYAxisCategories(YAxisSource.ALERTS);
if (!categories.length) {
return [];
}
return (
categories
.find((category) => category.name === name)

View File

@@ -1,15 +1,12 @@
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import {
UniversalYAxisUnit,
YAxisSource,
} from 'components/YAxisUnitSelector/types';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -29,7 +26,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
alertState,
setAlertState,
isEditMode,
setThresholdState,
} = useCreateAlertState();
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
@@ -55,18 +51,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
}
}, [initialYAxisUnit, setAlertState, shouldUpdateYAxisUnit]);
const handleYAxisUnitChange = useCallback(
(value: UniversalYAxisUnit): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
// Reset all threshold units when the y-axis unit changes
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) => ({ ...t, unit: '' })),
});
},
[setAlertState, setThresholdState, thresholdState.thresholds],
);
const headline = (
<div className="chart-preview-headline">
<PlotTag
@@ -76,7 +60,9 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
<YAxisUnitSelector
value={yAxisUnit}
initialValue={initialYAxisUnit}
onChange={handleYAxisUnitChange}
onChange={(value): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
}}
source={YAxisSource.ALERTS}
loading={isLoading}
/>

View File

@@ -1,12 +1,6 @@
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable sonarjs/no-duplicate-string */
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
IDashboardVariable,
TSortVariableValuesType,
@@ -645,186 +639,4 @@ describe('VariableItem Component', () => {
await expectCircularDependencyError();
});
});
describe('Textbox Variable Default Value Handling', () => {
test('saves textbox variable with defaultValue and selectedValue set to textboxValue', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'my-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue and selectedValue equal to textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'my-default-value',
defaultValue: 'my-default-value',
selectedValue: 'my-default-value',
}),
expect.anything(),
);
});
test('saves textbox variable with empty values when textboxValue is empty', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: '',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with empty defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: '',
defaultValue: '',
selectedValue: '',
}),
expect.anything(),
);
});
test('updates textbox defaultValue and selectedValue when user changes textboxValue input', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'initial-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Change the textbox value
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.clear(textboxInput);
await user.type(textboxInput, 'updated-value');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with the updated defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'updated-value',
defaultValue: 'updated-value',
selectedValue: 'updated-value',
}),
expect.anything(),
);
});
test('non-textbox variables use variableDefaultValue instead of textboxValue', async () => {
const user = userEvent.setup();
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Query Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
textboxValue: 'should-not-be-used',
defaultValue: 'query-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue not being textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'QUERY',
defaultValue: 'query-default-value',
}),
expect.anything(),
);
// Verify that defaultValue is NOT the textboxValue
const savedVariable = onSave.mock.calls[0][1];
expect(savedVariable.defaultValue).not.toBe('should-not-be-used');
});
test('switching to textbox type sets defaultValue and selectedValue correctly on save', async () => {
const user = userEvent.setup();
// Start with a QUERY variable
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Switch to TEXTBOX type
const textboxButton = findButtonByText(TEXT.TEXTBOX);
expect(textboxButton).toBeInTheDocument();
if (textboxButton) {
await user.click(textboxButton);
}
// Enter a default value in the textbox input
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.type(textboxInput, 'new-textbox-default');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with type TEXTBOX and correct defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'new-textbox-default',
defaultValue: 'new-textbox-default',
selectedValue: 'new-textbox-default',
}),
expect.anything(),
);
});
});
});

View File

@@ -320,10 +320,6 @@ function VariableItem({
]);
const variableValue = useMemo(() => {
if (queryType === 'TEXTBOX') {
return variableTextboxValue;
}
if (variableMultiSelect) {
let value = variableData.selectedValue;
if (isEmpty(value)) {
@@ -356,8 +352,6 @@ function VariableItem({
variableData.selectedValue,
variableData.showALLOption,
variableDefaultValue,
variableTextboxValue,
queryType,
previewValues,
]);
@@ -373,10 +367,13 @@ function VariableItem({
multiSelect: variableMultiSelect,
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
sort: variableSortType,
// the reason we need to do this is because defaultValues are treated differently in case of textbox type
// They are the exact same and not like the other types where defaultValue is a separate field
defaultValue:
queryType === 'TEXTBOX' ? variableTextboxValue : variableDefaultValue,
...(queryType === 'TEXTBOX' && {
selectedValue: (variableData.selectedValue ||
variableTextboxValue) as never,
}),
...(queryType !== 'TEXTBOX' && {
defaultValue: variableDefaultValue as never,
}),
modificationUUID: generateUUID(),
id: variableData.id || generateUUID(),
order: variableData.order,

View File

@@ -25,12 +25,6 @@
}
}
&.focused {
.variable-value {
outline: 1px solid var(--bg-robin-400);
}
}
.variable-value {
display: flex;
min-width: 120px;
@@ -99,12 +93,6 @@
.lightMode {
.variable-item {
&.focused {
.variable-value {
border: 1px solid var(--bg-robin-400);
}
}
.variable-name {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);

View File

@@ -94,7 +94,7 @@ function DashboardVariableSelection(): JSX.Element | null {
cycleNodes,
});
}
}, [variables, variablesTableData]);
}, [setVariablesToGetUpdated, variables, variablesTableData]);
// this handles the case where the dependency order changes i.e. variable list updated via creation or deletion etc. and we need to refetch the variables
// also trigger when the global time changes

View File

@@ -80,12 +80,10 @@ describe('VariableItem', () => {
/>
</MockQueryClientProvider>,
);
expect(
screen.getByTestId('variable-textbox-test_variable'),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter value')).toBeInTheDocument();
});
test('calls onValueUpdate when Input value changes and blurs', async () => {
test('calls onChange event handler when Input value changes', async () => {
render(
<MockQueryClientProvider>
<VariableItem
@@ -104,19 +102,13 @@ describe('VariableItem', () => {
</MockQueryClientProvider>,
);
const inputElement = screen.getByTestId('variable-textbox-test_variable');
// Change the value
act(() => {
const inputElement = screen.getByPlaceholderText('Enter value');
fireEvent.change(inputElement, { target: { value: 'newValue' } });
});
// Blur the input to trigger the update
act(() => {
fireEvent.blur(inputElement);
});
await waitFor(() => {
// expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'testVariable',
'test_variable',

View File

@@ -8,14 +8,14 @@ import './DashboardVariableSelection.styles.scss';
import { orange } from '@ant-design/colors';
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { Input, InputRef, Popover, Tooltip, Typography } from 'antd';
import { Input, Popover, Tooltip, Typography } from 'antd';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -71,15 +71,6 @@ function VariableItem({
string | string[] | undefined
>(undefined);
// Local state for textbox input to ensure smooth editing experience
const [textboxInputValue, setTextboxInputValue] = useState<string>(
(variableData.selectedValue?.toString() ||
variableData.defaultValue?.toString()) ??
'',
);
const [isTextboxFocused, setIsTextboxFocused] = useState<boolean>(false);
const textboxInputRef = useRef<InputRef>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -380,7 +371,7 @@ function VariableItem({
}, [variableData.type, variableData.customValue]);
return (
<div className={`variable-item${isTextboxFocused ? ' focused' : ''}`}>
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
{variableData.description && (
@@ -393,40 +384,16 @@ function VariableItem({
<div className="variable-value">
{variableData.type === 'TEXTBOX' ? (
<Input
ref={textboxInputRef}
placeholder="Enter value"
data-testid={`variable-textbox-${variableData.id}`}
bordered={false}
value={textboxInputValue}
title={textboxInputValue}
key={variableData.selectedValue?.toString()}
defaultValue={variableData.selectedValue?.toString()}
onChange={(e): void => {
setTextboxInputValue(e.target.value);
debouncedHandleChange(e.target.value || '');
}}
onFocus={(): void => {
setIsTextboxFocused(true);
}}
onBlur={(e): void => {
setIsTextboxFocused(false);
const value = e.target.value.trim();
// If empty, reset to default value
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
const value = textboxInputValue.trim();
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
textboxInputRef.current?.blur();
}
style={{
width:
50 + ((variableData.selectedValue?.toString()?.length || 0) * 7 || 50),
}}
/>
) : (

View File

@@ -257,15 +257,6 @@ export const onUpdateVariableNode = (
): void => {
const visited = new Set<string>();
// If nodeToUpdate is not in topologicalOrder (e.g., CUSTOM variable),
// we still need to mark its children as needing updates
if (!topologicalOrder.includes(nodeToUpdate)) {
// Mark direct children of the node as visited so they get processed
(graph[nodeToUpdate] || []).forEach((child) => {
visited.add(child);
});
}
// Start processing from the node to update
topologicalOrder.forEach((node) => {
if (node === nodeToUpdate || visited.has(node)) {

View File

@@ -1,4 +1,4 @@
import { areArraysEqual, onUpdateVariableNode, VariableGraph } from './util';
import { areArraysEqual } from './util';
describe('areArraysEqual', () => {
it('should return true for equal arrays with same order', () => {
@@ -31,121 +31,3 @@ describe('areArraysEqual', () => {
expect(areArraysEqual(array1, array2)).toBe(true);
});
});
describe('onUpdateVariableNode', () => {
// Graph structure:
// deployment -> namespace -> service -> pod
// deployment has no parents, namespace depends on deployment, etc.
const graph: VariableGraph = {
deployment: ['namespace'],
namespace: ['service'],
service: ['pod'],
pod: [],
customVar: ['namespace'], // CUSTOM variable that affects namespace
};
const topologicalOrder = ['deployment', 'namespace', 'service', 'pod'];
it('should call callback for the node and all its descendants', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('deployment', graph, topologicalOrder, callback);
expect(visited).toEqual(['deployment', 'namespace', 'service', 'pod']);
});
it('should call callback starting from a middle node', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('namespace', graph, topologicalOrder, callback);
expect(visited).toEqual(['namespace', 'service', 'pod']);
});
it('should only call callback for the leaf node when updating leaf', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('pod', graph, topologicalOrder, callback);
expect(visited).toEqual(['pod']);
});
it('should handle CUSTOM variable not in topologicalOrder by updating its children', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
// customVar is not in topologicalOrder but has namespace as a child
onUpdateVariableNode('customVar', graph, topologicalOrder, callback);
// Should process namespace and its descendants (service, pod)
expect(visited).toEqual(['namespace', 'service', 'pod']);
});
it('should handle node not in graph gracefully', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('unknownNode', graph, topologicalOrder, callback);
// Should not call callback for any node since unknownNode has no children
expect(visited).toEqual([]);
});
it('should handle empty graph', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('deployment', {}, topologicalOrder, callback);
// deployment is in topologicalOrder, so callback is called for it
expect(visited).toEqual(['deployment']);
});
it('should handle empty topologicalOrder', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('deployment', graph, [], callback);
expect(visited).toEqual([]);
});
it('should handle CUSTOM variable with multiple children', () => {
const graphWithMultipleChildren: VariableGraph = {
...graph,
customMulti: ['namespace', 'service'], // CUSTOM variable affecting multiple nodes
};
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode(
'customMulti',
graphWithMultipleChildren,
topologicalOrder,
callback,
);
// Should process namespace, service, and pod (descendants)
expect(visited).toEqual(['namespace', 'service', 'pod']);
});
});

View File

@@ -4,7 +4,3 @@
gap: 8px;
align-items: center;
}
.rule-unit-selector {
width: 150px;
}

View File

@@ -15,7 +15,8 @@ import { DefaultOptionType } from 'antd/es/select';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
} from 'container/CreateAlertV2/AlertCondition/utils';
} from 'container/NewWidget/RightContainer/alertFomatCategories';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useTranslation } from 'react-i18next';
import {
AlertDef,
@@ -42,10 +43,10 @@ function RuleOptions({
setAlertDef,
queryCategory,
queryOptions,
yAxisUnit,
}: RuleOptionsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('alerts');
const { currentQuery } = useQueryBuilder();
const { ruleType } = alertDef;
@@ -364,9 +365,11 @@ function RuleOptions({
</InlineSelect>
);
const selectedCategory = getCategoryByOptionId(yAxisUnit);
const selectedCategory = getCategoryByOptionId(currentQuery?.unit || '');
const categorySelectOptions = getCategorySelectOptionByName(selectedCategory);
const categorySelectOptions = getCategorySelectOptionByName(
selectedCategory?.name,
);
const step3Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '3' : '2';
@@ -399,7 +402,6 @@ function RuleOptions({
<Form.Item noStyle>
<Select
className="rule-unit-selector"
getPopupContainer={popupContainer}
allowClear
showSearch
@@ -513,6 +515,5 @@ interface RuleOptionsProps {
setAlertDef: (a: AlertDef) => void;
queryCategory: EQueryType;
queryOptions: DefaultOptionType[];
yAxisUnit: string;
}
export default RuleOptions;

View File

@@ -788,18 +788,11 @@ function FormAlertRules({
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
const source = useMemo(() => urlQuery.get(QueryParams.source) as YAxisSource, [
urlQuery,
]);
// Only update automatically when creating a new metrics-based alert rule
const shouldUpdateYAxisUnit = useMemo(() => {
// Do not update if we are coming to the page from dashboards (we still show warning)
if (source === YAxisSource.DASHBOARDS) {
return false;
}
return isNewRule && alertType === AlertTypes.METRICS_BASED_ALERT;
}, [isNewRule, alertType, source]);
const shouldUpdateYAxisUnit = useMemo(
() => isNewRule && alertType === AlertTypes.METRICS_BASED_ALERT,
[isNewRule, alertType],
);
const { yAxisUnit: initialYAxisUnit, isLoading } = useGetYAxisUnit(
alertDef.condition.selectedQueryName,
@@ -914,7 +907,6 @@ function FormAlertRules({
alertDef={alertDef}
setAlertDef={setAlertDef}
queryOptions={queryOptions}
yAxisUnit={yAxisUnit || ''}
/>
{renderBasicInfo()}

View File

@@ -318,9 +318,7 @@ function GridCardGraph({
version={version}
threshold={threshold}
headerMenuList={menuList}
isFetchingResponse={
queryResponse.isFetching || variablesToGetUpdated.length > 0
}
isFetchingResponse={queryResponse.isFetching}
setRequestData={setRequestData}
onClickHandler={onClickHandler}
onDragSelect={onDragSelect}

View File

@@ -1,419 +1,44 @@
@import '@signozhq/design-tokens/dist/style.css';
.login-form-container {
display: flex;
justify-content: center;
width: 100%;
align-items: flex-start;
}
.login-form-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
text-align: center;
width: 100%;
}
.login-form-emoji {
width: 32px;
height: 32px;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.login-form-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 1;
letter-spacing: 0;
color: var(--levels-l1-foreground, #eceef2);
margin: 0 !important;
}
.login-form-description {
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
color: var(--semantic-secondary-foreground, #adb4c2);
max-width: 317px;
margin: 0 !important;
text-align: center;
}
.login-form-card {
width: 100%;
background: var(--semantic-secondary-background, #121317);
border: 1px solid var(--semantic-secondary-border, #23262e);
border-radius: 4px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.login-error-container {
margin-top: 24px;
width: 100%;
.error-content {
background: rgba(229, 72, 77, 0.1);
border: 1px solid rgba(229, 72, 77, 0.2);
border-radius: 4px;
&__summary-section {
border-bottom: 1px solid rgba(229, 72, 77, 0.2);
}
&__summary {
padding: 16px;
}
&__summary-left {
gap: 10px;
}
&__icon-wrapper {
width: 12px;
height: 12px;
flex-shrink: 0;
}
.login-error-icon {
color: var(--bg-cherry-200);
padding-top: 1px;
}
&__summary-text {
gap: 6px;
}
&__error-code {
color: #fadadb;
font-size: 13px;
font-weight: 500;
line-height: 1;
letter-spacing: -0.065px;
}
&__error-message {
color: #f5b6b8;
font-size: 13px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
}
&__message-badge {
padding: 0px 16px 16px;
}
&__message-badge-label-text {
color: #fadadb;
}
&__message-badge-line {
background-image: radial-gradient(
circle,
rgba(229, 72, 77, 0.3) 1px,
transparent 2px
);
}
&__messages-section {
padding: 0;
}
&__message-list {
max-height: 200px;
}
&__message-item {
color: #f5b6b8;
font-size: 13px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
&::before {
background: #f5b6b8;
}
}
&__scroll-hint {
background: rgba(229, 72, 77, 0.2);
}
&__scroll-hint-text {
color: #fadadb;
}
}
}
.lightMode {
.login-error-container {
.error-content {
background: rgba(229, 72, 77, 0.1);
border-color: rgba(229, 72, 77, 0.2);
&__error-code {
color: var(--bg-ink-100);
}
&__error-message {
color: var(--bg-ink-400);
}
&__docs-button {
color: var(--bg-ink-400);
border-color: var(--bg-vanilla-300, #e9e9e9);
background: transparent;
&:hover {
color: var(--bg-ink-100);
border-color: var(--bg-vanilla-400, #d1d5db);
background: transparent;
}
}
&__message-badge-label-text {
color: var(--bg-ink-100);
}
&__message-item {
color: var(--bg-ink-400);
&::before {
background: var(--bg-ink-400);
}
}
&__scroll-hint-text {
color: var(--bg-ink-100);
}
}
}
}
.password-label-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 12px;
> label {
margin-bottom: 0;
}
}
.forgot-password-link {
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
color: var(--text-neutral-dark-200) !important;
&:hover {
color: var(--text-neutral-dark-300) !important;
}
}
.login-form-input {
height: 32px;
background: var(--levels-l3-background, #23262e);
border: 1px solid var(--levels-l3-border, #2c303a);
border-radius: 2px;
padding: 6px 8px;
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
color: var(--levels-l1-foreground, #eceef2);
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
.login-form-header {
margin-bottom: 16px;
}
&:hover {
border-color: var(--levels-l3-border, #2c303a);
.login-form-header-text {
color: var(--text-vanilla-300);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
box-shadow: none;
}
// Select component styling to match Input
&.ant-select {
width: 100%;
height: 32px;
margin: 0;
padding: 0;
.ant-select-selector {
height: 32px !important;
min-height: 32px !important;
padding: 0 8px !important;
border-radius: 2px !important;
font-family: Inter, sans-serif !important;
font-size: 13px !important;
font-weight: 400 !important;
line-height: 1 !important;
letter-spacing: -0.065px !important;
background: var(--levels-l3-background, #23262e) !important;
border: 1px solid var(--levels-l3-border, #2c303a) !important;
display: flex !important;
align-items: center !important;
}
.ant-select-selection-search {
height: 20px;
line-height: 20px;
}
.ant-select-selection-item,
.ant-select-selection-placeholder {
line-height: 20px !important;
height: 20px !important;
font-family: Inter, sans-serif !important;
font-size: 13px !important;
font-weight: 400 !important;
letter-spacing: -0.065px !important;
display: flex !important;
align-items: center !important;
margin: 0 !important;
}
.ant-select-selection-placeholder {
color: var(--levels-l3-foreground, #747b8b) !important;
}
.ant-select-selection-item {
color: var(--levels-l1-foreground, #eceef2) !important;
}
&:hover .ant-select-selector {
border-color: var(--levels-l3-border, #2c303a) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--semantic-primary-background, #4e74f8) !important;
box-shadow: none !important;
}
}
// Remove border for orgId Select only
&.login-form-select-no-border {
&.ant-select .ant-select-selector {
border: none !important;
}
&.ant-select:hover .ant-select-selector {
border: none !important;
}
&.ant-select.ant-select-focused .ant-select-selector {
border: none !important;
box-shadow: none !important;
}
}
}
.login-form-actions {
width: 100%;
margin-top: 24px;
}
.login-submit-btn {
width: 100%;
height: 32px;
padding: 10px 16px;
background: var(--semantic-primary-background, #4e74f8);
border: none;
border-radius: 2px;
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 500;
line-height: 1;
color: var(--semantic-primary-foreground, #eceef2);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:hover:not(:disabled) {
background: var(--semantic-primary-background, #4e74f8);
opacity: 0.9;
}
&:disabled {
background: var(--semantic-primary-background, #4e74f8);
opacity: 0.6;
cursor: not-allowed;
}
}
.lightMode {
.login-form-title {
color: var(--text-ink-500);
}
.login-form-description {
color: var(--text-neutral-light-200, #80828d);
}
.login-form-card {
background: var(--bg-base-white, #ffffff);
border-color: var(--bg-vanilla-300, #e9e9e9);
}
.forgot-password-link {
color: var(--text-neutral-dark-300);
.next-btn {
padding: 0px 16px;
}
.login-form-input {
background: var(--bg-vanilla-200, #f5f5f5);
border-color: var(--bg-vanilla-300, #e9e9e9);
color: var(--text-ink-500);
height: 40px;
}
&::placeholder {
color: var(--text-neutral-light-200, #80828d);
.no-acccount {
color: var(--text-vanilla-300);
font-size: 12px;
margin-top: 16px;
}
}
.lightMode {
.login-form-container {
.login-form-header {
color: var(--text-ink-500);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
.login-form-header-text {
color: var(--text-ink-500);
}
// Select component light mode styling
&.ant-select {
.ant-select-selector {
background: var(--bg-vanilla-200, #f5f5f5) !important;
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
color: var(--text-ink-500) !important;
}
.ant-select-selection-placeholder {
color: var(--text-neutral-light-200, #80828d) !important;
}
&:hover .ant-select-selector {
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--semantic-primary-background, #4e74f8) !important;
}
.no-acccount {
color: var(--text-ink-500);
}
}
}

View File

@@ -146,7 +146,7 @@ describe('Login Component', () => {
).toBeInTheDocument();
expect(getByTestId('email')).toBeInTheDocument();
expect(getByTestId('initiate_login')).toBeInTheDocument();
expect(getByPlaceholderText('e.g. john@signoz.io')).toBeInTheDocument();
expect(getByPlaceholderText('name@yourcompany.com')).toBeInTheDocument();
});
it('shows loading state when version data is being fetched', () => {
@@ -213,27 +213,19 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
);
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -261,21 +253,10 @@ describe('Login Component', () => {
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -288,27 +269,19 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
);
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -325,33 +298,25 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockMultiOrgMixedAuth }),
),
),
);
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByText('Organization Name')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
await screen.findByRole('combobox');
// Click on the dropdown to reveal the options
await user.click(screen.getByRole('combobox'));
@@ -373,30 +338,25 @@ describe('Login Component', () => {
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await screen.findByRole('combobox');
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// Select CALLBACK_AUTHN_ORG
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
await screen.findByRole('button', { name: /sign in with sso/i });
await waitFor(() => {
expect(
screen.getByRole('button', { name: /login with callback/i }),
).toBeInTheDocument();
});
});
});
@@ -406,27 +366,19 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
);
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -441,7 +393,10 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgCallbackAuth })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgCallbackAuth }),
),
),
);
@@ -449,21 +404,10 @@ describe('Login Component', () => {
initialRoute: '/login?password=Y',
});
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -479,27 +423,19 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgCallbackAuth })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgCallbackAuth }),
),
),
);
const { getByTestId, queryByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -522,27 +458,19 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgCallbackAuth })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgCallbackAuth }),
),
),
);
const { getByTestId, queryByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -566,7 +494,10 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
rest.post('*/api/v2/sessions/email_password', async (_, res, ctx) =>
res(
@@ -578,21 +509,10 @@ describe('Login Component', () => {
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -617,7 +537,10 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockSingleOrgPasswordAuth }),
),
),
rest.post('*/api/v2/sessions/email_password', (_, res, ctx) =>
res(
@@ -635,21 +558,10 @@ describe('Login Component', () => {
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -687,7 +599,7 @@ describe('Login Component', () => {
});
await waitFor(() => {
expect(getByText('Authentication failed')).toBeInTheDocument();
expect(getByText('AUTH_ERROR')).toBeInTheDocument();
});
});
@@ -699,7 +611,7 @@ describe('Login Component', () => {
await waitFor(() => {
expect(queryByText('invalid-json')).not.toBeInTheDocument();
expect(getByText('Authentication failed')).toBeInTheDocument();
expect(getByText('AUTH_ERROR')).toBeInTheDocument();
});
});
});
@@ -710,27 +622,19 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockOrgWithWarning })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockOrgWithWarning }),
),
),
);
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -762,30 +666,24 @@ describe('Login Component', () => {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgWithWarning })),
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockMultiOrgWithWarning }),
),
),
);
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = getByTestId('email');
const nextButton = getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await screen.findByRole('combobox');
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// Select the organization with a warning
await user.click(screen.getByRole('combobox'));
@@ -815,21 +713,10 @@ describe('Login Component', () => {
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
// Button should be disabled during API call
@@ -850,25 +737,14 @@ describe('Login Component', () => {
// Initially shows "Next" button
expect(screen.getByTestId('initiate_login')).toBeInTheDocument();
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
// Should show "Sign in with Password" button for password auth
// Should show "Login" button for password auth
expect(screen.getByTestId('password_authn_submit')).toBeInTheDocument();
expect(screen.queryByTestId('initiate_login')).not.toBeInTheDocument();
});
@@ -892,21 +768,10 @@ describe('Login Component', () => {
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
@@ -945,21 +810,10 @@ describe('Login Component', () => {
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
const emailInput = screen.getByTestId('email');
const nextButton = screen.getByTestId('initiate_login');
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {

View File

@@ -1,16 +1,15 @@
import './Login.styles.scss';
import { Button } from '@signozhq/button';
import { Form, Input, Select, Tooltip, Typography } from 'antd';
import { Button, Form, Input, Select, Space, Tooltip, Typography } from 'antd';
import getVersion from 'api/v1/version/get';
import get from 'api/v2/sessions/context/get';
import post from 'api/v2/sessions/email_password/post';
import afterLogin from 'AppRoutes/utils';
import AuthError from 'components/AuthError/AuthError';
import ROUTES from 'constants/routes';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { ArrowRight } from 'lucide-react';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { ErrorV2 } from 'types/api';
@@ -38,7 +37,6 @@ type FormValues = {
url: string;
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function Login(): JSX.Element {
const urlQueryParams = useUrlQuery();
// override for callbackAuthN in case of some misconfiguration
@@ -63,12 +61,7 @@ function Login(): JSX.Element {
setIsLoadingSessionsContext,
] = useState<boolean>(false);
const [form] = Form.useForm<FormValues>();
const [errorMessage, setErrorMessage] = useState<APIError>();
// Watch form values for validation
const email = Form.useWatch('email', form);
const password = Form.useWatch('password', form);
const orgId = Form.useWatch('orgId', form);
const { showErrorModal } = useErrorModal();
// setupCompleted information to route to signup page in case setup is incomplete
const {
@@ -97,7 +90,6 @@ function Login(): JSX.Element {
const onNextHandler = async (): Promise<void> => {
const email = form.getFieldValue('email');
setIsLoadingSessionsContext(true);
setErrorMessage(undefined);
try {
const sessionsContextResponse = await get({
@@ -110,7 +102,7 @@ function Login(): JSX.Element {
setSessionsOrgId(sessionsContextResponse.data.orgs[0].id);
}
} catch (error) {
setErrorMessage(error as APIError);
showErrorModal(error as APIError);
}
setIsLoadingSessionsContext(false);
};
@@ -189,7 +181,6 @@ function Login(): JSX.Element {
const onSubmitHandler: () => Promise<void> = async () => {
setIsSubmitting(true);
setErrorMessage(undefined);
try {
if (isPasswordAuthN) {
@@ -214,7 +205,7 @@ function Login(): JSX.Element {
window.location.href = url;
}
} catch (error) {
setErrorMessage(error as APIError);
showErrorModal(error as APIError);
} finally {
setIsSubmitting(false);
}
@@ -222,7 +213,7 @@ function Login(): JSX.Element {
useEffect(() => {
if (callbackAuthError) {
setErrorMessage(
showErrorModal(
new APIError({
httpStatusCode: 500,
error: {
@@ -240,140 +231,110 @@ function Login(): JSX.Element {
callbackAuthErrorCode,
callbackAuthErrorMessage,
callbackAuthErrorURL,
setErrorMessage,
showErrorModal,
]);
useEffect(() => {
if (sessionsOrgWarning) {
setErrorMessage(
showErrorModal(
new APIError({
httpStatusCode: 400,
error: {
code: sessionsOrgWarning.code,
message: sessionsOrgWarning.message,
url: sessionsOrgWarning.url,
errors: sessionsOrgWarning.errors,
},
httpStatusCode: 400,
}),
);
}
}, [sessionsOrgWarning, setErrorMessage]);
// Validation helpers
const isEmailValid = Boolean(
email?.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email),
);
const isNextButtonEnabled =
isEmailValid && !versionLoading && !sessionsContextLoading;
const isSubmitButtonEnabled = useMemo((): boolean => {
if (!isEmailValid || isSubmitting) return false;
const hasMultipleOrgs = (sessionsContext?.orgs.length ?? 0) > 1;
if (hasMultipleOrgs && !orgId) {
return false;
}
return !(isPasswordAuthN && !password?.trim());
}, [
isEmailValid,
isSubmitting,
sessionsContext,
orgId,
isPasswordAuthN,
password,
]);
}, [sessionsOrgWarning, showErrorModal]);
return (
<div className="login-form-container">
<FormContainer form={form} onFinish={onSubmitHandler}>
<div className="login-form-header">
<div className="login-form-emoji">
<img src="/svgs/tv.svg" alt="TV" width="32" height="32" />
</div>
<Typography.Title level={4} className="login-form-title">
Sign in to your workspace
</Typography.Title>
<Typography.Paragraph className="login-form-description">
<Typography.Paragraph className="login-form-header-text">
Sign in to monitor, trace, and troubleshoot your applications
effortlessly.
</Typography.Paragraph>
</div>
<div className="login-form-card">
<ParentContainer>
<Label htmlFor="signupEmail" style={{ marginTop: 0 }}>
Email
</Label>
<FormContainer.Item name="email">
<Input
type="email"
id="email"
data-testid="email"
required
placeholder="name@yourcompany.com"
autoFocus
disabled={versionLoading}
className="login-form-input"
onPressEnter={onNextHandler}
/>
</FormContainer.Item>
</ParentContainer>
{sessionsContext && sessionsContext.orgs.length > 1 && (
<ParentContainer>
<Label htmlFor="signupEmail">Email address</Label>
<FormContainer.Item name="email">
<Input
type="email"
id="email"
data-testid="email"
required
placeholder="e.g. john@signoz.io"
autoFocus
disabled={versionLoading}
<Label htmlFor="orgId">Organization Name</Label>
<FormContainer.Item name="orgId">
<Select
id="orgId"
data-testid="orgId"
className="login-form-input"
onPressEnter={onNextHandler}
placeholder="Select your organization"
options={sessionsContext.orgs.map((org) => ({
value: org.id,
label: org.name || 'default',
}))}
onChange={(value: string): void => {
setSessionsOrgId(value);
}}
/>
</FormContainer.Item>
</ParentContainer>
)}
{sessionsContext && sessionsContext.orgs.length > 1 && (
<ParentContainer>
<Label htmlFor="orgId">Organization Name</Label>
<FormContainer.Item name="orgId">
<Select
id="orgId"
data-testid="orgId"
className="login-form-input login-form-select-no-border"
placeholder="Select your organization"
options={sessionsContext.orgs.map((org) => ({
value: org.id,
label: org.name || 'default',
}))}
onChange={(value: string): void => {
setSessionsOrgId(value);
}}
/>
</FormContainer.Item>
</ParentContainer>
)}
{sessionsContext && isPasswordAuthN && (
<ParentContainer>
<Label htmlFor="Password">Password</Label>
<FormContainer.Item name="password">
<Input.Password
required
id="currentPassword"
data-testid="password"
disabled={isSubmitting}
className="login-form-input"
/>
</FormContainer.Item>
{sessionsContext && isPasswordAuthN && (
<ParentContainer>
<div className="password-label-container">
<Label htmlFor="Password">Password</Label>
<Tooltip title="Ask your admin to reset your password and send you a new invite link">
<Typography.Link className="forgot-password-link">
Forgot password?
</Typography.Link>
</Tooltip>
</div>
<FormContainer.Item name="password">
<Input.Password
required
placeholder="Enter password"
id="currentPassword"
data-testid="password"
disabled={isSubmitting}
className="login-form-input"
/>
</FormContainer.Item>
</ParentContainer>
)}
</div>
<div style={{ marginTop: 8 }}>
<Tooltip title="Ask your admin to reset your password and send you a new invite link">
<Typography.Link>Forgot password?</Typography.Link>
</Tooltip>
</div>
</ParentContainer>
)}
{errorMessage && <AuthError error={errorMessage} />}
<div className="login-form-actions">
<Space
style={{ marginTop: 16 }}
align="start"
direction="vertical"
size={20}
>
{!sessionsContext && (
<Button
disabled={!isNextButtonEnabled}
variant="solid"
disabled={versionLoading || sessionsContextLoading}
type="primary"
onClick={onNextHandler}
data-testid="initiate_login"
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
className="periscope-btn primary next-btn"
icon={<ArrowRight size={12} />}
>
Next
</Button>
@@ -381,34 +342,32 @@ function Login(): JSX.Element {
{sessionsContext && isCallbackAuthN && (
<Button
disabled={!isSubmitButtonEnabled}
variant="solid"
type="submit"
color="primary"
disabled={isSubmitting}
type="primary"
htmlType="submit"
data-testid="callback_authn_submit"
data-attr="signup"
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
className="periscope-btn primary next-btn"
icon={<ArrowRight size={12} />}
>
Sign in with SSO
Login With Callback
</Button>
)}
{sessionsContext && isPasswordAuthN && (
<Button
disabled={!isSubmitButtonEnabled}
variant="solid"
color="primary"
disabled={isSubmitting}
type="primary"
data-testid="password_authn_submit"
type="submit"
htmlType="submit"
data-attr="signup"
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
className="periscope-btn primary next-btn"
icon={<ArrowRight size={12} />}
>
Sign in with Password
Login
</Button>
)}
</div>
</Space>
</FormContainer>
</div>
);

View File

@@ -1,21 +1,27 @@
import { Form } from 'antd';
import { Card, Form } from 'antd';
import styled from 'styled-components';
export const Label = styled.label`
font-family: var(--font-family-inter, Inter), sans-serif;
font-size: 13px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.065px;
color: var(--levels-l1-foreground, #eceef2);
margin-bottom: 12px;
display: block;
.lightMode & {
color: var(--text-ink-500);
export const FormWrapper = styled(Card)`
display: flex;
justify-content: center;
min-width: 390px;
min-height: 430px;
max-width: 432px;
flex: 1;
align-items: flex-start;
&&&.ant-card-body {
min-width: 100%;
}
`;
export const Label = styled.label`
margin-bottom: 11px;
margin-top: 19px;
display: inline-block;
font-size: 1rem;
line-height: 24px;
`;
export const FormContainer = styled(Form)`
display: flex;
flex-direction: column;
@@ -24,58 +30,9 @@ export const FormContainer = styled(Form)`
& .ant-form-item {
margin-bottom: 0px;
width: 100%;
& .ant-select {
width: 100%;
margin: 0;
}
& .ant-form-item-control {
width: 100%;
}
& .ant-form-item-control-input {
width: 100%;
}
& .ant-form-item-control-input-content {
width: 100%;
}
}
& .ant-input,
& .ant-input-password,
& .ant-select-selector {
background: var(--levels-l3-background, #23262e) !important;
border-color: var(--levels-l3-border, #2c303a) !important;
color: var(--levels-l1-foreground, #eceef2) !important;
.lightMode & {
background: var(--bg-vanilla-200, #f5f5f5) !important;
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
color: var(--text-ink-500) !important;
}
}
& .ant-input::placeholder {
color: var(--levels-l3-foreground, #747b8b) !important;
.lightMode & {
color: var(--text-neutral-light-200, #80828d) !important;
}
}
& .ant-input:focus,
& .ant-input-password:focus,
& .ant-select-focused .ant-select-selector {
border-color: var(--semantic-primary-background, #4e74f8) !important;
box-shadow: none !important;
}
`;
export const ParentContainer = styled.div`
width: 100%;
display: flex;
flex-direction: column;
`;

View File

@@ -80,14 +80,14 @@ describe('splitQueryIntoOneChartPerQuery', () => {
expect(result[2].builder.queryData).toHaveLength(2); // 2 disabled queries
expect(result[2].builder.queryData[0].disabled).toBe(true);
expect(result[2].builder.queryData[1].disabled).toBe(true);
expect(result[2].unit).toBe('');
expect(result[2].unit).toBeUndefined();
// Verify query 4 has the correct data
expect(result[3].builder.queryFormulas).toHaveLength(1);
expect(result[3].builder.queryFormulas[0]).toEqual(MOCK_FORMULA_DATA);
expect(result[3].builder.queryData).toHaveLength(2); // 2 disabled queries
expect(result[3].builder.queryData[0].disabled).toBe(true);
expect(result[3].builder.queryData[1].disabled).toBe(true);
expect(result[3].unit).toBe('');
expect(result[3].unit).toBeUndefined();
});
});

View File

@@ -1,84 +0,0 @@
.license-section {
display: flex;
flex-direction: column;
gap: 16px;
.license-section-header {
display: flex;
flex-direction: column;
gap: 4px;
.license-section-title {
color: #fff;
font-family: Inter;
font-size: 16px;
font-style: normal;
line-height: 24px;
letter-spacing: -0.08px;
}
}
.license-section-content {
display: flex;
flex-direction: column;
gap: 16px;
.license-section-content-item {
padding: 16px;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
.license-section-content-item-title-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--Vanilla-300, #eee);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: normal;
letter-spacing: -0.07px;
margin-bottom: 8px;
}
.license-section-content-item-description {
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
line-height: 20px;
letter-spacing: -0.07px;
}
}
}
}
.lightMode {
.license-section {
.license-section-header {
.license-section-title {
color: var(--bg-ink-400);
}
}
.license-section-content {
.license-section-content-item {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.license-section-content-item-title-action {
color: var(--bg-ink-400);
}
.license-section-content-item-description {
color: var(--bg-ink-300);
}
}
}
}
}

View File

@@ -1,65 +0,0 @@
import './LicenseSection.styles.scss';
import { Button } from '@signozhq/button';
import { Typography } from 'antd';
import { useNotifications } from 'hooks/useNotifications';
import { Copy } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useCopyToClipboard } from 'react-use';
function LicenseSection(): JSX.Element | null {
const { activeLicense } = useAppContext();
const { notifications } = useNotifications();
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 => {
handleCopyToClipboard(text);
notifications.success({
message: 'Copied to clipboard',
});
};
if (!activeLicense?.key) {
return <></>;
}
return (
<div className="license-section">
<div className="license-section-header">
<div className="license-section-title">License</div>
</div>
<div className="license-section-content">
<div className="license-section-content-item">
<div className="license-section-content-item-title-action">
<span>License key</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Typography.Text code>{getMaskedKey(activeLicense.key)}</Typography.Text>
<Button
variant="ghost"
aria-label="Copy license key"
data-testid="license-key-copy-btn"
onClick={(): void => handleCopyKey(activeLicense.key)}
>
<Copy size={14} />
</Button>
</span>
</div>
<div className="license-section-content-item-description">
Your SigNoz license key.
</div>
</div>
</div>
</div>
);
}
export default LicenseSection;

View File

@@ -1 +0,0 @@
export { default } from './LicenseSection';

View File

@@ -1,31 +1,8 @@
import userEvent from '@testing-library/user-event';
import MySettingsContainer from 'container/MySettings';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from 'tests/test-utils';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
const toggleThemeFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, (text: string) => void] => [
null,
copyToClipboardFn,
],
}));
jest.mock('api/v1/user/id/update', () => ({
__esModule: true,
default: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
}));
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
@@ -67,7 +44,6 @@ const PASSWORD_VALIDATION_MESSAGE_TEST_ID = 'password-validation-message';
describe('MySettings Flows', () => {
beforeEach(() => {
jest.clearAllMocks();
editUserFn.mockResolvedValue({});
render(<MySettingsContainer />);
});
@@ -239,71 +215,4 @@ describe('MySettings Flows', () => {
expect(submitButton).not.toBeDisabled();
});
});
describe('License section', () => {
it('Should render license section content when license key exists', () => {
expect(screen.getByText('License')).toBeInTheDocument();
expect(screen.getByText('License key')).toBeInTheDocument();
expect(screen.getByText('Your SigNoz license key.')).toBeInTheDocument();
});
it('Should not render license section when license key is missing', () => {
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: null,
},
});
const scoped = within(container);
expect(scoped.queryByText('License')).not.toBeInTheDocument();
expect(scoped.queryByText('License key')).not.toBeInTheDocument();
expect(
scoped.queryByText('Your SigNoz license key.'),
).not.toBeInTheDocument();
});
it('Should mask license key in the UI', () => {
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: {
key: 'abcd',
} as any,
},
});
expect(within(container).getByText('ab********cd')).toBeInTheDocument();
});
it('Should not mask license key if it is too short', () => {
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: {
key: 'abc',
} as any,
},
});
expect(within(container).getByText('abc')).toBeInTheDocument();
});
it('Should copy license key and show success toast', async () => {
const user = userEvent.setup();
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: {
key: 'test-license-key-12345',
} as any,
},
});
await user.click(within(container).getByTestId('license-key-copy-btn'));
await waitFor(() => {
expect(copyToClipboardFn).toHaveBeenCalledWith('test-license-key-12345');
expect(successNotification).toHaveBeenCalledWith({
message: 'Copied to clipboard',
});
});
});
});
});

View File

@@ -15,7 +15,6 @@ import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import LicenseSection from './LicenseSection';
import TimezoneAdaptation from './TimezoneAdaptation/TimezoneAdaptation';
import UserInfo from './UserInfo';
@@ -231,8 +230,6 @@ function MySettings(): JSX.Element {
</div>
</div>
</div>
<LicenseSection />
</div>
);
}

View File

@@ -73,7 +73,6 @@ const compositeQueryParam = {
},
],
id: '12e1d311-cb47-4b76-af68-65d8e85c9e0d',
unit: '',
};
jest.mock('react-router-dom', () => ({

View File

@@ -1,16 +1,13 @@
/* eslint-disable sonarjs/cognitive-complexity */
import '../OnboardingQuestionaire.styles.scss';
import { Button } from '@signozhq/button';
import { Checkbox } from '@signozhq/checkbox';
import { Input } from '@signozhq/input';
import { Color } from '@signozhq/design-tokens';
import { Button, Checkbox, Input, Typography } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import logEvent from 'api/common/logEvent';
import { ArrowRight } from 'lucide-react';
import { ArrowLeft, ArrowRight, CheckCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
export interface SignozDetails {
interestInSignoz: string[] | null;
otherInterestInSignoz: string | null;
@@ -21,6 +18,7 @@ interface AboutSigNozQuestionsProps {
signozDetails: SignozDetails;
setSignozDetails: (details: SignozDetails) => void;
onNext: () => void;
onBack: () => void;
}
const interestedInOptions: Record<string, string> = {
@@ -36,6 +34,7 @@ export function AboutSigNozQuestions({
signozDetails,
setSignozDetails,
onNext,
onBack,
}: AboutSigNozQuestionsProps): JSX.Element {
const [interestInSignoz, setInterestInSignoz] = useState<string[]>(
signozDetails?.interestInSignoz || [],
@@ -68,12 +67,6 @@ export function AboutSigNozQuestions({
}
};
const createInterestChangeHandler = (option: string) => (
checked: boolean,
): void => {
handleInterestChange(option, Boolean(checked));
};
const handleOnNext = (): void => {
setSignozDetails({
discoverSignoz,
@@ -90,12 +83,24 @@ export function AboutSigNozQuestions({
onNext();
};
const handleOnBack = (): void => {
setSignozDetails({
discoverSignoz,
interestInSignoz,
otherInterestInSignoz,
});
onBack();
};
return (
<div className="questions-container">
<OnboardingQuestionHeader
title="Set up your workspace"
subtitle="Tailor SigNoz to suit your observability needs."
/>
<Typography.Title level={3} className="title">
Tell Us About Your Interest in SigNoz
</Typography.Title>
<Typography.Paragraph className="sub-title">
We&apos;d love to know a little bit about you and your interest in SigNoz
</Typography.Paragraph>
<div className="questions-form-container">
<div className="questions-form">
@@ -118,28 +123,37 @@ export function AboutSigNozQuestions({
{Object.keys(interestedInOptions).map((option: string) => (
<div key={option} className="checkbox-item">
<Checkbox
id={`checkbox-${option}`}
checked={interestInSignoz.includes(option)}
onCheckedChange={createInterestChangeHandler(option)}
labelName={interestedInOptions[option]}
/>
onChange={(e): void => handleInterestChange(option, e.target.checked)}
>
{interestedInOptions[option]}
</Checkbox>
</div>
))}
<div className="checkbox-item checkbox-item-others">
<div className="checkbox-item">
<Checkbox
id="others-checkbox"
checked={interestInSignoz.includes('Others')}
onCheckedChange={createInterestChangeHandler('Others')}
labelName={interestInSignoz.includes('Others') ? '' : 'Others'}
/>
onChange={(e): void =>
handleInterestChange('Others', e.target.checked)
}
>
Others
</Checkbox>
{interestInSignoz.includes('Others') && (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="What got you interested in SigNoz?"
placeholder="Please specify your interest"
value={otherInterestInSignoz}
autoFocus
addonAfter={
otherInterestInSignoz !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
}
onChange={(e): void => setOtherInterestInSignoz(e.target.value)}
/>
)}
@@ -148,16 +162,20 @@ export function AboutSigNozQuestions({
</div>
</div>
<div className="onboarding-buttons-container">
<div className="next-prev-container">
<Button type="default" className="next-button" onClick={handleOnBack}>
<ArrowLeft size={14} />
Back
</Button>
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${isNextDisabled ? 'disabled' : ''}`}
type="primary"
className={`next-button ${isNextDisabled ? 'disabled' : ''}`}
onClick={handleOnNext}
disabled={isNextDisabled}
suffixIcon={<ArrowRight size={12} />}
>
Next
<ArrowRight size={14} />
</Button>
</div>
</div>

View File

@@ -1,301 +1,30 @@
.invite-team-members-table {
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.invite-team-members-table-header {
display: flex;
gap: 16px;
align-items: center;
flex-shrink: 0;
height: auto;
width: 100%;
justify-content: flex-end;
> div:first-child {
flex: 0 0 180px;
width: 180px;
}
> div:nth-child(2) {
flex: 1 0 0;
min-width: 0;
}
> div:last-child {
flex: 0 0 32px;
width: 32px;
}
.table-header-cell {
color: var(--levels-l1-foreground, #eceef2);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 100%;
letter-spacing: -0.065px;
}
}
.invite-team-members-container {
display: flex;
flex-direction: column;
gap: 16px !important;
width: 100%;
flex: 0 1 auto;
min-height: 0;
overflow-x: hidden;
}
.invite-team-members-error-callout {
background: rgba(229, 72, 77, 0.1);
border: 1px solid rgba(229, 72, 77, 0.2);
border-radius: 4px;
animation: horizontal-shaking 300ms ease-out;
}
.team-member-row {
display: flex;
width: 100%;
> div:first-child {
flex: 0 0 180px;
width: 180px;
margin-right: 16px;
}
> div:nth-child(2) {
flex: 1 0 0;
min-width: 0;
margin-right: 8px;
}
> div:last-child {
flex: 0 0 32px;
width: 32px;
}
}
.team-member-cell {
display: flex;
flex-direction: column;
gap: 8px;
&.email-cell {
width: 180px;
flex: 0 0 180px;
}
&.role-cell {
flex: 1 0 0;
min-width: 0;
}
&.action-cell {
display: flex;
align-items: flex-end;
justify-content: center;
flex: 0 0 32px;
width: 32px;
height: 32px;
}
}
.team-member-email-input {
width: 100%;
height: 32px !important;
height: 32px !important;
border-radius: 2px;
background: var(--levels-l3-background, #23262e);
border: 1px solid var(--levels-l3-border, #2c303a);
color: var(--levels-l1-foreground, #eceef2);
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
padding: 6px 8px;
box-sizing: border-box;
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
}
&:hover {
border-color: var(--levels-l3-border, #2c303a);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
box-shadow: none;
}
}
.team-member-role-select {
width: 100%;
.ant-select-selector {
height: 32px !important;
border-radius: 2px !important;
background: var(--levels-l3-background, #23262e) !important;
border: 1px solid var(--levels-l3-border, #2c303a) !important;
color: var(--levels-l1-foreground, #eceef2) !important;
font-family: Inter, sans-serif !important;
font-size: 13px !important;
font-weight: 400 !important;
line-height: 1 !important;
letter-spacing: -0.065px !important;
padding: 0 8px !important;
box-sizing: border-box !important;
.ant-select-selection-placeholder {
color: var(--levels-l3-foreground, #747b8b) !important;
}
.ant-select-selection-item {
color: var(--levels-l1-foreground, #eceef2) !important;
line-height: 30px !important;
}
}
.ant-select-arrow {
color: var(--levels-l3-foreground, #747b8b) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--semantic-primary-background, #4e74f8) !important;
}
&:hover .ant-select-selector {
border-color: var(--semantic-primary-background, #4e74f8) !important;
}
}
.remove-team-member-button {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: #e5484d !important;
opacity: 0.6 !important;
cursor: pointer;
padding: 0 !important;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none !important;
svg {
color: #e5484d !important;
width: 12px !important;
height: 12px !important;
}
&:hover {
background: rgba(229, 72, 77, 0.1) !important;
opacity: 0.9 !important;
color: #e5484d !important;
svg {
color: #e5484d !important;
}
}
&:active {
opacity: 0.7 !important;
background: rgba(229, 72, 77, 0.15) !important;
}
&:focus-visible {
outline: 2px solid var(--semantic-primary-background, #4e74f8);
outline-offset: 2px;
}
}
.email-error-message,
.role-error-message {
font-size: 12px;
font-weight: 400;
line-height: 16px;
margin-top: 4px;
width: 100%;
display: block;
color: var(--bg-cherry-500);
}
.invite-team-members-add-another-member-container {
width: 100%;
display: flex !important;
justify-content: flex-start !important;
align-items: center;
margin-top: 0;
flex-shrink: 0;
height: auto;
}
.add-another-member-button {
.team-member-container {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 2px;
border: 1px dashed var(--semantic-secondary-border, #23262e) !important;
background: transparent !important;
color: var(--semantic-secondary-foreground, #adb4c2);
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 500;
line-height: 1;
letter-spacing: 0;
height: auto !important;
padding: 6px 8px;
transition: all 0.2s;
cursor: pointer;
// Ensure icon is visible
svg,
[class*='icon'] {
color: var(--semantic-secondary-foreground, #adb4c2) !important;
display: inline-block !important;
opacity: 1 !important;
}
.team-member-role-select {
width: 20%;
button,
& {
border: 1px dashed var(--semantic-secondary-border, #23262e) !important;
background: transparent !important;
}
&:hover {
border-color: var(--semantic-primary-background, #4e74f8) !important;
border-style: dashed !important;
color: var(--levels-l1-foreground, #eceef2);
background: rgba(78, 116, 248, 0.1) !important;
svg,
[class*='icon'] {
color: var(--levels-l1-foreground, #eceef2) !important;
}
button,
& {
border-color: var(--semantic-primary-background, #4e74f8) !important;
border-style: dashed !important;
background: rgba(78, 116, 248, 0.1) !important;
.ant-select-selector {
border: 1px solid #1d212d;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
&:focus-visible {
outline: 2px solid var(--semantic-primary-background, #4e74f8);
outline-offset: 2px;
.team-member-email-input {
width: 80%;
background-color: #121317;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-input,
.ant-input-group-addon {
background-color: #121317 !important;
border-right: 0px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
}
}
@@ -356,112 +85,19 @@
}
.lightMode {
.invite-team-members-table-header {
.table-header-cell {
color: var(--semantic-secondary-foreground, #747b8b);
}
}
.team-member-email-input {
background: var(--bg-vanilla-200, #f5f5f5) !important;
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
color: var(--text-ink-500, #1a1d26) !important;
input {
background: var(--bg-vanilla-200, #f5f5f5) !important;
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
color: var(--text-ink-500, #1a1d26) !important;
&::placeholder {
color: var(--text-neutral-light-200, #80828d) !important;
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8) !important;
.team-member-container {
.team-member-role-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
}
}
&::placeholder {
color: var(--text-neutral-light-200, #80828d) !important;
}
.team-member-email-input {
background-color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8) !important;
}
}
.team-member-role-select {
.ant-select-selector {
background: var(--levels-l3-background, #ffffff) !important;
border: 1px solid var(--levels-l3-border, #e9e9e9) !important;
color: var(--levels-l1-foreground, #1a1d26) !important;
.ant-select-selection-placeholder {
color: var(--levels-l3-foreground, #747b8b) !important;
}
.ant-select-selection-item {
color: var(--levels-l1-foreground, #1a1d26) !important;
}
}
.ant-select-arrow {
color: var(--levels-l3-foreground, #747b8b) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--semantic-primary-background, #4e74f8) !important;
}
&:hover .ant-select-selector {
border-color: var(--semantic-primary-background, #4e74f8) !important;
}
}
.remove-team-member-button {
border: none !important;
background: transparent !important;
color: var(--bg-cherry-500, #f56565) !important;
svg {
color: var(--bg-cherry-500, #f56565) !important;
}
&:hover {
background: rgba(245, 101, 101, 0.1) !important;
color: var(--bg-cherry-500, #f56565) !important;
svg {
color: var(--bg-cherry-500, #f56565) !important;
}
}
}
.add-another-member-button {
border: 1px dashed var(--semantic-secondary-border, #e9e9e9) !important;
background: transparent !important;
color: var(--semantic-secondary-foreground, #747b8b);
svg,
[class*='icon'] {
color: var(--semantic-secondary-foreground, #747b8b) !important;
display: inline-block !important;
opacity: 1 !important;
}
&:hover {
border-color: var(--semantic-primary-background, #4e74f8) !important;
border-style: dashed !important;
color: var(--levels-l1-foreground, #1a1d26);
background: rgba(78, 116, 248, 0.1) !important;
svg,
[class*='icon'] {
color: var(--levels-l1-foreground, #1a1d26) !important;
.ant-input,
.ant-input-group-addon {
background-color: var(--bg-vanilla-100) !important;
}
}
}
@@ -484,21 +120,3 @@
}
}
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -1,29 +1,25 @@
import './InviteTeamMembers.styles.scss';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { Input } from '@signozhq/input';
import { Select, Typography } from 'antd';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Select, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import inviteUsers from 'api/v1/invite/bulk/create';
import AuthError from 'components/AuthError/AuthError';
import { useNotifications } from 'hooks/useNotifications';
import { cloneDeep, debounce, isEmpty } from 'lodash-es';
import {
ArrowLeft,
ArrowRight,
ChevronDown,
CircleAlert,
CheckCircle,
Loader2,
Plus,
Trash2,
TriangleAlert,
X,
} from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from 'react-query';
import APIError from 'types/api/error';
import { v4 as uuid } from 'uuid';
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
interface TeamMember {
email: string;
role: string;
@@ -37,6 +33,7 @@ interface InviteTeamMembersProps {
teamMembers: TeamMember[] | null;
setTeamMembers: (teamMembers: TeamMember[]) => void;
onNext: () => void;
onBack: () => void;
}
function InviteTeamMembers({
@@ -44,6 +41,7 @@ function InviteTeamMembers({
teamMembers,
setTeamMembers,
onNext,
onBack,
}: InviteTeamMembersProps): JSX.Element {
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
TeamMember[] | null
@@ -52,13 +50,11 @@ function InviteTeamMembers({
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
const [inviteError, setInviteError] = useState<APIError | null>(null);
const { notifications } = useNotifications();
const defaultTeamMember: TeamMember = {
email: '',
role: '',
role: 'EDITOR',
name: '',
frontendBaseUrl: window.location.origin,
id: '',
@@ -66,12 +62,12 @@ function InviteTeamMembers({
useEffect(() => {
if (isEmpty(teamMembers)) {
const initialTeamMembers = Array.from({ length: 3 }, () => ({
const teamMember = {
...defaultTeamMember,
id: uuid(),
}));
};
setTeamMembersToInvite(initialTeamMembers);
setTeamMembersToInvite([teamMember]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [teamMembers]);
@@ -91,32 +87,19 @@ function InviteTeamMembers({
// Validation function to check all users
const validateAllUsers = (): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const updatedValidity: Record<string, boolean> = {};
teamMembersToInvite?.forEach((member) => {
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
const roleValid = Boolean(member.role && member.role.trim() !== '');
if (!emailValid || !member.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
if (member.id) {
updatedEmailValidity[member.id] = emailValid;
setHasInvalidEmails(true);
}
updatedValidity[member.id!] = emailValid;
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
setEmailValidity(updatedValidity);
return isValid;
};
@@ -143,7 +126,10 @@ function InviteTeamMembers({
logEvent('Org Onboarding: Invite Team Members Failed', {
teamMembers: teamMembersToInvite,
});
setInviteError(error);
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
},
},
);
@@ -152,8 +138,6 @@ function InviteTeamMembers({
if (validateAllUsers()) {
setTeamMembers(teamMembersToInvite || []);
setHasInvalidEmails(false);
setHasInvalidRoles(false);
setInviteError(null);
sendInvites({
invites: teamMembersToInvite || [],
});
@@ -162,82 +146,37 @@ function InviteTeamMembers({
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedValidateEmail = useCallback(
debounce((email: string, memberId: string, updatedMembers: TeamMember[]) => {
debounce((email: string, memberId: string) => {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
// Clear hasInvalidEmails only when ALL emails are valid
if (hasInvalidEmails) {
const allEmailsValid = updatedMembers.every(
(m) => m.email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.email),
);
if (allEmailsValid) {
setHasInvalidEmails(false);
}
}
}, 500),
[hasInvalidEmails],
[],
);
const handleEmailChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, member: TeamMember): void => {
const { value } = e.target;
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const handleEmailChange = (
e: React.ChangeEvent<HTMLInputElement>,
member: TeamMember,
): void => {
const { value } = e.target;
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate && member.id) {
memberToUpdate.email = value;
setTeamMembersToInvite(updatedMembers);
debouncedValidateEmail(value, member.id, updatedMembers);
// Clear API error when user starts typing
if (inviteError) {
setInviteError(null);
}
}
},
[debouncedValidateEmail, inviteError, teamMembersToInvite],
);
const createEmailChangeHandler = useCallback(
(member: TeamMember) => (e: React.ChangeEvent<HTMLInputElement>): void => {
handleEmailChange(e, member);
},
[handleEmailChange],
);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate) {
memberToUpdate.email = value;
setTeamMembersToInvite(updatedMembers);
debouncedValidateEmail(value, member.id!);
}
};
const handleRoleChange = (role: string, member: TeamMember): void => {
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate && member.id) {
if (memberToUpdate) {
memberToUpdate.role = role;
setTeamMembersToInvite(updatedMembers);
// Clear errors when user selects a role
if (hasInvalidRoles) {
// Check if all roles are now valid
const allRolesValid = updatedMembers.every(
(m) => m.role && m.role.trim() !== '',
);
if (allRolesValid) {
setHasInvalidRoles(false);
}
}
if (inviteError) {
setInviteError(null);
}
}
};
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for all team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for all team members';
}
return 'Please select roles for all team members';
};
const handleDoLater = (): void => {
logEvent('Org Onboarding: Clicked Do Later', {
currentPageID: 4,
@@ -246,137 +185,122 @@ function InviteTeamMembers({
onNext();
};
const isButtonDisabled = isSendingInvites || isLoading;
return (
<div className="questions-container">
<OnboardingQuestionHeader
title="Invite your team"
subtitle="SigNoz is a lot more useful with collaborators on board."
/>
<Typography.Title level={3} className="title">
Invite your team members
</Typography.Title>
<Typography.Paragraph className="sub-title">
The more your team uses SigNoz, the stronger your observability. Share
dashboards, collaborate on alerts, and troubleshoot faster together.
</Typography.Paragraph>
<div className="questions-form-container">
<div className="questions-form invite-team-members-form">
<div className="form-group">
<div className="question-label">
Invite your team to the SigNoz workspace
Collaborate with your team
<div className="question-sub-label">
Invite your team to the SigNoz workspace
</div>
</div>
<div className="invite-team-members-table">
<div className="invite-team-members-table-header">
<div className="table-header-cell email-header">Email address</div>
<div className="table-header-cell role-header">Roles</div>
<div className="table-header-cell action-header" />
</div>
<div className="invite-team-members-container">
{teamMembersToInvite?.map((member) => (
<div className="team-member-container" key={member.id}>
<Input
placeholder="your-teammate@org.com"
value={member.email}
type="email"
required
autoFocus
autoComplete="off"
className="team-member-email-input"
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
handleEmailChange(e, member)
}
addonAfter={
// eslint-disable-next-line no-nested-ternary
emailValidity[member.id!] === undefined ? null : emailValidity[
member.id!
] ? (
<CheckCircle size={14} color={Color.BG_FOREST_500} />
) : (
<TriangleAlert size={14} color={Color.BG_SIENNA_500} />
)
}
/>
<Select
defaultValue={member.role}
onChange={(value): void => handleRoleChange(value, member)}
className="team-member-role-select"
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
<div className="invite-team-members-container">
{teamMembersToInvite?.map((member) => (
<div className="team-member-row" key={member.id}>
<div className="team-member-cell email-cell">
<Input
placeholder="e.g. john@signoz.io"
value={member.email}
type="email"
id={`email-input-${member.id}`}
name={`email-input-${member.id}`}
required
autoComplete="off"
className="team-member-email-input"
onChange={createEmailChangeHandler(member)}
/>
{member.id &&
emailValidity[member.id] === false &&
member.email.trim() !== '' && (
<Typography.Text className="email-error-message">
Invalid email address
</Typography.Text>
)}
</div>
<div className="team-member-cell role-cell">
<Select
value={member.role || undefined}
onChange={(value): void => handleRoleChange(value, member)}
className="team-member-role-select"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
</div>
<div className="team-member-cell action-cell">
{teamMembersToInvite && teamMembersToInvite.length > 1 && (
<Button
variant="ghost"
color="secondary"
className="remove-team-member-button"
onClick={(): void => handleRemoveTeamMember(member.id)}
aria-label="Remove team member"
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
))}
</div>
{teamMembersToInvite?.length > 1 && (
<Button
type="primary"
className="remove-team-member-button"
icon={<X size={14} />}
onClick={(): void => handleRemoveTeamMember(member.id)}
/>
)}
</div>
))}
</div>
<div className="invite-team-members-add-another-member-container">
<Button
variant="dashed"
color="secondary"
className="add-another-member-button"
prefixIcon={<Plus size={12} />}
onClick={handleAddTeamMember}
>
Add another
</Button>
</div>
<div className="invite-team-members-add-another-member-container">
<Button
type="primary"
className="add-another-member-button"
icon={<Plus size={14} />}
onClick={handleAddTeamMember}
>
Member
</Button>
</div>
</div>
{hasInvalidEmails && (
<div className="error-message-container">
<Typography.Text className="error-message" type="danger">
<TriangleAlert size={14} /> Please enter valid emails for all team
members
</Typography.Text>
</div>
)}
</div>
{(hasInvalidEmails || hasInvalidRoles) && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="invite-team-members-error-callout"
description={getValidationErrorMessage()}
/>
)}
{inviteError && !hasInvalidEmails && !hasInvalidRoles && (
<AuthError error={inviteError} />
)}
<div className="onboarding-buttons-container">
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${isButtonDisabled ? 'disabled' : ''}`}
onClick={handleNext}
disabled={isButtonDisabled}
suffixIcon={
isButtonDisabled ? (
<Loader2 className="animate-spin" size={12} />
) : (
<ArrowRight size={12} />
)
}
>
Complete
<div className="next-prev-container">
<Button type="default" className="next-button" onClick={onBack}>
<ArrowLeft size={14} />
Back
</Button>
<Button
variant="ghost"
color="secondary"
className="onboarding-do-later-button"
onClick={handleDoLater}
disabled={isButtonDisabled}
type="primary"
className="next-button"
onClick={handleNext}
loading={isSendingInvites || isLoading}
>
I&apos;ll do this later
Send Invites
<ArrowRight size={14} />
</Button>
</div>
<div className="do-later-container">
<Button
type="link"
className="do-later-button"
onClick={handleDoLater}
disabled={isSendingInvites}
>
{isLoading && <Loader2 className="animate-spin" size={16} />}
<span>I&apos;ll do this later</span>
</Button>
</div>
</div>

View File

@@ -1,25 +0,0 @@
import { Typography } from 'antd';
interface OnboardingQuestionHeaderProps {
title: string;
subtitle: string;
}
export function OnboardingQuestionHeader({
title,
subtitle,
}: OnboardingQuestionHeaderProps): JSX.Element {
return (
<div className="onboarding-header-section">
<div className="onboarding-header-icon">
<img src="/svgs/barber-pool.svg" alt="SigNoz" width="32" height="32" />
</div>
<Typography.Title level={4} className="onboarding-header-title">
{title}
</Typography.Title>
<Typography.Paragraph className="onboarding-header-subtitle">
{subtitle}
</Typography.Paragraph>
</div>
);
}

View File

@@ -1 +0,0 @@
export { OnboardingQuestionHeader } from './OnboardingQuestionHeader';

View File

@@ -4,67 +4,36 @@
margin: 0 auto;
align-items: center;
flex-direction: column;
justify-content: center;
min-height: 100%;
height: 100vh;
max-width: 1176px;
.onboarding-questionaire-header {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
height: 56px;
}
.onboarding-questionaire-content {
height: calc(100vh - 56px - 60px);
width: 100%;
max-width: 576px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
overflow-y: auto;
.questions-container {
width: 100%;
max-width: 576px;
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
}
.onboarding-header-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
padding: 0 24px;
.onboarding-header-icon {
width: 32px;
height: 32px;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.onboarding-header-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 1;
letter-spacing: 0;
color: var(--levels-l1-foreground, #eceef2);
margin: 0 !important;
text-align: center;
}
.onboarding-header-subtitle {
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
color: var(--semantic-secondary-foreground, #adb4c2);
max-width: 528px;
margin: 0 !important;
text-align: center;
}
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 24px;
font-style: normal;
font-weight: 600;
line-height: 32px;
max-width: 600px;
margin: 0 auto;
border-radius: 8px;
max-height: 100%;
}
.title {
@@ -85,22 +54,22 @@
}
.questions-form-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
max-width: 600px;
width: 600px;
margin: 0 auto;
}
.questions-form {
width: 100%;
display: flex;
padding: 24px;
min-height: 420px;
padding: 20px 24px 24px 24px;
flex-direction: column;
align-items: stretch;
align-items: center;
gap: 24px;
border-radius: 4px;
border: 1px solid var(--semantic-secondary-border, #23262e);
background: var(--semantic-secondary-background, #121317);
border: 1px solid var(--bg-slate-500);
background: var(--bg-ink-400);
.ant-form-item {
margin-bottom: 0px !important;
@@ -117,36 +86,43 @@
.discover-signoz-input {
width: 100%;
height: 80px;
height: 100px;
resize: none;
border: 1px solid var(--levels-l3-border, #2c303a);
background: var(--levels-l3-background, #23262e);
color: var(--levels-l1-foreground, #eceef2);
border-radius: 2px;
font-family: Inter, sans-serif;
font-size: 13px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-100);
border-radius: 4px;
font-size: 14px;
padding: 12px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
padding: 6px 8px;
box-sizing: border-box;
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
color: var(--bg-vanilla-400);
opacity: 1;
}
&:focus-visible {
outline: none;
border-color: var(--semantic-primary-background, #4e74f8);
}
}
&.invite-team-members-form {
padding-right: 12px;
min-height: calc(420px - 24px);
max-height: calc(420px - 24px);
.form-group {
gap: 24px !important;
.invite-team-members-container {
max-height: 260px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
border-radius: 0.625rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
}
}
@@ -182,106 +158,30 @@
}
.question-label {
color: var(--levels-l1-foreground, #eceef2);
font-variant-numeric: slashed-zero;
font-family: Inter;
color: var(--bg-vanilla-100);
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 100%;
letter-spacing: -0.065px;
}
.onboarding-buttons-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.onboarding-back-button {
width: 100%;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 2px;
background: transparent;
border: 1px solid var(--semantic-secondary-border, #23262e);
color: var(--semantic-secondary-foreground, #adb4c2);
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
border-color: var(--semantic-primary-background, #4e74f8);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
line-height: 20px;
}
.onboarding-next-button {
width: 100%;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 2px;
background: var(--semantic-primary-background, #4e74f8);
border: none;
color: var(--semantic-primary-foreground, #eceef2);
font-family: Inter, sans-serif;
.question-sub-label {
color: var(--bg-vanilla-400);
font-size: 11px;
font-weight: 500;
line-height: 1;
cursor: pointer;
opacity: 1;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
font-style: normal;
font-weight: 400;
line-height: 16px;
}
.onboarding-do-later-button {
width: 100%;
height: 32px;
.next-prev-container {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: center;
border-radius: 2px;
background: transparent;
border: none;
color: var(--semantic-secondary-foreground, #adb4c2);
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 0.2s;
gap: 10px;
margin-bottom: 24px;
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
.ant-btn {
flex: 1;
}
}
@@ -289,38 +189,15 @@
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
gap: 8px;
align-self: stretch;
}
.slider-container {
width: calc(100% - 16px);
width: 100%;
.ant-slider .ant-slider-mark {
margin-top: 12px;
.ant-slider-mark-text {
color: var(--levels-l3-foreground, #747b8b);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings: 'dlig' on, 'salt' on, 'cpsp' on, 'case' on;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 100%;
}
}
&.logs-slider-container {
.ant-slider .ant-slider-mark {
.ant-slider-mark-text {
&:last-child {
left: calc(100% - 8px) !important;
white-space: nowrap;
}
}
}
font-size: 10px;
}
}
@@ -342,57 +219,29 @@
}
.question {
font-family: Inter, sans-serif;
color: var(--levels-l1-foreground, #eceef2);
font-variant-numeric: slashed-zero;
font-size: 13px;
color: var(--bg-vanilla-100);
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 100%;
letter-spacing: -0.065px;
font-weight: 500;
line-height: 20px;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.question-slider {
color: var(--levels-l1-foreground, #eceef2);
font-variant-numeric: slashed-zero;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 100%;
letter-spacing: -0.065px;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
input[type='text'] {
width: 100%;
padding: 6px 8px;
padding: 12px;
border-radius: 2px;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
height: 32px;
border: 1px solid var(--levels-l3-border, #2c303a);
background: var(--levels-l3-background, #23262e);
color: var(--levels-l1-foreground, #eceef2);
box-sizing: border-box;
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
}
font-size: 14px;
height: 40px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-100);
&:focus-visible {
outline: none;
border-color: var(--semantic-primary-background, #4e74f8);
}
}
@@ -442,170 +291,34 @@
gap: 10px;
}
.observability-tools-checkbox-container {
display: flex;
flex-wrap: wrap;
gap: 0 12px;
width: 528px;
align-items: flex-start;
.observability-tool-checkbox-item {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
width: calc((528px - 12px) / 2);
min-width: 258px;
flex: 0 0 calc((528px - 12px) / 2);
padding: 0;
box-sizing: border-box;
cursor: pointer;
&.checkbox-item {
width: calc((528px - 12px) / 2) !important;
flex-direction: row;
}
&:hover {
opacity: 0.8;
}
}
.observability-tool-other-input {
width: 100%;
margin-top: 12px;
}
}
.opentelemetry-radio-container {
width: 528px;
.opentelemetry-radio-group {
width: 100%;
.opentelemetry-radio-items-wrapper {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 12px;
width: 100%;
}
.opentelemetry-radio-item {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
width: calc((528px - 12px) / 2);
min-width: 258px;
flex: 0 0 calc((528px - 12px) / 2);
color: var(--levels-l1-foreground, #eceef2);
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
box-sizing: border-box;
.ant-radio {
.ant-radio-inner {
width: 16px;
height: 16px;
border-color: var(--levels-l3-border, #2c303a);
}
&.ant-radio-checked .ant-radio-inner {
border-color: var(--semantic-primary-background, #4e74f8);
background-color: var(--semantic-primary-background, #4e74f8);
}
}
}
}
}
.checkbox-grid {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 0;
width: 100%;
gap: 12px;
margin-top: 12px;
}
.checkbox-item {
display: flex;
flex-direction: row;
align-items: center;
flex-direction: column;
gap: 8px;
height: 32px;
padding: 0;
width: 100%;
label {
color: var(--levels-l1-foreground, #eceef2) !important;
}
&.checkbox-item-others {
.onboarding-questionaire-other-input {
display: flex;
align-items: center;
flex: 1 0 0;
height: 32px;
padding: 6px 8px;
gap: 4px;
border-radius: 2px;
border: 1px solid var(--levels-l3-border, #2c303a);
background: var(--levels-l3-background, #23262e);
color: var(--levels-l1-foreground, #eceef2);
font-family: Inter, sans-serif;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 100%;
letter-spacing: -0.065px;
font-variant-numeric: slashed-zero;
box-sizing: border-box;
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
color: var(--levels-l1-foreground, #eceef2);
}
}
}
.ant-checkbox-wrapper {
color: var(--levels-l1-foreground, #eceef2);
font-family: Inter, sans-serif;
font-size: 13px;
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
.ant-checkbox {
.ant-checkbox-inner {
width: 16px;
height: 16px;
border: 1.5px solid var(--levels-l3-background, #23262e);
border-radius: 2px;
background-color: transparent;
border-color: var(--bg-slate-100);
background-color: var(--bg-ink-200);
}
&.ant-checkbox-checked .ant-checkbox-inner {
background-color: var(--semantic-primary-background, #4e74f8);
border-color: var(--semantic-primary-background, #4e74f8);
background-color: var(--bg-robin-500);
border-color: var(--bg-robin-500);
}
}
span {
color: var(--levels-l1-foreground, #eceef2) !important;
}
}
}
@@ -646,14 +359,8 @@
.add-another-member-button,
.remove-team-member-button {
color: var(--semantic-secondary-foreground, #adb4c2);
text-align: center;
font-variant-numeric: slashed-zero;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 100%;
font-size: 12px;
height: 32px;
}
.remove-team-member-button {
@@ -695,6 +402,26 @@
min-width: 258px;
}
.next-button {
display: flex;
height: 40px;
padding: 8px 12px 8px 16px;
justify-content: center;
align-items: center;
gap: 6px;
align-self: stretch;
border: 0px;
border-radius: 50px;
margin-top: 24px;
cursor: pointer;
}
.next-button.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.arrow {
font-size: 18px;
color: var(--bg-vanilla-100);
@@ -713,7 +440,7 @@
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 16px !important;
margin-top: 12px;
}
}
@@ -734,24 +461,25 @@
color: var(--bg-slate-300);
}
.onboarding-header-title {
color: var(--levels-l1-foreground, #1a1d26) !important;
.title {
color: var(--bg-slate-300) !important;
}
.onboarding-header-subtitle {
color: var(--semantic-secondary-foreground, #747b8b) !important;
.sub-title {
color: var(--bg-slate-400) !important;
}
.questions-form {
width: 100%;
display: flex;
padding: 24px;
min-height: 420px;
padding: 20px 24px 24px 24px;
flex-direction: column;
align-items: stretch;
align-items: center;
gap: 24px;
border-radius: 4px;
border: 1px solid var(--semantic-secondary-border, #e9e9e9);
background: var(--semantic-secondary-background, #ffffff);
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.ant-form-item {
margin-bottom: 0px !important;
@@ -767,18 +495,35 @@
}
.discover-signoz-input {
border: 1px solid var(--levels-l3-border, #e9e9e9);
background: var(--levels-l3-background, #ffffff);
color: var(--levels-l1-foreground, #1a1d26);
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
font-weight: 400;
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
color: var(--bg-slate-400);
opacity: 1;
}
}
&:focus-visible {
border-color: var(--semantic-primary-background, #4e74f8);
&.invite-team-members-form {
.invite-team-members-container {
max-height: 260px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
border-radius: 0.625rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
}
}
@@ -807,86 +552,36 @@
color: var(--bg-slate-300);
}
.question {
color: var(--levels-l1-foreground, #1a1d26);
.question-sub-label {
color: var(--bg-slate-400);
}
.question-slider {
color: var(--levels-l1-foreground, #1a1d26);
.question {
color: var(--bg-slate-300);
}
.checkbox-item {
label {
color: var(--levels-l1-foreground, #1a1d26) !important;
}
.ant-checkbox-wrapper {
color: var(--levels-l1-foreground, #1a1d26);
color: var(--bg-ink-300);
.ant-checkbox {
.ant-checkbox-inner {
border-color: var(--levels-l3-background, #ffffff);
background-color: transparent;
border-color: var(--bg-vanilla-300);
background-color: var(--bg-vanilla-100);
}
&.ant-checkbox-checked .ant-checkbox-inner {
background-color: var(--semantic-primary-background, #4e74f8);
border-color: var(--semantic-primary-background, #4e74f8);
background-color: var(--bg-robin-500);
border-color: var(--bg-robin-500);
}
}
span {
color: var(--levels-l1-foreground, #1a1d26) !important;
}
}
&.checkbox-item-others {
.onboarding-questionaire-other-input {
border: 1px solid var(--levels-l3-border, #e9e9e9);
background: var(--levels-l3-background, #ffffff);
color: var(--levels-l1-foreground, #1a1d26);
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
color: var(--levels-l1-foreground, #1a1d26);
}
}
}
}
.observability-tool-others-item {
.onboarding-questionaire-other-input {
border: 1px solid var(--levels-l3-border, #e9e9e9);
background: var(--levels-l3-background, #ffffff);
color: var(--levels-l1-foreground, #1a1d26);
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
color: var(--levels-l1-foreground, #1a1d26);
}
}
}
input[type='text'] {
border: 1px solid var(--levels-l3-border, #e9e9e9);
background: var(--levels-l3-background, #ffffff);
color: var(--levels-l3-foreground, #1a1d26);
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
}
&:focus-visible {
border-color: var(--semantic-primary-background, #4e74f8);
}
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
}
.radio-button,
@@ -976,36 +671,6 @@
.arrow {
color: var(--bg-slate-300);
}
.opentelemetry-radio-container {
.opentelemetry-radio-group {
.opentelemetry-radio-items-wrapper {
.opentelemetry-radio-item {
color: var(--levels-l1-foreground, #1a1d26);
.ant-radio {
.ant-radio-inner {
border-color: var(--levels-l3-border, #e9e9e9);
}
&.ant-radio-checked .ant-radio-inner {
border-color: var(--semantic-primary-background, #4e74f8);
background-color: var(--semantic-primary-background, #4e74f8);
}
}
}
}
}
}
.onboarding-back-button {
border-color: var(--semantic-secondary-border, #e9e9e9);
color: var(--semantic-secondary-foreground, #747b8b);
&:hover {
border-color: var(--semantic-primary-background, #4e74f8);
}
}
}
}
}

View File

@@ -1,11 +1,8 @@
import { Button } from '@signozhq/button';
import { Slider, Typography } from 'antd';
import { Button, Slider, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { ArrowRight, Loader2, Minus } from 'lucide-react';
import { ArrowLeft, ArrowRight, Loader2, Minus } from 'lucide-react';
import { useEffect, useState } from 'react';
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
export interface OptimiseSignozDetails {
logsPerDay: number;
hostsPerDay: number;
@@ -50,6 +47,7 @@ interface OptimiseSignozNeedsProps {
optimiseSignozDetails: OptimiseSignozDetails;
setOptimiseSignozDetails: (details: OptimiseSignozDetails) => void;
onNext: () => void;
onBack: () => void;
onWillDoLater: () => void;
isUpdatingProfile: boolean;
isNextDisabled: boolean;
@@ -84,6 +82,7 @@ function OptimiseSignozNeeds({
optimiseSignozDetails,
setOptimiseSignozDetails,
onNext,
onBack,
onWillDoLater,
isNextDisabled,
}: OptimiseSignozNeedsProps): JSX.Element {
@@ -132,6 +131,10 @@ function OptimiseSignozNeeds({
onNext();
};
const handleOnBack = (): void => {
onBack();
};
const handleWillDoLater = (): void => {
setOptimiseSignozDetails({
logsPerDay: 0,
@@ -186,24 +189,24 @@ function OptimiseSignozNeeds({
return (
<div className="questions-container">
<OnboardingQuestionHeader
title="Set up your workspace"
subtitle="Tailor SigNoz to suit your observability needs."
/>
<Typography.Title level={3} className="title">
Optimize SigNoz for Your Needs
</Typography.Title>
<Typography.Paragraph className="sub-title">
Give us a quick sense of your scale so SigNoz can keep up!
</Typography.Paragraph>
<div className="questions-form-container">
<div className="questions-form">
<div className="form-group">
<Typography.Paragraph className="question">
What does your scale approximately look like?
</Typography.Paragraph>
</div>
<Typography.Paragraph className="question">
What does your scale approximately look like?
</Typography.Paragraph>
<div className="form-group">
<label className="question-slider" htmlFor="organisationName">
<label className="question" htmlFor="organisationName">
Logs / Day
</label>
<div className="slider-container logs-slider-container">
<div className="slider-container">
<div>
<Slider
min={0}
@@ -227,7 +230,7 @@ function OptimiseSignozNeeds({
</div>
<div className="form-group">
<label className="question-slider" htmlFor="organisationName">
<label className="question" htmlFor="organisationName">
Metrics <Minus size={14} /> Number of Hosts
</label>
<div className="slider-container">
@@ -254,7 +257,7 @@ function OptimiseSignozNeeds({
</div>
<div className="form-group">
<label className="question-slider" htmlFor="organisationName">
<label className="question" htmlFor="organisationName">
Number of services
</label>
<div className="slider-container">
@@ -281,32 +284,34 @@ function OptimiseSignozNeeds({
</div>
</div>
<div className="onboarding-buttons-container">
<div className="next-prev-container">
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${
isUpdatingProfile || isNextDisabled ? 'disabled' : ''
}`}
onClick={handleOnNext}
disabled={isUpdatingProfile || isNextDisabled}
suffixIcon={
isUpdatingProfile ? (
<Loader2 className="animate-spin" size={12} />
) : (
<ArrowRight size={12} />
)
}
>
Next
</Button>
<Button
variant="ghost"
color="secondary"
className="onboarding-do-later-button"
onClick={handleWillDoLater}
type="default"
className="next-button"
onClick={handleOnBack}
disabled={isUpdatingProfile}
>
<ArrowLeft size={14} />
Back
</Button>
<Button
type="primary"
className="next-button"
onClick={handleOnNext}
disabled={isUpdatingProfile || isNextDisabled}
>
Next{' '}
{isUpdatingProfile ? (
<Loader2 className="animate-spin" />
) : (
<ArrowRight size={14} />
)}
</Button>
</div>
<div className="do-later-container">
<Button type="link" onClick={handleWillDoLater}>
I&apos;ll do this later
</Button>
</div>

View File

@@ -1,15 +1,12 @@
/* eslint-disable sonarjs/cognitive-complexity */
import '../OnboardingQuestionaire.styles.scss';
import { Button } from '@signozhq/button';
import { Checkbox } from '@signozhq/checkbox';
import { Input } from '@signozhq/input';
import { Radio, Typography } from 'antd';
import { RadioChangeEvent } from 'antd/es/radio';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import editOrg from 'api/organization/editOrg';
import { useNotifications } from 'hooks/useNotifications';
import { ArrowRight, Loader2 } from 'lucide-react';
import { ArrowRight, CheckCircle, Loader2 } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -42,7 +39,6 @@ const observabilityTools = {
GCPNativeO11yTools: 'GCP-native o11y tools',
Honeycomb: 'Honeycomb',
None: 'None/Starting fresh',
Others: 'Others',
};
function OrgQuestions({
@@ -50,7 +46,7 @@ function OrgQuestions({
orgDetails,
onNext,
}: OrgQuestionsProps): JSX.Element {
const { updateOrg } = useAppContext();
const { user, updateOrg } = useAppContext();
const { notifications } = useNotifications();
const { t } = useTranslation(['organizationsettings', 'common']);
@@ -72,12 +68,11 @@ function OrgQuestions({
const [isLoading, setIsLoading] = useState<boolean>(false);
const [usesOtel, setUsesOtel] = useState<boolean | null>(orgDetails.usesOtel);
const [usesOtel, setUsesOtel] = useState<boolean | null>(
orgDetails?.usesOtel || null,
);
const handleOrgNameUpdate = async (): Promise<void> => {
const usesObservability =
!observabilityTool?.includes('None') && observabilityTool !== null;
/* Early bailout if orgData is not set or if the organisation name is not set or if the organisation name is empty or if the organisation name is the same as the one in the orgData */
if (
!currentOrgData ||
@@ -86,7 +81,7 @@ function OrgQuestions({
orgDetails.organisationName === organisationName
) {
logEvent('Org Onboarding: Answered', {
usesObservability,
usesObservability: !observabilityTool?.includes('None'),
observabilityTool,
otherTool,
usesOtel,
@@ -94,7 +89,7 @@ function OrgQuestions({
onNext({
organisationName,
usesObservability,
usesObservability: !observabilityTool?.includes('None'),
observabilityTool,
otherTool,
usesOtel,
@@ -117,7 +112,7 @@ function OrgQuestions({
});
logEvent('Org Onboarding: Answered', {
usesObservability,
usesObservability: !observabilityTool?.includes('None'),
observabilityTool,
otherTool,
usesOtel,
@@ -125,7 +120,7 @@ function OrgQuestions({
onNext({
organisationName,
usesObservability,
usesObservability: !observabilityTool?.includes('None'),
observabilityTool,
otherTool,
usesOtel,
@@ -182,47 +177,31 @@ function OrgQuestions({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organisationName, usesOtel, observabilityTool, otherTool]);
const createObservabilityToolHandler = (tool: string) => (
checked: boolean,
): void => {
if (checked) {
setObservabilityTool(tool);
} else if (observabilityTool === tool) {
setObservabilityTool(null);
}
};
const handleOtelChange = (value: string): void => {
setUsesOtel(value === 'yes');
};
const handleOnNext = (): void => {
handleOrgNameUpdate();
};
return (
<div className="questions-container">
<div className="onboarding-header-section">
<div className="onboarding-header-icon">🎉</div>
<Typography.Title level={4} className="onboarding-header-title">
Welcome to SigNoz Cloud
</Typography.Title>
<Typography.Paragraph className="onboarding-header-subtitle">
Let&apos;s get you started
</Typography.Paragraph>
</div>
<Typography.Title level={3} className="title">
{user?.displayName ? `Welcome, ${user.displayName}!` : 'Welcome!'}
</Typography.Title>
<Typography.Paragraph className="sub-title">
We&apos;ll help you get the most out of SigNoz, whether you&apos;re new to
observability or a seasoned pro.
</Typography.Paragraph>
<div className="questions-form-container">
<div className="questions-form">
<div className="form-group">
<label className="question" htmlFor="organisationName">
Name of your company
Your Organisation Name
</label>
<Input
<input
type="text"
name="organisationName"
id="organisationName"
placeholder="e.g. Simpsonville"
placeholder="For eg. Simpsonville..."
autoComplete="off"
value={organisationName}
onChange={(e): void => setOrganisationName(e.target.value)}
@@ -233,93 +212,105 @@ function OrgQuestions({
<label className="question" htmlFor="observabilityTool">
Which observability tool do you currently use?
</label>
<div className="observability-tools-checkbox-container">
{Object.entries(observabilityTools).map(([tool, label]) => {
if (tool === 'Others') {
return (
<div
key={tool}
className="checkbox-item observability-tool-checkbox-item observability-tool-others-item"
>
<Checkbox
id={`checkbox-${tool}`}
checked={observabilityTool === tool}
onCheckedChange={createObservabilityToolHandler(tool)}
labelName={observabilityTool === 'Others' ? '' : label}
/>
{observabilityTool === 'Others' && (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="What tool do you currently use?"
value={otherTool || ''}
autoFocus
onChange={(e): void => setOtherTool(e.target.value)}
/>
)}
</div>
);
}
return (
<div
key={tool}
className="checkbox-item observability-tool-checkbox-item"
>
<Checkbox
id={`checkbox-${tool}`}
checked={observabilityTool === tool}
onCheckedChange={createObservabilityToolHandler(tool)}
labelName={label}
/>
</div>
);
})}
<div className="two-column-grid">
{Object.keys(observabilityTools).map((tool) => (
<Button
key={tool}
type="primary"
className={`onboarding-questionaire-button ${
observabilityTool === tool ? 'active' : ''
}`}
onClick={(): void => setObservabilityTool(tool)}
>
{observabilityTools[tool as keyof typeof observabilityTools]}
{observabilityTool === tool && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
))}
{observabilityTool === 'Others' ? (
<Input
type="text"
className="onboarding-questionaire-other-input"
placeholder="Please specify the tool"
value={otherTool || ''}
autoFocus
addonAfter={
otherTool && otherTool !== '' ? (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
) : (
''
)
}
onChange={(e): void => setOtherTool(e.target.value)}
/>
) : (
<Button
type="primary"
className={`onboarding-questionaire-button ${
observabilityTool === 'Others' ? 'active' : ''
}`}
onClick={(): void => setObservabilityTool('Others')}
>
Others
</Button>
)}
</div>
</div>
<div className="form-group">
<div className="question">Do you already use OpenTelemetry?</div>
<div className="opentelemetry-radio-container">
<Radio.Group
value={((): string | undefined => {
if (usesOtel === true) return 'yes';
if (usesOtel === false) return 'no';
return undefined;
})()}
onChange={(e: RadioChangeEvent): void =>
handleOtelChange(e.target.value)
}
className="opentelemetry-radio-group"
<div className="two-column-grid">
<Button
type="primary"
name="usesObservability"
className={`onboarding-questionaire-button ${
usesOtel === true ? 'active' : ''
}`}
onClick={(): void => {
setUsesOtel(true);
}}
>
<div className="opentelemetry-radio-items-wrapper">
<Radio value="yes" className="opentelemetry-radio-item">
Yes
</Radio>
<Radio value="no" className="opentelemetry-radio-item">
No
</Radio>
</div>
</Radio.Group>
Yes{' '}
{usesOtel === true && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
<Button
type="primary"
className={`onboarding-questionaire-button ${
usesOtel === false ? 'active' : ''
}`}
onClick={(): void => {
setUsesOtel(false);
}}
>
No{' '}
{usesOtel === false && (
<CheckCircle size={12} color={Color.BG_FOREST_500} />
)}
</Button>
</div>
</div>
</div>
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${isNextDisabled ? 'disabled' : ''}`}
onClick={handleOnNext}
disabled={isNextDisabled}
suffixIcon={
isLoading ? (
<Loader2 className="animate-spin" size={12} />
<div className="next-prev-container">
<Button
type="primary"
className={`next-button ${isNextDisabled ? 'disabled' : ''}`}
onClick={handleOnNext}
disabled={isNextDisabled}
>
Next
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
<ArrowRight size={12} />
)
}
>
Next
</Button>
<ArrowRight size={14} />
)}
</Button>
</div>
</div>
</div>
);

View File

@@ -1,329 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import OnboardingQuestionaire from '../index';
// Mock dependencies
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
pathname: '/onboarding',
search: '',
hash: '',
state: null,
},
},
}));
// API Endpoints
const ORG_PREFERENCES_ENDPOINT = '*/api/v1/org/preferences/list';
const UPDATE_ORG_PREFERENCE_ENDPOINT = '*/api/v1/org/preferences/name/update';
const UPDATE_PROFILE_ENDPOINT = '*/api/gateway/v2/profiles/me';
const EDIT_ORG_ENDPOINT = '*/api/v2/orgs/me';
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk/create';
const mockOrgPreferences = {
data: {
org_onboarding: false,
},
status: 'success',
};
describe('OnboardingQuestionaire Component', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(ORG_PREFERENCES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockOrgPreferences)),
),
rest.put(EDIT_ORG_ENDPOINT, (_, res, ctx) =>
res(ctx.status(204), ctx.json({ status: 'success' })),
),
rest.put(UPDATE_PROFILE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.post(UPDATE_ORG_PREFERENCE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success' })),
),
);
});
afterEach(() => {
server.resetHandlers();
});
describe('Step 1: Organization Details', () => {
it('renders organization questions on initial load', () => {
render(<OnboardingQuestionaire />);
expect(screen.getByText(/welcome to signoz cloud/i)).toBeInTheDocument();
expect(screen.getByLabelText(/name of your company/i)).toBeInTheDocument();
expect(
screen.getByText(/which observability tool do you currently use/i),
).toBeInTheDocument();
});
it('disables next button when required fields are empty', () => {
render(<OnboardingQuestionaire />);
const nextButton = screen.getByRole('button', { name: /next/i });
expect(nextButton).toBeDisabled();
});
it('enables next button when all required fields are filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);
const orgNameInput = screen.getByLabelText(/name of your company/i);
await user.clear(orgNameInput);
await user.type(orgNameInput, 'Test Company');
const datadogCheckbox = screen.getByLabelText(/datadog/i);
await user.click(datadogCheckbox);
const otelYes = screen.getByRole('radio', { name: /yes/i });
await user.click(otelYes);
const nextButton = await screen.findByRole('button', { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
it('shows other tool input when Others is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);
const othersCheckbox = screen.getByLabelText(/^others$/i);
await user.click(othersCheckbox);
expect(
await screen.findByPlaceholderText(/what tool do you currently use/i),
).toBeInTheDocument();
});
it('proceeds to step 2 when next is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);
const orgNameInput = screen.getByLabelText(/name of your company/i);
await user.clear(orgNameInput);
await user.type(orgNameInput, 'Test Company');
await user.click(screen.getByLabelText(/datadog/i));
await user.click(screen.getByRole('radio', { name: /yes/i }));
const nextButton = screen.getByRole('button', { name: /next/i });
await user.click(nextButton);
expect(
await screen.findByText(/how did you first come across signoz/i, {}),
).toBeInTheDocument();
});
});
describe('Step 2: About SigNoz', () => {
it('renders about signoz questions after step 1 completion', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);
// Navigate to step 2
const orgNameInput = screen.getByLabelText(/name of your company/i);
await user.clear(orgNameInput);
await user.type(orgNameInput, 'Test Company');
await user.click(screen.getByLabelText(/datadog/i));
await user.click(screen.getByRole('radio', { name: /yes/i }));
await user.click(screen.getByRole('button', { name: /next/i }));
expect(
await screen.findByText(/set up your workspace/i, {}),
).toBeInTheDocument();
expect(
await screen.findByText(/how did you first come across signoz/i, {}),
).toBeInTheDocument();
});
it('disables next button when fields are empty', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);
// Navigate to step 2
const orgNameInput = screen.getByLabelText(/name of your company/i);
await user.clear(orgNameInput);
await user.type(orgNameInput, 'Test Company');
await user.click(screen.getByLabelText(/datadog/i));
await user.click(screen.getByRole('radio', { name: /yes/i }));
await user.click(screen.getByRole('button', { name: /next/i }));
await waitFor(() => {
const nextButton = screen.getByRole('button', { name: /next/i });
expect(nextButton).toBeDisabled();
});
});
it('enables next button when all fields are filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);
// Navigate to step 2
const orgNameInput = screen.getByLabelText(/name of your company/i);
await user.clear(orgNameInput);
await user.type(orgNameInput, 'Test Company');
await user.click(screen.getByLabelText(/datadog/i));
await user.click(screen.getByRole('radio', { name: /yes/i }));
await user.click(screen.getByRole('button', { name: /next/i }));
expect(
await screen.findByPlaceholderText(/e\.g\., googling/i, {}),
).toBeInTheDocument();
const discoverInput = screen.getByPlaceholderText(/e\.g\., googling/i);
await user.type(discoverInput, 'Found via Google search');
const interestCheckbox = screen.getByLabelText(
/lowering observability costs/i,
);
await user.click(interestCheckbox);
const nextButton = await screen.findByRole('button', { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
it('shows other interest input when Others checkbox is selected', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);
// Navigate to step 2
const orgNameInput = screen.getByLabelText(/name of your company/i);
await user.clear(orgNameInput);
await user.type(orgNameInput, 'Test Company');
await user.click(screen.getByLabelText(/datadog/i));
await user.click(screen.getByRole('radio', { name: /yes/i }));
await user.click(screen.getByRole('button', { name: /next/i }));
expect(
await screen.findByText(/what got you interested in signoz/i, {}),
).toBeInTheDocument();
const othersCheckbox = screen.getByLabelText(/^others$/i);
await user.click(othersCheckbox);
expect(
await screen.findByPlaceholderText(
/what got you interested in signoz/i,
{},
),
).toBeInTheDocument();
});
});
describe('Step 3: Optimize SigNoz Needs', () => {
it('renders scale questions after step 2 completion', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);
// Navigate through steps 1 and 2
const orgNameInput = screen.getByLabelText(/name of your company/i);
await user.clear(orgNameInput);
await user.type(orgNameInput, 'Test Company');
await user.click(screen.getByLabelText(/datadog/i));
await user.click(screen.getByRole('radio', { name: /yes/i }));
await user.click(screen.getByRole('button', { name: /next/i }));
expect(
await screen.findByPlaceholderText(/e\.g\., googling/i, {}),
).toBeInTheDocument();
await user.type(
screen.getByPlaceholderText(/e\.g\., googling/i),
'Found via Google',
);
await user.click(screen.getByLabelText(/lowering observability costs/i));
await user.click(screen.getByRole('button', { name: /next/i }));
expect(
await screen.findByText(
/what does your scale approximately look like/i,
{},
),
).toBeInTheDocument();
expect(await screen.findByText(/logs \/ day/i, {})).toBeInTheDocument();
expect(
await screen.findByText(/number of services/i, {}),
).toBeInTheDocument();
});
it('shows do later button', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<OnboardingQuestionaire />);
// Navigate to step 3
const orgNameInput = screen.getByLabelText(/name of your company/i);
await user.clear(orgNameInput);
await user.type(orgNameInput, 'Test Company');
await user.click(screen.getByLabelText(/datadog/i));
await user.click(screen.getByRole('radio', { name: /yes/i }));
await user.click(screen.getByRole('button', { name: /next/i }));
expect(
await screen.findByPlaceholderText(/e\.g\., googling/i, {}),
).toBeInTheDocument();
await user.type(
screen.getByPlaceholderText(/e\.g\., googling/i),
'Found via Google',
);
await user.click(screen.getByLabelText(/lowering observability costs/i));
await user.click(screen.getByRole('button', { name: /next/i }));
expect(
await screen.findByRole('button', { name: /i'll do this later/i }),
).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('handles organization update error gracefully', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(EDIT_ORG_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({
error: {
code: 'INTERNAL_ERROR',
message: 'Failed to update organization',
},
}),
),
),
);
render(<OnboardingQuestionaire />);
const orgNameInput = screen.getByLabelText(/name of your company/i);
await user.clear(orgNameInput);
await user.type(orgNameInput, 'Test Company');
await user.click(screen.getByLabelText(/datadog/i));
await user.click(screen.getByRole('radio', { name: /yes/i }));
const nextButton = screen.getByRole('button', { name: /next/i });
await user.click(nextButton);
// Component should still be functional
await waitFor(() => {
expect(nextButton).not.toBeDisabled();
});
});
});
});

View File

@@ -22,6 +22,7 @@ import {
SignozDetails,
} from './AboutSigNozQuestions/AboutSigNozQuestions';
import InviteTeamMembers from './InviteTeamMembers/InviteTeamMembers';
import { OnboardingHeader } from './OnboardingHeader/OnboardingHeader';
import OptimiseSignozNeeds, {
OptimiseSignozDetails,
} from './OptimiseSignozNeeds/OptimiseSignozNeeds';
@@ -56,6 +57,7 @@ const INITIAL_OPTIMISE_SIGNOZ_DETAILS: OptimiseSignozDetails = {
services: 0,
};
const BACK_BUTTON_EVENT_NAME = 'Org Onboarding: Back Button Clicked';
const NEXT_BUTTON_EVENT_NAME = 'Org Onboarding: Next Button Clicked';
const ONBOARDING_COMPLETE_EVENT_NAME = 'Org Onboarding: Complete';
@@ -205,14 +207,15 @@ function OnboardingQuestionaire(): JSX.Element {
return (
<div className="onboarding-questionaire-container">
<div className="onboarding-questionaire-header">
<OnboardingHeader />
</div>
<div className="onboarding-questionaire-content">
{currentStep === 1 && (
<OrgQuestions
currentOrgData={currentOrgData}
orgDetails={{
...orgDetails,
usesOtel: orgDetails.usesOtel ?? null,
}}
orgDetails={orgDetails}
onNext={(orgDetails: OrgDetails): void => {
logEvent(NEXT_BUTTON_EVENT_NAME, {
currentPageID: 1,
@@ -229,6 +232,13 @@ function OnboardingQuestionaire(): JSX.Element {
<AboutSigNozQuestions
signozDetails={signozDetails}
setSignozDetails={setSignozDetails}
onBack={(): void => {
logEvent(BACK_BUTTON_EVENT_NAME, {
currentPageID: 2,
prevPageID: 1,
});
setCurrentStep(1);
}}
onNext={(): void => {
logEvent(NEXT_BUTTON_EVENT_NAME, {
currentPageID: 2,
@@ -245,6 +255,13 @@ function OnboardingQuestionaire(): JSX.Element {
isUpdatingProfile={isUpdatingProfile}
optimiseSignozDetails={optimiseSignozDetails}
setOptimiseSignozDetails={setOptimiseSignozDetails}
onBack={(): void => {
logEvent(BACK_BUTTON_EVENT_NAME, {
currentPageID: 3,
prevPageID: 2,
});
setCurrentStep(2);
}}
onNext={handleUpdateProfile}
onWillDoLater={handleUpdateProfile}
/>
@@ -255,6 +272,13 @@ function OnboardingQuestionaire(): JSX.Element {
isLoading={updatingOrgOnboardingStatus}
teamMembers={teamMembers}
setTeamMembers={setTeamMembers}
onBack={(): void => {
logEvent(BACK_BUTTON_EVENT_NAME, {
currentPageID: 4,
prevPageID: 3,
});
setCurrentStep(3);
}}
onNext={handleOnboardingComplete}
/>
)}

View File

@@ -1,212 +0,0 @@
.reset-password-card {
width: 576px;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
.reset-password-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 32px;
text-align: center;
padding: 0 24px;
width: 100%;
.reset-password-header-icon {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
color: var(--semantic-primary-foreground);
}
.reset-password-header-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 1;
letter-spacing: 0;
color: var(--levels-l1-foreground, #eceef2);
margin: 0 !important;
}
.reset-password-header-subtitle {
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
color: var(--semantic-secondary-foreground, #adb4c2);
margin: 0 !important;
text-align: center;
}
.reset-password-version-badge {
margin-top: 8px;
padding: 4px 12px;
border-radius: 4px;
background: var(--semantic-secondary-background, #121317);
border: 1px solid var(--semantic-secondary-border, #23262e);
font-size: 11px;
font-weight: 400;
line-height: 1.45;
color: var(--semantic-secondary-foreground);
text-align: center;
}
}
.reset-password-form {
width: 100%;
.reset-password-form-container {
width: 100%;
background: var(--semantic-secondary-background, #121317);
border: 1px solid var(--semantic-secondary-border, #23262e);
border-radius: 4px;
padding: 24px;
.reset-password-form-fields {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
}
.reset-password-field-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.reset-password-form-input {
height: 32px;
width: 100%;
border-radius: 2px;
&.ant-input,
&.ant-input-password,
&.ant-input-affix-wrapper {
height: 32px;
border-radius: 2px;
background: var(--levels-l3-background, #23262e);
border-color: var(--levels-l3-border, #2c303a);
}
&.ant-input-affix-wrapper {
.ant-input {
height: auto;
background: transparent;
}
}
}
}
.reset-password-error-callout {
margin-top: 24px;
background: rgba(229, 72, 77, 0.1);
border: 1px solid rgba(229, 72, 77, 0.2);
border-radius: 4px;
animation: horizontal-shaking 300ms ease-out;
}
.reset-password-form-actions {
margin-top: 24px;
display: flex;
width: 100%;
.reset-password-submit-button {
width: 100%;
}
}
.ant-form-item {
margin-bottom: 0;
}
}
@media (max-width: 768px) {
width: 100%;
padding: 0 16px;
.reset-password-header {
padding: 0;
}
}
}
.lightMode {
.reset-password-card {
.reset-password-header {
.reset-password-header-icon {
color: var(--text-ink-500);
}
.reset-password-header-title {
color: var(--text-ink-500);
}
.reset-password-header-subtitle {
color: var(--text-neutral-light-200, #80828d);
}
.reset-password-version-badge {
background: var(--bg-vanilla-200, #f5f5f5);
border: 1px solid var(--bg-vanilla-300, #e9e9e9);
color: var(--text-neutral-light-200, #80828d);
}
}
.reset-password-form {
.reset-password-form-container {
background: var(--bg-base-white, #ffffff);
border: 1px solid var(--bg-vanilla-300, #e9e9e9);
.reset-password-form-input {
&.ant-input,
&.ant-input-password,
&.ant-input-affix-wrapper {
background: var(--bg-vanilla-200, #f5f5f5);
border-color: var(--bg-vanilla-300, #e9e9e9);
color: var(--text-ink-500);
}
&.ant-input-affix-wrapper {
.ant-input {
background: transparent;
color: var(--text-ink-500);
}
}
&::placeholder {
color: var(--text-neutral-light-200, #80828d);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
}
}
}
}
}
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -0,0 +1,72 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from 'react-dom/test-utils';
import ResetPassword from './index';
jest.mock('api/v1/factor_password/resetPassword', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.useFakeTimers();
describe('ResetPassword Component', () => {
beforeEach(() => {
userEvent.setup();
jest.clearAllMocks();
});
it('renders ResetPassword component correctly', () => {
render(<ResetPassword version="1.0" />);
expect(screen.getByText('Reset Your Password')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
// eslint-disable-next-line sonarjs/no-duplicate-string
expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument();
expect(
// eslint-disable-next-line sonarjs/no-duplicate-string
screen.getByRole('button', { name: 'Get Started' }),
).toBeInTheDocument();
});
it('disables the "Get Started" button when password is invalid', async () => {
render(<ResetPassword version="1.0" />);
const passwordInput = screen.getByLabelText('Password');
const confirmPasswordInput = screen.getByLabelText('Confirm Password');
const submitButton = screen.getByRole('button', { name: 'Get Started' });
act(() => {
// Set invalid password
fireEvent.change(passwordInput, { target: { value: 'password' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'password' } });
});
await waitFor(() => {
// Expect the "Get Started" button to be disabled
expect(submitButton).toBeDisabled();
});
});
it('enables the "Get Started" button when password is valid', async () => {
render(<ResetPassword version="1.0" />);
const passwordInput = screen.getByLabelText('Password');
const confirmPasswordInput = screen.getByLabelText('Confirm Password');
const submitButton = screen.getByRole('button', { name: 'Get Started' });
act(() => {
fireEvent.change(passwordInput, { target: { value: 'newPassword' } });
fireEvent.change(confirmPasswordInput, { target: { value: 'newPassword' } });
});
act(() => {
jest.advanceTimersByTime(500);
});
await waitFor(() => {
// Expect the "Get Started" button to be enabled
expect(submitButton).toBeEnabled();
});
});
});

View File

@@ -1,357 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { Logout } from 'api/utils';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import ResetPassword from '../index';
// Mock dependencies
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
search: '?token=reset-token-123',
},
},
}));
jest.mock('api/utils', () => ({
Logout: jest.fn(),
}));
const mockSuccessNotification = jest.fn();
const mockErrorNotification = jest.fn();
interface MockNotifications {
success: jest.MockedFunction<(...args: unknown[]) => void>;
error: jest.MockedFunction<(...args: unknown[]) => void>;
}
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): { notifications: MockNotifications } => ({
notifications: {
success: mockSuccessNotification,
error: mockErrorNotification,
},
}),
}));
const RESET_PASSWORD_ENDPOINT = '*/resetPassword';
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push
>;
describe('ResetPassword Component', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSuccessNotification.mockClear();
mockErrorNotification.mockClear();
window.history.pushState({}, '', '/password-reset?token=reset-token-123');
});
afterEach(() => {
server.resetHandlers();
});
describe('Initial Render', () => {
it('renders reset password form with all required fields', () => {
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
expect(screen.getByText(/reset your password/i)).toBeInTheDocument();
expect(screen.getByTestId('password')).toBeInTheDocument();
expect(screen.getByTestId('confirmPassword')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /reset password/i }),
).toBeInTheDocument();
expect(screen.getByText(/signoz 1\.0\.0/i)).toBeInTheDocument();
});
it('redirects to login when token is missing', () => {
window.history.pushState({}, '', '/password-reset');
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset',
});
expect(Logout).toHaveBeenCalled();
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
});
});
describe('Form Validation', () => {
it('disables submit button when passwords do not match', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
expect(submitButton).toBeDisabled();
await user.type(passwordInput, 'password123');
await user.type(confirmPasswordInput, 'password456');
await user.tab(); // Blur the confirm password field to trigger validation
await waitFor(() => {
expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
expect(submitButton).toBeDisabled();
});
});
it('enables submit button when passwords match', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'newPassword123');
await user.type(confirmPasswordInput, 'newPassword123');
// Wait for debounced validation
await waitFor(
() => {
expect(submitButton).not.toBeDisabled();
},
{ timeout: 200 },
);
});
it('clears password mismatch error when passwords match', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
await user.type(passwordInput, 'password123');
await user.type(confirmPasswordInput, 'password456');
await user.tab(); // Blur the confirm password field to trigger validation
await waitFor(() => {
expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
});
await user.clear(confirmPasswordInput);
await user.type(confirmPasswordInput, 'password123');
await user.tab(); // Blur again to trigger validation
await waitFor(
() => {
expect(
screen.queryByText(/passwords don't match/i),
).not.toBeInTheDocument();
},
{ timeout: 200 },
);
});
});
describe('Successful Password Reset', () => {
it('successfully resets password and redirects to login', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(RESET_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
message: 'Password reset successfully',
}),
),
),
);
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'newPassword123');
await user.type(confirmPasswordInput, 'newPassword123');
await waitFor(
() => {
expect(submitButton).not.toBeDisabled();
},
{ timeout: 200 },
);
await user.click(submitButton);
await waitFor(() => {
expect(mockSuccessNotification).toHaveBeenCalled();
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
});
});
});
describe('Error Handling', () => {
it('displays error message when reset password API fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(RESET_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
error: {
code: 'INVALID_TOKEN',
message: 'Invalid or expired reset token',
},
}),
),
),
);
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=invalid-token',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'newPassword123');
await user.type(confirmPasswordInput, 'newPassword123');
await waitFor(
() => {
expect(submitButton).not.toBeDisabled();
},
{ timeout: 200 },
);
await user.click(submitButton);
await waitFor(() => {
expect(
screen.getByText(/invalid or expired reset token/i),
).toBeInTheDocument();
});
});
it('does not show API error when password mismatch error is shown', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(RESET_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(400),
ctx.json({
error: {
code: 'INVALID_TOKEN',
message: 'Invalid token',
},
}),
),
),
);
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
await user.type(passwordInput, 'password123');
await user.type(confirmPasswordInput, 'password456');
await user.tab(); // Blur the confirm password field to trigger validation
await waitFor(() => {
expect(screen.getByText(/passwords don't match/i)).toBeInTheDocument();
expect(screen.queryByText(/invalid token/i)).not.toBeInTheDocument();
});
});
});
describe('Loading States', () => {
it('disables submit button during password reset', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.post(RESET_PASSWORD_ENDPOINT, (_req, res, ctx) =>
res(
ctx.delay(100),
ctx.status(200),
ctx.json({
status: 'success',
message: 'Password reset successfully',
}),
),
),
);
render(<ResetPassword version="1.0.0" />, undefined, {
initialRoute: '/password-reset?token=reset-token-123',
});
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
const confirmPasswordInput = screen.getByPlaceholderText(
/confirm your new password/i,
);
const submitButton = screen.getByRole('button', {
name: /reset password/i,
});
await user.type(passwordInput, 'newPassword123');
await user.type(confirmPasswordInput, 'newPassword123');
await waitFor(
() => {
expect(submitButton).not.toBeDisabled();
},
{ timeout: 200 },
);
await user.click(submitButton);
// Button should be disabled during API call
await waitFor(() => {
expect(submitButton).toBeDisabled();
});
});
});
});

View File

@@ -1,24 +1,20 @@
import './ResetPassword.styles.scss';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { Form, Input as AntdInput, Typography } from 'antd';
import { Button, Form, Input, Typography } from 'antd';
import { Logout } from 'api/utils';
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import WelcomeLeftContainer from 'components/WelcomeLeftContainer';
import ROUTES from 'constants/routes';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { ArrowRight, CircleAlert, KeyRound } from 'lucide-react';
import { Label } from 'pages/SignUp/styles';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-use';
import APIError from 'types/api/error';
import { FormContainer } from './styles';
import { ButtonContainer, FormContainer, FormWrapper } from './styles';
const { Title } = Typography;
type FormValues = { password: string; confirmPassword: string };
@@ -27,8 +23,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
false,
);
const [errorMessage, setErrorMessage] = useState<APIError | null>();
const [isValidPassword, setIsValidPassword] = useState(false);
const [loading, setLoading] = useState(false);
const { t } = useTranslation(['common']);
@@ -48,7 +42,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const handleFormSubmit: () => Promise<void> = async () => {
try {
setLoading(true);
setErrorMessage(null);
const { password } = form.getFieldsValue();
await resetPasswordApi({
@@ -66,7 +59,10 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
setLoading(false);
} catch (error) {
setLoading(false);
setErrorMessage(error as APIError);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
};
@@ -94,7 +90,6 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
setIsValidPassword(false);
}
// Only clear error if passwords match while typing (but don't set error until blur)
if (
password &&
confirmPassword &&
@@ -102,39 +97,12 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
confirmPassword.trim()
) {
const isValid = validatePassword();
setIsValidPassword(isValid);
// Only clear error if passwords match, don't set error on mismatch
if (isValid) {
setConfirmPasswordError(false);
}
setIsValidPassword(isValid);
setConfirmPasswordError(!isValid);
}
}, 100);
const handlePasswordBlur = (): void => {
const { confirmPassword } = form.getFieldsValue();
// Only validate if confirm password has a value
if (confirmPassword && confirmPassword.trim()) {
const isValid = validatePassword();
setIsValidPassword(isValid);
setConfirmPasswordError(!isValid);
}
};
const handleConfirmPasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (
password &&
password.trim() &&
confirmPassword &&
confirmPassword.trim()
) {
const isValid = validatePassword();
setIsValidPassword(isValid);
setConfirmPasswordError(!isValid);
}
};
const handleSubmit = (): void => {
const isValid = validatePassword();
setIsValidPassword(isValid);
@@ -145,100 +113,69 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
};
return (
<AuthPageContainer>
<div className="reset-password-card">
<div className="reset-password-header">
<div className="reset-password-header-icon">
<KeyRound size={32} />
</div>
<Typography.Title level={4} className="reset-password-header-title">
Reset Your Password
</Typography.Title>
<Typography.Paragraph className="reset-password-header-subtitle">
Monitor your applications. Find what is causing issues.
</Typography.Paragraph>
{version && (
<div className="reset-password-version-badge">SigNoz {version}</div>
)}
</div>
<WelcomeLeftContainer version={version}>
<FormWrapper>
<FormContainer form={form} onFinish={handleSubmit}>
<Title level={4}>Reset Your Password</Title>
<FormContainer
form={form}
onFinish={handleSubmit}
className="reset-password-form"
>
<div className="reset-password-form-container">
<div className="reset-password-form-fields">
<div className="reset-password-field-container">
<Label htmlFor="password">New Password</Label>
<Form.Item
name="password"
validateTrigger="onBlur"
rules={[{ required: true, message: 'Please enter password!' }]}
>
<AntdInput.Password
tabIndex={0}
onChange={handleValuesChange}
onBlur={handlePasswordBlur}
id="password"
data-testid="password"
placeholder="Enter new password"
className="reset-password-form-input"
/>
</Form.Item>
</div>
<div className="reset-password-field-container">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Form.Item
name="confirmPassword"
validateTrigger="onBlur"
rules={[{ required: true, message: 'Please enter confirm password!' }]}
>
<AntdInput.Password
onChange={handleValuesChange}
onBlur={handleConfirmPasswordBlur}
id="confirmPassword"
data-testid="confirmPassword"
placeholder="Confirm your new password"
className="reset-password-form-input"
/>
</Form.Item>
</div>
</div>
</div>
{confirmPasswordError && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="reset-password-error-callout"
description="Passwords don't match. Please try again."
/>
)}
{errorMessage && !confirmPasswordError && (
<AuthError error={errorMessage} />
)}
<div className="reset-password-form-actions">
<Button
variant="solid"
color="primary"
type="submit"
data-attr="reset-password"
disabled={!isValidPassword || loading}
className="reset-password-submit-button"
suffixIcon={<ArrowRight size={16} />}
<div>
<Label htmlFor="password">Password</Label>
<Form.Item
name="password"
validateTrigger="onBlur"
rules={[{ required: true, message: 'Please enter password!' }]}
>
Reset Password
</Button>
<Input.Password
tabIndex={0}
onChange={handleValuesChange}
id="password"
data-testid="password"
/>
</Form.Item>
</div>
<div>
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Form.Item
name="confirmPassword"
// validateTrigger="onChange"
validateTrigger="onBlur"
rules={[{ required: true, message: 'Please enter confirm password!' }]}
>
<Input.Password
onChange={handleValuesChange}
id="confirmPassword"
data-testid="confirmPassword"
/>
</Form.Item>
{confirmPasswordError && (
<Typography.Paragraph
italic
style={{
color: '#D89614',
marginTop: '0.50rem',
}}
>
The passwords entered do not match. Please double-check and re-enter
your passwords.
</Typography.Paragraph>
)}
</div>
<ButtonContainer>
<Button
type="primary"
htmlType="submit"
data-attr="signup"
loading={loading}
disabled={!isValidPassword || loading}
>
Get Started
</Button>
</ButtonContainer>
</FormContainer>
</div>
</AuthPageContainer>
</FormWrapper>
</WelcomeLeftContainer>
);
}

View File

@@ -1,6 +1,24 @@
import { Form } from 'antd';
import { Card, Form } from 'antd';
import styled from 'styled-components';
export const FormWrapper = styled(Card)`
display: flex;
justify-content: center;
width: 432px;
flex: 1;
.ant-card-body {
width: 100%;
}
`;
export const ButtonContainer = styled.div`
margin-top: 1.8125rem;
display: flex;
justify-content: center;
align-items: center;
`;
export const FormContainer = styled(Form)`
& .ant-form-item {
margin-bottom: 0px;

View File

@@ -20,17 +20,13 @@ interface AttributeRecord {
interface AttributeActionsProps {
record: AttributeRecord;
isPinned?: boolean;
onTogglePin?: (fieldKey: string) => void;
showPinned?: boolean;
showCopyOptions?: boolean;
onTogglePin: (fieldKey: string) => void;
}
export default function AttributeActions({
record,
isPinned,
onTogglePin,
showPinned = true,
showCopyOptions = true,
}: AttributeActionsProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
@@ -95,7 +91,7 @@ export default function AttributeActions({
}, [onCopyFieldValue, textToCopy]);
const handleTogglePin = useCallback((): void => {
onTogglePin?.(record.field);
onTogglePin(record.field);
}, [onTogglePin, record.field]);
const moreActionsContent = (
@@ -109,41 +105,35 @@ export default function AttributeActions({
>
Group By Attribute
</Button>
{showCopyOptions && (
<>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</>
)}
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</div>
);
return (
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
{showPinned && (
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
onClick={handleTogglePin}
/>
</Tooltip>
)}
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
onClick={handleTogglePin}
/>
</Tooltip>
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
@@ -194,7 +184,4 @@ export default function AttributeActions({
AttributeActions.defaultProps = {
isPinned: false,
showPinned: true,
showCopyOptions: true,
onTogglePin: undefined,
};

View File

@@ -47,56 +47,15 @@
.description {
display: flex;
flex-direction: column;
padding: 10px 0px;
gap: 16px;
padding: 10px 12px;
.item {
padding: 8px 12px;
&,
.attribute-container {
display: flex;
flex-direction: column;
gap: 8px;
position: relative; // ensure absolutely-positioned children anchor to the row
}
// Show attribute actions on hover for hardcoded rows
.attribute-actions-wrapper {
display: none;
gap: 8px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
padding: 2px;
// style the action button group
.action-btn {
display: flex;
gap: 4px;
}
.filter-btn {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--bg-slate-400);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--bg-slate-300);
}
}
}
&:hover {
background-color: var(--bg-slate-500);
.attribute-actions-wrapper {
display: flex;
}
}
.span-name-wrapper {
@@ -687,29 +646,6 @@
.description {
.item {
.attribute-actions-wrapper {
display: none;
gap: 8px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
padding: 2px;
.filter-btn {
background: var(--bg-vanilla-200);
&:hover {
background: var(--bg-vanilla-100);
}
}
}
&:hover {
background-color: var(--bg-vanilla-300);
.attribute-actions-wrapper {
display: flex;
}
}
.span-name-wrapper {
.span-percentile-value-container {
&.span-percentile-value-container-open {

View File

@@ -21,7 +21,6 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { themeColors } from 'constants/theme';
import { USER_PREFERENCES } from 'constants/userPreferences';
import AttributeActions from 'container/SpanDetailsDrawer/Attributes/AttributeActions';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
@@ -104,10 +103,6 @@ interface IResourceAttribute {
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
spanId: 'span_id',
spanKind: 'kind_string',
statusCodeString: 'status_code_string',
statusMessage: 'status_message',
};
// eslint-disable-next-line sonarjs/cognitive-complexity
@@ -840,16 +835,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.spanId}
</Typography.Text>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.spanId,
value: selectedSpan.spanId,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
<div className="item">
<Typography.Text className="attribute-key">start time</Typography.Text>
@@ -878,16 +863,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Typography.Text>
</Tooltip>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.serviceName,
value: selectedSpan.serviceName,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
</div>
<div className="item">
@@ -897,16 +872,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.spanKind}
</Typography.Text>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.spanKind,
value: selectedSpan.spanKind,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
<div className="item">
<Typography.Text className="attribute-key">
@@ -917,16 +882,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.statusCodeString}
</Typography.Text>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.statusCodeString,
value: selectedSpan.statusCodeString,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
{selectedSpan.statusMessage && (
@@ -936,16 +891,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
attributeValue={selectedSpan.statusMessage}
onExpand={showStatusMessageModal}
/>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.statusMessage,
value: selectedSpan.statusMessage,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
)}
<div className="item">

View File

@@ -1,247 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import AttributeActions from '../Attributes/AttributeActions';
// Mock only Popover from antd to simplify hover/open behavior while keeping other components real
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
const MockPopover = ({
content,
children,
open,
onOpenChange,
...rest
}: any): JSX.Element => (
<div
data-testid="mock-popover-wrapper"
onMouseEnter={(): void => onOpenChange?.(true)}
{...rest}
>
{children}
{open ? <div data-testid="mock-popover-content">{content}</div> : null}
</div>
);
return { ...actual, Popover: MockPopover };
});
// Mock getAggregateKeys API used inside useTraceActions to resolve autocomplete keys
jest.mock('api/queryBuilder/getAttributeKeys', () => ({
getAggregateKeys: jest.fn().mockResolvedValue({
payload: {
attributeKeys: [
{
key: 'http.method',
dataType: 'string',
type: 'tag',
isColumn: true,
},
],
},
}),
}));
const record = { field: 'http.method', value: 'GET' };
describe('AttributeActions (unit)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders core action buttons (pin, filter in/out, more)', async () => {
render(<AttributeActions record={record} isPinned={false} />);
expect(
screen.getByRole('button', { name: 'Pin attribute' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter for value' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter out value' }),
).toBeInTheDocument();
// more actions (ellipsis) button
expect(
document.querySelector('.lucide-ellipsis')?.closest('button'),
).toBeInTheDocument();
});
it('applies "Filter for" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterForBtn = screen.getByRole('button', { name: 'Filter for value' });
await userEvent.click(filterForBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('applies "Filter out" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterOutBtn = screen.getByRole('button', { name: 'Filter out value' });
await userEvent.click(filterOutBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '!=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('opens more actions on hover and calls Group By handler; closes after click', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const ellipsisBtn = document
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
expect(ellipsisBtn).toBeInTheDocument();
// hover to trigger Popover open via mock
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
// content appears
await waitFor(() =>
expect(screen.getByText('Group By Attribute')).toBeInTheDocument(),
);
await userEvent.click(screen.getByText('Group By Attribute'));
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
groupBy: expect.arrayContaining([
expect.objectContaining({ key: 'http.method' }),
]),
}),
]),
}),
}),
{},
expect.any(String),
);
});
// After clicking group by, popover should close
await waitFor(() =>
expect(screen.queryByTestId('mock-popover-content')).not.toBeInTheDocument(),
);
});
it('hides pin button when showPinned=false', async () => {
render(<AttributeActions record={record} showPinned={false} />);
expect(
screen.queryByRole('button', { name: /pin attribute/i }),
).not.toBeInTheDocument();
});
it('hides copy options when showCopyOptions=false', async () => {
render(<AttributeActions record={record} showCopyOptions={false} />);
const ellipsisBtn = document
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
await waitFor(() =>
expect(screen.queryByText('Copy Field Name')).not.toBeInTheDocument(),
);
expect(screen.queryByText('Copy Field Value')).not.toBeInTheDocument();
});
});

View File

@@ -1,183 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
initialClickHouseData,
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { IDashboardVariable, Widgets } from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { createDynamicVariableToWidgetsMap } from './utils';
const createMockDynamicVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: 'var-1',
name: 'testVar',
description: '',
type: 'DYNAMIC',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
dynamicVariablesAttribute: 'service.name',
...overrides,
});
const createBaseWidget = (id: string, query: Query): Widgets => ({
id,
title: 'Test Widget',
description: '',
panelTypes: PANEL_TYPES.TIME_SERIES,
opacity: '1',
nullZeroValues: '',
timePreferance: 'GLOBAL_TIME',
softMin: null,
softMax: null,
selectedLogFields: null,
selectedTracesFields: null,
query,
});
const createMockPromQLWidget = (
id: string,
queries: {
query: string;
name?: string;
legend?: string;
disabled?: boolean;
}[],
): Widgets => {
const promqlQueries = queries.map((q) => ({
...initialQueryPromQLData,
query: q.query,
name: q.name || 'A',
legend: q.legend || '',
disabled: q.disabled ?? false,
}));
const query: Query = {
queryType: EQueryType.PROM,
promql: promqlQueries,
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: 'query-1',
};
return createBaseWidget(id, query);
};
const createMockClickHouseWidget = (
id: string,
queries: {
query: string;
name?: string;
legend?: string;
disabled?: boolean;
}[],
): Widgets => {
const clickhouseQueries = queries.map((q) => ({
...initialClickHouseData,
query: q.query,
name: q.name || 'A',
legend: q.legend || '',
disabled: q.disabled ?? false,
}));
const query: Query = {
queryType: EQueryType.CLICKHOUSE,
promql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: clickhouseQueries,
id: 'query-1',
};
return createBaseWidget(id, query);
};
const createMockQueryBuilderWidget = (
id: string,
filters: { key: string; value: string | string[]; op?: string }[],
): Widgets => {
const queryData = {
...initialQueryBuilderFormValuesMap[DataSource.LOGS],
queryName: 'A',
filters: {
items: filters.map((f, index) => ({
id: `filter-${index}`,
key: { key: f.key, dataType: DataTypes.String, type: '', id: f.key },
op: f.op || '=',
value: f.value,
})),
op: 'AND',
},
};
const query: Query = {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: 'query-1',
};
return createBaseWidget(id, query);
};
describe('createDynamicVariableToWidgetsMap', () => {
it('should handle widgets with different query types', () => {
const dynamicVariables = [
createMockDynamicVariable({
id: 'var-1',
name: 'service.name123',
dynamicVariablesAttribute: 'service.name',
}),
];
const widgets = [
createMockPromQLWidget('widget-promql-pass', [
{ query: 'up{service="$service.name123"}' },
]),
createMockPromQLWidget('widget-promql-fail', [
{ query: 'up{service="$service.name"}' },
]),
createMockClickHouseWidget('widget-clickhouse-pass', [
{ query: "SELECT * FROM logs WHERE service_name = '$service.name123'" },
]),
createMockClickHouseWidget('widget-clickhouse-fail', [
{ query: "SELECT * FROM logs WHERE service_name = '$service.name'" },
]),
createMockQueryBuilderWidget('widget-builder-pass', [
{ key: 'service.name', value: '$service.name123' },
]),
createMockQueryBuilderWidget('widget-builder-fail', [
{ key: 'service.name', value: '$service.name' },
]),
];
const result = createDynamicVariableToWidgetsMap(dynamicVariables, widgets);
expect(result['var-1']).toContain('widget-promql-pass');
expect(result['var-1']).toContain('widget-clickhouse-pass');
expect(result['var-1']).toContain('widget-builder-pass');
expect(result['var-1']).not.toContain('widget-promql-fail');
expect(result['var-1']).not.toContain('widget-clickhouse-fail');
expect(result['var-1']).not.toContain('widget-builder-fail');
});
});

View File

@@ -104,9 +104,10 @@ export const createDynamicVariableToWidgetsMap = (
// Check each widget for usage of dynamic variables
if (Array.isArray(widgets)) {
widgets.forEach((widget) => {
if (widget.query?.queryType === EQueryType.QUERY_BUILDER) {
if (!Array.isArray(widget.query.builder.queryData)) return;
if (
widget.query?.builder?.queryData &&
widget.query?.queryType === EQueryType.QUERY_BUILDER
) {
widget.query.builder.queryData.forEach((queryData: IBuilderQuery) => {
// Check filter items for dynamic variables
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
@@ -138,34 +139,6 @@ export const createDynamicVariableToWidgetsMap = (
});
}
});
} else if (widget.query?.queryType === EQueryType.PROM) {
if (!Array.isArray(widget.query.promql)) return;
widget.query.promql.forEach((promqlQuery) => {
dynamicVariables.forEach((variable) => {
if (
variable.dynamicVariablesAttribute &&
promqlQuery.query?.includes(`$${variable.name}`) &&
!dynamicVariableToWidgetsMap[variable.id].includes(widget.id)
) {
dynamicVariableToWidgetsMap[variable.id].push(widget.id);
}
});
});
} else if (widget.query?.queryType === EQueryType.CLICKHOUSE) {
if (!Array.isArray(widget.query.clickhouse_sql)) return;
widget.query.clickhouse_sql.forEach((clickhouseQuery) => {
dynamicVariables.forEach((variable) => {
if (
variable.dynamicVariablesAttribute &&
clickhouseQuery.query?.includes(`$${variable.name}`) &&
!dynamicVariableToWidgetsMap[variable.id].includes(widget.id)
) {
dynamicVariableToWidgetsMap[variable.id].push(widget.id);
}
});
});
}
});
}

View File

@@ -1,7 +1,6 @@
import logEvent from 'api/common/logEvent';
import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
@@ -10,7 +9,6 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useMemo } from 'react';
import { useMutation } from 'react-query';
@@ -73,21 +71,11 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
queryRangeMutation.mutate(queryPayload, {
onSuccess: (data) => {
const updatedQuery = mapQueryDataFromApi(data.data.compositeQuery);
// If widget has a y-axis unit, set it to the updated query if it is not already set
if (widget.yAxisUnit && !isEmpty(widget.yAxisUnit)) {
updatedQuery.unit = widget.yAxisUnit;
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
const url = `${ROUTES.ALERTS_NEW}?${params.toString()}`;
const url = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(JSON.stringify(updatedQuery))}&${
QueryParams.panelTypes
}=${widget.panelTypes}&version=${ENTITY_VERSION_V5}`;
window.open(url, '_blank', 'noreferrer');
},

View File

@@ -1,132 +0,0 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { convertValue, getFormattedUnit } from 'lib/getConvertedValue';
describe('getFormattedUnit', () => {
it('should return the grafana unit for universal unit if it exists', () => {
const formattedUnit = getFormattedUnit(UniversalYAxisUnit.KILOBYTES);
expect(formattedUnit).toBe('deckbytes');
});
it('should return the unit directly if it is not a universal unit', () => {
const formattedUnit = getFormattedUnit('{reason}');
expect(formattedUnit).toBe('{reason}');
});
it('should return the universal unit directly if it does not have a grafana equivalent', () => {
const formattedUnit = getFormattedUnit(UniversalYAxisUnit.EXABYTES);
expect(formattedUnit).toBe(UniversalYAxisUnit.EXABYTES);
});
});
describe('convertValue', () => {
describe('data', () => {
it('should convert bytes (IEC) to kilobytes', () => {
expect(
convertValue(
1000,
UniversalYAxisUnit.BYTES_IEC,
UniversalYAxisUnit.KILOBYTES,
),
).toBe(1);
});
it('should convert bytes (SI) to kilobytes', () => {
expect(
convertValue(1000, UniversalYAxisUnit.BYTES, UniversalYAxisUnit.KILOBYTES),
).toBe(1);
});
it('should convert kilobytes to bytes', () => {
expect(
convertValue(1, UniversalYAxisUnit.KILOBYTES, UniversalYAxisUnit.BYTES),
).toBe(1000);
});
it('should convert megabytes to kilobytes', () => {
expect(convertValue(1, 'mbytes', 'kbytes')).toBe(1024);
});
it('should convert gigabytes to megabytes', () => {
expect(convertValue(1, 'gbytes', 'mbytes')).toBe(1024);
});
it('should convert kilobytes to megabytes', () => {
expect(convertValue(1024, 'kbytes', 'mbytes')).toBe(1);
});
it('should convert bits to gigabytes', () => {
// 12 GB = 103079215104 bits
expect(convertValue(103079215104, 'bits', 'gbytes')).toBe(12);
});
});
describe('time', () => {
it('should convert milliseconds to seconds', () => {
expect(convertValue(1000, 'ms', 's')).toBe(1);
});
it('should convert seconds to milliseconds', () => {
expect(convertValue(1, 's', 'ms')).toBe(1000);
});
it('should convert nanoseconds to milliseconds', () => {
expect(convertValue(1000000, 'ns', 'ms')).toBe(1);
});
it('should convert seconds to minutes', () => {
expect(convertValue(60, 's', 'm')).toBe(1);
});
it('should convert minutes to hours', () => {
expect(convertValue(60, 'm', 'h')).toBe(1);
});
});
describe('data rate', () => {
it('should convert bytes/sec to kibibytes/sec', () => {
expect(convertValue(1024, 'binBps', 'KiBs')).toBe(1);
});
it('should convert kibibytes/sec to bytes/sec', () => {
expect(convertValue(1, 'KiBs', 'binBps')).toBe(1024);
});
});
describe('throughput', () => {
it('should convert counts per second to counts per minute', () => {
expect(convertValue(1, 'cps', 'cpm')).toBe(1 / 60);
});
it('should convert operations per second to operations per minute', () => {
expect(convertValue(1, 'ops', 'opm')).toBe(1 / 60);
});
it('should convert counts per minute to counts per second', () => {
expect(convertValue(1, 'cpm', 'cps')).toBe(60);
});
it('should convert operations per minute to operations per second', () => {
expect(convertValue(1, 'opm', 'ops')).toBe(60);
});
});
describe('percent', () => {
it('should convert percentunit to percent', () => {
expect(convertValue(0.5, 'percentunit', 'percent')).toBe(50);
});
it('should convert percent to percentunit', () => {
expect(convertValue(50, 'percent', 'percentunit')).toBe(0.5);
});
});
describe('invalid values', () => {
it('should return null when currentUnit is invalid', () => {
expect(convertValue(100, 'invalidUnit', 'bytes')).toBeNull();
});
it('should return null when targetUnit is invalid', () => {
expect(convertValue(100, 'bytes', 'invalidUnit')).toBeNull();
});
});
});

View File

@@ -1,17 +1,3 @@
import {
UniversalUnitToGrafanaUnit,
Y_AXIS_UNIT_NAMES,
} from 'components/YAxisUnitSelector/constants';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { isUniversalUnit } from 'components/YAxisUnitSelector/utils';
// 1 byte = 8 bits
// Or 1 bit = 1/8 bytes
const BIT_FACTOR = 1 / 8;
const DECIMAL_FACTOR = 1000;
const BINARY_FACTOR = 1024;
const unitsMapping = [
{
label: 'Data',
@@ -29,132 +15,62 @@ const unitsMapping = [
{
label: 'bits(IEC)',
value: 'bits',
factor: BIT_FACTOR,
factor: 8, // 1 byte = 8 bits
},
{
label: 'bits(SI)',
value: 'decbits',
factor: BIT_FACTOR,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBITS],
value: UniversalYAxisUnit.KILOBITS,
factor: BIT_FACTOR * DECIMAL_FACTOR,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABITS],
value: UniversalYAxisUnit.MEGABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 2,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABITS],
value: UniversalYAxisUnit.GIGABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 3,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABITS],
value: UniversalYAxisUnit.TERABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 4,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABITS],
value: UniversalYAxisUnit.PETABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 5,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABITS],
value: UniversalYAxisUnit.EXABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABITS],
value: UniversalYAxisUnit.ZETTABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABITS],
value: UniversalYAxisUnit.YOTTABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 8,
factor: 8, // 1 byte = 8 bits
},
{
label: 'kibibytes',
value: 'kbytes',
factor: BINARY_FACTOR,
factor: 1024,
},
{
label: 'kilobytes',
value: 'deckbytes',
factor: DECIMAL_FACTOR,
factor: 1000,
},
{
label: 'mebibytes',
value: 'mbytes',
factor: BINARY_FACTOR ** 2,
factor: 1024 * 1024,
},
{
label: 'megabytes',
value: 'decmbytes',
factor: DECIMAL_FACTOR ** 2,
factor: 1000 * 1000,
},
{
label: 'gibibytes',
value: 'gbytes',
factor: BINARY_FACTOR ** 3,
factor: 1024 * 1024 * 1024,
},
{
label: 'gigabytes',
value: 'decgbytes',
factor: DECIMAL_FACTOR ** 3,
factor: 1000 * 1000 * 1000,
},
{
label: 'tebibytes',
value: 'tbytes',
factor: BINARY_FACTOR ** 4,
factor: 1024 * 1024 * 1024 * 1024,
},
{
label: 'terabytes',
value: 'dectbytes',
factor: DECIMAL_FACTOR ** 4,
factor: 1000 * 1000 * 1000 * 1000,
},
{
label: 'pebibytes',
value: 'pbytes',
factor: BINARY_FACTOR ** 5,
factor: 1024 * 1024 * 1024 * 1024 * 1024,
},
{
label: 'petabytes',
value: 'decpbytes',
factor: DECIMAL_FACTOR ** 5,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABYTES],
value: UniversalYAxisUnit.EXABYTES,
factor: DECIMAL_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXBIBYTES],
value: UniversalYAxisUnit.EXBIBYTES,
factor: BINARY_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABYTES],
value: UniversalYAxisUnit.ZETTABYTES,
factor: DECIMAL_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZEBIBYTES],
value: UniversalYAxisUnit.ZEBIBYTES,
factor: BINARY_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABYTES],
value: UniversalYAxisUnit.YOTTABYTES,
factor: DECIMAL_FACTOR ** 8,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOBIBYTES],
value: UniversalYAxisUnit.YOBIBYTES,
factor: BINARY_FACTOR ** 8,
factor: 1000 * 1000 * 1000 * 1000 * 1000,
},
],
},
@@ -174,103 +90,44 @@ const unitsMapping = [
{
label: 'bits/sec(IEC)',
value: 'binbps',
factor: BIT_FACTOR, // 1 byte = 8 bits
factor: 8, // 1 byte = 8 bits
},
{
label: 'bits/sec(SI)',
value: 'bps',
factor: BIT_FACTOR, // 1 byte = 8 bits
factor: 8, // 1 byte = 8 bits
},
{
label: 'kibibytes/sec',
value: 'KiBs',
factor: BINARY_FACTOR,
factor: 1024,
},
{
label: 'kibibits/sec',
value: 'Kibits',
factor: BIT_FACTOR * BINARY_FACTOR, // 1 KiB = 8 Kibits
factor: 8 * 1024, // 1 KiB = 8 Kibits
},
{
label: 'kilobytes/sec',
value: 'KBs',
factor: DECIMAL_FACTOR,
factor: 1000,
},
{
label: 'kilobits/sec',
value: 'Kbits',
factor: BIT_FACTOR * DECIMAL_FACTOR, // 1 KB = 8 Kbits
factor: 8 * 1000, // 1 KB = 8 Kbits
},
{
label: 'mebibytes/sec',
value: 'MiBs',
factor: BINARY_FACTOR ** 2,
factor: 1024 * 1024,
},
{
label: 'mebibits/sec',
value: 'Mibits',
factor: BIT_FACTOR * BINARY_FACTOR ** 2, // 1 MiB = 8 Mibits
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABYTES_SECOND],
value: UniversalYAxisUnit.EXABYTES_SECOND,
factor: DECIMAL_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABYTES_SECOND],
value: UniversalYAxisUnit.ZETTABYTES_SECOND,
factor: DECIMAL_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABYTES_SECOND],
value: UniversalYAxisUnit.YOTTABYTES_SECOND,
factor: DECIMAL_FACTOR ** 8,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXBIBYTES_SECOND],
value: UniversalYAxisUnit.EXBIBYTES_SECOND,
factor: BINARY_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZEBIBYTES_SECOND],
value: UniversalYAxisUnit.ZEBIBYTES_SECOND,
factor: BINARY_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOBIBYTES_SECOND],
value: UniversalYAxisUnit.YOBIBYTES_SECOND,
factor: BINARY_FACTOR ** 8,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABITS_SECOND],
value: UniversalYAxisUnit.EXABITS_SECOND,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABITS_SECOND],
value: UniversalYAxisUnit.ZETTABITS_SECOND,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABITS_SECOND],
value: UniversalYAxisUnit.YOTTABITS_SECOND,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 8,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXBIBITS_SECOND],
value: UniversalYAxisUnit.EXBIBITS_SECOND,
factor: BIT_FACTOR * BINARY_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZEBIBITS_SECOND],
value: UniversalYAxisUnit.ZEBIBITS_SECOND,
factor: BIT_FACTOR * BINARY_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOBIBITS_SECOND],
value: UniversalYAxisUnit.YOBIBITS_SECOND,
factor: BIT_FACTOR * BINARY_FACTOR ** 8,
factor: 8 * 1024 * 1024, // 1 MiB = 8 Mibits
},
// ... (other options)
],
},
{
@@ -411,14 +268,6 @@ function findUnitObject(
return unitObj || null;
}
export function getFormattedUnit(unit: string): string {
const isUniversalYAxisUnit = isUniversalUnit(unit);
if (isUniversalYAxisUnit) {
return UniversalUnitToGrafanaUnit[unit as UniversalYAxisUnit] || unit;
}
return unit;
}
export function convertValue(
value: number,
currentUnit?: string,
@@ -432,12 +281,8 @@ export function convertValue(
) {
return value;
}
const formattedCurrentUnit = getFormattedUnit(currentUnit);
const formattedTargetUnit = getFormattedUnit(targetUnit);
const currentUnitObj = findUnitObject(formattedCurrentUnit);
const targetUnitObj = findUnitObject(formattedTargetUnit);
const currentUnitObj = findUnitObject(currentUnit);
const targetUnitObj = findUnitObject(targetUnit);
if (currentUnitObj && targetUnitObj) {
const baseValue = value * currentUnitObj.factor;

View File

@@ -1,14 +1,116 @@
.auth-form-card {
width: 576px;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.login-page-container {
height: 100vh;
gap: 32px;
z-index: 1;
@media (max-width: 768px) {
display: flex;
justify-content: center;
align-items: center;
.brand-container {
width: 100%;
padding: 0 16px;
padding: 16px 0px;
display: flex;
gap: 8px;
align-items: center;
.brand {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
.brand-logo {
width: 32px;
height: 32px;
}
.brand-title {
font-size: 24px;
font-weight: 500;
color: var(--text-vanilla-300);
}
}
.perilin-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, #fff 10%, transparent 0);
background-size: 12px 12px;
opacity: 1;
mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
-webkit-mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
}
.login-page-content {
width: 480px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 16px;
padding: 32px;
background: rgb(18 19 23);
z-index: 1;
}
}
.lightMode {
.login-page-container {
.brand-container {
.brand-title {
color: var(--text-ink-500);
}
}
.perilin-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, #000000 10%, transparent 0);
background-size: 12px 12px;
opacity: 1;
mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
-webkit-mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
}
.login-page-content {
background: rgb(255 255 255);
border: 1px solid var(--border-vanilla-200);
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
color: var(--text-ink-500);
}
}
}

View File

@@ -1,15 +1,25 @@
import './Login.styles.scss';
import AuthPageContainer from 'components/AuthPageContainer';
import LoginContainer from 'container/Login';
function Login(): JSX.Element {
return (
<AuthPageContainer>
<div className="auth-form-card">
<div className="login-page-container">
<div className="perilin-bg" />
<div className="login-page-content">
<div className="brand-container">
<img
src="/Logos/signoz-brand-logo.svg"
alt="logo"
className="brand-logo"
/>
<div className="brand-title">SigNoz</div>
</div>
<LoginContainer />
</div>
</AuthPageContainer>
</div>
);
}

View File

@@ -1,11 +1,10 @@
import AuthPageContainer from 'components/AuthPageContainer';
import OnboardingQuestionaire from 'container/OnboardingQuestionaire';
function OrgOnboarding(): JSX.Element {
return (
<AuthPageContainer isOnboarding>
<div className="onboarding-v2">
<OnboardingQuestionaire />
</AuthPageContainer>
</div>
);
}

View File

@@ -1,247 +1,213 @@
.signup-card {
width: 576px;
max-width: 100%;
.signup-page-container {
height: 100vh;
gap: 32px;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.signup-form-header {
.brand-container {
width: 100%;
padding: 16px 0px;
display: flex;
gap: 8px;
align-items: center;
.brand {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
.brand-logo {
width: 32px;
height: 32px;
}
.brand-title {
font-size: 24px;
font-weight: 500;
color: var(--text-vanilla-300);
}
}
.perilin-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, #fff 10%, transparent 0);
background-size: 12px 12px;
opacity: 1;
mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
-webkit-mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
}
.signup-page-content {
width: 540px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 32px;
text-align: center;
padding: 0 24px;
width: 100%;
justify-content: center;
.signup-header-icon {
width: 32px;
height: 32px;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
}
border-radius: 16px;
padding: 32px;
.signup-header-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
line-height: 1;
letter-spacing: 0;
color: var(--levels-l1-foreground, #eceef2);
margin: 0 !important;
}
background: rgb(18 19 23);
.signup-header-subtitle {
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
color: var(--semantic-secondary-foreground, #adb4c2);
max-width: 360px;
margin: 0 !important;
text-align: center;
}
}
z-index: 1;
.signup-form {
width: 100%;
.signup-form-container {
.signup-form {
width: 100%;
background: var(--semantic-secondary-background, #121317);
border: 1px solid var(--semantic-secondary-border, #23262e);
border-radius: 4px;
padding: 24px;
.signup-form-fields {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
.ant-input {
height: 40px;
}
.signup-field-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.ant-input-affix-wrapper {
height: 40px;
.signup-form-input,
.signup-antd-input {
height: 32px;
width: 100%;
border-radius: 2px;
&.ant-input,
&.ant-input-password,
&.ant-input-affix-wrapper {
height: 32px;
border-radius: 2px;
background: var(--levels-l3-background, #23262e);
border-color: var(--levels-l3-border, #2c303a);
}
&.ant-input-affix-wrapper {
.ant-input {
height: auto;
background: transparent;
}
.ant-input {
height: auto;
}
}
}
.signup-form-input {
height: 32px;
background: var(--levels-l3-background, #23262e);
border: 1px solid var(--levels-l3-border, #2c303a);
border-radius: 2px;
padding: 6px 8px;
font-family: Inter, sans-serif;
font-size: 13px;
.signup-form-header {
.signup-form-header-text {
color: var(--text-vanilla-300);
}
}
.email-container,
.first-name-container,
.org-name-container {
display: flex;
flex-direction: column;
.ant-input {
width: 100%;
}
}
.password-section {
display: flex;
flex-direction: row;
gap: 16px;
margin-top: 16px;
.password-container {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
}
.password-error-container {
margin-top: 8px;
margin-bottom: 16px;
.password-error-message {
color: var(--text-amber-400);
font-size: 12px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
color: var(--levels-l1-foreground, #eceef2);
line-height: 16px;
letter-spacing: 0px;
text-align: left;
text-underline-position: from-font;
text-decoration-skip-ink: none;
&::placeholder {
color: var(--levels-l3-foreground, #747b8b);
}
&:hover {
border-color: var(--levels-l3-border, #2c303a);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
box-shadow: none;
}
margin-bottom: 4px;
}
}
.signup-error-callout,
.signup-info-callout {
margin-top: 24px;
}
.signup-error-callout {
background: rgba(229, 72, 77, 0.1);
border: 1px solid rgba(229, 72, 77, 0.2);
border-radius: 4px;
animation: horizontal-shaking 300ms ease-out;
}
.signup-info-message {
color: var(--semantic-secondary-foreground);
font-size: 11px;
color: var(--text-vanilla-300);
font-size: 12px;
font-weight: 400;
line-height: 1.45;
margin: 24px 0 0 0;
line-height: 16px;
letter-spacing: 0px;
}
.signup-form-actions {
margin-top: 24px;
.signup-button-container {
margin-top: 32px;
display: flex;
width: 100%;
.signup-submit-button {
width: 100%;
}
}
.ant-form-item {
margin-bottom: 0;
}
}
@media (max-width: 768px) {
width: 100%;
padding: 0 16px;
.signup-form-header {
padding: 0;
align-items: center;
}
}
}
.lightMode {
.signup-card {
.signup-form-header {
.signup-header-icon {
.signup-page-container {
.brand-container {
.brand-title {
color: var(--text-ink-500);
}
.signup-header-title {
color: var(--text-ink-500);
}
.signup-header-subtitle {
color: var(--text-neutral-light-200, #80828d);
}
}
.signup-form {
.signup-form-container {
background: var(--bg-base-white, #ffffff);
border: 1px solid var(--bg-vanilla-300, #e9e9e9);
.signup-form-header {
.signup-form-header-text {
color: var(--text-ink-500);
}
}
.signup-form-input,
.signup-antd-input {
&.ant-input,
&.ant-input-password,
&.ant-input-affix-wrapper {
background: var(--bg-vanilla-200, #f5f5f5);
border-color: var(--bg-vanilla-300, #e9e9e9);
color: var(--text-ink-500);
}
.perilin-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
&::placeholder {
color: var(--text-neutral-light-200, #80828d);
}
background: radial-gradient(circle, #000000 10%, transparent 0);
background-size: 12px 12px;
opacity: 1;
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
}
}
mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
-webkit-mask-image: radial-gradient(
circle at 50% 0,
rgba(11, 12, 14, 0.1) 0,
rgba(11, 12, 14, 0) 100%
);
}
.signup-form-input {
background: var(--bg-vanilla-200, #f5f5f5);
border-color: var(--bg-vanilla-300, #e9e9e9);
color: var(--text-ink-500);
.signup-page-content {
background: rgb(255 255 255);
border: 1px solid var(--border-vanilla-200);
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
color: var(--text-ink-500);
&::placeholder {
color: var(--text-neutral-light-200, #80828d);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
}
.password-error-container {
.password-error-message {
color: var(--text-amber-400);
}
}
.signup-info-message {
color: var(--text-neutral-light-200, #80828d);
color: var(--text-ink-500);
}
}
}
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -1,20 +1,15 @@
import './SignUp.styles.scss';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { Input } from '@signozhq/input';
import { Form, Input as AntdInput, Typography } from 'antd';
import { Button, Form, Input, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import accept from 'api/v1/invite/id/accept';
import getInviteDetails from 'api/v1/invite/id/get';
import signUpApi from 'api/v1/register/post';
import passwordAuthNContext from 'api/v2/sessions/email_password/post';
import afterLogin from 'AppRoutes/utils';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import { useNotifications } from 'hooks/useNotifications';
import { ArrowRight, CircleAlert } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useEffect, useState } from 'react';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import { SuccessResponseV2 } from 'types/api';
@@ -38,7 +33,6 @@ function SignUp(): JSX.Element {
const [confirmPasswordError, setConfirmPasswordError] = useState<boolean>(
false,
);
const [formError, setFormError] = useState<APIError | null>();
const { search } = useLocation();
const params = new URLSearchParams(search);
const token = params.get('token');
@@ -59,17 +53,13 @@ function SignUp(): JSX.Element {
const { notifications } = useNotifications();
const [form] = Form.useForm<FormValues>();
// Watch form values for reactive validation
const email = Form.useWatch('email', form);
const password = Form.useWatch('password', form);
const confirmPassword = Form.useWatch('confirmPassword', form);
useEffect(() => {
if (
getInviteDetailsResponse.status === 'success' &&
getInviteDetailsResponse.data.data
) {
const responseDetails = getInviteDetailsResponse.data.data;
form.setFieldValue('firstName', responseDetails.name);
form.setFieldValue('email', responseDetails.email);
form.setFieldValue('organizationName', responseDetails.organization);
setIsDetailsDisable(true);
@@ -107,6 +97,7 @@ function SignUp(): JSX.Element {
]);
const isSignUp = token === null;
const { showErrorModal } = useErrorModal();
const signUp = async (values: FormValues): Promise<void> => {
try {
@@ -126,7 +117,7 @@ function SignUp(): JSX.Element {
await afterLogin(token.data.accessToken, token.data.refreshToken);
} catch (error) {
setFormError(error as APIError);
showErrorModal(error as APIError);
}
};
@@ -145,7 +136,10 @@ function SignUp(): JSX.Element {
await afterLogin(token.data.accessToken, token.data.refreshToken);
} catch (error) {
setFormError(error as APIError);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
};
@@ -155,7 +149,6 @@ function SignUp(): JSX.Element {
try {
const values = form.getFieldsValue();
setLoading(true);
setFormError(null);
if (isSignUp) {
await signUp(values);
@@ -179,57 +172,37 @@ function SignUp(): JSX.Element {
const handleValuesChange: (changedValues: Partial<FormValues>) => void = (
changedValues,
) => {
// Clear error if passwords match while typing (but don't set error until blur)
if ('password' in changedValues || 'confirmPassword' in changedValues) {
const { password, confirmPassword } = form.getFieldsValue();
if (password && confirmPassword && password === confirmPassword) {
setConfirmPasswordError(false);
}
}
};
const handlePasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
// Only validate if confirm password has a value
if (confirmPassword) {
const isSamePassword = password === confirmPassword;
setConfirmPasswordError(!isSamePassword);
}
};
const handleConfirmPasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (password && confirmPassword) {
const isSamePassword = password === confirmPassword;
setConfirmPasswordError(!isSamePassword);
}
const isValidForm: () => boolean = () => {
const values = form.getFieldsValue();
return (
loading ||
!values.email ||
!values.password ||
!values.confirmPassword ||
confirmPasswordError
);
};
const isValidForm = useMemo(
(): boolean =>
!loading &&
Boolean(email?.trim()) &&
Boolean(password?.trim()) &&
Boolean(confirmPassword?.trim()) &&
!confirmPasswordError,
[loading, email, password, confirmPassword, confirmPasswordError],
);
return (
<AuthPageContainer>
<div className="signup-card">
<div className="signup-form-header">
<div className="signup-header-icon">
<img src="/svgs/tv.svg" alt="TV" width="32" height="32" />
</div>
<Typography.Title level={4} className="signup-header-title">
Create your account
</Typography.Title>
<Typography.Paragraph className="signup-header-subtitle">
You&apos;re almost in. Create a password to start monitoring your
applications with SigNoz.
</Typography.Paragraph>
<div className="signup-page-container">
<div className="perilin-bg" />
<div className="signup-page-content">
<div className="brand-container">
<img
src="/Logos/signoz-brand-logo.svg"
alt="logo"
className="brand-logo"
/>
<div className="brand-title">SigNoz</div>
</div>
<FormContainer
@@ -238,100 +211,75 @@ function SignUp(): JSX.Element {
form={form}
className="signup-form"
>
<div className="signup-form-container">
<div className="signup-form-fields">
<div className="signup-field-container">
<Label htmlFor="signupEmail">Email address</Label>
<FormContainer.Item noStyle name="email">
<Input
placeholder="e.g. john@signoz.io"
type="email"
autoFocus
required
id="signupEmail"
disabled={isDetailsDisable}
className="signup-form-input"
/>
</FormContainer.Item>
</div>
<div className="signup-form-header">
<Typography.Paragraph className="signup-form-header-text">
You&apos;re almost in. Create a password to start monitoring your
applications with SigNoz.
</Typography.Paragraph>
</div>
<div className="signup-field-container">
<Label htmlFor="currentPassword">Set your password</Label>
<FormContainer.Item
name="password"
validateTrigger="onBlur"
rules={[{ required: true, message: 'Please enter password!' }]}
>
<AntdInput.Password
required
id="currentPassword"
placeholder="Enter new password"
disabled={loading}
className="signup-antd-input"
onBlur={handlePasswordBlur}
/>
</FormContainer.Item>
</div>
<div className="email-container">
<Label htmlFor="signupEmail">Email</Label>
<FormContainer.Item noStyle name="email">
<Input
placeholder="name@yourcompany.com"
type="email"
autoFocus
required
id="signupEmail"
disabled={isDetailsDisable}
/>
</FormContainer.Item>
</div>
<div className="signup-field-container">
<Label htmlFor="confirmPassword">Confirm your new password</Label>
<FormContainer.Item
name="confirmPassword"
validateTrigger="onBlur"
rules={[{ required: true, message: 'Please enter confirm password!' }]}
>
<AntdInput.Password
required
id="confirmPassword"
placeholder="Confirm your new password"
disabled={loading}
className="signup-antd-input"
onBlur={handleConfirmPasswordBlur}
/>
</FormContainer.Item>
</div>
</div>
<div className="password-container">
<Label htmlFor="currentPassword">Password</Label>
<FormContainer.Item noStyle name="password">
<Input.Password required id="currentPassword" />
</FormContainer.Item>
</div>
<div className="password-container">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<FormContainer.Item noStyle name="confirmPassword">
<Input.Password required id="confirmPassword" />
</FormContainer.Item>
</div>
<div className="password-error-container">
{confirmPasswordError && (
<Typography.Paragraph
id="password-confirm-error"
className="password-error-message"
>
Passwords dont match. Please try again
</Typography.Paragraph>
)}
</div>
{isSignUp && (
<Callout
type="info"
size="small"
showIcon
className="signup-info-callout"
description="This will create an admin account. If you are not an admin, please ask your admin for an invite link"
/>
<Typography.Paragraph className="signup-info-message">
* This will create an admin account. If you are not an admin, please ask
your admin for an invite link
</Typography.Paragraph>
)}
{confirmPasswordError && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="signup-error-callout"
description="Passwords don't match. Please try again."
/>
)}
{formError && !confirmPasswordError && <AuthError error={formError} />}
<div className="signup-form-actions">
<div className="signup-button-container">
<Button
variant="solid"
color="primary"
type="submit"
type="primary"
htmlType="submit"
data-attr="signup"
disabled={!isValidForm}
className="signup-submit-button"
suffixIcon={<ArrowRight size={16} />}
loading={loading}
disabled={isValidForm()}
className="periscope-btn primary next-btn"
block
>
Access My Workspace
</Button>
</div>
</FormContainer>
</div>
</AuthPageContainer>
</div>
);
}

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