mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-20 23:30:31 +01:00
Compare commits
8 Commits
chore/push
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e1be83f9 | ||
|
|
b53b40121d | ||
|
|
70cd4a6e58 | ||
|
|
4af551b483 | ||
|
|
ceee42616a | ||
|
|
e10c1b9723 | ||
|
|
06ae762070 | ||
|
|
3cc34baf74 |
52
.github/CODEOWNERS
vendored
52
.github/CODEOWNERS
vendored
@@ -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,53 +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
|
||||
/pkg/authz/ @vikrantgupta25 @therealpandey
|
||||
|
||||
# Integration tests
|
||||
|
||||
/tests/integration/ @vikrantgupta25
|
||||
/tests/integration/ @therealpandey
|
||||
|
||||
# Dashboard Owners
|
||||
|
||||
|
||||
96
.github/pull_request_template.md
vendored
96
.github/pull_request_template.md
vendored
@@ -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 -->
|
||||
|
||||
---
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,7 @@ frontend/coverage
|
||||
|
||||
# production
|
||||
frontend/build
|
||||
frontend/.vscode
|
||||
frontend/.yarnclean
|
||||
frontend/.temp_cache
|
||||
frontend/test-results
|
||||
@@ -30,6 +31,7 @@ frontend/src/constants/env.ts
|
||||
|
||||
.idea
|
||||
|
||||
**/.vscode
|
||||
**/build
|
||||
**/storage
|
||||
**/locust-scripts/__pycache__/
|
||||
|
||||
62
.vscode/launch.json
vendored
62
.vscode/launch.json
vendored
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "enterprise",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"buildFlags": [
|
||||
"-race",
|
||||
"-ldflags=-X github.com/SigNoz/signoz/pkg/version.version=dev -X github.com/SigNoz/signoz/pkg/version.variant=enterprise -X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud"
|
||||
],
|
||||
"program": "${workspaceFolder}/cmd/enterprise/",
|
||||
"args": ["server"],
|
||||
"env": {
|
||||
"SIGNOZ_VERSION_BANNER_ENABLED": "true",
|
||||
"SIGNOZ_INSTRUMENTATION_LOGS_LEVEL": "debug",
|
||||
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
|
||||
"SIGNOZ_SQLSTORE_SQLITE_PATH": "${workspaceFolder}/.dev/data/sqlite/enterprise.db",
|
||||
"SIGNOZ_WEB_ENABLED": "false",
|
||||
"SIGNOZ_SQLMIGRATOR_LOCK_INTERVAL": "1m",
|
||||
"SIGNOZ_ALERTMANAGER_PROVIDER": "signoz",
|
||||
"SIGNOZ_TELEMETRYSTORE_PROVIDER": "clickhouse",
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER": "cluster",
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN": "tcp://0.0.0.0:9001",
|
||||
"SIGNOZ_PROMETHEUS_ACTIVE__QUERY__TRACKER_ENABLED": "false",
|
||||
"SIGNOZ_EMAILING_ENABLED": "false",
|
||||
"DOT_METRICS_ENABLED": "true",
|
||||
"SIGNOZ_GLOBAL_INGESTION__URL": "http://localhost:3001",
|
||||
"SIGNOZ_TOKENIZER_PROVIDER": "opaque"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "community",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"buildFlags": [
|
||||
"-race",
|
||||
"-ldflags=-X github.com/SigNoz/signoz/pkg/version.version=dev -X github.com/SigNoz/signoz/pkg/version.variant=community -X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud"
|
||||
],
|
||||
"program": "${workspaceFolder}/cmd/community/",
|
||||
"args": ["server"],
|
||||
"env": {
|
||||
"SIGNOZ_VERSION_BANNER_ENABLED": "true",
|
||||
"SIGNOZ_INSTRUMENTATION_LOGS_LEVEL": "debug",
|
||||
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
|
||||
"SIGNOZ_SQLSTORE_SQLITE_PATH": "${workspaceFolder}/.dev/data/sqlite/community.db",
|
||||
"SIGNOZ_WEB_ENABLED": "false",
|
||||
"SIGNOZ_SQLMIGRATOR_LOCK_INTERVAL": "1m",
|
||||
"SIGNOZ_ALERTMANAGER_PROVIDER": "signoz",
|
||||
"SIGNOZ_TELEMETRYSTORE_PROVIDER": "clickhouse",
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER": "cluster",
|
||||
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN": "tcp://0.0.0.0:9001",
|
||||
"SIGNOZ_PROMETHEUS_ACTIVE__QUERY__TRACKER_ENABLED": "false",
|
||||
"SIGNOZ_EMAILING_ENABLED": "false",
|
||||
"DOT_METRICS_ENABLED": "true",
|
||||
"SIGNOZ_GLOBAL_INGESTION__URL": "http://localhost:3001",
|
||||
"SIGNOZ_TOKENIZER_PROVIDER": "opaque"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"eslint.workingDirectories": ["./frontend"],
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"prettier.requireConfig": true
|
||||
}
|
||||
4
Makefile
4
Makefile
@@ -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 \
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"url": "https://context7.com/signoz/signoz",
|
||||
"public_key": "pk_6g9GfjdkuPEIDuTGAxnol"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -2736,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:
|
||||
@@ -2788,6 +2775,11 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesClaimMapping:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesDeprecatedGettableLogin:
|
||||
properties:
|
||||
accessJwt:
|
||||
@@ -2819,8 +2811,6 @@ components:
|
||||
$ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
orgId:
|
||||
type: string
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
samlConfig:
|
||||
$ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
ssoEnabled:
|
||||
@@ -2844,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:
|
||||
@@ -2921,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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
8
frontend/.vscode/settings.json
vendored
8
frontend/.vscode/settings.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"prettier.requireConfig": true
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -58,7 +58,6 @@
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-left: 30px;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -53,5 +53,4 @@ export enum QueryParams {
|
||||
variables = 'variables',
|
||||
version = 'version',
|
||||
showNewCreateAlertsPage = 'showNewCreateAlertsPage',
|
||||
source = 'source',
|
||||
}
|
||||
|
||||
@@ -301,7 +301,6 @@ export const initialQueryState: QueryState = {
|
||||
builder: initialQueryBuilderData,
|
||||
clickhouse_sql: [initialClickHouseData],
|
||||
promql: [initialQueryPromQLData],
|
||||
unit: '',
|
||||
};
|
||||
|
||||
const initialQueryWithType: Query = {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ const compositeQueryParam = {
|
||||
},
|
||||
],
|
||||
id: '12e1d311-cb47-4b76-af68-65d8e85c9e0d',
|
||||
unit: '',
|
||||
};
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'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'll do this later</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { OnboardingQuestionHeader } from './OnboardingQuestionHeader';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'll do this later
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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'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'll help you get the most out of SigNoz, whether you'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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
72
frontend/src/container/ResetPassword/ResetPassword.test.tsx
Normal file
72
frontend/src/container/ResetPassword/ResetPassword.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'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'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 don’t 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import afterLogin from 'AppRoutes/utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { InviteDetails } from 'types/api/user/getInviteDetails';
|
||||
import { SignupResponse } from 'types/api/v1/register/post';
|
||||
import { Token } from 'types/api/v2/sessions/email_password/post';
|
||||
|
||||
import SignUp from '../SignUp';
|
||||
|
||||
// Mock dependencies - must be before imports
|
||||
jest.mock('AppRoutes/utils', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockAfterLogin = jest.mocked(afterLogin);
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const REGISTER_ENDPOINT = '*/api/v1/register';
|
||||
const EMAIL_PASSWORD_ENDPOINT = '*/api/v2/sessions/email_password';
|
||||
const INVITE_DETAILS_ENDPOINT = '*/api/v1/invite/*';
|
||||
const ACCEPT_INVITE_ENDPOINT = '*/api/v1/invite/accept';
|
||||
|
||||
interface MockSignupResponse extends SignupResponse {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
const mockSignupResponse: MockSignupResponse = {
|
||||
orgId: 'test-org-id',
|
||||
createdAt: Date.now(),
|
||||
email: 'test@signoz.io',
|
||||
id: 'test-user-id',
|
||||
displayName: 'Test User',
|
||||
role: 'ADMIN',
|
||||
};
|
||||
|
||||
const mockTokenResponse: Token = {
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
};
|
||||
|
||||
const mockInviteDetails: InviteDetails = {
|
||||
email: 'invited@signoz.io',
|
||||
name: 'Invited User',
|
||||
organization: 'Test Org',
|
||||
createdAt: Date.now(),
|
||||
role: 'ADMIN',
|
||||
token: 'invite-token-123',
|
||||
};
|
||||
|
||||
describe('SignUp Component - Regular Signup', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAfterLogin.mockClear();
|
||||
window.history.pushState({}, '', '/signup');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Initial Render', () => {
|
||||
it('renders signup form with all required fields', () => {
|
||||
render(<SignUp />, undefined, { initialRoute: '/signup' });
|
||||
|
||||
expect(screen.getByText(/create your account/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/set your password/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText(/confirm your new password/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /access my workspace/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows info callout for admin account creation', () => {
|
||||
render(<SignUp />, undefined, { initialRoute: '/signup' });
|
||||
|
||||
expect(
|
||||
screen.getByText(/this will create an admin account/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('disables submit button when form is invalid', async () => {
|
||||
render(<SignUp />, undefined, { initialRoute: '/signup' });
|
||||
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables submit button for partially filled fields', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<SignUp />, undefined, { initialRoute: '/signup' });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
// Missing email
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Missing password
|
||||
await user.clear(passwordInput);
|
||||
await user.clear(confirmPasswordInput);
|
||||
await user.type(emailInput, 'test@signoz.io');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Missing confirm password
|
||||
await user.clear(confirmPasswordInput);
|
||||
await user.type(passwordInput, 'password123');
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows error when passwords do not match', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<SignUp />, undefined, { initialRoute: '/signup' });
|
||||
|
||||
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
|
||||
|
||||
expect(
|
||||
await screen.findByText(/passwords don't match/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears password mismatch error when passwords match', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<SignUp />, undefined, { initialRoute: '/signup' });
|
||||
|
||||
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
|
||||
|
||||
expect(
|
||||
await screen.findByText(/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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful Signup', () => {
|
||||
it('successfully creates account and logs in user', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(REGISTER_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockSignupResponse,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(EMAIL_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockTokenResponse,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, { initialRoute: '/signup' });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
await user.type(emailInput, 'test@signoz.io');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAfterLogin).toHaveBeenCalledWith(
|
||||
'mock-access-token',
|
||||
'mock-refresh-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('displays error message when signup API fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(REGISTER_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'EMAIL_EXISTS',
|
||||
message: 'Email already exists',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, { initialRoute: '/signup' });
|
||||
|
||||
const emailInput = screen.getByLabelText(/email address/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
await user.type(emailInput, 'existing@signoz.io');
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
const errorCallouts = await screen.findAllByText(/email already exists/i);
|
||||
expect(errorCallouts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SignUp Component - Accept Invite', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.history.pushState({}, '', '/signup?token=invite-token-123');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Initial Render with Invite', () => {
|
||||
it('pre-fills form fields from invite details', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveValue('invited@signoz.io');
|
||||
});
|
||||
});
|
||||
|
||||
it('disables email field when invite details are loaded', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show admin account info callout for invite flow', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/this will create an admin account/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful Invite Acceptance', () => {
|
||||
it('successfully accepts invite and logs in user', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(ACCEPT_INVITE_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockSignupResponse,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(EMAIL_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockTokenResponse,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveValue('invited@signoz.io');
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAfterLogin).toHaveBeenCalledWith(
|
||||
'mock-access-token',
|
||||
'mock-refresh-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling for Invite', () => {
|
||||
it('displays error when invite details fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INVITE_NOT_FOUND',
|
||||
message: 'Invite not found',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invalid-token',
|
||||
});
|
||||
|
||||
// Verify form is still accessible and fields are enabled
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(emailInput).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('displays error when accept invite API fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(ACCEPT_INVITE_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INVALID_TOKEN',
|
||||
message: 'Invalid or expired invite token',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=expired-token',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveValue('invited@signoz.io');
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/invalid or expired invite token/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,17 +10,11 @@ export const FormWrapper = styled(Card)`
|
||||
`;
|
||||
|
||||
export const Label = styled.label`
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 11px;
|
||||
margin-top: 19px;
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--levels-l1-foreground, #eceef2);
|
||||
|
||||
.lightMode & {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
font-size: 1rem;
|
||||
line-height: 24px;
|
||||
`;
|
||||
|
||||
export const ButtonContainer = styled.div`
|
||||
|
||||
@@ -169,10 +169,6 @@
|
||||
|
||||
border: none;
|
||||
height: 36px;
|
||||
.ant-select-selection-search-input {
|
||||
min-width: max-content !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
|
||||
@@ -291,10 +291,6 @@ export function DashboardProvider({
|
||||
|
||||
variable.order = order;
|
||||
existingOrders.add(order);
|
||||
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
|
||||
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
|
||||
variable.defaultValue = variable.textboxValue || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (variable.id === undefined) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import getDashboard from 'api/v1/dashboards/id/get';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -379,9 +379,12 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
// Empty URL variables - tests initialization flow
|
||||
mockGetUrlVariables.mockReturnValue({});
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
const { getByTestId } = renderWithDashboardProvider(
|
||||
`/dashboard/${DASHBOARD_ID}`,
|
||||
{
|
||||
dashboardId: DASHBOARD_ID,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -412,14 +415,16 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
});
|
||||
|
||||
// Verify dashboard state contains the variables with default values
|
||||
const dashboardVariables = await screen.findByTestId('dashboard-variables');
|
||||
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
|
||||
await waitFor(() => {
|
||||
const dashboardVariables = getByTestId('dashboard-variables');
|
||||
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
|
||||
|
||||
expect(parsedVariables).toHaveProperty('environment');
|
||||
expect(parsedVariables).toHaveProperty('services');
|
||||
// Default allSelected values should be preserved
|
||||
expect(parsedVariables.environment.allSelected).toBe(false);
|
||||
expect(parsedVariables.services.allSelected).toBe(false);
|
||||
expect(parsedVariables).toHaveProperty('environment');
|
||||
expect(parsedVariables).toHaveProperty('services');
|
||||
// Default allSelected values should be preserved
|
||||
expect(parsedVariables.environment.allSelected).toBe(false);
|
||||
expect(parsedVariables.services.allSelected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge URL variables with dashboard data and normalize values correctly', async () => {
|
||||
@@ -433,9 +438,12 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
.mockReturnValueOnce('development')
|
||||
.mockReturnValueOnce(['db', 'cache']);
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
const { getByTestId } = renderWithDashboardProvider(
|
||||
`/dashboard/${DASHBOARD_ID}`,
|
||||
{
|
||||
dashboardId: DASHBOARD_ID,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -466,16 +474,18 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
});
|
||||
|
||||
// Verify the dashboard state reflects the normalized URL values
|
||||
const dashboardVariables = await screen.findByTestId('dashboard-variables');
|
||||
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
|
||||
await waitFor(() => {
|
||||
const dashboardVariables = getByTestId('dashboard-variables');
|
||||
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
|
||||
|
||||
// The selectedValue should be updated with normalized URL values
|
||||
expect(parsedVariables.environment.selectedValue).toBe('development');
|
||||
expect(parsedVariables.services.selectedValue).toEqual(['db', 'cache']);
|
||||
// The selectedValue should be updated with normalized URL values
|
||||
expect(parsedVariables.environment.selectedValue).toBe('development');
|
||||
expect(parsedVariables.services.selectedValue).toEqual(['db', 'cache']);
|
||||
|
||||
// allSelected should be set to false when URL values override
|
||||
expect(parsedVariables.environment.allSelected).toBe(false);
|
||||
expect(parsedVariables.services.allSelected).toBe(false);
|
||||
// allSelected should be set to false when URL values override
|
||||
expect(parsedVariables.environment.allSelected).toBe(false);
|
||||
expect(parsedVariables.services.allSelected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ALL_SELECTED_VALUE from URL and set allSelected correctly', async () => {
|
||||
@@ -485,9 +495,12 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
|
||||
mockGetUrlVariables.mockReturnValue(urlVariables);
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
const { getByTestId } = renderWithDashboardProvider(
|
||||
`/dashboard/${DASHBOARD_ID}`,
|
||||
{
|
||||
dashboardId: DASHBOARD_ID,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
@@ -500,8 +513,8 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
);
|
||||
|
||||
// Verify that allSelected is set to true for the services variable
|
||||
await waitFor(async () => {
|
||||
const dashboardVariables = await screen.findByTestId('dashboard-variables');
|
||||
await waitFor(() => {
|
||||
const dashboardVariables = getByTestId('dashboard-variables');
|
||||
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
|
||||
|
||||
expect(parsedVariables.services.allSelected).toBe(true);
|
||||
@@ -550,203 +563,3 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
|
||||
const DASHBOARD_ID = 'test-dashboard-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetUrlVariables.mockReturnValue({});
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
mockNormalizeUrlValueForVariable.mockImplementation((urlValue) => {
|
||||
if (urlValue === undefined || urlValue === null) {
|
||||
return urlValue;
|
||||
}
|
||||
return urlValue as IDashboardVariable['selectedValue'];
|
||||
});
|
||||
});
|
||||
|
||||
describe('Textbox Variable defaultValue Migration', () => {
|
||||
it('should set defaultValue from textboxValue for TEXTBOX variables without defaultValue (BWC)', async () => {
|
||||
// Mock dashboard with TEXTBOX variable that has textboxValue but no defaultValue
|
||||
// This simulates old data format before the migration
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
mockGetDashboard.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: {
|
||||
id: DASHBOARD_ID,
|
||||
title: 'Test Dashboard',
|
||||
data: {
|
||||
variables: {
|
||||
myTextbox: {
|
||||
id: 'textbox-id',
|
||||
name: 'myTextbox',
|
||||
type: 'TEXTBOX',
|
||||
textboxValue: 'legacy-default-value',
|
||||
// defaultValue is intentionally missing to test BWC
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
sort: 'DISABLED',
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
});
|
||||
|
||||
// Verify that defaultValue is set from textboxValue
|
||||
await waitFor(async () => {
|
||||
const dashboardVariables = await screen.findByTestId('dashboard-variables');
|
||||
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
|
||||
|
||||
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
|
||||
expect(parsedVariables.myTextbox.textboxValue).toBe('legacy-default-value');
|
||||
expect(parsedVariables.myTextbox.defaultValue).toBe('legacy-default-value');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not override existing defaultValue for TEXTBOX variables', async () => {
|
||||
// Mock dashboard with TEXTBOX variable that already has defaultValue
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
mockGetDashboard.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: {
|
||||
id: DASHBOARD_ID,
|
||||
title: 'Test Dashboard',
|
||||
data: {
|
||||
variables: {
|
||||
myTextbox: {
|
||||
id: 'textbox-id',
|
||||
name: 'myTextbox',
|
||||
type: 'TEXTBOX',
|
||||
textboxValue: 'old-textbox-value',
|
||||
defaultValue: 'existing-default-value',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
sort: 'DISABLED',
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
});
|
||||
|
||||
// Verify that existing defaultValue is preserved
|
||||
await waitFor(async () => {
|
||||
const dashboardVariables = await screen.findByTestId('dashboard-variables');
|
||||
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
|
||||
|
||||
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
|
||||
expect(parsedVariables.myTextbox.defaultValue).toBe(
|
||||
'existing-default-value',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set empty defaultValue when textboxValue is also empty for TEXTBOX variables', async () => {
|
||||
// Mock dashboard with TEXTBOX variable with empty textboxValue and no defaultValue
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
mockGetDashboard.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: {
|
||||
id: DASHBOARD_ID,
|
||||
title: 'Test Dashboard',
|
||||
data: {
|
||||
variables: {
|
||||
myTextbox: {
|
||||
id: 'textbox-id',
|
||||
name: 'myTextbox',
|
||||
type: 'TEXTBOX',
|
||||
textboxValue: '',
|
||||
// defaultValue is intentionally missing
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
sort: 'DISABLED',
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
});
|
||||
|
||||
// Verify that defaultValue is set to empty string
|
||||
await waitFor(async () => {
|
||||
const dashboardVariables = await screen.findByTestId('dashboard-variables');
|
||||
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
|
||||
|
||||
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
|
||||
expect(parsedVariables.myTextbox.defaultValue).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not apply BWC logic to non-TEXTBOX variables', async () => {
|
||||
// Mock dashboard with QUERY variable that has no defaultValue
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
mockGetDashboard.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: {
|
||||
id: DASHBOARD_ID,
|
||||
title: 'Test Dashboard',
|
||||
data: {
|
||||
variables: {
|
||||
myQuery: {
|
||||
id: 'query-id',
|
||||
name: 'myQuery',
|
||||
type: 'QUERY',
|
||||
queryValue: 'SELECT * FROM test',
|
||||
textboxValue: 'should-not-be-used',
|
||||
// defaultValue is intentionally missing
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
sort: 'DISABLED',
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
|
||||
dashboardId: DASHBOARD_ID,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
|
||||
});
|
||||
|
||||
// Verify that defaultValue is NOT set from textboxValue for QUERY type
|
||||
await waitFor(async () => {
|
||||
const dashboardVariables = await screen.findByTestId('dashboard-variables');
|
||||
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
|
||||
|
||||
expect(parsedVariables.myQuery.type).toBe('QUERY');
|
||||
// defaultValue should not be set to textboxValue for non-TEXTBOX variables
|
||||
expect(parsedVariables.myQuery.defaultValue).not.toBe('should-not-be-used');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,8 +344,6 @@ const customRender = (
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/export -- re-exporting custom render alongside @testing-library/react
|
||||
export * from '@testing-library/react';
|
||||
export { default as userEvent } from '@testing-library/user-event';
|
||||
// eslint-disable-next-line import/export -- custom render wraps the original
|
||||
export { customRender as render };
|
||||
|
||||
@@ -37,7 +37,6 @@ export interface IDashboardVariable {
|
||||
// Custom
|
||||
customValue?: string;
|
||||
// Textbox
|
||||
// special case of variable where defaultValue is same as this. Otherwise, defaultValue is a single field
|
||||
textboxValue?: string;
|
||||
|
||||
sort: TSortVariableValuesType;
|
||||
|
||||
2
go.mod
2
go.mod
@@ -343,7 +343,7 @@ require (
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
gonum.org/v1/gonum v0.16.0 // indirect
|
||||
google.golang.org/api v0.236.0
|
||||
google.golang.org/api v0.236.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
|
||||
google.golang.org/grpc v1.75.1 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1717,8 +1717,6 @@ google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX
|
||||
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
|
||||
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
|
||||
@@ -6,15 +6,10 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/http/client"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
admin "google.golang.org/api/admin/directory/v1"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,28 +17,19 @@ const (
|
||||
redirectPath string = "/api/v1/complete/google"
|
||||
)
|
||||
|
||||
var scopes []string = []string{"email", "profile"}
|
||||
var (
|
||||
scopes []string = []string{"email"}
|
||||
)
|
||||
|
||||
var _ authn.CallbackAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
store authtypes.AuthNStore
|
||||
settings factory.ScopedProviderSettings
|
||||
httpClient *client.Client
|
||||
store authtypes.AuthNStore
|
||||
}
|
||||
|
||||
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings) (*AuthN, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn")
|
||||
|
||||
httpClient, err := client.New(settings.Logger(), providerSettings.TracerProvider, providerSettings.MeterProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func New(ctx context.Context, store authtypes.AuthNStore) (*AuthN, error) {
|
||||
return &AuthN{
|
||||
store: store,
|
||||
settings: settings,
|
||||
httpClient: httpClient,
|
||||
store: store,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -72,13 +58,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
if err := query.Get("error"); err != "" {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: error while authenticating", "error", err, "error_description", query.Get("error_description"))
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: error while authenticating").WithAdditional(query.Get("error_description"))
|
||||
}
|
||||
|
||||
state, err := authtypes.NewStateFromString(query.Get("state"))
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: invalid state", "error", err)
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeInvalidState, "google: invalid state").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
@@ -92,12 +76,10 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
if err != nil {
|
||||
var retrieveError *oauth2.RetrieveError
|
||||
if errors.As(err, &retrieveError) {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: failed to get token", "error", err, "error_description", retrieveError.ErrorDescription, "body", string(retrieveError.Body))
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to get token").WithAdditional(retrieveError.ErrorDescription)
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to get token").WithAdditional(retrieveError.ErrorDescription).WithAdditional(string(retrieveError.Body))
|
||||
}
|
||||
|
||||
a.settings.Logger().ErrorContext(ctx, "google: failed to get token", "error", err)
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: failed to get token")
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: failed to get token").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
@@ -108,8 +90,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Google.ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: failed to verify token", "error", err)
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to verify token")
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to verify token").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
@@ -120,20 +101,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: missing or invalid claims", "error", err)
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: missing or invalid claims").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if claims.HostedDomain != authDomain.StorableAuthDomain().Name {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: unexpected hd claim", "expected", authDomain.StorableAuthDomain().Name, "actual", claims.HostedDomain)
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: unexpected hd claim")
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().Google.InsecureSkipEmailVerified {
|
||||
if !claims.EmailVerified {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: email is not verified", "email", claims.Email)
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: email is not verified")
|
||||
}
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: unexpected hd claim %s", claims.HostedDomain)
|
||||
}
|
||||
|
||||
email, err := valuer.NewEmail(claims.Email)
|
||||
@@ -141,24 +113,8 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "google: failed to parse email").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if authDomain.AuthDomainConfig().Google.FetchGroups {
|
||||
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.AuthDomainConfig().Google)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: could not fetch groups", "error", err)
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: could not fetch groups").WithAdditional(err.Error())
|
||||
}
|
||||
return authtypes.NewCallbackIdentity(claims.Name, email, authDomain.StorableAuthDomain().OrgID, state), nil
|
||||
|
||||
allowedGroups := authDomain.AuthDomainConfig().Google.AllowedGroups
|
||||
if len(allowedGroups) > 0 {
|
||||
groups = filterGroups(groups, allowedGroups)
|
||||
if len(groups) == 0 {
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: user %q is not in any allowed groups", claims.Email).WithAdditional(allowedGroups...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return authtypes.NewCallbackIdentity(claims.Name, email, authDomain.StorableAuthDomain().OrgID, state, groups, ""), nil
|
||||
}
|
||||
|
||||
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
|
||||
@@ -180,90 +136,3 @@ func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain,
|
||||
}).String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthN) fetchGoogleWorkspaceGroups(ctx context.Context, userEmail string, config *authtypes.GoogleConfig) ([]string, error) {
|
||||
adminEmail := config.GetAdminEmailForDomain(userEmail)
|
||||
if adminEmail == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no admin email configured for domain of %s", userEmail)
|
||||
}
|
||||
|
||||
jwtConfig, err := google.JWTConfigFromJSON([]byte(config.ServiceAccountJSON), admin.AdminDirectoryGroupReadonlyScope)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: invalid service account credentials", "error", err)
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid service account credentials")
|
||||
}
|
||||
|
||||
jwtConfig.Subject = adminEmail
|
||||
|
||||
customCtx := context.WithValue(ctx, oauth2.HTTPClient, a.httpClient.Client())
|
||||
|
||||
adminService, err := admin.NewService(ctx, option.WithHTTPClient(jwtConfig.Client(customCtx)))
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: unable to create directory service", "error", err)
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "unable to create directory service")
|
||||
}
|
||||
|
||||
checkedGroups := make(map[string]struct{})
|
||||
|
||||
return a.getGroups(ctx, adminService, userEmail, config.FetchTransitiveGroupMembership, checkedGroups)
|
||||
}
|
||||
|
||||
// Recursive method
|
||||
func (a *AuthN) getGroups(ctx context.Context, adminService *admin.Service, userEmail string, fetchTransitive bool, checkedGroups map[string]struct{}) ([]string, error) {
|
||||
var userGroups []string
|
||||
var pageToken string
|
||||
|
||||
for {
|
||||
call := adminService.Groups.List().UserKey(userEmail)
|
||||
if pageToken != "" {
|
||||
call = call.PageToken(pageToken)
|
||||
}
|
||||
|
||||
groupList, err := call.Context(ctx).Do()
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: unable to list groups", "error", err)
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "unable to list groups")
|
||||
}
|
||||
|
||||
for _, group := range groupList.Groups {
|
||||
if _, exists := checkedGroups[group.Email]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
checkedGroups[group.Email] = struct{}{}
|
||||
userGroups = append(userGroups, group.Email)
|
||||
|
||||
if fetchTransitive {
|
||||
transitiveGroups, err := a.getGroups(ctx, adminService, group.Email, fetchTransitive, checkedGroups)
|
||||
if err != nil {
|
||||
a.settings.Logger().ErrorContext(ctx, "google: unable to list transitive groups", "error", err)
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "unable to list transitive groups")
|
||||
}
|
||||
userGroups = append(userGroups, transitiveGroups...)
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = groupList.NextPageToken
|
||||
if pageToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return userGroups, nil
|
||||
}
|
||||
|
||||
func filterGroups(userGroups, allowedGroups []string) []string {
|
||||
allowed := make(map[string]struct{}, len(allowedGroups))
|
||||
for _, g := range allowedGroups {
|
||||
allowed[g] = struct{}{} // just to make o(1) searches
|
||||
}
|
||||
|
||||
var filtered []string
|
||||
for _, g := range userGroups {
|
||||
if _, ok := allowed[g]; ok {
|
||||
filtered = append(filtered, g)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func (b *base) WithUrl(u string) *base {
|
||||
}
|
||||
}
|
||||
|
||||
// WithAdditional adds additional messages to the base error and returns a new base error.
|
||||
// WithUrl adds additional messages to the base error and returns a new base error.
|
||||
func (b *base) WithAdditional(a ...string) *base {
|
||||
return &base{
|
||||
t: b.t,
|
||||
|
||||
@@ -31,13 +31,7 @@ func (plugin *reqResLog) OnRequestStart(request *http.Request) {
|
||||
string(semconv.ServerAddressKey), host,
|
||||
string(semconv.ServerPortKey), port,
|
||||
string(semconv.HTTPRequestSizeKey), request.ContentLength,
|
||||
}
|
||||
|
||||
// only include all the headers if we are at debug level
|
||||
if plugin.logger.Handler().Enabled(request.Context(), slog.LevelDebug) {
|
||||
fields = append(fields, "http.request.headers", request.Header)
|
||||
} else {
|
||||
fields = append(fields, "http.request.headers", redactSensitiveHeaders(request.Header))
|
||||
"http.request.headers", request.Header,
|
||||
}
|
||||
|
||||
plugin.logger.InfoContext(request.Context(), "::SENT-REQUEST::", fields...)
|
||||
@@ -81,24 +75,3 @@ func (plugin *reqResLog) OnError(request *http.Request, err error) {
|
||||
|
||||
plugin.logger.ErrorContext(request.Context(), "::UNABLE-TO-SEND-REQUEST::", fields...)
|
||||
}
|
||||
|
||||
func redactSensitiveHeaders(headers http.Header) http.Header {
|
||||
// maintained list of headers to redact
|
||||
sensitiveHeaders := map[string]bool{
|
||||
"Authorization": true,
|
||||
"Cookie": true,
|
||||
"X-Signoz-Cloud-Api-Key": true,
|
||||
}
|
||||
|
||||
safeHeaders := make(http.Header)
|
||||
|
||||
for header, value := range headers {
|
||||
if sensitiveHeaders[header] {
|
||||
safeHeaders[header] = []string{"REDACTED"}
|
||||
} else {
|
||||
safeHeaders[header] = value
|
||||
}
|
||||
}
|
||||
|
||||
return safeHeaders
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ type Module interface {
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
|
||||
// Get the IDP info of the domain provided.
|
||||
GetAuthNProviderInfo(context.Context, *authtypes.AuthDomain) *authtypes.AuthNProviderInfo
|
||||
GetAuthNProviderInfo(context.Context, *authtypes.AuthDomain) (*authtypes.AuthNProviderInfo)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
|
||||
@@ -123,7 +123,7 @@ func (module *module) DeprecatedCreateSessionByEmailPassword(ctx context.Context
|
||||
}
|
||||
|
||||
if !factorPassword.Equals(password) {
|
||||
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
|
||||
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email orpassword")
|
||||
}
|
||||
|
||||
identity := authtypes.NewIdentity(users[0].ID, users[0].OrgID, users[0].Email, users[0].Role)
|
||||
@@ -157,15 +157,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
|
||||
return "", err
|
||||
}
|
||||
|
||||
authDomain, err := module.authDomain.GetByOrgIDAndID(ctx, callbackIdentity.OrgID, callbackIdentity.State.DomainID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
roleMapping := authDomain.AuthDomainConfig().RoleMapping
|
||||
role := roleMapping.NewRoleFromCallbackIdentity(callbackIdentity)
|
||||
|
||||
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, role, callbackIdentity.OrgID)
|
||||
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, types.RoleViewer, callbackIdentity.OrgID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -3135,10 +3135,7 @@ func (aH *APIHandler) getProducerConsumerEval(w http.ResponseWriter, r *http.Req
|
||||
queryRangeParams, err := kafka.BuildQueryRangeParams(messagingQueue, "producer-consumer-eval", kafkaSpanEval)
|
||||
if err != nil {
|
||||
zap.L().Error(err.Error())
|
||||
RespondError(w, &model.ApiError{
|
||||
Typ: model.ErrorBadData,
|
||||
Err: err,
|
||||
}, nil)
|
||||
RespondError(w, apiErr, nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
255
pkg/query-service/rules/eval_delay.go
Normal file
255
pkg/query-service/rules/eval_delay.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
// Monotonicity describes the direction of value change as more data is processed
|
||||
type Monotonicity int
|
||||
|
||||
const (
|
||||
MonotonicityNone Monotonicity = iota
|
||||
MonotonicityIncreasing // Value only goes up or stays same
|
||||
MonotonicityDecreasing // Value only goes down or stays same
|
||||
)
|
||||
|
||||
// Aggregation holds both time and space aggregation
|
||||
type Aggregation struct {
|
||||
Time metrictypes.TimeAggregation
|
||||
Space metrictypes.SpaceAggregation
|
||||
}
|
||||
|
||||
// SafeAggregations maps an aggregation pair to its monotonic behavior
|
||||
var SafeAggregations = map[Aggregation]Monotonicity{
|
||||
// --- Min Aggregation ---
|
||||
{Time: metrictypes.TimeAggregationMin, Space: metrictypes.SpaceAggregationMin}: MonotonicityDecreasing,
|
||||
{Time: metrictypes.TimeAggregationMin, Space: metrictypes.SpaceAggregationUnspecified}: MonotonicityDecreasing, // Logs/Trace
|
||||
|
||||
// --- Max Aggregation ---
|
||||
{Time: metrictypes.TimeAggregationMax, Space: metrictypes.SpaceAggregationMax}: MonotonicityIncreasing,
|
||||
{Time: metrictypes.TimeAggregationMax, Space: metrictypes.SpaceAggregationSum}: MonotonicityIncreasing,
|
||||
{Time: metrictypes.TimeAggregationMax, Space: metrictypes.SpaceAggregationUnspecified}: MonotonicityIncreasing, // Logs/Trace
|
||||
|
||||
// --- Count Aggregation ---
|
||||
{Time: metrictypes.TimeAggregationCount, Space: metrictypes.SpaceAggregationSum}: MonotonicityIncreasing,
|
||||
{Time: metrictypes.TimeAggregationCount, Space: metrictypes.SpaceAggregationUnspecified}: MonotonicityIncreasing, // Logs/Trace
|
||||
|
||||
// --- Count Distinct Aggregation ---
|
||||
{Time: metrictypes.TimeAggregationCountDistinct, Space: metrictypes.SpaceAggregationSum}: MonotonicityIncreasing,
|
||||
{Time: metrictypes.TimeAggregationCountDistinct, Space: metrictypes.SpaceAggregationUnspecified}: MonotonicityIncreasing, // Logs/Trace
|
||||
}
|
||||
|
||||
// CalculateEvalDelay determines if the default evaluation delay can be removed (set to 0)
|
||||
// based on the rule's match type, compare operator, and aggregation type.
|
||||
// If the combination ensures that new data will not invalidate the alert condition
|
||||
// (e.g. values only increase for a "Greater Than" check), the delay is removed.
|
||||
//
|
||||
// A combination is considered "safe" if new data arriving late cannot invalidate
|
||||
// a previously triggered alert condition. This happens when:
|
||||
// - The aggregation function is monotonic (only increases or only decreases)
|
||||
// - The comparison operator aligns with the monotonic direction
|
||||
// - The match type allows the safety property to hold
|
||||
//
|
||||
// Safe combinations include:
|
||||
// - Min aggregation + Below/BelowOrEq operators (Min can only decrease)
|
||||
// - Max aggregation + Above/AboveOrEq operators (Max can only increase)
|
||||
// - Count/CountDistinct + Above/AboveOrEq operators (Count can only increase)
|
||||
//
|
||||
// Returns 0 if all queries are safe, otherwise returns defaultDelay.
|
||||
func CalculateEvalDelay(rule *ruletypes.PostableRule, defaultDelay time.Duration) time.Duration {
|
||||
// Phase 1: Validate rule condition
|
||||
if !isRuleConditionValid(rule) {
|
||||
return defaultDelay
|
||||
}
|
||||
|
||||
// Phase 2: Get match type and compare operator from thresholds
|
||||
matchType, compareOp, ok := getThresholdMatchTypeAndCompareOp(rule)
|
||||
if !ok {
|
||||
return defaultDelay
|
||||
}
|
||||
|
||||
// Phase 3: Check if all queries are safe
|
||||
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
|
||||
if !isQuerySafe(query, matchType, compareOp) {
|
||||
return defaultDelay
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: All queries are safe, delay can be removed
|
||||
return 0
|
||||
}
|
||||
|
||||
// isRuleConditionValid checks if the rule condition is valid for delay calculation.
|
||||
// Returns false if the rule condition is nil, has no queries, or has invalid thresholds.
|
||||
func isRuleConditionValid(rule *ruletypes.PostableRule) bool {
|
||||
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// BuilderQueries, PromQL Queries, ClickHouse SQL Queries attributes of CompositeQuery
|
||||
// are not supported for now, only Queries attribute is supported
|
||||
if len(rule.RuleCondition.CompositeQuery.Queries) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate that thresholds exist and contain valid match type and compare operator
|
||||
matchType, compareOp, ok := getThresholdMatchTypeAndCompareOp(rule)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if matchType == ruletypes.MatchTypeNone || compareOp == ruletypes.CompareOpNone {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getThresholdMatchTypeAndCompareOp extracts match type and compare operator from the rule's thresholds.
|
||||
// Returns the match type, compare operator, and a boolean indicating success.
|
||||
// All thresholds share the same match type and compare operator, so we use the first threshold's values.
|
||||
func getThresholdMatchTypeAndCompareOp(rule *ruletypes.PostableRule) (ruletypes.MatchType, ruletypes.CompareOp, bool) {
|
||||
if rule.RuleCondition == nil || rule.RuleCondition.Thresholds == nil {
|
||||
return ruletypes.MatchTypeNone, ruletypes.CompareOpNone, false
|
||||
}
|
||||
|
||||
// Get the threshold interface
|
||||
threshold, err := rule.RuleCondition.Thresholds.GetRuleThreshold()
|
||||
if err != nil {
|
||||
return ruletypes.MatchTypeNone, ruletypes.CompareOpNone, false
|
||||
}
|
||||
|
||||
// Cast to BasicRuleThresholds (only supported kind)
|
||||
basicThresholds, ok := threshold.(ruletypes.BasicRuleThresholds)
|
||||
if !ok || len(basicThresholds) == 0 {
|
||||
return ruletypes.MatchTypeNone, ruletypes.CompareOpNone, false
|
||||
}
|
||||
|
||||
// Use first threshold's MatchType and CompareOp (all thresholds share the same values)
|
||||
matchType := basicThresholds[0].MatchType
|
||||
compareOp := basicThresholds[0].CompareOp
|
||||
|
||||
return matchType, compareOp, true
|
||||
}
|
||||
|
||||
// aggregationExpressionToTimeAggregation converts the aggregation expression to the corresponding time aggregation
|
||||
// based on the expression
|
||||
// if the expression is not a valid aggregation expression, it returns the unspecified time aggregation
|
||||
// Note: Longer/more specific prefixes (e.g., "count_distinct") must be checked before shorter ones (e.g., "count")
|
||||
func aggregationExpressionToTimeAggregation(expression string) metrictypes.TimeAggregation {
|
||||
expression = strings.TrimSpace(strings.ToLower(expression))
|
||||
switch {
|
||||
case strings.HasPrefix(expression, "count_distinct"):
|
||||
return metrictypes.TimeAggregationCountDistinct
|
||||
case strings.HasPrefix(expression, "count"):
|
||||
return metrictypes.TimeAggregationCount
|
||||
case strings.HasPrefix(expression, "min"):
|
||||
return metrictypes.TimeAggregationMin
|
||||
case strings.HasPrefix(expression, "max"):
|
||||
return metrictypes.TimeAggregationMax
|
||||
case strings.HasPrefix(expression, "avg"):
|
||||
return metrictypes.TimeAggregationAvg
|
||||
case strings.HasPrefix(expression, "sum"):
|
||||
return metrictypes.TimeAggregationSum
|
||||
case strings.HasPrefix(expression, "rate"):
|
||||
return metrictypes.TimeAggregationRate
|
||||
case strings.HasPrefix(expression, "increase"):
|
||||
return metrictypes.TimeAggregationIncrease
|
||||
case strings.HasPrefix(expression, "latest"):
|
||||
return metrictypes.TimeAggregationLatest
|
||||
default:
|
||||
return metrictypes.TimeAggregationUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
// extractAggregationsFromQuerySpec extracts the aggregation (time and space) from the query spec
|
||||
func extractAggregationsFromQuerySpec(spec any) []Aggregation {
|
||||
aggs := []Aggregation{}
|
||||
|
||||
// Extract the time aggregation from the query spec
|
||||
// based on different types of query spec
|
||||
switch spec := spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
for _, agg := range spec.Aggregations {
|
||||
aggs = append(aggs, Aggregation{
|
||||
Time: agg.TimeAggregation,
|
||||
Space: agg.SpaceAggregation,
|
||||
})
|
||||
}
|
||||
// the log and trace aggregations don't store the time aggregation directly but expression for the aggregation
|
||||
// so we need to convert the expression to the corresponding time aggregation
|
||||
// logs and traces don't support space aggregation in the same way, so we assume Unspecified
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
for _, agg := range spec.Aggregations {
|
||||
aggs = append(aggs, Aggregation{
|
||||
Time: aggregationExpressionToTimeAggregation(agg.Expression),
|
||||
Space: metrictypes.SpaceAggregationUnspecified,
|
||||
})
|
||||
}
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
for _, agg := range spec.Aggregations {
|
||||
aggs = append(aggs, Aggregation{
|
||||
Time: aggregationExpressionToTimeAggregation(agg.Expression),
|
||||
Space: metrictypes.SpaceAggregationUnspecified,
|
||||
})
|
||||
}
|
||||
}
|
||||
return aggs
|
||||
}
|
||||
|
||||
// isQuerySafe determines if a single query is safe to remove the eval delay.
|
||||
// A query is safe only if it's a Builder query (with MetricAggregation, LogAggregation, or TraceAggregation type)
|
||||
// and all its aggregations are safe (checked against SafeCombinations map).
|
||||
func isQuerySafe(query qbtypes.QueryEnvelope, matchType ruletypes.MatchType, compareOp ruletypes.CompareOp) bool {
|
||||
// We only handle Builder Queries for now
|
||||
if query.Type != qbtypes.QueryTypeBuilder {
|
||||
return false
|
||||
}
|
||||
|
||||
// extract aggregations from the query spec
|
||||
aggs := extractAggregationsFromQuerySpec(query.Spec)
|
||||
|
||||
// A query must have at least one aggregation
|
||||
if len(aggs) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// All aggregations in the query must be safe
|
||||
for _, agg := range aggs {
|
||||
if !isAggregationSafe(agg, matchType, compareOp) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isAggregationSafe checks if the aggregation is safe to remove the eval delay
|
||||
func isAggregationSafe(agg Aggregation, matchType ruletypes.MatchType, compareOp ruletypes.CompareOp) bool {
|
||||
// Get Monotonicity
|
||||
monotonicity, ok := SafeAggregations[agg]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch monotonicity {
|
||||
case MonotonicityDecreasing:
|
||||
if matchType == ruletypes.AtleastOnce || matchType == ruletypes.AllTheTimes {
|
||||
if compareOp == ruletypes.ValueIsBelow || compareOp == ruletypes.ValueBelowOrEq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case MonotonicityIncreasing:
|
||||
if matchType == ruletypes.AtleastOnce || matchType == ruletypes.AllTheTimes || matchType == ruletypes.InTotal {
|
||||
if compareOp == ruletypes.ValueIsAbove || compareOp == ruletypes.ValueAboveOrEq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
1514
pkg/query-service/rules/eval_delay_test.go
Normal file
1514
pkg/query-service/rules/eval_delay_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -160,6 +160,8 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
|
||||
}
|
||||
// calculate eval delay based on rule config
|
||||
evalDelay := CalculateEvalDelay(opts.Rule, opts.ManagerOpts.EvalDelay)
|
||||
|
||||
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
|
||||
// create a threshold rule
|
||||
@@ -170,7 +172,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
opts.Reader,
|
||||
opts.Querier,
|
||||
opts.SLogger,
|
||||
WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
WithEvalDelay(evalDelay),
|
||||
WithSQLStore(opts.SQLStore),
|
||||
WithQueryParser(opts.ManagerOpts.QueryParser),
|
||||
WithMetadataStore(opts.ManagerOpts.MetadataStore),
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
emailPasswordAuthN := emailpasswordauthn.New(store)
|
||||
|
||||
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store, providerSettings)
|
||||
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/tokenizer/jwttokenizer")
|
||||
|
||||
if config.JWT.Secret == "" {
|
||||
settings.Logger().ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_TOKENIZER_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_TOKENIZER_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
|
||||
settings.Logger().ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_TOKENIZER_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
|
||||
}
|
||||
|
||||
lastObservedAtCache, err := ristretto.NewCache(&ristretto.Config[string, map[valuer.UUID]time.Time]{
|
||||
|
||||
@@ -32,12 +32,10 @@ type Identity struct {
|
||||
}
|
||||
|
||||
type CallbackIdentity struct {
|
||||
Name string `json:"name"`
|
||||
Email valuer.Email `json:"email"`
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
State State `json:"state"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Email valuer.Email `json:"email"`
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
State State `json:"state"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
@@ -87,14 +85,12 @@ func NewIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, role
|
||||
}
|
||||
}
|
||||
|
||||
func NewCallbackIdentity(name string, email valuer.Email, orgID valuer.UUID, state State, groups []string, role string) *CallbackIdentity {
|
||||
func NewCallbackIdentity(name string, email valuer.Email, orgID valuer.UUID, state State) *CallbackIdentity {
|
||||
return &CallbackIdentity{
|
||||
Name: name,
|
||||
Email: email,
|
||||
OrgID: orgID,
|
||||
State: state,
|
||||
Groups: groups,
|
||||
Role: role,
|
||||
Name: name,
|
||||
Email: email,
|
||||
OrgID: orgID,
|
||||
State: state,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ type AuthDomainConfig struct {
|
||||
SAML *SamlConfig `json:"samlConfig"`
|
||||
Google *GoogleConfig `json:"googleAuthConfig"`
|
||||
OIDC *OIDCConfig `json:"oidcConfig"`
|
||||
RoleMapping *RoleMapping `json:"roleMapping"`
|
||||
}
|
||||
|
||||
type AuthDomain struct {
|
||||
|
||||
@@ -2,14 +2,10 @@ package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const wildCardDomain = "*"
|
||||
|
||||
type GoogleConfig struct {
|
||||
// ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.
|
||||
ClientID string `json:"clientId"`
|
||||
@@ -19,30 +15,6 @@ type GoogleConfig struct {
|
||||
|
||||
// What is the meaning of this? Should we remove this?
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
|
||||
// Whether to fetch the Google workspace groups (required additional API scopes)
|
||||
FetchGroups bool `json:"fetchGroups"`
|
||||
|
||||
// Service Account creds JSON stored for Google Admin SDK access
|
||||
// This is content of the JSON file stored directly into db as string
|
||||
// Required if FetchGroups is true (unless running on GCE with default credentials)
|
||||
ServiceAccountJSON string `json:"serviceAccountJson,omitempty"`
|
||||
|
||||
// Map of workspace domain to admin email for service account impersonation
|
||||
// The service account will impersonate this admin to call the directory API
|
||||
// Use "*" as key for wildcard/default that matches any domain
|
||||
// Example: {"example.com": "admin@exmaple.com", "*": "fallbackadmin@company.com"}
|
||||
DomainToAdminEmail map[string]valuer.Email `json:"domainToAdminEmail,omitempty"`
|
||||
|
||||
// If true, fetch transitive group membership (recursive - groups that contains other groups)
|
||||
FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership,omitempty"`
|
||||
|
||||
// Optional list of allowed groups
|
||||
// If this is present, only users belonging to one of these groups will be allowed to login
|
||||
AllowedGroups []string `json:"allowedGroups,omitempty"`
|
||||
|
||||
// Whether to skip email verification. Defaults to "false"
|
||||
InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"`
|
||||
}
|
||||
|
||||
func (config *GoogleConfig) UnmarshalJSON(data []byte) error {
|
||||
@@ -61,37 +33,6 @@ func (config *GoogleConfig) UnmarshalJSON(data []byte) error {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "clientSecret is required")
|
||||
}
|
||||
|
||||
if temp.FetchGroups {
|
||||
if len(temp.DomainToAdminEmail) == 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "domainToAdminEmail is required if fetchGroups is true")
|
||||
}
|
||||
|
||||
if temp.ServiceAccountJSON == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "serviceAccountJSON is required if fetchGroups is true")
|
||||
}
|
||||
}
|
||||
|
||||
if len(temp.AllowedGroups) > 0 && !temp.FetchGroups {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "fetchGroups must be true when allowedGroups is configured")
|
||||
}
|
||||
|
||||
*config = GoogleConfig(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config *GoogleConfig) GetAdminEmailForDomain(userEmail string) string {
|
||||
domain := extractDomainFromEmail(userEmail)
|
||||
|
||||
if adminEmail, ok := config.DomainToAdminEmail[domain]; ok {
|
||||
return adminEmail.StringValue()
|
||||
}
|
||||
|
||||
return config.DomainToAdminEmail[wildCardDomain].StringValue()
|
||||
}
|
||||
|
||||
func extractDomainFromEmail(email string) string {
|
||||
if at := strings.LastIndex(email, "@"); at >= 0 {
|
||||
return email[at+1:]
|
||||
}
|
||||
return wildCardDomain
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
)
|
||||
|
||||
type AttributeMapping struct {
|
||||
// Key which contains the email in the claim/token/attributes map. Defaults to "email"
|
||||
Email string `json:"email"`
|
||||
|
||||
// Key which contains the name in the claim/token/attributes map. Defaults to "name"
|
||||
Name string `json:"name"`
|
||||
|
||||
// Key which contains the groups in the claim/token/attributes map. Defaults to "groups"
|
||||
Groups string `json:"groups"`
|
||||
|
||||
// Key which contains the role in the claim/token/attributes map. Defaults to "role"
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (attr *AttributeMapping) UnmarshalJSON(data []byte) error {
|
||||
type Alias AttributeMapping
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.Email == "" {
|
||||
temp.Email = "email"
|
||||
}
|
||||
|
||||
if temp.Name == "" {
|
||||
temp.Name = "name"
|
||||
}
|
||||
|
||||
if temp.Groups == "" {
|
||||
temp.Groups = "groups"
|
||||
}
|
||||
|
||||
if temp.Role == "" {
|
||||
temp.Role = "role"
|
||||
}
|
||||
|
||||
*attr = AttributeMapping(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
type RoleMapping struct {
|
||||
// Default role any new SSO users. Defaults to "VIEWER"
|
||||
DefaultRole string `json:"defaultRole"`
|
||||
// Map of IDP group names to SigNoz roles. Key is group name, value is SigNoz role
|
||||
GroupMappings map[string]string `json:"groupMappings"`
|
||||
// If true, use the role claim directly from IDP instead of group mappings
|
||||
UseRoleAttribute bool `json:"useRoleAttribute"`
|
||||
}
|
||||
|
||||
func (typ *RoleMapping) UnmarshalJSON(data []byte) error {
|
||||
type Alias RoleMapping
|
||||
|
||||
var temp Alias
|
||||
if err := json.Unmarshal(data, &temp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if temp.DefaultRole != "" {
|
||||
if _, err := types.NewRole(strings.ToUpper(temp.DefaultRole)); err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid default role %s", temp.DefaultRole)
|
||||
}
|
||||
}
|
||||
|
||||
for group, role := range temp.GroupMappings {
|
||||
if _, err := types.NewRole(strings.ToUpper(role)); err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid role %s for group %s", role, group)
|
||||
}
|
||||
}
|
||||
|
||||
*typ = RoleMapping(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (roleMapping *RoleMapping) NewRoleFromCallbackIdentity(callbackIdentity *CallbackIdentity) types.Role {
|
||||
if roleMapping == nil {
|
||||
return types.RoleViewer
|
||||
}
|
||||
|
||||
if roleMapping.UseRoleAttribute && callbackIdentity.Role != "" {
|
||||
if role, err := types.NewRole(strings.ToUpper(callbackIdentity.Role)); err == nil {
|
||||
return role
|
||||
}
|
||||
}
|
||||
|
||||
if len(roleMapping.GroupMappings) > 0 && len(callbackIdentity.Groups) > 0 {
|
||||
highestRole := types.RoleViewer
|
||||
found := false
|
||||
|
||||
for _, group := range callbackIdentity.Groups {
|
||||
if mappedRole, exists := roleMapping.GroupMappings[group]; exists {
|
||||
found = true
|
||||
if role, err := types.NewRole(strings.ToUpper(mappedRole)); err == nil {
|
||||
if compareRoles(role, highestRole) > 0 {
|
||||
highestRole = role
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
return highestRole
|
||||
}
|
||||
}
|
||||
|
||||
if roleMapping.DefaultRole != "" {
|
||||
if role, err := types.NewRole(strings.ToUpper(roleMapping.DefaultRole)); err == nil {
|
||||
return role
|
||||
}
|
||||
}
|
||||
|
||||
return types.RoleViewer
|
||||
}
|
||||
|
||||
func compareRoles(a, b types.Role) int {
|
||||
order := map[types.Role]int{
|
||||
types.RoleViewer: 0,
|
||||
types.RoleEditor: 1,
|
||||
types.RoleAdmin: 2,
|
||||
}
|
||||
return order[a] - order[b]
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type OIDCConfig struct {
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
|
||||
// Mapping of claims to the corresponding fields in the token.
|
||||
ClaimMapping AttributeMapping `json:"claimMapping"`
|
||||
ClaimMapping ClaimMapping `json:"claimMapping"`
|
||||
|
||||
// Whether to skip email verification. Defaults to "false"
|
||||
InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"`
|
||||
@@ -31,6 +31,11 @@ type OIDCConfig struct {
|
||||
GetUserInfo bool `json:"getUserInfo"`
|
||||
}
|
||||
|
||||
type ClaimMapping struct {
|
||||
// Configurable key which contains the email claims. Defaults to "email"
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (config *OIDCConfig) UnmarshalJSON(data []byte) error {
|
||||
type Alias OIDCConfig
|
||||
|
||||
@@ -51,10 +56,8 @@ func (config *OIDCConfig) UnmarshalJSON(data []byte) error {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "clientSecret is required")
|
||||
}
|
||||
|
||||
if temp.ClaimMapping == (AttributeMapping{}) {
|
||||
if err := json.Unmarshal([]byte("{}"), &temp.ClaimMapping); err != nil {
|
||||
return err
|
||||
}
|
||||
if temp.ClaimMapping.Email == "" {
|
||||
temp.ClaimMapping.Email = "email"
|
||||
}
|
||||
|
||||
*config = OIDCConfig(temp)
|
||||
|
||||
@@ -20,9 +20,6 @@ type SamlConfig struct {
|
||||
// For providers like jumpcloud, this should be set to true.
|
||||
// Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.
|
||||
InsecureSkipAuthNRequestsSigned bool `json:"insecureSkipAuthNRequestsSigned"`
|
||||
|
||||
// Mapping of SAML assertion attributes
|
||||
AttributeMapping AttributeMapping `json:"attributeMapping"`
|
||||
}
|
||||
|
||||
func (config *SamlConfig) UnmarshalJSON(data []byte) error {
|
||||
@@ -45,12 +42,6 @@ func (config *SamlConfig) UnmarshalJSON(data []byte) error {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlCert is required")
|
||||
}
|
||||
|
||||
if temp.AttributeMapping == (AttributeMapping{}) {
|
||||
if err := json.Unmarshal([]byte("{}"), &temp.AttributeMapping); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
*config = SamlConfig(temp)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import Any, Callable, Dict, List
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from typing import Any, Callable, Dict
|
||||
from urllib.parse import urljoin
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytest
|
||||
@@ -114,43 +114,6 @@ def create_saml_client(
|
||||
"attribute.name": "Role",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "groups",
|
||||
"protocol": "saml",
|
||||
"protocolMapper": "saml-group-membership-mapper",
|
||||
"consentRequired": False,
|
||||
"config": {
|
||||
"full.path": "false",
|
||||
"attribute.nameformat": "Basic",
|
||||
"single": "true", # ! this was changed to true as we need the groups in the single attribute section
|
||||
"friendly.name": "groups",
|
||||
"attribute.name": "groups",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "role attribute",
|
||||
"protocol": "saml",
|
||||
"protocolMapper": "saml-user-attribute-mapper",
|
||||
"consentRequired": False,
|
||||
"config": {
|
||||
"attribute.nameformat": "Basic",
|
||||
"user.attribute": "signoz_role",
|
||||
"friendly.name": "signoz_role",
|
||||
"attribute.name": "signoz_role",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "displayName",
|
||||
"protocol": "saml",
|
||||
"protocolMapper": "saml-user-property-mapper",
|
||||
"consentRequired": False,
|
||||
"config": {
|
||||
"attribute.nameformat": "Basic",
|
||||
"user.attribute": "firstName",
|
||||
"friendly.name": "displayName",
|
||||
"attribute.name": "displayName",
|
||||
},
|
||||
},
|
||||
],
|
||||
"defaultClientScopes": ["saml_organization", "role_list"],
|
||||
"optionalClientScopes": [],
|
||||
@@ -200,8 +163,6 @@ def create_oidc_client(
|
||||
realm_name="master",
|
||||
)
|
||||
|
||||
_ensure_groups_client_scope(client)
|
||||
|
||||
client.create_client(
|
||||
skip_exists=True,
|
||||
payload={
|
||||
@@ -247,7 +208,6 @@ def create_oidc_client(
|
||||
"profile",
|
||||
"basic",
|
||||
"email",
|
||||
"groups",
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
@@ -322,7 +282,7 @@ def get_oidc_settings(idp: types.TestContainerIDP) -> dict:
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_idp", scope="function")
|
||||
def create_user_idp(idp: types.TestContainerIDP) -> Callable[[str, str, bool, str, str], None]:
|
||||
def create_user_idp(idp: types.TestContainerIDP) -> Callable[[str, str, bool], None]:
|
||||
client = KeycloakAdmin(
|
||||
server_url=idp.container.host_configs["6060"].base(),
|
||||
username=IDP_ROOT_USERNAME,
|
||||
@@ -332,20 +292,17 @@ def create_user_idp(idp: types.TestContainerIDP) -> Callable[[str, str, bool, st
|
||||
|
||||
created_users = []
|
||||
|
||||
def _create_user_idp(email: str, password: str, verified: bool = True, first_name: str = "", last_name: str = "") -> None:
|
||||
payload = {
|
||||
"username": email,
|
||||
"email": email,
|
||||
"enabled": True,
|
||||
"emailVerified": verified,
|
||||
}
|
||||
def _create_user_idp(email: str, password: str, verified: bool = True) -> None:
|
||||
user_id = client.create_user(
|
||||
exist_ok=False,
|
||||
payload={
|
||||
"username": email,
|
||||
"email": email,
|
||||
"enabled": True,
|
||||
"emailVerified": verified,
|
||||
},
|
||||
)
|
||||
|
||||
if first_name:
|
||||
payload["firstName"] = first_name
|
||||
if last_name:
|
||||
payload["lastName"] = last_name
|
||||
|
||||
user_id = client.create_user(exist_ok=False, payload=payload)
|
||||
client.set_user_password(user_id, password, temporary=False)
|
||||
created_users.append(user_id)
|
||||
|
||||
@@ -376,342 +333,3 @@ def idp_login(driver: webdriver.Chrome) -> Callable[[str, str], None]:
|
||||
wait.until(EC.invisibility_of_element((By.ID, "kc-login")))
|
||||
|
||||
return _idp_login
|
||||
|
||||
|
||||
@pytest.fixture(name="create_group_idp", scope="function")
|
||||
def create_group_idp(idp: types.TestContainerIDP) -> Callable[[str], str]:
|
||||
"""Creates a group in Keycloak IDP."""
|
||||
client = KeycloakAdmin(
|
||||
server_url=idp.container.host_configs["6060"].base(),
|
||||
username=IDP_ROOT_USERNAME,
|
||||
password=IDP_ROOT_PASSWORD,
|
||||
realm_name="master",
|
||||
)
|
||||
|
||||
created_groups = []
|
||||
|
||||
def _create_group_idp(group_name: str) -> str:
|
||||
group_id = client.create_group({"name": group_name}, skip_exists=True)
|
||||
created_groups.append(group_id)
|
||||
return group_id
|
||||
|
||||
yield _create_group_idp
|
||||
|
||||
for group_id in created_groups:
|
||||
try:
|
||||
client.delete_group(group_id)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_idp_with_groups", scope="function")
|
||||
def create_user_idp_with_groups(
|
||||
idp: types.TestContainerIDP,
|
||||
create_group_idp: Callable[[str], str], # pylint: disable=redefined-outer-name
|
||||
) -> Callable[[str, str, bool, List[str]], None]:
|
||||
"""Creates a user in Keycloak IDP with specified groups."""
|
||||
client = KeycloakAdmin(
|
||||
server_url=idp.container.host_configs["6060"].base(),
|
||||
username=IDP_ROOT_USERNAME,
|
||||
password=IDP_ROOT_PASSWORD,
|
||||
realm_name="master",
|
||||
)
|
||||
|
||||
created_users = []
|
||||
|
||||
def _create_user_idp_with_groups(
|
||||
email: str, password: str, verified: bool, groups: List[str]
|
||||
) -> None:
|
||||
# Create groups first
|
||||
group_ids = []
|
||||
for group_name in groups:
|
||||
group_id = create_group_idp(group_name)
|
||||
group_ids.append(group_id)
|
||||
|
||||
# Create user
|
||||
user_id = client.create_user(
|
||||
exist_ok=False,
|
||||
payload={
|
||||
"username": email,
|
||||
"email": email,
|
||||
"enabled": True,
|
||||
"emailVerified": verified,
|
||||
},
|
||||
)
|
||||
client.set_user_password(user_id, password, temporary=False)
|
||||
created_users.append(user_id)
|
||||
|
||||
# Add user to groups
|
||||
for group_id in group_ids:
|
||||
client.group_user_add(user_id, group_id)
|
||||
|
||||
yield _create_user_idp_with_groups
|
||||
|
||||
for user_id in created_users:
|
||||
try:
|
||||
client.delete_user(user_id)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(name="add_user_to_group", scope="function")
|
||||
def add_user_to_group(
|
||||
idp: types.TestContainerIDP,
|
||||
create_group_idp: Callable[[str], str], # pylint: disable=redefined-outer-name
|
||||
) -> Callable[[str, str], None]:
|
||||
"""Adds an existing user to a group."""
|
||||
client = KeycloakAdmin(
|
||||
server_url=idp.container.host_configs["6060"].base(),
|
||||
username=IDP_ROOT_USERNAME,
|
||||
password=IDP_ROOT_PASSWORD,
|
||||
realm_name="master",
|
||||
)
|
||||
|
||||
def _add_user_to_group(email: str, group_name: str) -> None:
|
||||
user_id = client.get_user_id(email)
|
||||
group_id = create_group_idp(group_name)
|
||||
client.group_user_add(user_id, group_id)
|
||||
|
||||
return _add_user_to_group
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_idp_with_role", scope="function")
|
||||
def create_user_idp_with_role(
|
||||
idp: types.TestContainerIDP,
|
||||
create_group_idp: Callable[[str], str], # pylint: disable=redefined-outer-name
|
||||
) -> Callable[[str, str, bool, str, List[str]], None]:
|
||||
"""Creates a user in Keycloak IDP with a custom role attribute and optional groups."""
|
||||
client = KeycloakAdmin(
|
||||
server_url=idp.container.host_configs["6060"].base(),
|
||||
username=IDP_ROOT_USERNAME,
|
||||
password=IDP_ROOT_PASSWORD,
|
||||
realm_name="master",
|
||||
)
|
||||
|
||||
created_users = []
|
||||
|
||||
def _create_user_idp_with_role(
|
||||
email: str, password: str, verified: bool, role: str, groups: List[str]
|
||||
) -> None:
|
||||
# Create groups first
|
||||
group_ids = []
|
||||
for group_name in groups:
|
||||
group_id = create_group_idp(group_name)
|
||||
group_ids.append(group_id)
|
||||
|
||||
# Create user with role attribute
|
||||
user_id = client.create_user(
|
||||
exist_ok=False,
|
||||
payload={
|
||||
"username": email,
|
||||
"email": email,
|
||||
"enabled": True,
|
||||
"emailVerified": verified,
|
||||
"attributes": {
|
||||
"signoz_role": role,
|
||||
},
|
||||
},
|
||||
)
|
||||
client.set_user_password(user_id, password, temporary=False)
|
||||
created_users.append(user_id)
|
||||
|
||||
# Add user to groups
|
||||
for group_id in group_ids:
|
||||
client.group_user_add(user_id, group_id)
|
||||
|
||||
yield _create_user_idp_with_role
|
||||
|
||||
for user_id in created_users:
|
||||
try:
|
||||
client.delete_user(user_id)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(name="setup_user_profile", scope="package")
|
||||
def setup_user_profile(idp: types.TestContainerIDP) -> Callable[[], None]:
|
||||
"""Setup Keycloak User Profile with signoz_role attribute."""
|
||||
def _setup_user_profile() -> None:
|
||||
client = KeycloakAdmin(
|
||||
server_url=idp.container.host_configs["6060"].base(),
|
||||
username=IDP_ROOT_USERNAME,
|
||||
password=IDP_ROOT_PASSWORD,
|
||||
realm_name="master",
|
||||
)
|
||||
|
||||
# Get current user profile config
|
||||
profile = client.get_realm_users_profile()
|
||||
|
||||
# Check if signoz_role attribute already exists
|
||||
attributes = profile.get("attributes", [])
|
||||
signoz_role_exists = any(attr.get("name") == "signoz_role" for attr in attributes)
|
||||
|
||||
if not signoz_role_exists:
|
||||
# Add signoz_role attribute to user profile
|
||||
attributes.append({
|
||||
"name": "signoz_role",
|
||||
"displayName": "SigNoz Role",
|
||||
"validations": {},
|
||||
"annotations": {},
|
||||
# "required": {
|
||||
# "roles": [] # Not required
|
||||
# },
|
||||
"permissions": {
|
||||
"view": ["admin", "user"],
|
||||
"edit": ["admin"]
|
||||
},
|
||||
"multivalued": False
|
||||
})
|
||||
profile["attributes"] = attributes
|
||||
|
||||
# Update the realm user profile
|
||||
client.update_realm_users_profile(payload=profile)
|
||||
|
||||
return _setup_user_profile
|
||||
|
||||
|
||||
def _ensure_groups_client_scope(client: KeycloakAdmin) -> None:
|
||||
"""Create 'groups' client scope if it doesn't exist."""
|
||||
# Check if groups scope exists
|
||||
scopes = client.get_client_scopes()
|
||||
groups_scope_exists = any(s.get("name") == "groups" for s in scopes)
|
||||
|
||||
if not groups_scope_exists:
|
||||
# Create the groups client scope
|
||||
client.create_client_scope(
|
||||
payload={
|
||||
"name": "groups",
|
||||
"description": "Group membership",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "true",
|
||||
"display.on.consent.screen": "true",
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "groups",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-group-membership-mapper",
|
||||
"consentRequired": False,
|
||||
"config": {
|
||||
"full.path": "false",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "groups",
|
||||
"userinfo.token.claim": "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "signoz_role",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"consentRequired": False,
|
||||
"config": {
|
||||
"user.attribute": "signoz_role",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"claim.name": "signoz_role",
|
||||
"userinfo.token.claim": "true",
|
||||
"jsonType.label": "String",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
skip_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def get_oidc_domain(signoz: types.SigNoz, admin_token: str) -> dict:
|
||||
"""Helper to get the OIDC domain."""
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
return next(
|
||||
(
|
||||
domain
|
||||
for domain in response.json()["data"]
|
||||
if domain["name"] == "oidc.integration.test"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_email(signoz: types.SigNoz, admin_token: str, email: str) -> dict:
|
||||
"""Helper to get a user by email."""
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/user"),
|
||||
timeout=2,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
return next(
|
||||
(user for user in response.json()["data"] if user["email"] == email),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def perform_oidc_login(
|
||||
signoz: types.SigNoz, # pylint: disable=unused-argument
|
||||
idp: types.TestContainerIDP,
|
||||
driver: webdriver.Chrome,
|
||||
get_session_context: Callable[[str], str],
|
||||
idp_login: Callable[[str, str], None], # pylint: disable=redefined-outer-name
|
||||
email: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
"""Helper to perform OIDC login flow."""
|
||||
session_context = get_session_context(email)
|
||||
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
|
||||
parsed_url = urlparse(url)
|
||||
actual_url = (
|
||||
f"{idp.container.host_configs['6060'].get(parsed_url.path)}?{parsed_url.query}"
|
||||
)
|
||||
driver.get(actual_url)
|
||||
idp_login(email, password)
|
||||
|
||||
|
||||
def get_saml_domain(signoz: types.SigNoz, admin_token: str) -> dict:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/domains"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
return next(
|
||||
(
|
||||
domain
|
||||
for domain in response.json()["data"]
|
||||
if domain["name"] == "saml.integration.test"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def perform_saml_login(
|
||||
signoz: types.SigNoz, # pylint: disable=unused-argument
|
||||
driver: webdriver.Chrome,
|
||||
get_session_context: Callable[[str], str],
|
||||
idp_login: Callable[[str, str], None], # pylint: disable=redefined-outer-name
|
||||
email: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
session_context = get_session_context(email)
|
||||
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
|
||||
driver.get(url)
|
||||
idp_login(email, password)
|
||||
|
||||
|
||||
def delete_keycloak_client(idp: types.TestContainerIDP, client_id: str) -> None:
|
||||
keycloak_client = KeycloakAdmin(
|
||||
server_url=idp.container.host_configs["6060"].base(),
|
||||
username=IDP_ROOT_USERNAME,
|
||||
password=IDP_ROOT_PASSWORD,
|
||||
realm_name="master",
|
||||
)
|
||||
try:
|
||||
# Get the internal Keycloak client ID from the clientId
|
||||
internal_client_id = keycloak_client.get_client_id(client_id=client_id)
|
||||
if internal_client_id:
|
||||
keycloak_client.delete_client(internal_client_id)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
pass # Client doesn't exist or already deleted, that's fine
|
||||
|
||||
@@ -122,124 +122,6 @@ class MetricsSample(ABC):
|
||||
]
|
||||
|
||||
|
||||
class MetricsExpHist(ABC):
|
||||
"""Represents a row in the exp_hist table for exponential histograms."""
|
||||
|
||||
env: str
|
||||
temporality: str
|
||||
metric_name: str
|
||||
fingerprint: np.uint64
|
||||
unix_milli: np.int64
|
||||
count: np.uint64
|
||||
sum: np.float64
|
||||
min: np.float64
|
||||
max: np.float64
|
||||
sketch: bytes
|
||||
flags: np.uint32
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
metric_name: str,
|
||||
fingerprint: np.uint64,
|
||||
timestamp: datetime.datetime,
|
||||
count: int,
|
||||
sum_value: float,
|
||||
min_value: float,
|
||||
max_value: float,
|
||||
sketch: bytes = b"",
|
||||
temporality: str = "Unspecified",
|
||||
env: str = "default",
|
||||
flags: int = 0,
|
||||
) -> None:
|
||||
self.env = env
|
||||
self.temporality = temporality
|
||||
self.metric_name = metric_name
|
||||
self.fingerprint = fingerprint
|
||||
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
|
||||
self.count = np.uint64(count)
|
||||
self.sum = np.float64(sum_value)
|
||||
self.min = np.float64(min_value)
|
||||
self.max = np.float64(max_value)
|
||||
self.sketch = sketch
|
||||
self.flags = np.uint32(flags)
|
||||
|
||||
def to_row(self) -> list:
|
||||
return [
|
||||
self.env,
|
||||
self.temporality,
|
||||
self.metric_name,
|
||||
self.fingerprint,
|
||||
self.unix_milli,
|
||||
self.count,
|
||||
self.sum,
|
||||
self.min,
|
||||
self.max,
|
||||
self.sketch,
|
||||
self.flags,
|
||||
]
|
||||
|
||||
|
||||
class MetricsMetadata(ABC):
|
||||
"""Represents a row in the metadata table for metric metadata."""
|
||||
|
||||
temporality: str
|
||||
metric_name: str
|
||||
description: str
|
||||
unit: str
|
||||
type: str
|
||||
is_monotonic: bool
|
||||
attr_name: str
|
||||
attr_type: str
|
||||
attr_datatype: str
|
||||
attr_string_value: str
|
||||
first_reported_unix_milli: np.int64
|
||||
last_reported_unix_milli: np.int64
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
metric_name: str,
|
||||
attr_name: str,
|
||||
attr_type: str,
|
||||
attr_datatype: str,
|
||||
attr_string_value: str,
|
||||
timestamp: datetime.datetime,
|
||||
temporality: str = "Unspecified",
|
||||
description: str = "",
|
||||
unit: str = "",
|
||||
type_: str = "Sum",
|
||||
is_monotonic: bool = True,
|
||||
) -> None:
|
||||
self.temporality = temporality
|
||||
self.metric_name = metric_name
|
||||
self.description = description
|
||||
self.unit = unit
|
||||
self.type = type_
|
||||
self.is_monotonic = is_monotonic
|
||||
self.attr_name = attr_name
|
||||
self.attr_type = attr_type
|
||||
self.attr_datatype = attr_datatype
|
||||
self.attr_string_value = attr_string_value
|
||||
unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
|
||||
self.first_reported_unix_milli = unix_milli
|
||||
self.last_reported_unix_milli = unix_milli
|
||||
|
||||
def to_row(self) -> list:
|
||||
return [
|
||||
self.temporality,
|
||||
self.metric_name,
|
||||
self.description,
|
||||
self.unit,
|
||||
self.type,
|
||||
self.is_monotonic,
|
||||
self.attr_name,
|
||||
self.attr_type,
|
||||
self.attr_datatype,
|
||||
self.attr_string_value,
|
||||
self.first_reported_unix_milli,
|
||||
self.last_reported_unix_milli,
|
||||
]
|
||||
|
||||
|
||||
class Metrics(ABC):
|
||||
"""High-level metric representation. Produces both time series and sample entries."""
|
||||
|
||||
@@ -307,119 +189,6 @@ class Metrics(ABC):
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"metric_name": self.metric_name,
|
||||
"labels": self.labels,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"value": self.value,
|
||||
"temporality": self.temporality,
|
||||
"type_": self._time_series.type,
|
||||
"is_monotonic": self._time_series.is_monotonic,
|
||||
"flags": self.flags,
|
||||
"description": self._time_series.description,
|
||||
"unit": self._time_series.unit,
|
||||
"env": self._time_series.env,
|
||||
"resource_attrs": self._time_series.resource_attrs,
|
||||
"scope_attrs": self._time_series.scope_attrs,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
data: dict,
|
||||
# base_time: Optional[datetime.datetime] = None,
|
||||
metric_name_override: Optional[str] = None,
|
||||
) -> "Metrics":
|
||||
"""
|
||||
Create a Metrics instance from a dict.
|
||||
|
||||
Args:
|
||||
data: The dict containing metric data
|
||||
base_time: If provided, timestamps are shifted relative to this time.
|
||||
The earliest timestamp in the data becomes base_time.
|
||||
metric_name_override: If provided, overrides the metric_name from data
|
||||
"""
|
||||
# parse timestamp from iso format
|
||||
ts_str = data["timestamp"]
|
||||
if ts_str.endswith("Z"):
|
||||
ts_str = ts_str[:-1] + "+00:00"
|
||||
timestamp = datetime.datetime.fromisoformat(ts_str)
|
||||
|
||||
return cls(
|
||||
metric_name=metric_name_override or data["metric_name"],
|
||||
labels=data.get("labels", {}),
|
||||
timestamp=timestamp,
|
||||
value=data["value"],
|
||||
temporality=data.get("temporality", "Unspecified"),
|
||||
flags=data.get("flags", 0),
|
||||
description=data.get("description", ""),
|
||||
unit=data.get("unit", ""),
|
||||
type_=data.get("type_", "Sum"),
|
||||
is_monotonic=data.get("is_monotonic", True),
|
||||
env=data.get("env", "default"),
|
||||
resource_attributes=data.get("resource_attrs", {}),
|
||||
scope_attributes=data.get("scope_attrs", {}),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_from_file(
|
||||
cls,
|
||||
file_path: str,
|
||||
base_time: Optional[datetime.datetime] = None,
|
||||
metric_name_override: Optional[str] = None,
|
||||
) -> List["Metrics"]:
|
||||
"""
|
||||
Load metrics from a JSONL file.
|
||||
|
||||
Each line should be a JSON object representing a metric.
|
||||
|
||||
Args:
|
||||
file_path: Path to the JSONL file
|
||||
base_time: If provided, all timestamps are shifted so the earliest
|
||||
timestamp in the file maps to base_time
|
||||
metric_name_override: If provided, overrides metric_name for all metrics
|
||||
"""
|
||||
data_list = []
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
data_list.append(json.loads(line))
|
||||
|
||||
if not data_list:
|
||||
return []
|
||||
|
||||
# If base_time provided, calculate time offset
|
||||
time_offset = datetime.timedelta(0)
|
||||
if base_time is not None:
|
||||
# Find earliest timestamp
|
||||
earliest = None
|
||||
for data in data_list:
|
||||
ts_str = data["timestamp"]
|
||||
if ts_str.endswith("Z"):
|
||||
ts_str = ts_str[:-1] + "+00:00"
|
||||
ts = datetime.datetime.fromisoformat(ts_str)
|
||||
if earliest is None or ts < earliest:
|
||||
earliest = ts
|
||||
if earliest is not None:
|
||||
time_offset = base_time - earliest
|
||||
|
||||
metrics = []
|
||||
for data in data_list:
|
||||
ts_str = data["timestamp"]
|
||||
if ts_str.endswith("Z"):
|
||||
ts_str = ts_str[:-1] + "+00:00"
|
||||
original_ts = datetime.datetime.fromisoformat(ts_str)
|
||||
adjusted_ts = original_ts + time_offset
|
||||
data["timestamp"] = adjusted_ts.isoformat()
|
||||
metrics.append(
|
||||
cls.from_dict(data, metric_name_override=metric_name_override)
|
||||
)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
@pytest.fixture(name="insert_metrics", scope="function")
|
||||
def insert_metrics(
|
||||
@@ -431,7 +200,6 @@ def insert_metrics(
|
||||
This function handles insertion into:
|
||||
- distributed_time_series_v4 (time series metadata)
|
||||
- distributed_samples_v4 (actual sample values)
|
||||
- distributed_metadata (metric attribute metadata)
|
||||
"""
|
||||
time_series_map: dict[int, MetricsTimeSeries] = {}
|
||||
for metric in metrics:
|
||||
@@ -479,93 +247,15 @@ def insert_metrics(
|
||||
data=[sample.to_row() for sample in samples],
|
||||
)
|
||||
|
||||
# (metric_name, attr_type, attr_name, attr_value) -> MetricsMetadata
|
||||
metadata_map: dict[tuple, MetricsMetadata] = {}
|
||||
for metric in metrics:
|
||||
ts = metric.time_series
|
||||
for attr_name, attr_value in metric.labels.items():
|
||||
key = (ts.metric_name, "point", attr_name, str(attr_value))
|
||||
if key not in metadata_map:
|
||||
metadata_map[key] = MetricsMetadata(
|
||||
metric_name=ts.metric_name,
|
||||
attr_name=attr_name,
|
||||
attr_type="point",
|
||||
attr_datatype="String",
|
||||
attr_string_value=str(attr_value),
|
||||
timestamp=metric.timestamp,
|
||||
temporality=ts.temporality,
|
||||
description=ts.description,
|
||||
unit=ts.unit,
|
||||
type_=ts.type,
|
||||
is_monotonic=ts.is_monotonic,
|
||||
)
|
||||
for attr_name, attr_value in ts.resource_attrs.items():
|
||||
key = (ts.metric_name, "resource", attr_name, str(attr_value))
|
||||
if key not in metadata_map:
|
||||
metadata_map[key] = MetricsMetadata(
|
||||
metric_name=ts.metric_name,
|
||||
attr_name=attr_name,
|
||||
attr_type="resource",
|
||||
attr_datatype="String",
|
||||
attr_string_value=str(attr_value),
|
||||
timestamp=metric.timestamp,
|
||||
temporality=ts.temporality,
|
||||
description=ts.description,
|
||||
unit=ts.unit,
|
||||
type_=ts.type,
|
||||
is_monotonic=ts.is_monotonic,
|
||||
)
|
||||
for attr_name, attr_value in ts.scope_attrs.items():
|
||||
key = (ts.metric_name, "scope", attr_name, str(attr_value))
|
||||
if key not in metadata_map:
|
||||
metadata_map[key] = MetricsMetadata(
|
||||
metric_name=ts.metric_name,
|
||||
attr_name=attr_name,
|
||||
attr_type="scope",
|
||||
attr_datatype="String",
|
||||
attr_string_value=str(attr_value),
|
||||
timestamp=metric.timestamp,
|
||||
temporality=ts.temporality,
|
||||
description=ts.description,
|
||||
unit=ts.unit,
|
||||
type_=ts.type,
|
||||
is_monotonic=ts.is_monotonic,
|
||||
)
|
||||
|
||||
if len(metadata_map) > 0:
|
||||
clickhouse.conn.insert(
|
||||
database="signoz_metrics",
|
||||
table="distributed_metadata",
|
||||
column_names=[
|
||||
"temporality",
|
||||
"metric_name",
|
||||
"description",
|
||||
"unit",
|
||||
"type",
|
||||
"is_monotonic",
|
||||
"attr_name",
|
||||
"attr_type",
|
||||
"attr_datatype",
|
||||
"attr_string_value",
|
||||
"first_reported_unix_milli",
|
||||
"last_reported_unix_milli",
|
||||
],
|
||||
data=[m.to_row() for m in metadata_map.values()],
|
||||
)
|
||||
|
||||
yield _insert_metrics
|
||||
|
||||
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
tables_to_truncate = [
|
||||
"time_series_v4",
|
||||
"samples_v4",
|
||||
"exp_hist",
|
||||
"metadata",
|
||||
]
|
||||
for table in tables_to_truncate:
|
||||
clickhouse.conn.query(
|
||||
f"TRUNCATE TABLE signoz_metrics.{table} ON CLUSTER '{cluster}' SYNC"
|
||||
)
|
||||
# Cleanup
|
||||
clickhouse.conn.query(
|
||||
f"TRUNCATE TABLE signoz_metrics.time_series_v4 ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
|
||||
)
|
||||
clickhouse.conn.query(
|
||||
f"TRUNCATE TABLE signoz_metrics.samples_v4 ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="remove_metrics_ttl_and_storage_settings", scope="function")
|
||||
@@ -582,18 +272,15 @@ def remove_metrics_ttl_and_storage_settings(signoz: types.SigNoz):
|
||||
"time_series_v4_6hrs",
|
||||
"time_series_v4_1day",
|
||||
"time_series_v4_1week",
|
||||
"exp_hist",
|
||||
"metadata",
|
||||
]
|
||||
|
||||
cluster = signoz.telemetrystore.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
|
||||
for table in tables:
|
||||
try:
|
||||
signoz.telemetrystore.conn.query(
|
||||
f"ALTER TABLE signoz_metrics.{table} ON CLUSTER '{cluster}' REMOVE TTL"
|
||||
f"ALTER TABLE signoz_metrics.{table} ON CLUSTER '{signoz.telemetrystore.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' REMOVE TTL"
|
||||
)
|
||||
signoz.telemetrystore.conn.query(
|
||||
f"ALTER TABLE signoz_metrics.{table} ON CLUSTER '{cluster}' RESET SETTING storage_policy;"
|
||||
f"ALTER TABLE signoz_metrics.{table} ON CLUSTER '{signoz.telemetrystore.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' RESET SETTING storage_policy;"
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
print(f"ttl and storage policy reset failed for {table}: {e}")
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
|
||||
DEFAULT_STEP_INTERVAL = 60 # seconds
|
||||
DEFAULT_TOLERANCE = 1e-9
|
||||
QUERY_TIMEOUT = 30 # seconds
|
||||
|
||||
|
||||
def make_query_request(
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
queries: List[Dict],
|
||||
*,
|
||||
request_type: str = "time_series",
|
||||
format_options: Optional[Dict] = None,
|
||||
variables: Optional[Dict] = None,
|
||||
no_cache: bool = True,
|
||||
timeout: int = QUERY_TIMEOUT,
|
||||
) -> requests.Response:
|
||||
if format_options is None:
|
||||
format_options = {"formatTableResultForUI": False, "fillGaps": False}
|
||||
|
||||
payload = {
|
||||
"schemaVersion": "v1",
|
||||
"start": start_ms,
|
||||
"end": end_ms,
|
||||
"requestType": request_type,
|
||||
"compositeQuery": {"queries": queries},
|
||||
"formatOptions": format_options,
|
||||
"noCache": no_cache,
|
||||
}
|
||||
if variables:
|
||||
payload["variables"] = variables
|
||||
|
||||
return requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
|
||||
timeout=timeout,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
|
||||
def build_builder_query(
|
||||
name: str,
|
||||
metric_name: str,
|
||||
time_aggregation: str,
|
||||
space_aggregation: str,
|
||||
*,
|
||||
temporality: str = "cumulative",
|
||||
step_interval: int = DEFAULT_STEP_INTERVAL,
|
||||
group_by: Optional[List[str]] = None,
|
||||
filter_expression: Optional[str] = None,
|
||||
functions: Optional[List[Dict]] = None,
|
||||
disabled: bool = False,
|
||||
) -> Dict:
|
||||
spec: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"signal": "metrics",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": metric_name,
|
||||
"temporality": temporality,
|
||||
"timeAggregation": time_aggregation,
|
||||
"spaceAggregation": space_aggregation,
|
||||
}
|
||||
],
|
||||
"stepInterval": step_interval,
|
||||
"disabled": disabled,
|
||||
}
|
||||
|
||||
if group_by:
|
||||
spec["groupBy"] = [
|
||||
{
|
||||
"name": label,
|
||||
}
|
||||
for label in group_by
|
||||
]
|
||||
|
||||
if filter_expression:
|
||||
spec["filter"] = {"expression": filter_expression}
|
||||
|
||||
if functions:
|
||||
spec["functions"] = functions
|
||||
|
||||
return {"type": "builder_query", "spec": spec}
|
||||
|
||||
|
||||
def build_formula_query(
|
||||
name: str,
|
||||
expression: str,
|
||||
*,
|
||||
functions: Optional[List[Dict]] = None,
|
||||
disabled: bool = False,
|
||||
) -> Dict:
|
||||
spec: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"expression": expression,
|
||||
"disabled": disabled,
|
||||
}
|
||||
if functions:
|
||||
spec["functions"] = functions
|
||||
return {"type": "builder_formula", "spec": spec}
|
||||
|
||||
|
||||
def build_function(name: str, *args: Any) -> Dict:
|
||||
func: Dict[str, Any] = {"name": name}
|
||||
if args:
|
||||
func["args"] = [{"value": arg} for arg in args]
|
||||
return func
|
||||
|
||||
|
||||
def get_series_values(response_json: Dict, query_name: str) -> List[Dict]:
|
||||
results = response_json.get("data", {}).get("data", {}).get("results", [])
|
||||
result = find_named_result(results, query_name)
|
||||
if not result:
|
||||
return []
|
||||
aggregations = result.get("aggregations", [])
|
||||
if not aggregations:
|
||||
return []
|
||||
# at the time of writing this, the series is always a list with one element
|
||||
series = aggregations[0].get("series", [])
|
||||
if not series:
|
||||
return []
|
||||
return series[0].get("values", [])
|
||||
|
||||
|
||||
def get_all_series(response_json: Dict, query_name: str) -> List[Dict]:
|
||||
results = response_json.get("data", {}).get("data", {}).get("results", [])
|
||||
result = find_named_result(results, query_name)
|
||||
if not result:
|
||||
return []
|
||||
aggregations = result.get("aggregations", [])
|
||||
if not aggregations:
|
||||
return []
|
||||
# at the time of writing this, the series is always a list with one element
|
||||
return aggregations[0].get("series", [])
|
||||
|
||||
|
||||
def get_scalar_value(response_json: Dict, query_name: str) -> Optional[float]:
|
||||
values = get_series_values(response_json, query_name)
|
||||
if values:
|
||||
return values[0].get("value")
|
||||
return None
|
||||
|
||||
|
||||
def compare_values(
|
||||
v1: float,
|
||||
v2: float,
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
) -> bool:
|
||||
return abs(v1 - v2) <= tolerance
|
||||
|
||||
|
||||
def compare_series_values(
|
||||
values1: List[Dict],
|
||||
values2: List[Dict],
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
) -> bool:
|
||||
if len(values1) != len(values2):
|
||||
return False
|
||||
|
||||
sorted1 = sorted(values1, key=lambda x: x["timestamp"])
|
||||
sorted2 = sorted(values2, key=lambda x: x["timestamp"])
|
||||
|
||||
for v1, v2 in zip(sorted1, sorted2):
|
||||
if v1["timestamp"] != v2["timestamp"]:
|
||||
return False
|
||||
if not compare_values(v1["value"], v2["value"], tolerance):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def compare_all_series(
|
||||
series1: List[Dict],
|
||||
series2: List[Dict],
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
) -> bool:
|
||||
if len(series1) != len(series2):
|
||||
return False
|
||||
|
||||
# oh my lovely python
|
||||
def series_key(s: Dict) -> str:
|
||||
labels = s.get("labels", [])
|
||||
return str(
|
||||
sorted(
|
||||
[
|
||||
(lbl.get("key", {}).get("name", ""), lbl.get("value", ""))
|
||||
for lbl in labels
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
sorted1 = sorted(series1, key=series_key)
|
||||
sorted2 = sorted(series2, key=series_key)
|
||||
|
||||
for s1, s2 in zip(sorted1, sorted2):
|
||||
if series_key(s1) != series_key(s2):
|
||||
return False
|
||||
if not compare_series_values(
|
||||
s1.get("values", []),
|
||||
s2.get("values", []),
|
||||
tolerance,
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def assert_results_equal(
|
||||
result_cached: Dict,
|
||||
result_no_cache: Dict,
|
||||
query_name: str,
|
||||
context: str,
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
) -> None:
|
||||
values_cached = get_series_values(result_cached, query_name)
|
||||
values_no_cache = get_series_values(result_no_cache, query_name)
|
||||
|
||||
sorted_cached = sorted(values_cached, key=lambda x: x["timestamp"])
|
||||
sorted_no_cache = sorted(values_no_cache, key=lambda x: x["timestamp"])
|
||||
|
||||
assert len(sorted_cached) == len(sorted_no_cache), (
|
||||
f"{context}: Different number of values. "
|
||||
f"Cached: {len(sorted_cached)}, No-cache: {len(sorted_no_cache)}\n"
|
||||
f"Cached timestamps: {[v['timestamp'] for v in sorted_cached]}\n"
|
||||
f"No-cache timestamps: {[v['timestamp'] for v in sorted_no_cache]}"
|
||||
)
|
||||
|
||||
for v_cached, v_no_cache in zip(sorted_cached, sorted_no_cache):
|
||||
assert v_cached["timestamp"] == v_no_cache["timestamp"], (
|
||||
f"{context}: Timestamp mismatch. "
|
||||
f"Cached: {v_cached['timestamp']}, No-cache: {v_no_cache['timestamp']}"
|
||||
)
|
||||
assert compare_values(v_cached["value"], v_no_cache["value"], tolerance), (
|
||||
f"{context}: Value mismatch at timestamp {v_cached['timestamp']}. "
|
||||
f"Cached: {v_cached['value']}, No-cache: {v_no_cache['value']}"
|
||||
)
|
||||
|
||||
|
||||
def assert_all_series_equal(
|
||||
result_cached: Dict,
|
||||
result_no_cache: Dict,
|
||||
query_name: str,
|
||||
context: str,
|
||||
tolerance: float = DEFAULT_TOLERANCE,
|
||||
) -> None:
|
||||
series_cached = get_all_series(result_cached, query_name)
|
||||
series_no_cache = get_all_series(result_no_cache, query_name)
|
||||
|
||||
assert compare_all_series(
|
||||
series_cached, series_no_cache, tolerance
|
||||
), f"{context}: Cached series differ from non-cached series"
|
||||
|
||||
|
||||
def expected_minutely_bucket_timestamps_ms(now: datetime) -> List[List[int]]:
|
||||
previous_five = [
|
||||
int((now - timedelta(minutes=m)).timestamp() * 1000) for m in range(5, 0, -1)
|
||||
]
|
||||
with_current = previous_five + [int(now.timestamp() * 1000)]
|
||||
return [previous_five, with_current]
|
||||
|
||||
|
||||
def assert_minutely_bucket_timestamps(
|
||||
points: List[Dict[str, Any]],
|
||||
now: datetime,
|
||||
*,
|
||||
context: str,
|
||||
) -> List[int]:
|
||||
expected = expected_minutely_bucket_timestamps_ms(now)
|
||||
actual = [p["timestamp"] for p in points]
|
||||
assert actual in expected, f"Unexpected timestamps for {context}: {actual}"
|
||||
return actual
|
||||
|
||||
|
||||
def assert_minutely_bucket_values(
|
||||
points: List[Dict[str, Any]],
|
||||
now: datetime,
|
||||
*,
|
||||
expected_by_ts: Dict[int, float],
|
||||
context: str,
|
||||
) -> None:
|
||||
timestamps = assert_minutely_bucket_timestamps(points, now, context=context)
|
||||
expected = {ts: 0 for ts in timestamps}
|
||||
expected.update(expected_by_ts)
|
||||
|
||||
for point in points:
|
||||
ts = point["timestamp"]
|
||||
assert point["value"] == expected[ts], (
|
||||
f"Unexpected value for {context} at timestamp={ts}: "
|
||||
f"got {point['value']}, expected {expected[ts]}"
|
||||
)
|
||||
|
||||
|
||||
def index_series_by_label(
|
||||
series: List[Dict[str, Any]],
|
||||
label_name: str,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
series_by_label: Dict[str, Dict[str, Any]] = {}
|
||||
for s in series:
|
||||
label = next(
|
||||
(
|
||||
l
|
||||
for l in s.get("labels", [])
|
||||
if l.get("key", {}).get("name") == label_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert label is not None, f"Expected {label_name} label in series"
|
||||
series_by_label[label["value"]] = s
|
||||
return series_by_label
|
||||
|
||||
|
||||
def find_named_result(
|
||||
results: List[Dict[str, Any]],
|
||||
name: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return next(
|
||||
(
|
||||
r
|
||||
for r in results
|
||||
if r.get("name") == name
|
||||
or r.get("queryName") == name
|
||||
or (r.get("spec") or {}).get("name") == name
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -65,7 +65,6 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"SIGNOZ_INSTRUMENTATION_LOGS_LEVEL": "debug",
|
||||
"SIGNOZ_PROMETHEUS_ACTIVE__QUERY__TRACKER_ENABLED": False,
|
||||
"SIGNOZ_GATEWAY_URL": gateway.container_configs["8080"].base(),
|
||||
"SIGNOZ_TOKENIZER_JWT_SECRET": "secret",
|
||||
}
|
||||
| sqlstore.env
|
||||
| clickhouse.env
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user