Compare commits

...

17 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
1427fbd6c3 chore: added validation for match type and compOp if provided 2026-01-21 16:32:48 +05:30
Abhishek Kumar Singh
9d973e8e26 feat: validation for ruleCondition 2026-01-19 18:46:56 +05:30
Ashwin Bhatkal
31e9e896ec fix: dashboard - textbox default variable not working (#9843) 2026-01-19 18:23:17 +05:30
SagarRajput-7
325974292f feat: auth revamp flow (#9901)
* feat: auth revamp base setup

* feat: auth revamp signin changes

* feat: auth revamp signup changes

* feat: auth revamp reset password changes

* feat: auth revamp light mode changes

* feat: clean up and added get help link

* feat: cleanup and addressed comments

* feat: cleanup

* feat: comment addressed

* feat: auth revamp for onboarding flow (#9915)

* feat: auth revamp for onboarding flow

* feat: updated invite team member step

* feat: updated light mode

* feat: comment addressed

* feat: added onboarding flow test cases

* feat: fixed feedbacks

* feat: resolved comments and refactoring

* feat: resolved comments and refactoring

* fix: svg import fix

* feat: added test cases for the new auth flow (#9914)

* feat: added test cases for the new auth flow

* feat: updated test cases

* feat: updated test cases

* fix: updated test cases

* fix: updated test cases

* fix: use error content for auth revamp error

* feat: refactor and cleanup

* feat: refactor and cleanup

* fix: add shake animation and light mode fix

* feat: feedback resolved

* feat: feedback resolved

* feat: updated test cases

* feat: feedback resolved

* feat: shake animation update
2026-01-19 13:40:37 +05:30
Vishal Sharma
7c1a531d01 Add context7.json with URL and public key (#10037) 2026-01-19 11:35:37 +05:30
Vikrant Gupta
5a45532a72 chore: update codeowners file (#10031) 2026-01-18 22:00:38 +05:30
Srikanth Chekuri
e9501d2e0f test(integration): add cumulative counter rate tests (#9976) 2026-01-18 16:13:41 +05:30
Srikanth Chekuri
c306e66bcd chore: update CODEOWNERS (#10028) 2026-01-18 15:42:46 +05:30
Yunus M
767a0cc28e chore: improve pull request template clarity and visibility (#10021) 2026-01-16 16:17:17 +00:00
Ashwin Bhatkal
4f9efcc133 chore: remove airbnb from ESLint, update prettier rules and update VS Code settings (#10013)
* chore: remove airbnb from ESLint, update prettier rules and update VS Code settings

* fix: lint errors
2026-01-16 12:05:23 +00:00
Amlan Kumar Nandy
bf2dd612e0 chore: fill y axis unit in alerts coming from dashboards (#9982) 2026-01-16 09:37:16 +00:00
Aditya Singh
d3f15022a4 fix: overflowing tag input on antd (#10018) 2026-01-16 09:15:22 +00:00
Pandey
a5c021e96c fix(kafka): fix nil pointer error in evaluation api (#10015) 2026-01-16 02:24:20 +05:30
Abhi kumar
8b9fcae0cb fix: added layout fix for changelog modal (#10012) 2026-01-15 12:30:10 +00:00
Abhi kumar
e75abcb108 Fix/animation offload to cpu (#9961)
* fix: transition is getting offloaded to cpu because gpu can't handle it in composition

* chore: changed transition from all to transform only
2026-01-15 13:43:44 +05:30
primus-bot[bot]
ffa15f9ec1 chore(release): bump to v0.107.0 (#10007)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-01-15 12:18:52 +05:30
Ishan
87562f5387 feat: onGroupBy - show timeseries panel - #3403 (#9921)
* feat: onGroupBy - show timeseries

* feat: updated testcases for tableview actions

* feat: updated pipeline file to remove groupBy as not used

* feat: updated better code checks

* feat: updated better code checks

* feat: updated tableview actions to update query

* feat: updated types and test cases

* feat: updated dataType (noramlized)
2026-01-15 08:53:08 +05:30
106 changed files with 7090 additions and 1656 deletions

52
.github/CODEOWNERS vendored
View File

@@ -16,13 +16,13 @@
# Scaffold Owners
/pkg/config/ @therealpandey
/pkg/errors/ @therealpandey
/pkg/factory/ @therealpandey
/pkg/types/ @therealpandey
/pkg/valuer/ @therealpandey
/cmd/ @therealpandey
.golangci.yml @therealpandey
/pkg/config/ @vikrantgupta25
/pkg/errors/ @vikrantgupta25
/pkg/factory/ @vikrantgupta25
/pkg/types/ @vikrantgupta25
/pkg/valuer/ @vikrantgupta25
/cmd/ @vikrantgupta25
.golangci.yml @vikrantgupta25
# Zeus Owners
@@ -48,19 +48,53 @@
/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 @therealpandey
/pkg/authz/ @vikrantgupta25
# Integration tests
/tests/integration/ @therealpandey
/tests/integration/ @vikrantgupta25
# Dashboard Owners

View File

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

2
.gitignore vendored
View File

@@ -12,7 +12,6 @@ frontend/coverage
# production
frontend/build
frontend/.vscode
frontend/.yarnclean
frontend/.temp_cache
frontend/test-results
@@ -31,7 +30,6 @@ frontend/src/constants/env.ts
.idea
**/.vscode
**/build
**/storage
**/locust-scripts/__pycache__/

9
.vscode/settings.json vendored Normal file
View File

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

4
context7.json Normal file
View File

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

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.106.0
image: signoz/signoz:v0.107.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.106.0
image: signoz/signoz:v0.107.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.106.0}
image: signoz/signoz:${VERSION:-v0.107.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.106.0}
image: signoz/signoz:${VERSION:-v0.107.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -7,8 +7,6 @@ module.exports = {
'jest/globals': true,
},
extends: [
'airbnb',
'airbnb-typescript',
'eslint:recommended',
'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
@@ -35,6 +33,7 @@ module.exports = {
'react-hooks',
'prettier',
'jest',
'jsx-a11y',
],
settings: {
react: {
@@ -72,9 +71,6 @@ 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',
@@ -87,6 +83,9 @@ 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',
@@ -104,7 +103,10 @@ module.exports = {
},
},
],
'@typescript-eslint/no-unused-vars': 'error',
// 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',
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'arrow-body-style': ['error', 'as-needed'],

View File

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

8
frontend/.vscode/settings.json vendored Normal file
View File

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

View File

@@ -34,12 +34,18 @@ 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, 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)
- In our project, we rely on several essential ESLint plugins and configurations:
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.
- [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.
### Naming Conventions

View File

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

View File

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

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,13 +6,15 @@ 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 }: ErrorContentProps): JSX.Element {
function ErrorContent({ error, icon }: ErrorContentProps): JSX.Element {
const {
url: errorUrl,
errors: errorMessages,
@@ -25,9 +27,7 @@ function ErrorContent({ error }: 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">
<ErrorIcon />
</div>
<div className="error-content__icon-wrapper">{icon || <ErrorIcon />}</div>
<div className="error-content__summary-text">
<h2 className="error-content__error-code">{errorCode}</h2>
@@ -95,4 +95,8 @@ function ErrorContent({ error }: ErrorContentProps): JSX.Element {
);
}
ErrorContent.defaultProps = {
icon: undefined,
};
export default ErrorContent;

View File

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

View File

@@ -1,16 +1,16 @@
import { DrawerProps } from 'antd';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ActionItemProps } from 'container/LogDetailedView/ActionItem';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { VIEWS } from './constants';
export type LogDetailProps = {
log: ILog | null;
selectedTab: VIEWS;
onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
listViewPanelSelectedFields?: IField[] | null;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &

View File

@@ -55,11 +55,11 @@ function LogDetailInner({
log,
onClose,
onAddToQuery,
onGroupByAttribute,
onClickActionItem,
selectedTab,
isListViewPanel = false,
listViewPanelSelectedFields,
handleChangeSelectedView,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -365,10 +365,10 @@ function LogDetailInner({
logData={log}
onAddToQuery={onAddToQuery}
onClickActionItem={onClickActionItem}
onGroupByAttribute={onGroupByAttribute}
isListViewPanel={isListViewPanel}
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}

View File

@@ -6,6 +6,7 @@ import cx from 'classnames';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { useActiveLog } from 'hooks/logs/useActiveLog';
@@ -108,6 +109,7 @@ type ListLogViewProps = {
activeLog?: ILog | null;
linesPerRow: number;
fontSize: FontSize;
handleChangeSelectedView?: ChangeViewFunctionType;
};
function ListLogView({
@@ -118,6 +120,7 @@ function ListLogView({
activeLog,
linesPerRow,
fontSize,
handleChangeSelectedView,
}: ListLogViewProps): JSX.Element {
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
@@ -131,7 +134,6 @@ function ListLogView({
onAddToQuery: handleAddToQuery,
onSetActiveLog: handleSetActiveContextLog,
onClearActiveLog: handleClearActiveContextLog,
onGroupByAttribute,
} = useActiveLog();
const isDarkMode = useIsDarkMode();
@@ -255,7 +257,7 @@ function ListLogView({
onAddToQuery={handleAddToQuery}
selectedTab={VIEW_TYPES.CONTEXT}
onClose={handlerClearActiveContextLog}
onGroupByAttribute={onGroupByAttribute}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
</>
@@ -264,6 +266,7 @@ function ListLogView({
ListLogView.defaultProps = {
activeLog: null,
handleChangeSelectedView: undefined,
};
LogGeneralField.defaultProps = {

View File

@@ -39,6 +39,7 @@ function RawLogView({
selectedFields = [],
fontSize,
onLogClick,
handleChangeSelectedView,
}: RawLogViewProps): JSX.Element {
const {
isHighlighted: isUrlHighlighted,
@@ -52,7 +53,6 @@ function RawLogView({
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
onGroupByAttribute,
} = useActiveLog();
const [selectedTab, setSelectedTab] = useState<VIEWS | undefined>();
@@ -224,13 +224,12 @@ function RawLogView({
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onGroupByAttribute={onGroupByAttribute}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
</RawLogViewContainer>
);
}
RawLogView.defaultProps = {
isActiveLog: false,
isReadOnly: false,

View File

@@ -1,3 +1,4 @@
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { FontSize } from 'container/OptionsMenu/types';
import { MouseEvent } from 'react';
import { IField } from 'types/api/logs/fields';
@@ -14,6 +15,7 @@ export interface RawLogViewProps {
fontSize: FontSize;
selectedFields?: IField[];
onLogClick?: (log: ILog, event: MouseEvent) => void;
handleChangeSelectedView?: ChangeViewFunctionType;
}
export interface RawLogContentProps {

View File

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

View File

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

View File

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

View File

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

View File

@@ -320,6 +320,10 @@ function VariableItem({
]);
const variableValue = useMemo(() => {
if (queryType === 'TEXTBOX') {
return variableTextboxValue;
}
if (variableMultiSelect) {
let value = variableData.selectedValue;
if (isEmpty(value)) {
@@ -352,6 +356,8 @@ function VariableItem({
variableData.selectedValue,
variableData.showALLOption,
variableDefaultValue,
variableTextboxValue,
queryType,
previewValues,
]);
@@ -367,13 +373,10 @@ function VariableItem({
multiSelect: variableMultiSelect,
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
sort: variableSortType,
...(queryType === 'TEXTBOX' && {
selectedValue: (variableData.selectedValue ||
variableTextboxValue) as never,
}),
...(queryType !== 'TEXTBOX' && {
defaultValue: variableDefaultValue as never,
}),
// 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,
modificationUUID: generateUUID(),
id: variableData.id || generateUUID(),
order: variableData.order,

View File

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

View File

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

View File

@@ -8,14 +8,14 @@ import './DashboardVariableSelection.styles.scss';
import { orange } from '@ant-design/colors';
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { Input, Popover, Tooltip, Typography } from 'antd';
import { Input, InputRef, 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, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -71,6 +71,15 @@ 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,
);
@@ -371,7 +380,7 @@ function VariableItem({
}, [variableData.type, variableData.customValue]);
return (
<div className="variable-item">
<div className={`variable-item${isTextboxFocused ? ' focused' : ''}`}>
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
{variableData.description && (
@@ -384,16 +393,40 @@ function VariableItem({
<div className="variable-value">
{variableData.type === 'TEXTBOX' ? (
<Input
ref={textboxInputRef}
placeholder="Enter value"
data-testid={`variable-textbox-${variableData.id}`}
bordered={false}
key={variableData.selectedValue?.toString()}
defaultValue={variableData.selectedValue?.toString()}
value={textboxInputValue}
title={textboxInputValue}
onChange={(e): void => {
debouncedHandleChange(e.target.value || '');
setTextboxInputValue(e.target.value);
}}
style={{
width:
50 + ((variableData.selectedValue?.toString()?.length || 0) * 7 || 50),
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();
}
}}
/>
) : (

View File

@@ -788,11 +788,18 @@ 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(
() => isNewRule && alertType === AlertTypes.METRICS_BASED_ALERT,
[isNewRule, alertType],
);
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 { yAxisUnit: initialYAxisUnit, isLoading } = useGetYAxisUnit(
alertDef.condition.selectedQueryName,

View File

@@ -4,6 +4,7 @@ import { Switch, Typography } from 'antd';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import GoToTop from 'container/GoToTop';
import { useOptionsMenu } from 'container/OptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
@@ -21,7 +22,13 @@ import { ILiveLogsLog } from '../LiveLogsList/types';
import LiveLogsListChart from '../LiveLogsListChart';
import { QueryHistoryState } from '../types';
function LiveLogsContainer(): JSX.Element {
interface LiveLogsContainerProps {
handleChangeSelectedView?: ChangeViewFunctionType;
}
function LiveLogsContainer({
handleChangeSelectedView,
}: LiveLogsContainerProps): JSX.Element {
const location = useLocation();
const [logs, setLogs] = useState<ILiveLogsLog[]>([]);
const { currentQuery, stagedQuery } = useQueryBuilder();
@@ -247,6 +254,7 @@ function LiveLogsContainer(): JSX.Element {
<LiveLogsList
logs={logs}
isLoading={initialLoading && logs.length === 0}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
@@ -256,4 +264,8 @@ function LiveLogsContainer(): JSX.Element {
);
}
LiveLogsContainer.defaultProps = {
handleChangeSelectedView: undefined,
};
export default LiveLogsContainer;

View File

@@ -25,7 +25,11 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { LiveLogsListProps } from './types';
function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
function LiveLogsList({
logs,
isLoading,
handleChangeSelectedView,
}: LiveLogsListProps): JSX.Element {
const ref = useRef<VirtuosoHandle>(null);
const { isConnectionLoading } = useEventSource();
@@ -36,7 +40,6 @@ function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
activeLog,
onClearActiveLog,
onAddToQuery,
onGroupByAttribute,
onSetActiveLog,
} = useActiveLog();
@@ -72,6 +75,7 @@ function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
);
}
@@ -85,10 +89,12 @@ function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
onAddToQuery={onAddToQuery}
onSetActiveLog={onSetActiveLog}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
);
},
[
handleChangeSelectedView,
onAddToQuery,
onSetActiveLog,
options.fontSize,
@@ -147,6 +153,7 @@ function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
appendTo: 'end',
activeLogIndex,
}}
handleChangeSelectedView={handleChangeSelectedView}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
@@ -170,12 +177,11 @@ function LiveLogsList({ logs, isLoading }: LiveLogsListProps): JSX.Element {
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onGroupByAttribute={onGroupByAttribute}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
</div>
);
}
export default memo(LiveLogsList);

View File

@@ -1,3 +1,4 @@
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ILog } from 'types/api/logs/log';
export interface ILiveLogsLog {
@@ -8,4 +9,5 @@ export interface ILiveLogsLog {
export type LiveLogsListProps = {
logs: ILiveLogsLog[];
isLoading: boolean;
handleChangeSelectedView?: ChangeViewFunctionType;
};

View File

@@ -12,13 +12,13 @@ import {
Typography,
} from 'antd';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ChevronDown, ChevronRight, Search } from 'lucide-react';
import { ReactNode, useState } from 'react';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { ActionItemProps } from './ActionItem';
import TableView from './TableView';
@@ -29,7 +29,7 @@ interface OverviewProps {
isListViewPanel?: boolean;
selectedOptions: OptionsQuery;
listViewPanelSelectedFields?: IField[] | null;
onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
handleChangeSelectedView?: ChangeViewFunctionType;
}
type Props = OverviewProps &
@@ -42,8 +42,8 @@ function Overview({
onClickActionItem,
isListViewPanel = false,
selectedOptions,
onGroupByAttribute,
listViewPanelSelectedFields,
handleChangeSelectedView,
}: Props): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
@@ -208,11 +208,11 @@ function Overview({
logData={logData}
onAddToQuery={onAddToQuery}
fieldSearchInput={fieldSearchInput}
onGroupByAttribute={onGroupByAttribute}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={selectedOptions}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
/>
</>
),
@@ -227,7 +227,7 @@ function Overview({
Overview.defaultProps = {
isListViewPanel: false,
listViewPanelSelectedFields: null,
onGroupByAttribute: undefined,
handleChangeSelectedView: undefined,
};
export default Overview;

View File

@@ -13,6 +13,7 @@ import AddToQueryHOC, {
import { ResizeTable } from 'components/ResizeTable';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
@@ -47,7 +48,7 @@ interface TableViewProps {
selectedOptions: OptionsQuery;
isListViewPanel?: boolean;
listViewPanelSelectedFields?: IField[] | null;
onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
handleChangeSelectedView?: ChangeViewFunctionType;
}
type Props = TableViewProps &
@@ -61,8 +62,8 @@ function TableView({
onClickActionItem,
isListViewPanel = false,
selectedOptions,
onGroupByAttribute,
listViewPanelSelectedFields,
handleChangeSelectedView,
}: Props): JSX.Element | null {
const dispatch = useDispatch<Dispatch<AppActions>>();
const [isfilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
@@ -295,7 +296,7 @@ function TableView({
isfilterInLoading={isfilterInLoading}
isfilterOutLoading={isfilterOutLoading}
onClickHandler={onClickHandler}
onGroupByAttribute={onGroupByAttribute}
handleChangeSelectedView={handleChangeSelectedView}
/>
),
},
@@ -338,7 +339,7 @@ function TableView({
TableView.defaultProps = {
isListViewPanel: false,
listViewPanelSelectedFields: null,
onGroupByAttribute: undefined,
handleChangeSelectedView: undefined,
};
export interface DataType {

View File

@@ -7,15 +7,24 @@ import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import cx from 'classnames';
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { QueryParams } from 'constants/query';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { useTimezone } from 'providers/Timezone';
import React, { useCallback, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataType } from '../TableView';
import {
@@ -33,7 +42,6 @@ interface ITableViewActionsProps {
isListViewPanel: boolean;
isfilterInLoading: boolean;
isfilterOutLoading: boolean;
onGroupByAttribute?: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
onClickHandler: (
operator: string,
fieldKey: string,
@@ -41,6 +49,7 @@ interface ITableViewActionsProps {
dataType: string | undefined,
logType: MetricsType | undefined,
) => () => void;
handleChangeSelectedView?: ChangeViewFunctionType;
}
// Memoized Tree Component
@@ -118,10 +127,12 @@ export default function TableViewActions(
isfilterInLoading,
isfilterOutLoading,
onClickHandler,
onGroupByAttribute,
handleChangeSelectedView,
} = props;
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const { dataType, logType: fieldType } = getFieldAttributes(record.field);
// there is no option for where clause in old logs explorer and live logs page
@@ -145,6 +156,42 @@ export default function TableViewActions(
const fieldFilterKey = filterKeyForField(fieldData.field);
const handleGroupByAttribute = useCallback((): void => {
if (!stagedQuery) return;
const normalizedDataType: DataTypes | undefined =
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
? (dataType as DataTypes)
: undefined;
const updatedQuery = updateQueriesData(stagedQuery, 'queryData', (item) => {
const newGroupByItem: BaseAutocompleteData = {
key: fieldFilterKey,
type: fieldType || '',
dataType: normalizedDataType,
};
const updatedGroupBy = [...(item.groupBy || []), newGroupByItem];
return { ...item, groupBy: updatedGroupBy };
});
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
}, [
stagedQuery,
updateQueriesData,
fieldFilterKey,
fieldType,
dataType,
handleChangeSelectedView,
viewName,
]);
// Memoize textToCopy computation
const textToCopy = useMemo(() => {
let text = fieldData.value;
@@ -268,9 +315,7 @@ export default function TableViewActions(
className="group-by-clause"
type="text"
icon={<GroupByIcon />}
onClick={(): Promise<void> | void =>
onGroupByAttribute?.(fieldFilterKey)
}
onClick={handleGroupByAttribute}
>
Group By Attribute
</Button>
@@ -348,9 +393,7 @@ export default function TableViewActions(
className="group-by-clause"
type="text"
icon={<GroupByIcon />}
onClick={(): Promise<void> | void =>
onGroupByAttribute?.(fieldFilterKey)
}
onClick={handleGroupByAttribute}
>
Group By Attribute
</Button>
@@ -373,5 +416,5 @@ export default function TableViewActions(
}
TableViewActions.defaultProps = {
onGroupByAttribute: undefined,
handleChangeSelectedView: undefined,
};

View File

@@ -1,5 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import TableViewActions from '../TableViewActions';
import useAsyncJSONProcessing from '../useAsyncJSONProcessing';
@@ -49,6 +52,20 @@ jest.mock('../useAsyncJSONProcessing', () => ({
default: jest.fn(),
}));
jest.mock('antd', () => {
const antd = jest.requireActual('antd');
return {
...antd,
// Render popover content inline to make its children testable
Popover: ({ content, children }: any): JSX.Element => (
<div data-testid="popover">
<div data-testid="popover-content">{content}</div>
{children}
</div>
),
};
});
jest.mock('providers/Timezone', () => ({
useTimezone: (): {
formatTimezoneAdjustedTimestamp: (timestamp: string) => string;
@@ -71,29 +88,35 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/queryBuilder/useGetSearchQueryParam');
describe('TableViewActions', () => {
const TEST_VALUE = 'test value';
const TEST_FIELD = 'test-field';
const ACTION_BUTTON_TEST_ID = '.action-btn';
const defaultProps = {
fieldData: {
field: 'test-field',
field: TEST_FIELD,
value: TEST_VALUE,
},
record: {
key: 'test-key',
field: 'test-field',
field: TEST_FIELD,
value: TEST_VALUE,
},
isListViewPanel: false,
isfilterInLoading: false,
isfilterOutLoading: false,
onClickHandler: jest.fn(),
onGroupByAttribute: jest.fn(),
handleChangeSelectedView: jest.fn(),
};
beforeEach(() => {
mockCopyToClipboard = jest.fn();
mockNotificationsSuccess = jest.fn();
defaultProps.onClickHandler = jest.fn();
defaultProps.handleChangeSelectedView = jest.fn();
// Default mock for useAsyncJSONProcessing
const mockUseAsyncJSONProcessing = jest.mocked(useAsyncJSONProcessing);
@@ -102,6 +125,24 @@ describe('TableViewActions', () => {
treeData: null,
error: null,
});
// Default mock for useQueryBuilder
jest.mocked(useQueryBuilder).mockReturnValue({
stagedQuery: null,
updateQueriesData: jest.fn((query, type, callback) => {
const updatedBuilder = {
...query.builder,
[type]: query.builder[type].map(callback),
};
return {
...query,
builder: updatedBuilder,
};
}),
} as any);
// Default mock for useGetSearchQueryParam
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
});
it('should render without crashing', () => {
@@ -113,7 +154,7 @@ describe('TableViewActions', () => {
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
expect(screen.getByText(TEST_VALUE)).toBeInTheDocument();
@@ -135,7 +176,7 @@ describe('TableViewActions', () => {
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
// Verify that action buttons are not rendered for restricted fields
@@ -154,13 +195,100 @@ describe('TableViewActions', () => {
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
// Verify that action buttons are rendered for non-restricted fields
expect(container.querySelector(ACTION_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should call handleChangeSelectedView when clicking group by', () => {
const mockStagedQuery = {
id: 'test-query-id',
queryType: 'queryBuilder',
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'logs',
aggregateOperator: 'count',
functions: [],
filter: {},
groupBy: [],
expression: '',
disabled: false,
having: [],
limit: null,
stepInterval: null,
orderBy: [],
legend: '',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
};
const mockUpdateQueriesData = jest.fn((query, type, callback) => {
const section = query.builder?.[type];
if (!Array.isArray(section)) {
return query;
}
return {
...query,
builder: {
...query.builder,
[type]: section.map(callback),
},
};
});
jest.mocked(useQueryBuilder).mockReturnValue({
stagedQuery: mockStagedQuery,
updateQueriesData: mockUpdateQueriesData,
} as any);
jest.mocked(useGetSearchQueryParam).mockReturnValue(null);
render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
fireEvent.click(screen.getByText('Group By Attribute'));
expect(defaultProps.handleChangeSelectedView).toHaveBeenCalledWith(
ExplorerViews.TIMESERIES,
expect.objectContaining({
name: '',
id: 'test-query-id',
query: expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
groupBy: expect.arrayContaining([
expect.objectContaining({
key: TEST_FIELD,
type: '',
}),
]),
}),
]),
}),
}),
}),
);
});
it('should not render action buttons in list view panel', () => {
const { container } = render(
<TableViewActions
@@ -170,7 +298,7 @@ describe('TableViewActions', () => {
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
handleChangeSelectedView={defaultProps.handleChangeSelectedView}
/>,
);
// Verify that action buttons are not rendered in list view panel
@@ -200,7 +328,7 @@ describe('TableViewActions', () => {
isfilterInLoading: false,
isfilterOutLoading: false,
onClickHandler: jest.fn(),
onGroupByAttribute: jest.fn(),
handleChangeSelectedView: jest.fn(),
};
// Render component with body field
@@ -212,7 +340,7 @@ describe('TableViewActions', () => {
isfilterInLoading={bodyProps.isfilterInLoading}
isfilterOutLoading={bodyProps.isfilterOutLoading}
onClickHandler={bodyProps.onClickHandler}
onGroupByAttribute={bodyProps.onGroupByAttribute}
handleChangeSelectedView={bodyProps.handleChangeSelectedView}
/>,
);

View File

@@ -1,44 +1,419 @@
@import '@signozhq/design-tokens/dist/style.css';
.login-form-container {
display: flex;
justify-content: center;
width: 100%;
align-items: flex-start;
}
.login-form-header {
margin-bottom: 16px;
}
.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-header-text {
color: var(--text-vanilla-300);
}
.login-form-emoji {
width: 32px;
height: 32px;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.next-btn {
padding: 0px 16px;
}
.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-input {
height: 40px;
}
.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;
}
.no-acccount {
color: var(--text-vanilla-300);
font-size: 12px;
margin-top: 16px;
.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-form-container {
.login-form-header {
color: var(--text-ink-500);
}
.login-error-container {
.error-content {
background: rgba(229, 72, 77, 0.1);
border-color: rgba(229, 72, 77, 0.2);
.login-form-header-text {
color: var(--text-ink-500);
}
&__error-code {
color: var(--bg-ink-100);
}
.no-acccount {
color: var(--text-ink-500);
&__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);
}
&:hover {
border-color: var(--levels-l3-border, #2c303a);
}
&: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);
}
.login-form-input {
background: var(--bg-vanilla-200, #f5f5f5);
border-color: var(--bg-vanilla-300, #e9e9e9);
color: var(--text-ink-500);
&::placeholder {
color: var(--text-neutral-light-200, #80828d);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
}
// 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;
}
}
}
}

View File

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

View File

@@ -1,15 +1,16 @@
import './Login.styles.scss';
import { Button, Form, Input, Select, Space, Tooltip, Typography } from 'antd';
import { Button } from '@signozhq/button';
import { Form, Input, Select, 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';
@@ -37,6 +38,7 @@ 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
@@ -61,7 +63,12 @@ function Login(): JSX.Element {
setIsLoadingSessionsContext,
] = useState<boolean>(false);
const [form] = Form.useForm<FormValues>();
const { showErrorModal } = useErrorModal();
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);
// setupCompleted information to route to signup page in case setup is incomplete
const {
@@ -90,6 +97,7 @@ function Login(): JSX.Element {
const onNextHandler = async (): Promise<void> => {
const email = form.getFieldValue('email');
setIsLoadingSessionsContext(true);
setErrorMessage(undefined);
try {
const sessionsContextResponse = await get({
@@ -102,7 +110,7 @@ function Login(): JSX.Element {
setSessionsOrgId(sessionsContextResponse.data.orgs[0].id);
}
} catch (error) {
showErrorModal(error as APIError);
setErrorMessage(error as APIError);
}
setIsLoadingSessionsContext(false);
};
@@ -181,6 +189,7 @@ function Login(): JSX.Element {
const onSubmitHandler: () => Promise<void> = async () => {
setIsSubmitting(true);
setErrorMessage(undefined);
try {
if (isPasswordAuthN) {
@@ -205,7 +214,7 @@ function Login(): JSX.Element {
window.location.href = url;
}
} catch (error) {
showErrorModal(error as APIError);
setErrorMessage(error as APIError);
} finally {
setIsSubmitting(false);
}
@@ -213,7 +222,7 @@ function Login(): JSX.Element {
useEffect(() => {
if (callbackAuthError) {
showErrorModal(
setErrorMessage(
new APIError({
httpStatusCode: 500,
error: {
@@ -231,110 +240,140 @@ function Login(): JSX.Element {
callbackAuthErrorCode,
callbackAuthErrorMessage,
callbackAuthErrorURL,
showErrorModal,
setErrorMessage,
]);
useEffect(() => {
if (sessionsOrgWarning) {
showErrorModal(
setErrorMessage(
new APIError({
httpStatusCode: 400,
error: {
code: sessionsOrgWarning.code,
message: sessionsOrgWarning.message,
url: sessionsOrgWarning.url,
errors: sessionsOrgWarning.errors,
},
httpStatusCode: 400,
}),
);
}
}, [sessionsOrgWarning, showErrorModal]);
}, [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,
]);
return (
<div className="login-form-container">
<FormContainer form={form} onFinish={onSubmitHandler}>
<div className="login-form-header">
<Typography.Paragraph className="login-form-header-text">
<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">
Sign in to monitor, trace, and troubleshoot your applications
effortlessly.
</Typography.Paragraph>
</div>
<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 && (
<div className="login-form-card">
<ParentContainer>
<Label htmlFor="orgId">Organization Name</Label>
<FormContainer.Item name="orgId">
<Select
id="orgId"
data-testid="orgId"
className="login-form-input"
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
<Label htmlFor="signupEmail">Email address</Label>
<FormContainer.Item name="email">
<Input
type="email"
id="email"
data-testid="email"
required
id="currentPassword"
data-testid="password"
disabled={isSubmitting}
placeholder="e.g. john@signoz.io"
autoFocus
disabled={versionLoading}
className="login-form-input"
onPressEnter={onNextHandler}
/>
</FormContainer.Item>
<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>
)}
<Space
style={{ marginTop: 16 }}
align="start"
direction="vertical"
size={20}
>
{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>
<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>
{errorMessage && <AuthError error={errorMessage} />}
<div className="login-form-actions">
{!sessionsContext && (
<Button
disabled={versionLoading || sessionsContextLoading}
type="primary"
disabled={!isNextButtonEnabled}
variant="solid"
onClick={onNextHandler}
data-testid="initiate_login"
className="periscope-btn primary next-btn"
icon={<ArrowRight size={12} />}
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
>
Next
</Button>
@@ -342,32 +381,34 @@ function Login(): JSX.Element {
{sessionsContext && isCallbackAuthN && (
<Button
disabled={isSubmitting}
type="primary"
htmlType="submit"
disabled={!isSubmitButtonEnabled}
variant="solid"
type="submit"
color="primary"
data-testid="callback_authn_submit"
data-attr="signup"
className="periscope-btn primary next-btn"
icon={<ArrowRight size={12} />}
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
>
Login With Callback
Sign in with SSO
</Button>
)}
{sessionsContext && isPasswordAuthN && (
<Button
disabled={isSubmitting}
type="primary"
disabled={!isSubmitButtonEnabled}
variant="solid"
color="primary"
data-testid="password_authn_submit"
htmlType="submit"
type="submit"
data-attr="signup"
className="periscope-btn primary next-btn"
icon={<ArrowRight size={12} />}
className="login-submit-btn"
suffixIcon={<ArrowRight size={12} />}
>
Login
Sign in with Password
</Button>
)}
</Space>
</div>
</FormContainer>
</div>
);

View File

@@ -1,25 +1,19 @@
import { Card, Form } from 'antd';
import { Form } from 'antd';
import styled from 'styled-components';
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;
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 FormContainer = styled(Form)`
@@ -30,9 +24,58 @@ export const FormContainer = styled(Form)`
& .ant-form-item {
margin-bottom: 0px;
width: 100%;
& .ant-select {
width: 100%;
margin: 0;
}
& .ant-form-item-control {
width: 100%;
}
& .ant-form-item-control-input {
width: 100%;
}
& .ant-form-item-control-input-content {
width: 100%;
}
}
& .ant-input,
& .ant-input-password,
& .ant-select-selector {
background: var(--levels-l3-background, #23262e) !important;
border-color: var(--levels-l3-border, #2c303a) !important;
color: var(--levels-l1-foreground, #eceef2) !important;
.lightMode & {
background: var(--bg-vanilla-200, #f5f5f5) !important;
border-color: var(--bg-vanilla-300, #e9e9e9) !important;
color: var(--text-ink-500) !important;
}
}
& .ant-input::placeholder {
color: var(--levels-l3-foreground, #747b8b) !important;
.lightMode & {
color: var(--text-neutral-light-200, #80828d) !important;
}
}
& .ant-input:focus,
& .ant-input-password:focus,
& .ant-select-focused .ant-select-selector {
border-color: var(--semantic-primary-background, #4e74f8) !important;
box-shadow: none !important;
}
`;
export const ParentContainer = styled.div`
width: 100%;
display: flex;
flex-direction: column;
`;

View File

@@ -47,7 +47,6 @@ function ColumnView({
onSetActiveLog: handleSetActiveLog,
onClearActiveLog: handleClearActiveLog,
onAddToQuery: handleAddToQuery,
onGroupByAttribute: handleGroupByAttribute,
} = useActiveLog();
const [showActiveLog, setShowActiveLog] = useState<boolean>(false);
@@ -271,7 +270,6 @@ function ColumnView({
onClose={handleLogDetailClose}
onAddToQuery={handleAddToQuery}
onClickActionItem={handleAddToQuery}
onGroupByAttribute={handleGroupByAttribute}
/>
)}
</div>

View File

@@ -58,7 +58,7 @@ const CustomTableRow: TableComponents<ILog>['TableRow'] = ({
const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
function InfinityTableView(
{ isLoading, tableViewProps, infitiyTableProps },
{ isLoading, tableViewProps, infitiyTableProps, handleChangeSelectedView },
ref,
): JSX.Element | null {
const {
@@ -72,7 +72,6 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
onGroupByAttribute,
} = useActiveLog();
const { dataSource, columns } = useTableView({
@@ -187,7 +186,7 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
onClose={handleClearActiveContextLog}
onAddToQuery={handleAddToQuery}
selectedTab={VIEW_TYPES.CONTEXT}
onGroupByAttribute={onGroupByAttribute}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
<LogDetail
@@ -196,7 +195,7 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onGroupByAttribute={onGroupByAttribute}
handleChangeSelectedView={handleChangeSelectedView}
/>
</>
);

View File

@@ -1,4 +1,5 @@
import { UseTableViewProps } from 'components/Logs/TableView/types';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
export type InfinityTableProps = {
isLoading?: boolean;
@@ -6,4 +7,5 @@ export type InfinityTableProps = {
infitiyTableProps?: {
onEndReached: (index: number) => void;
};
handleChangeSelectedView?: ChangeViewFunctionType;
};

View File

@@ -1,3 +1,4 @@
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import APIError from 'types/api/error';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -12,4 +13,5 @@ export type LogsExplorerListProps = {
error?: Error | APIError;
isFilterApplied: boolean;
isFrequencyChartVisible: boolean;
handleChangeSelectedView?: ChangeViewFunctionType;
};

View File

@@ -48,6 +48,7 @@ function LogsExplorerList({
isError,
error,
isFilterApplied,
handleChangeSelectedView,
}: LogsExplorerListProps): JSX.Element {
const ref = useRef<VirtuosoHandle>(null);
const { activeLogId } = useCopyLogLink();
@@ -56,7 +57,6 @@ function LogsExplorerList({
activeLog,
onClearActiveLog,
onAddToQuery,
onGroupByAttribute,
onSetActiveLog,
} = useActiveLog();
@@ -100,6 +100,7 @@ function LogsExplorerList({
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
);
}
@@ -114,11 +115,13 @@ function LogsExplorerList({
activeLog={activeLog}
fontSize={options.fontSize}
linesPerRow={options.maxLines}
handleChangeSelectedView={handleChangeSelectedView}
/>
);
},
[
activeLog,
handleChangeSelectedView,
onAddToQuery,
onSetActiveLog,
options.fontSize,
@@ -149,10 +152,10 @@ function LogsExplorerList({
activeLogIndex,
}}
infitiyTableProps={{ onEndReached }}
handleChangeSelectedView={handleChangeSelectedView}
/>
);
}
function getMarginTop(): string {
switch (options.fontSize) {
case FontSize.SMALL:
@@ -195,6 +198,7 @@ function LogsExplorerList({
onEndReached,
getItemContent,
selectedFields,
handleChangeSelectedView,
]);
const isTraceToLogsNavigation = useMemo(() => {
@@ -273,8 +277,8 @@ function LogsExplorerList({
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onGroupByAttribute={onGroupByAttribute}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
/>
</>
)}

View File

@@ -447,8 +447,9 @@ function LogsExplorerViewsContainer({
)}
<div className="logs-explorer-views-type-content">
{showLiveLogs && <LiveLogs />}
{showLiveLogs && (
<LiveLogs handleChangeSelectedView={handleChangeSelectedView} />
)}
{selectedPanelType === PANEL_TYPES.LIST && !showLiveLogs && (
<LogsExplorerList
isLoading={isLoading}
@@ -460,9 +461,9 @@ function LogsExplorerViewsContainer({
isError={isError}
error={error as APIError}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<div className="time-series-view-container">
<div className="time-series-view-container-header">
@@ -483,7 +484,6 @@ function LogsExplorerViewsContainer({
/>
</div>
)}
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
<LogsExplorerTable
data={

View File

@@ -89,7 +89,6 @@ function LogsPanelComponent({
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
onGroupByAttribute,
} = useActiveLog();
const handleRow = useCallback(
@@ -171,7 +170,6 @@ function LogsPanelComponent({
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onGroupByAttribute={onGroupByAttribute}
isListViewPanel
listViewPanelSelectedFields={widget?.selectedLogFields}
/>

View File

@@ -38,7 +38,6 @@ function LogsTable(props: LogsTableProps): JSX.Element {
activeLog,
onClearActiveLog,
onAddToQuery,
onGroupByAttribute,
onSetActiveLog,
} = useActiveLog();
@@ -157,7 +156,6 @@ function LogsTable(props: LogsTableProps): JSX.Element {
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onGroupByAttribute={onGroupByAttribute}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
/>

View File

@@ -80,14 +80,14 @@ describe('splitQueryIntoOneChartPerQuery', () => {
expect(result[2].builder.queryData).toHaveLength(2); // 2 disabled queries
expect(result[2].builder.queryData[0].disabled).toBe(true);
expect(result[2].builder.queryData[1].disabled).toBe(true);
expect(result[2].unit).toBeUndefined();
expect(result[2].unit).toBe('');
// 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).toBeUndefined();
expect(result[3].unit).toBe('');
});
});

View File

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

View File

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

View File

@@ -1,31 +1,302 @@
.team-member-container {
.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;
.team-member-role-select {
width: 20%;
> div:first-child {
flex: 0 0 180px;
width: 180px;
}
.ant-select-selector {
border: 1px solid #1d212d;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
> 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;
}
}
.team-member-email-input {
width: 80%;
background-color: #121317;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-select-arrow {
color: var(--levels-l3-foreground, #747b8b) !important;
}
.ant-input,
.ant-input-group-addon {
background-color: #121317 !important;
border-right: 0px;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
&.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 {
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;
}
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;
}
}
&:focus-visible {
outline: 2px solid var(--semantic-primary-background, #4e74f8);
outline-offset: 2px;
}
}
.questions-form-container {
@@ -85,19 +356,112 @@
}
.lightMode {
.team-member-container {
.team-member-role-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
.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-email-input {
background-color: var(--bg-vanilla-100);
&::placeholder {
color: var(--text-neutral-light-200, #80828d) !important;
}
.ant-input,
.ant-input-group-addon {
background-color: var(--bg-vanilla-100) !important;
&: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;
}
}
}
@@ -120,3 +484,21 @@
}
}
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -1,25 +1,29 @@
import './InviteTeamMembers.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Select, Typography } from 'antd';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { Input } from '@signozhq/input';
import { 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,
CheckCircle,
ChevronDown,
CircleAlert,
Loader2,
Plus,
TriangleAlert,
X,
Trash2,
} 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;
@@ -33,7 +37,6 @@ interface InviteTeamMembersProps {
teamMembers: TeamMember[] | null;
setTeamMembers: (teamMembers: TeamMember[]) => void;
onNext: () => void;
onBack: () => void;
}
function InviteTeamMembers({
@@ -41,7 +44,6 @@ function InviteTeamMembers({
teamMembers,
setTeamMembers,
onNext,
onBack,
}: InviteTeamMembersProps): JSX.Element {
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
TeamMember[] | null
@@ -50,11 +52,13 @@ 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: 'EDITOR',
role: '',
name: '',
frontendBaseUrl: window.location.origin,
id: '',
@@ -62,12 +66,12 @@ function InviteTeamMembers({
useEffect(() => {
if (isEmpty(teamMembers)) {
const teamMember = {
const initialTeamMembers = Array.from({ length: 3 }, () => ({
...defaultTeamMember,
id: uuid(),
};
}));
setTeamMembersToInvite([teamMember]);
setTeamMembersToInvite(initialTeamMembers);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [teamMembers]);
@@ -87,19 +91,32 @@ function InviteTeamMembers({
// Validation function to check all users
const validateAllUsers = (): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedValidity: Record<string, boolean> = {};
const updatedEmailValidity: 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;
setHasInvalidEmails(true);
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
if (member.id) {
updatedEmailValidity[member.id] = emailValid;
}
updatedValidity[member.id!] = emailValid;
});
setEmailValidity(updatedValidity);
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
};
@@ -126,10 +143,7 @@ function InviteTeamMembers({
logEvent('Org Onboarding: Invite Team Members Failed', {
teamMembers: teamMembersToInvite,
});
notifications.error({
message: error.getErrorCode(),
description: error.getErrorMessage(),
});
setInviteError(error);
},
},
);
@@ -138,6 +152,8 @@ function InviteTeamMembers({
if (validateAllUsers()) {
setTeamMembers(teamMembersToInvite || []);
setHasInvalidEmails(false);
setHasInvalidRoles(false);
setInviteError(null);
sendInvites({
invites: teamMembersToInvite || [],
});
@@ -146,37 +162,82 @@ function InviteTeamMembers({
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedValidateEmail = useCallback(
debounce((email: string, memberId: string) => {
debounce((email: string, memberId: string, updatedMembers: TeamMember[]) => {
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 = (
e: React.ChangeEvent<HTMLInputElement>,
member: TeamMember,
): void => {
const { value } = e.target;
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const handleEmailChange = useCallback(
(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) {
memberToUpdate.email = value;
setTeamMembersToInvite(updatedMembers);
debouncedValidateEmail(value, member.id!);
}
};
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 handleRoleChange = (role: string, member: TeamMember): void => {
const updatedMembers = cloneDeep(teamMembersToInvite || []);
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
if (memberToUpdate) {
if (memberToUpdate && member.id) {
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,
@@ -185,122 +246,137 @@ function InviteTeamMembers({
onNext();
};
const isButtonDisabled = isSendingInvites || isLoading;
return (
<div className="questions-container">
<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>
<OnboardingQuestionHeader
title="Invite your team"
subtitle="SigNoz is a lot more useful with collaborators on board."
/>
<div className="questions-form-container">
<div className="questions-form invite-team-members-form">
<div className="form-group">
<div className="question-label">
Collaborate with your team
<div className="question-sub-label">
Invite your team to the SigNoz workspace
Invite your team to the SigNoz workspace
</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-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>
<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>
<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>
{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
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>
<div className="next-prev-container">
<Button type="default" className="next-button" onClick={onBack}>
<ArrowLeft size={14} />
Back
</Button>
{(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
type="primary"
className="next-button"
variant="solid"
color="primary"
className={`onboarding-next-button ${isButtonDisabled ? 'disabled' : ''}`}
onClick={handleNext}
loading={isSendingInvites || isLoading}
disabled={isButtonDisabled}
suffixIcon={
isButtonDisabled ? (
<Loader2 className="animate-spin" size={12} />
) : (
<ArrowRight size={12} />
)
}
>
Send Invites
<ArrowRight size={14} />
Complete
</Button>
</div>
<div className="do-later-container">
<Button
type="link"
className="do-later-button"
variant="ghost"
color="secondary"
className="onboarding-do-later-button"
onClick={handleDoLater}
disabled={isSendingInvites}
disabled={isButtonDisabled}
>
{isLoading && <Loader2 className="animate-spin" size={16} />}
<span>I&apos;ll do this later</span>
I&apos;ll do this later
</Button>
</div>
</div>

View File

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

View File

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

View File

@@ -4,36 +4,67 @@
margin: 0 auto;
align-items: center;
flex-direction: column;
height: 100vh;
max-width: 1176px;
.onboarding-questionaire-header {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
height: 56px;
}
justify-content: center;
min-height: 100%;
.onboarding-questionaire-content {
height: calc(100vh - 56px - 60px);
width: 100%;
max-width: 576px;
display: flex;
flex-direction: column;
overflow-y: auto;
align-items: center;
justify-content: center;
gap: 0;
.questions-container {
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%;
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;
}
}
.title {
@@ -54,22 +85,22 @@
}
.questions-form-container {
max-width: 600px;
width: 600px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
}
.questions-form {
width: 100%;
display: flex;
min-height: 420px;
padding: 20px 24px 24px 24px;
padding: 24px;
flex-direction: column;
align-items: center;
align-items: stretch;
gap: 24px;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
background: var(--bg-ink-400);
border: 1px solid var(--semantic-secondary-border, #23262e);
background: var(--semantic-secondary-background, #121317);
.ant-form-item {
margin-bottom: 0px !important;
@@ -86,43 +117,36 @@
.discover-signoz-input {
width: 100%;
height: 100px;
height: 80px;
resize: none;
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;
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;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
padding: 6px 8px;
box-sizing: border-box;
&::placeholder {
color: var(--bg-vanilla-400);
color: var(--levels-l3-foreground, #747b8b);
opacity: 1;
}
&:focus-visible {
outline: none;
border-color: var(--semantic-primary-background, #4e74f8);
}
}
&.invite-team-members-form {
min-height: calc(420px - 24px);
max-height: calc(420px - 24px);
padding-right: 12px;
.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;
}
.form-group {
gap: 24px !important;
}
}
}
@@ -158,30 +182,106 @@
}
.question-label {
color: var(--bg-vanilla-100);
color: var(--levels-l1-foreground, #eceef2);
font-variant-numeric: slashed-zero;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px;
font-weight: 600;
line-height: 100%;
letter-spacing: -0.065px;
}
.question-sub-label {
color: var(--bg-vanilla-400);
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
.next-prev-container {
.onboarding-buttons-container {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 24px;
flex-direction: column;
gap: 8px;
}
.ant-btn {
flex: 1;
.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;
}
}
.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;
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;
}
}
.onboarding-do-later-button {
width: 100%;
height: 32px;
display: flex;
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;
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
@@ -189,15 +289,38 @@
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
gap: 12px;
align-self: stretch;
}
.slider-container {
width: 100%;
width: calc(100% - 16px);
.ant-slider .ant-slider-mark {
font-size: 10px;
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;
}
}
}
}
}
@@ -219,29 +342,57 @@
}
.question {
color: var(--bg-vanilla-100);
font-size: 14px;
font-family: Inter, sans-serif;
color: var(--levels-l1-foreground, #eceef2);
font-variant-numeric: slashed-zero;
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px;
font-weight: 600;
line-height: 100%;
letter-spacing: -0.065px;
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: 12px;
padding: 6px 8px;
border-radius: 2px;
font-size: 14px;
height: 40px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
color: var(--bg-vanilla-100);
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);
}
&:focus-visible {
outline: none;
border-color: var(--semantic-primary-background, #4e74f8);
}
}
@@ -291,34 +442,170 @@
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: 12px;
margin-top: 12px;
gap: 0;
margin-top: 0;
width: 100%;
}
.checkbox-item {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
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(--bg-vanilla-400);
font-size: 14px;
color: var(--levels-l1-foreground, #eceef2);
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
.ant-checkbox {
.ant-checkbox-inner {
border-color: var(--bg-slate-100);
background-color: var(--bg-ink-200);
width: 16px;
height: 16px;
border: 1.5px solid var(--levels-l3-background, #23262e);
border-radius: 2px;
background-color: transparent;
}
&.ant-checkbox-checked .ant-checkbox-inner {
background-color: var(--bg-robin-500);
border-color: var(--bg-robin-500);
background-color: var(--semantic-primary-background, #4e74f8);
border-color: var(--semantic-primary-background, #4e74f8);
}
}
span {
color: var(--levels-l1-foreground, #eceef2) !important;
}
}
}
@@ -359,8 +646,14 @@
.add-another-member-button,
.remove-team-member-button {
font-size: 12px;
height: 32px;
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%;
}
.remove-team-member-button {
@@ -402,26 +695,6 @@
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);
@@ -440,7 +713,7 @@
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 12px;
margin-top: 16px !important;
}
}
@@ -461,25 +734,24 @@
color: var(--bg-slate-300);
}
.title {
color: var(--bg-slate-300) !important;
.onboarding-header-title {
color: var(--levels-l1-foreground, #1a1d26) !important;
}
.sub-title {
color: var(--bg-slate-400) !important;
.onboarding-header-subtitle {
color: var(--semantic-secondary-foreground, #747b8b) !important;
}
.questions-form {
width: 100%;
display: flex;
min-height: 420px;
padding: 20px 24px 24px 24px;
padding: 24px;
flex-direction: column;
align-items: center;
align-items: stretch;
gap: 24px;
border-radius: 4px;
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
border: 1px solid var(--semantic-secondary-border, #e9e9e9);
background: var(--semantic-secondary-background, #ffffff);
.ant-form-item {
margin-bottom: 0px !important;
@@ -495,35 +767,18 @@
}
.discover-signoz-input {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
border: 1px solid var(--levels-l3-border, #e9e9e9);
background: var(--levels-l3-background, #ffffff);
color: var(--levels-l1-foreground, #1a1d26);
font-weight: 400;
&::placeholder {
color: var(--bg-slate-400);
color: var(--levels-l3-foreground, #747b8b);
opacity: 1;
}
}
&.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;
}
&:focus-visible {
border-color: var(--semantic-primary-background, #4e74f8);
}
}
}
@@ -552,36 +807,86 @@
color: var(--bg-slate-300);
}
.question-sub-label {
color: var(--bg-slate-400);
.question {
color: var(--levels-l1-foreground, #1a1d26);
}
.question {
color: var(--bg-slate-300);
.question-slider {
color: var(--levels-l1-foreground, #1a1d26);
}
.checkbox-item {
label {
color: var(--levels-l1-foreground, #1a1d26) !important;
}
.ant-checkbox-wrapper {
color: var(--bg-ink-300);
color: var(--levels-l1-foreground, #1a1d26);
.ant-checkbox {
.ant-checkbox-inner {
border-color: var(--bg-vanilla-300);
background-color: var(--bg-vanilla-100);
border-color: var(--levels-l3-background, #ffffff);
background-color: transparent;
}
&.ant-checkbox-checked .ant-checkbox-inner {
background-color: var(--bg-robin-500);
border-color: var(--bg-robin-500);
background-color: var(--semantic-primary-background, #4e74f8);
border-color: var(--semantic-primary-background, #4e74f8);
}
}
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(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--text-ink-300);
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);
}
}
.radio-button,
@@ -671,6 +976,36 @@
.arrow {
color: var(--bg-slate-300);
}
.opentelemetry-radio-container {
.opentelemetry-radio-group {
.opentelemetry-radio-items-wrapper {
.opentelemetry-radio-item {
color: var(--levels-l1-foreground, #1a1d26);
.ant-radio {
.ant-radio-inner {
border-color: var(--levels-l3-border, #e9e9e9);
}
&.ant-radio-checked .ant-radio-inner {
border-color: var(--semantic-primary-background, #4e74f8);
background-color: var(--semantic-primary-background, #4e74f8);
}
}
}
}
}
}
.onboarding-back-button {
border-color: var(--semantic-secondary-border, #e9e9e9);
color: var(--semantic-secondary-foreground, #747b8b);
&:hover {
border-color: var(--semantic-primary-background, #4e74f8);
}
}
}
}
}

View File

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

View File

@@ -1,12 +1,15 @@
/* eslint-disable sonarjs/cognitive-complexity */
import '../OnboardingQuestionaire.styles.scss';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Typography } from 'antd';
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 logEvent from 'api/common/logEvent';
import editOrg from 'api/organization/editOrg';
import { useNotifications } from 'hooks/useNotifications';
import { ArrowRight, CheckCircle, Loader2 } from 'lucide-react';
import { ArrowRight, Loader2 } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -39,6 +42,7 @@ const observabilityTools = {
GCPNativeO11yTools: 'GCP-native o11y tools',
Honeycomb: 'Honeycomb',
None: 'None/Starting fresh',
Others: 'Others',
};
function OrgQuestions({
@@ -46,7 +50,7 @@ function OrgQuestions({
orgDetails,
onNext,
}: OrgQuestionsProps): JSX.Element {
const { user, updateOrg } = useAppContext();
const { updateOrg } = useAppContext();
const { notifications } = useNotifications();
const { t } = useTranslation(['organizationsettings', 'common']);
@@ -68,11 +72,12 @@ function OrgQuestions({
const [isLoading, setIsLoading] = useState<boolean>(false);
const [usesOtel, setUsesOtel] = useState<boolean | null>(
orgDetails?.usesOtel || null,
);
const [usesOtel, setUsesOtel] = useState<boolean | null>(orgDetails.usesOtel);
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 ||
@@ -81,7 +86,7 @@ function OrgQuestions({
orgDetails.organisationName === organisationName
) {
logEvent('Org Onboarding: Answered', {
usesObservability: !observabilityTool?.includes('None'),
usesObservability,
observabilityTool,
otherTool,
usesOtel,
@@ -89,7 +94,7 @@ function OrgQuestions({
onNext({
organisationName,
usesObservability: !observabilityTool?.includes('None'),
usesObservability,
observabilityTool,
otherTool,
usesOtel,
@@ -112,7 +117,7 @@ function OrgQuestions({
});
logEvent('Org Onboarding: Answered', {
usesObservability: !observabilityTool?.includes('None'),
usesObservability,
observabilityTool,
otherTool,
usesOtel,
@@ -120,7 +125,7 @@ function OrgQuestions({
onNext({
organisationName,
usesObservability: !observabilityTool?.includes('None'),
usesObservability,
observabilityTool,
otherTool,
usesOtel,
@@ -177,31 +182,47 @@ 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">
<Typography.Title level={3} className="title">
{user?.displayName ? `Welcome, ${user.displayName}!` : 'Welcome!'}
</Typography.Title>
<Typography.Paragraph className="sub-title">
We&apos;ll help you get the most out of SigNoz, whether you&apos;re new to
observability or a seasoned pro.
</Typography.Paragraph>
<div className="onboarding-header-section">
<div className="onboarding-header-icon">🎉</div>
<Typography.Title level={4} className="onboarding-header-title">
Welcome to SigNoz Cloud
</Typography.Title>
<Typography.Paragraph className="onboarding-header-subtitle">
Let&apos;s get you started
</Typography.Paragraph>
</div>
<div className="questions-form-container">
<div className="questions-form">
<div className="form-group">
<label className="question" htmlFor="organisationName">
Your Organisation Name
Name of your company
</label>
<input
<Input
type="text"
name="organisationName"
id="organisationName"
placeholder="For eg. Simpsonville..."
placeholder="e.g. Simpsonville"
autoComplete="off"
value={organisationName}
onChange={(e): void => setOrganisationName(e.target.value)}
@@ -212,105 +233,93 @@ function OrgQuestions({
<label className="question" htmlFor="observabilityTool">
Which observability tool do you currently use?
</label>
<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 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>
</div>
<div className="form-group">
<div className="question">Do you already use OpenTelemetry?</div>
<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-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"
>
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 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>
</div>
</div>
</div>
<div className="next-prev-container">
<Button
type="primary"
className={`next-button ${isNextDisabled ? 'disabled' : ''}`}
onClick={handleOnNext}
disabled={isNextDisabled}
>
Next
{isLoading ? (
<Loader2 className="animate-spin" />
<Button
variant="solid"
color="primary"
className={`onboarding-next-button ${isNextDisabled ? 'disabled' : ''}`}
onClick={handleOnNext}
disabled={isNextDisabled}
suffixIcon={
isLoading ? (
<Loader2 className="animate-spin" size={12} />
) : (
<ArrowRight size={14} />
)}
</Button>
</div>
<ArrowRight size={12} />
)
}
>
Next
</Button>
</div>
</div>
);

View File

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

View File

@@ -22,7 +22,6 @@ import {
SignozDetails,
} from './AboutSigNozQuestions/AboutSigNozQuestions';
import InviteTeamMembers from './InviteTeamMembers/InviteTeamMembers';
import { OnboardingHeader } from './OnboardingHeader/OnboardingHeader';
import OptimiseSignozNeeds, {
OptimiseSignozDetails,
} from './OptimiseSignozNeeds/OptimiseSignozNeeds';
@@ -57,7 +56,6 @@ 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';
@@ -207,15 +205,14 @@ 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}
orgDetails={{
...orgDetails,
usesOtel: orgDetails.usesOtel ?? null,
}}
onNext={(orgDetails: OrgDetails): void => {
logEvent(NEXT_BUTTON_EVENT_NAME, {
currentPageID: 1,
@@ -232,13 +229,6 @@ 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,
@@ -255,13 +245,6 @@ 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}
/>
@@ -272,13 +255,6 @@ function OnboardingQuestionaire(): JSX.Element {
isLoading={updatingOrgOnboardingStatus}
teamMembers={teamMembers}
setTeamMembers={setTeamMembers}
onBack={(): void => {
logEvent(BACK_BUTTON_EVENT_NAME, {
currentPageID: 4,
prevPageID: 3,
});
setCurrentStep(3);
}}
onNext={handleOnboardingComplete}
/>
)}

View File

@@ -14,7 +14,6 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
onGroupByAttribute,
} = useActiveLog();
const makeLogDetailsHandler = (log: ILog) => (): void => onSetActiveLog(log);
@@ -49,7 +48,6 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onGroupByAttribute={onGroupByAttribute}
/>
</div>
);

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,24 @@
import { Button, Form, Input, Typography } from 'antd';
import './ResetPassword.styles.scss';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { Form, Input as AntdInput, Typography } from 'antd';
import { Logout } from 'api/utils';
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
import WelcomeLeftContainer from 'components/WelcomeLeftContainer';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
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 { ButtonContainer, FormContainer, FormWrapper } from './styles';
const { Title } = Typography;
import { FormContainer } from './styles';
type FormValues = { password: string; confirmPassword: string };
@@ -23,6 +27,8 @@ 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']);
@@ -42,6 +48,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const handleFormSubmit: () => Promise<void> = async () => {
try {
setLoading(true);
setErrorMessage(null);
const { password } = form.getFieldsValue();
await resetPasswordApi({
@@ -59,10 +66,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
setLoading(false);
} catch (error) {
setLoading(false);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
setErrorMessage(error as APIError);
}
};
@@ -90,6 +94,7 @@ 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 &&
@@ -97,11 +102,38 @@ 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);
}
}
}, 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);
}
}, 100);
};
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();
@@ -113,69 +145,100 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
};
return (
<WelcomeLeftContainer version={version}>
<FormWrapper>
<FormContainer form={form} onFinish={handleSubmit}>
<Title level={4}>Reset Your Password</Title>
<div>
<Label htmlFor="password">Password</Label>
<Form.Item
name="password"
validateTrigger="onBlur"
rules={[{ required: true, message: 'Please enter password!' }]}
>
<Input.Password
tabIndex={0}
onChange={handleValuesChange}
id="password"
data-testid="password"
/>
</Form.Item>
<AuthPageContainer>
<div className="reset-password-card">
<div className="reset-password-header">
<div className="reset-password-header-icon">
<KeyRound size={32} />
</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>
<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>
{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>
)}
<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>
<ButtonContainer>
{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
type="primary"
htmlType="submit"
data-attr="signup"
loading={loading}
variant="solid"
color="primary"
type="submit"
data-attr="reset-password"
disabled={!isValidPassword || loading}
className="reset-password-submit-button"
suffixIcon={<ArrowRight size={16} />}
>
Get Started
Reset Password
</Button>
</ButtonContainer>
</div>
</FormContainer>
</FormWrapper>
</WelcomeLeftContainer>
</div>
</AuthPageContainer>
);
}

View File

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

View File

@@ -1,6 +1,7 @@
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';
@@ -9,6 +10,7 @@ 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';
@@ -71,11 +73,21 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
queryRangeMutation.mutate(queryPayload, {
onSuccess: (data) => {
const updatedQuery = mapQueryDataFromApi(data.data.compositeQuery);
const url = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(JSON.stringify(updatedQuery))}&${
QueryParams.panelTypes
}=${widget.panelTypes}&version=${ENTITY_VERSION_V5}`;
// 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()}`;
window.open(url, '_blank', 'noreferrer');
},

View File

@@ -1,4 +1,5 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { liveLogsCompositeQuery } from 'container/LiveLogs/constants';
import LiveLogsContainer from 'container/LiveLogs/LiveLogsContainer';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -6,7 +7,11 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useEffect } from 'react';
import { DataSource } from 'types/common/queryBuilder';
function LiveLogs(): JSX.Element {
interface LiveLogsProps {
handleChangeSelectedView?: ChangeViewFunctionType;
}
function LiveLogs({ handleChangeSelectedView }: LiveLogsProps): JSX.Element {
useShareBuilderUrl({ defaultValue: liveLogsCompositeQuery });
const { handleSetConfig } = useQueryBuilder();
@@ -14,7 +19,13 @@ function LiveLogs(): JSX.Element {
handleSetConfig(PANEL_TYPES.LIST, DataSource.LOGS);
}, [handleSetConfig]);
return <LiveLogsContainer />;
return (
<LiveLogsContainer handleChangeSelectedView={handleChangeSelectedView} />
);
}
LiveLogs.defaultProps = {
handleChangeSelectedView: undefined,
};
export default LiveLogs;

View File

@@ -1,116 +1,14 @@
.login-page-container {
height: 100vh;
gap: 32px;
.auth-form-card {
width: 576px;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
.brand-container {
@media (max-width: 768px) {
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%
);
}
.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);
}
padding: 0 16px;
}
}

View File

@@ -1,25 +1,15 @@
import './Login.styles.scss';
import AuthPageContainer from 'components/AuthPageContainer';
import LoginContainer from 'container/Login';
function Login(): JSX.Element {
return (
<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>
<AuthPageContainer>
<div className="auth-form-card">
<LoginContainer />
</div>
</div>
</AuthPageContainer>
);
}

View File

@@ -79,21 +79,19 @@ function LogsExplorer(): JSX.Element {
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
handleSetConfig(
defaultTo(explorerViewToPanelType[view], PANEL_TYPES.LIST),
DataSource.LOGS,
const nextPanelType = defaultTo(
explorerViewToPanelType[view],
PANEL_TYPES.LIST,
);
handleSetConfig(nextPanelType, DataSource.LOGS);
setSelectedView(view);
if (view !== ExplorerViews.LIST) {
setShowLiveLogs(false);
}
handleExplorerTabChange(
explorerViewToPanelType[view],
querySearchParameters,
);
handleExplorerTabChange(nextPanelType, querySearchParameters);
},
[handleSetConfig, handleExplorerTabChange, setSelectedView],
);

View File

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

View File

@@ -1,213 +1,247 @@
.signup-page-container {
height: 100vh;
gap: 32px;
z-index: 1;
.signup-card {
width: 576px;
max-width: 100%;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
.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;
.signup-form-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
text-align: center;
padding: 0 24px;
width: 100%;
border-radius: 16px;
padding: 32px;
.signup-header-icon {
width: 32px;
height: 32px;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
}
background: rgb(18 19 23);
.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;
}
z-index: 1;
.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;
}
}
.signup-form {
.signup-form {
width: 100%;
.signup-form-container {
width: 100%;
background: var(--semantic-secondary-background, #121317);
border: 1px solid var(--semantic-secondary-border, #23262e);
border-radius: 4px;
padding: 24px;
.ant-input {
height: 40px;
.signup-form-fields {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
}
.ant-input-affix-wrapper {
height: 40px;
.signup-field-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.ant-input {
height: auto;
.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;
}
}
}
.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;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
color: var(--levels-l1-foreground, #eceef2);
&::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;
}
}
}
.signup-form-header {
.signup-form-header-text {
color: var(--text-vanilla-300);
}
.signup-error-callout,
.signup-info-callout {
margin-top: 24px;
}
.email-container,
.first-name-container,
.org-name-container {
display: flex;
flex-direction: column;
.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;
}
.ant-input {
.signup-info-message {
color: var(--semantic-secondary-foreground);
font-size: 11px;
font-weight: 400;
line-height: 1.45;
margin: 24px 0 0 0;
}
.signup-form-actions {
margin-top: 24px;
display: flex;
width: 100%;
.signup-submit-button {
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;
}
.ant-form-item {
margin-bottom: 0;
}
}
.password-error-container {
margin-top: 8px;
margin-bottom: 16px;
@media (max-width: 768px) {
width: 100%;
padding: 0 16px;
.password-error-message {
color: var(--text-amber-400);
font-size: 12px;
font-weight: 400;
line-height: 16px;
letter-spacing: 0px;
text-align: left;
text-underline-position: from-font;
text-decoration-skip-ink: none;
margin-bottom: 4px;
}
}
.signup-info-message {
color: var(--text-vanilla-300);
font-size: 12px;
font-weight: 400;
line-height: 16px;
letter-spacing: 0px;
}
.signup-button-container {
margin-top: 32px;
display: flex;
align-items: center;
.signup-form-header {
padding: 0;
}
}
}
.lightMode {
.signup-page-container {
.brand-container {
.brand-title {
color: var(--text-ink-500);
}
}
.signup-card {
.signup-form-header {
.signup-form-header-text {
.signup-header-icon {
color: var(--text-ink-500);
}
.signup-header-title {
color: var(--text-ink-500);
}
.signup-header-subtitle {
color: var(--text-neutral-light-200, #80828d);
}
}
.perilin-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.signup-form {
.signup-form-container {
background: var(--bg-base-white, #ffffff);
border: 1px solid var(--bg-vanilla-300, #e9e9e9);
background: radial-gradient(circle, #000000 10%, transparent 0);
background-size: 12px 12px;
opacity: 1;
.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);
}
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%
);
}
&::placeholder {
color: var(--text-neutral-light-200, #80828d);
}
.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);
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
}
}
.password-error-container {
.password-error-message {
color: var(--text-amber-400);
.signup-form-input {
background: var(--bg-vanilla-200, #f5f5f5);
border-color: var(--bg-vanilla-300, #e9e9e9);
color: var(--text-ink-500);
&::placeholder {
color: var(--text-neutral-light-200, #80828d);
}
&:focus {
border-color: var(--semantic-primary-background, #4e74f8);
}
}
}
.signup-info-message {
color: var(--text-ink-500);
color: var(--text-neutral-light-200, #80828d);
}
}
}
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}

View File

@@ -1,15 +1,20 @@
import './SignUp.styles.scss';
import { Button, Form, Input, Typography } from 'antd';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { Input } from '@signozhq/input';
import { Form, Input as AntdInput, 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 { useErrorModal } from 'providers/ErrorModalProvider';
import { useEffect, useState } from 'react';
import { ArrowRight, CircleAlert } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import { SuccessResponseV2 } from 'types/api';
@@ -33,6 +38,7 @@ 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');
@@ -53,13 +59,17 @@ 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);
@@ -97,7 +107,6 @@ function SignUp(): JSX.Element {
]);
const isSignUp = token === null;
const { showErrorModal } = useErrorModal();
const signUp = async (values: FormValues): Promise<void> => {
try {
@@ -117,7 +126,7 @@ function SignUp(): JSX.Element {
await afterLogin(token.data.accessToken, token.data.refreshToken);
} catch (error) {
showErrorModal(error as APIError);
setFormError(error as APIError);
}
};
@@ -136,10 +145,7 @@ function SignUp(): JSX.Element {
await afterLogin(token.data.accessToken, token.data.refreshToken);
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
setFormError(error as APIError);
}
};
@@ -149,6 +155,7 @@ function SignUp(): JSX.Element {
try {
const values = form.getFieldsValue();
setLoading(true);
setFormError(null);
if (isSignUp) {
await signUp(values);
@@ -172,37 +179,57 @@ 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 isValidForm: () => boolean = () => {
const values = form.getFieldsValue();
return (
loading ||
!values.email ||
!values.password ||
!values.confirmPassword ||
confirmPasswordError
);
const handleConfirmPasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (password && confirmPassword) {
const isSamePassword = password === confirmPassword;
setConfirmPasswordError(!isSamePassword);
}
};
return (
<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"
/>
const isValidForm = useMemo(
(): boolean =>
!loading &&
Boolean(email?.trim()) &&
Boolean(password?.trim()) &&
Boolean(confirmPassword?.trim()) &&
!confirmPasswordError,
[loading, email, password, confirmPassword, confirmPasswordError],
);
<div className="brand-title">SigNoz</div>
return (
<AuthPageContainer>
<div className="signup-card">
<div className="signup-form-header">
<div className="signup-header-icon">
<img src="/svgs/tv.svg" alt="TV" width="32" height="32" />
</div>
<Typography.Title level={4} className="signup-header-title">
Create your account
</Typography.Title>
<Typography.Paragraph className="signup-header-subtitle">
You&apos;re almost in. Create a password to start monitoring your
applications with SigNoz.
</Typography.Paragraph>
</div>
<FormContainer
@@ -211,75 +238,100 @@ function SignUp(): JSX.Element {
form={form}
className="signup-form"
>
<div className="signup-form-header">
<Typography.Paragraph className="signup-form-header-text">
You&apos;re almost in. Create a password to start monitoring your
applications with SigNoz.
</Typography.Paragraph>
</div>
<div className="signup-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="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="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="password-container">
<Label htmlFor="currentPassword">Password</Label>
<FormContainer.Item noStyle name="password">
<Input.Password required id="currentPassword" />
</FormContainer.Item>
</div>
<div className="password-container">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<FormContainer.Item noStyle name="confirmPassword">
<Input.Password required id="confirmPassword" />
</FormContainer.Item>
</div>
<div className="password-error-container">
{confirmPasswordError && (
<Typography.Paragraph
id="password-confirm-error"
className="password-error-message"
>
Passwords dont match. Please try again
</Typography.Paragraph>
)}
<div 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>
{isSignUp && (
<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>
<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"
/>
)}
<div className="signup-button-container">
{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">
<Button
type="primary"
htmlType="submit"
variant="solid"
color="primary"
type="submit"
data-attr="signup"
loading={loading}
disabled={isValidForm()}
className="periscope-btn primary next-btn"
block
disabled={!isValidForm}
className="signup-submit-button"
suffixIcon={<ArrowRight size={16} />}
>
Access My Workspace
</Button>
</div>
</FormContainer>
</div>
</div>
</AuthPageContainer>
);
}

View File

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

View File

@@ -10,11 +10,17 @@ export const FormWrapper = styled(Card)`
`;
export const Label = styled.label`
margin-bottom: 11px;
margin-top: 19px;
margin-bottom: 0;
margin-top: 0;
display: inline-block;
font-size: 1rem;
line-height: 24px;
font-size: 13px;
font-weight: 600;
line-height: 1;
color: var(--levels-l1-foreground, #eceef2);
.lightMode & {
color: var(--text-ink-500);
}
`;
export const ButtonContainer = styled.div`

View File

@@ -169,6 +169,10 @@
border: none;
height: 36px;
.ant-select-selection-search-input {
min-width: max-content !important;
max-width: 100% !important;
}
}
.ant-select-selector {

View File

@@ -291,6 +291,10 @@ 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) {

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { render, waitFor } from '@testing-library/react';
import { render, screen, 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,12 +379,9 @@ describe('Dashboard Provider - URL Variables Integration', () => {
// Empty URL variables - tests initialization flow
mockGetUrlVariables.mockReturnValue({});
const { getByTestId } = renderWithDashboardProvider(
`/dashboard/${DASHBOARD_ID}`,
{
dashboardId: DASHBOARD_ID,
},
);
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -415,16 +412,14 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
// Verify dashboard state contains the variables with default values
await waitFor(() => {
const dashboardVariables = getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
const dashboardVariables = await screen.findByTestId('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 () => {
@@ -438,12 +433,9 @@ describe('Dashboard Provider - URL Variables Integration', () => {
.mockReturnValueOnce('development')
.mockReturnValueOnce(['db', 'cache']);
const { getByTestId } = renderWithDashboardProvider(
`/dashboard/${DASHBOARD_ID}`,
{
dashboardId: DASHBOARD_ID,
},
);
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -474,18 +466,16 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
// Verify the dashboard state reflects the normalized URL values
await waitFor(() => {
const dashboardVariables = getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
const dashboardVariables = await screen.findByTestId('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 () => {
@@ -495,12 +485,9 @@ describe('Dashboard Provider - URL Variables Integration', () => {
mockGetUrlVariables.mockReturnValue(urlVariables);
const { getByTestId } = renderWithDashboardProvider(
`/dashboard/${DASHBOARD_ID}`,
{
dashboardId: DASHBOARD_ID,
},
);
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -513,8 +500,8 @@ describe('Dashboard Provider - URL Variables Integration', () => {
);
// Verify that allSelected is set to true for the services variable
await waitFor(() => {
const dashboardVariables = getByTestId('dashboard-variables');
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.services.allSelected).toBe(true);
@@ -563,3 +550,203 @@ 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');
});
});
});
});

View File

@@ -26,18 +26,6 @@ body {
box-sizing: border-box;
}
// Theme transition animations
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease,
box-shadow 0.3s ease;
}
// For components that shouldn't transition (like loading spinners, animations)
.no-transition,
.no-transition * {
transition: none !important;
}
// Respect user's reduced motion preference
@media (prefers-reduced-motion: reduce) {
* {
@@ -178,7 +166,7 @@ body {
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: all 0.2s ease;
transition: transform 0.2s ease;
position: relative;
&:hover {
@@ -292,7 +280,7 @@ body {
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: all 0.2s ease;
transition: transform 0.2s ease;
position: relative;
&:hover {

View File

@@ -344,6 +344,8 @@ 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 };

View File

@@ -37,6 +37,7 @@ 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;

View File

@@ -3135,7 +3135,10 @@ 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, apiErr, nil)
RespondError(w, &model.ApiError{
Typ: model.ErrorBadData,
Err: err,
}, nil)
return
}

View File

@@ -4,14 +4,17 @@ import (
"encoding/json"
"fmt"
"net/url"
"slices"
"sort"
"strings"
"time"
signozError "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"go.uber.org/zap"
)
// this file contains common structs and methods used by
@@ -94,6 +97,30 @@ const (
ValueOutsideBounds CompareOp = "7"
)
func (co CompareOp) Validate() error {
validCompareOps := []CompareOp{
CompareOpNone,
ValueIsAbove,
ValueIsBelow,
ValueIsEq,
ValueIsNotEq,
ValueAboveOrEq,
ValueBelowOrEq,
ValueOutsideBounds,
}
if slices.Contains(validCompareOps, co) {
return nil
}
validCompareOpValues := []string{}
for _, co := range validCompareOps {
validCompareOpValues = append(validCompareOpValues, string(co))
}
return signozError.NewInvalidInputf(signozError.CodeInvalidInput, "invalid compare operator: %s", string(co)).WithAdditional(fmt.Sprintf("valid compare operations are: %s", strings.Join(validCompareOpValues, ", ")))
}
type MatchType string
const (
@@ -105,6 +132,26 @@ const (
Last MatchType = "5"
)
func (mt MatchType) Validate() error {
validMatchTypes := []MatchType{
MatchTypeNone,
AtleastOnce,
AllTheTimes,
OnAverage,
InTotal,
Last,
}
if slices.Contains(validMatchTypes, mt) {
return nil
}
validMatchTypeValues := []string{}
for _, mt := range validMatchTypes {
validMatchTypeValues = append(validMatchTypeValues, string(mt))
}
return signozError.NewInvalidInputf(signozError.CodeInvalidInput, "invalid match type: %s", string(mt)).WithAdditional(fmt.Sprintf("valid match types are: %s", strings.Join(validMatchTypeValues, ", ")))
}
type RuleCondition struct {
CompositeQuery *v3.CompositeQuery `json:"compositeQuery,omitempty"`
CompareOp CompareOp `json:"op,omitempty"`
@@ -121,6 +168,122 @@ type RuleCondition struct {
Thresholds *RuleThresholdData `json:"thresholds,omitempty"`
}
func (rc *RuleCondition) UnmarshalJSON(data []byte) error {
type Alias RuleCondition
aux := (*Alias)(rc)
if err := json.Unmarshal(data, aux); err != nil {
return signozError.NewInvalidInputf(signozError.CodeInvalidInput, "failed to parse rule condition json: %v", err)
}
var errs []error
// Validate CompositeQuery - must be non-nil and pass validation
if rc.CompositeQuery == nil {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "composite query is required"))
}
// Validate AlertOnAbsent + AbsentFor - if AlertOnAbsent is true, AbsentFor must be > 0
if rc.AlertOnAbsent && rc.AbsentFor == 0 {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "absentFor must be greater than 0 when alertOnAbsent is true"))
}
// Validate Seasonality - must be one of the allowed values when provided
if !isValidSeasonality(rc.Seasonality) {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "invalid seasonality: %s, supported values: hourly, daily, weekly", rc.Seasonality))
}
// Only validate CompareOp and MatchType if they are provided
// as in new rule condition, these are not present in rule condition
if rc.CompareOp != "" {
if err := rc.CompareOp.Validate(); err != nil {
errs = append(errs, err)
}
}
if rc.MatchType != "" {
if err := rc.MatchType.Validate(); err != nil {
errs = append(errs, err)
}
}
// Validate SelectedQueryName - must match one of the query names from CompositeQuery
if rc.SelectedQuery != "" && rc.CompositeQuery != nil {
queryNames := getAllQueryNames(rc.CompositeQuery)
if _, exists := queryNames[rc.SelectedQuery]; !exists {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "selected query name '%s' does not match any query in composite query", rc.SelectedQuery))
}
}
// Validate RequireMinPoints + RequiredNumPoints - if RequireMinPoints is true, RequiredNumPoints must be > 0
if rc.RequireMinPoints && rc.RequiredNumPoints <= 0 {
errs = append(errs, signozError.NewInvalidInputf(signozError.CodeInvalidInput, "requiredNumPoints must be greater than 0 when requireMinPoints is true"))
}
if len(errs) > 0 {
// return signozError.Join(errs...)
zap.L().Warn("expected validation errors in rule condition", zap.Errors("errors", errs))
return nil
}
return nil
}
// getAllQueryNames extracts all query names from CompositeQuery across all query types
// Returns a map of query names for quick lookup
func getAllQueryNames(compositeQuery *v3.CompositeQuery) map[string]struct{} {
queryNames := make(map[string]struct{})
// Extract names from Queries (v5 envelopes)
if compositeQuery != nil && compositeQuery.Queries != nil {
for _, query := range compositeQuery.Queries {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.QueryBuilderFormula:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.QueryBuilderTraceOperator:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.PromQuery:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
case qbtypes.ClickHouseQuery:
if spec.Name != "" {
queryNames[spec.Name] = struct{}{}
}
}
}
}
return queryNames
}
// isValidSeasonality validates that Seasonality is one of the allowed values
func isValidSeasonality(seasonality string) bool {
if seasonality == "" {
return true // empty seasonality is allowed (optional field)
}
switch seasonality {
case "hourly", "daily", "weekly":
return true
default:
return false
}
}
func (rc *RuleCondition) GetSelectedQueryName() string {
if rc != nil {
if rc.SelectedQuery != "" {

View File

@@ -122,6 +122,124 @@ 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."""
@@ -189,6 +307,119 @@ 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(
@@ -200,6 +431,7 @@ 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:
@@ -247,15 +479,93 @@ 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
# 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"
)
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"
)
@pytest.fixture(name="remove_metrics_ttl_and_storage_settings", scope="function")
@@ -272,15 +582,18 @@ 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 '{signoz.telemetrystore.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' REMOVE TTL"
f"ALTER TABLE signoz_metrics.{table} ON CLUSTER '{cluster}' REMOVE TTL"
)
signoz.telemetrystore.conn.query(
f"ALTER TABLE signoz_metrics.{table} ON CLUSTER '{signoz.telemetrystore.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' RESET SETTING storage_policy;"
f"ALTER TABLE signoz_metrics.{table} ON CLUSTER '{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}")

View File

@@ -0,0 +1,331 @@
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,
)

View File

@@ -28,15 +28,14 @@ def test_create_and_delete_dashboard_without_license(
dashboard_id = data["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v1/dashboards/{dashboard_id}"
),
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -49,7 +48,6 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
def test_create_and_delete_dashboard_with_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -70,11 +68,9 @@ def test_create_and_delete_dashboard_with_license(
dashboard_id = data["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v1/dashboards/{dashboard_id}"
),
signoz.self.host_configs["8080"].get(f"/api/v1/dashboards/{dashboard_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
assert response.status_code == HTTPStatus.NO_CONTENT

View File

@@ -7,7 +7,7 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from src.querier.timeseries_utils import (
from fixtures.querier import (
assert_minutely_bucket_values,
find_named_result,
index_series_by_label,

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