mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-14 17:12:15 +00:00
Compare commits
21 Commits
feat/pnpm
...
service-ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881ac92165 | ||
|
|
0201e8055b | ||
|
|
b0b642d6be | ||
|
|
3ceedbb050 | ||
|
|
38230e820d | ||
|
|
222ccea1d6 | ||
|
|
f0e459da0e | ||
|
|
197a7518bb | ||
|
|
c00c4248ff | ||
|
|
35513b96ee | ||
|
|
65ef147e81 | ||
|
|
c16a622306 | ||
|
|
f728d9217f | ||
|
|
5b8d5fbfd3 | ||
|
|
0271be11e6 | ||
|
|
92d220c4d9 | ||
|
|
0ed8169bad | ||
|
|
ed553fb02e | ||
|
|
47daba3c17 | ||
|
|
2b3310809a | ||
|
|
542a648cc3 |
14
.github/workflows/goci.yaml
vendored
14
.github/workflows/goci.yaml
vendored
@@ -77,10 +77,6 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: setup-pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: docker-community
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -110,13 +106,9 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: setup-pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: install-frontend
|
||||
run: cd frontend && pnpm install
|
||||
run: cd frontend && yarn install
|
||||
- name: generate-api-clients
|
||||
run: |
|
||||
cd frontend && pnpm generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
|
||||
cd frontend && yarn generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run yarn generate:api in frontend/ locally and commit."; exit 1)
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -25,10 +25,6 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: setup-pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: build-frontend
|
||||
run: make js-build
|
||||
- name: upload-frontend-artifact
|
||||
|
||||
4
.github/workflows/gor-signoz.yaml
vendored
4
.github/workflows/gor-signoz.yaml
vendored
@@ -41,10 +41,6 @@ jobs:
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: setup-pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: build-frontend
|
||||
run: make js-build
|
||||
- name: upload-frontend-artifact
|
||||
|
||||
11
.github/workflows/jsci.yaml
vendored
11
.github/workflows/jsci.yaml
vendored
@@ -78,15 +78,10 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
pnpm install
|
||||
yarn install
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
@@ -103,7 +98,7 @@ jobs:
|
||||
- name: Generate permissions.type.ts
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
pnpm generate:permissions-type
|
||||
yarn generate:permissions-type
|
||||
|
||||
- name: Teardown test environment
|
||||
if: always()
|
||||
@@ -113,6 +108,6 @@ jobs:
|
||||
- name: Check for changes
|
||||
run: |
|
||||
if ! git diff --exit-code frontend/src/hooks/useAuthZ/permissions.type.ts; then
|
||||
echo "::error::frontend/src/hooks/useAuthZ/permissions.type.ts is out of date. Please run the generator locally and commit the changes: pnpm generate:permissions-type (from the frontend directory)"
|
||||
echo "::error::frontend/src/hooks/useAuthZ/permissions.type.ts is out of date. Please run the generator locally and commit the changes: npm run generate:permissions-type (from the frontend directory)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
12
.github/workflows/run-e2e.yaml
vendored
12
.github/workflows/run-e2e.yaml
vendored
@@ -27,11 +27,6 @@ jobs:
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Mask secrets and input
|
||||
run: |
|
||||
echo "::add-mask::${{ secrets.BASE_URL }}"
|
||||
@@ -42,11 +37,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: |
|
||||
pnpm install
|
||||
npm install -g yarn
|
||||
yarn
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: frontend
|
||||
run: pnpm playwright install --with-deps
|
||||
run: yarn playwright install --with-deps
|
||||
|
||||
- name: Run Playwright Tests
|
||||
working-directory: frontend
|
||||
@@ -55,7 +51,7 @@ jobs:
|
||||
LOGIN_USERNAME="${{ secrets.LOGIN_USERNAME }}" \
|
||||
LOGIN_PASSWORD="${{ secrets.LOGIN_PASSWORD }}" \
|
||||
USER_ROLE="${{ github.event.inputs.userRole }}" \
|
||||
pnpm playwright test
|
||||
yarn playwright test
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
10
.gitpod.yml
10
.gitpod.yml
@@ -1,21 +1,19 @@
|
||||
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
|
||||
# and commit this file to your remote git repository to share the goodness with others.
|
||||
|
||||
|
||||
tasks:
|
||||
- name: Run Docker Images
|
||||
init: |
|
||||
cd ./deploy/docker
|
||||
sudo docker compose up -d
|
||||
|
||||
- name: Install pnpm
|
||||
init: |
|
||||
npm i -g pnpm
|
||||
|
||||
- name: Run Frontend
|
||||
init: |
|
||||
cd ./frontend
|
||||
pnpm install
|
||||
command: pnpm dev
|
||||
yarn install
|
||||
command:
|
||||
yarn dev
|
||||
|
||||
ports:
|
||||
- port: 8080
|
||||
|
||||
2
Makefile
2
Makefile
@@ -154,7 +154,7 @@ $(GO_BUILD_ARCHS_ENTERPRISE_RACE): go-build-enterprise-race-%: $(TARGET_DIR)
|
||||
.PHONY: js-build
|
||||
js-build: ## Builds the js frontend
|
||||
@echo ">> building js frontend"
|
||||
@cd $(JS_BUILD_CONTEXT) && CI=1 pnpm install && pnpm build
|
||||
@cd $(JS_BUILD_CONTEXT) && CI=1 yarn install && yarn build
|
||||
|
||||
##############################################################
|
||||
# docker commands
|
||||
|
||||
@@ -3,9 +3,8 @@ FROM node:22-bookworm AS build
|
||||
WORKDIR /opt/
|
||||
COPY ./frontend/ ./
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
RUN CI=1 npm i -g pnpm
|
||||
RUN CI=1 pnpm install
|
||||
RUN CI=1 pnpm build
|
||||
RUN CI=1 yarn install
|
||||
RUN CI=1 yarn build
|
||||
|
||||
FROM golang:1.25-bookworm
|
||||
|
||||
|
||||
@@ -1768,19 +1768,19 @@ components:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
last_used:
|
||||
lastObservedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
service_account_id:
|
||||
serviceAccountId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
@@ -1788,9 +1788,9 @@ components:
|
||||
required:
|
||||
- id
|
||||
- key
|
||||
- expires_at
|
||||
- last_used
|
||||
- service_account_id
|
||||
- expiresAt
|
||||
- lastObservedAt
|
||||
- serviceAccountId
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKeyWithKey:
|
||||
properties:
|
||||
@@ -1804,14 +1804,14 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccount:
|
||||
properties:
|
||||
@@ -1833,13 +1833,16 @@ components:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
deletedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgID:
|
||||
orgId:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
@@ -1856,18 +1859,19 @@ components:
|
||||
- email
|
||||
- roles
|
||||
- status
|
||||
- orgID
|
||||
- orgId
|
||||
- deletedAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableFactorAPIKey:
|
||||
properties:
|
||||
expires_at:
|
||||
expiresAt:
|
||||
minimum: 0
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- expires_at
|
||||
- expiresAt
|
||||
type: object
|
||||
ServiceaccounttypesUpdatableServiceAccount:
|
||||
properties:
|
||||
|
||||
@@ -13,12 +13,13 @@ Before diving in, make sure you have these tools installed:
|
||||
- Download from [go.dev/dl](https://go.dev/dl/)
|
||||
- Check [go.mod](../../go.mod#L3) for the minimum version
|
||||
|
||||
|
||||
- **Node** - Powers our frontend
|
||||
- Download from [nodejs.org](https://nodejs.org)
|
||||
- Check [.nvmrc](../../frontend/.nvmrc) for the version
|
||||
|
||||
- **Pnpm** - Our frontend package manager
|
||||
- Follow the [installation guide](https://pnpm.io/installation)
|
||||
- **Yarn** - Our frontend package manager
|
||||
- Follow the [installation guide](https://yarnpkg.com/getting-started/install)
|
||||
|
||||
- **Docker** - For running Clickhouse and Postgres locally
|
||||
- Get it from [docs.docker.com/get-docker](https://docs.docker.com/get-docker/)
|
||||
@@ -94,7 +95,7 @@ This command:
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
yarn install
|
||||
```
|
||||
|
||||
3. Create a `.env` file in this directory:
|
||||
@@ -104,10 +105,10 @@ This command:
|
||||
|
||||
4. Start the development server:
|
||||
```bash
|
||||
pnpm dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
> 💡 **Tip**: `pnpm dev` will automatically rebuild when you make changes to the code
|
||||
> 💡 **Tip**: `yarn dev` will automatically rebuild when you make changes to the code
|
||||
|
||||
Now you're all set to start developing! Happy coding! 🎉
|
||||
|
||||
|
||||
@@ -18,40 +18,40 @@ The configuration file is a JSON array containing data source objects. Each obje
|
||||
|
||||
### Required Keys
|
||||
|
||||
| Key | Type | Description |
|
||||
| ------------ | ---------- | --------------------------------------------------------------------- |
|
||||
| `dataSource` | `string` | Unique identifier for the data source (kebab-case, e.g., `"aws-ec2"`) |
|
||||
| `label` | `string` | Display name shown to users (e.g., `"AWS EC2"`) |
|
||||
| `tags` | `string[]` | Array of category tags for grouping (e.g., `["AWS"]`, `["database"]`) |
|
||||
| `module` | `string` | Destination module after onboarding completion |
|
||||
| `imgUrl` | `string` | Path to the logo/icon **(SVG required)** (e.g., `"/Logos/ec2.svg"`) |
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `dataSource` | `string` | Unique identifier for the data source (kebab-case, e.g., `"aws-ec2"`) |
|
||||
| `label` | `string` | Display name shown to users (e.g., `"AWS EC2"`) |
|
||||
| `tags` | `string[]` | Array of category tags for grouping (e.g., `["AWS"]`, `["database"]`) |
|
||||
| `module` | `string` | Destination module after onboarding completion |
|
||||
| `imgUrl` | `string` | Path to the logo/icon **(SVG required)** (e.g., `"/Logos/ec2.svg"`) |
|
||||
|
||||
### Optional Keys
|
||||
|
||||
| Key | Type | Description |
|
||||
| ----------------------- | ---------- | -------------------------------------------------------------- |
|
||||
| `link` | `string` | Docs link to redirect to (e.g., `"/docs/aws-monitoring/ec2/"`) |
|
||||
| `relatedSearchKeywords` | `string[]` | Array of keywords for search functionality |
|
||||
| `question` | `object` | Nested question object for multi-step flows |
|
||||
| `internalRedirect` | `boolean` | When `true`, navigates within the app instead of showing docs |
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `link` | `string` | Docs link to redirect to (e.g., `"/docs/aws-monitoring/ec2/"`) |
|
||||
| `relatedSearchKeywords` | `string[]` | Array of keywords for search functionality |
|
||||
| `question` | `object` | Nested question object for multi-step flows |
|
||||
| `internalRedirect` | `boolean` | When `true`, navigates within the app instead of showing docs |
|
||||
|
||||
## Module Values
|
||||
|
||||
The `module` key determines where users are redirected after completing onboarding:
|
||||
|
||||
| Value | Destination |
|
||||
| ------------------------- | -------------------------------------- |
|
||||
| `apm` | APM / Traces |
|
||||
| `logs` | Logs Explorer |
|
||||
| `metrics` | Metrics Explorer |
|
||||
| `dashboards` | Dashboards |
|
||||
| `infra-monitoring-hosts` | Infrastructure Monitoring - Hosts |
|
||||
| `infra-monitoring-k8s` | Infrastructure Monitoring - Kubernetes |
|
||||
| `messaging-queues-kafka` | Messaging Queues - Kafka |
|
||||
| `messaging-queues-celery` | Messaging Queues - Celery |
|
||||
| `integrations` | Integrations page |
|
||||
| `home` | Home page |
|
||||
| `api-monitoring` | API Monitoring |
|
||||
| Value | Destination |
|
||||
|-------|-------------|
|
||||
| `apm` | APM / Traces |
|
||||
| `logs` | Logs Explorer |
|
||||
| `metrics` | Metrics Explorer |
|
||||
| `dashboards` | Dashboards |
|
||||
| `infra-monitoring-hosts` | Infrastructure Monitoring - Hosts |
|
||||
| `infra-monitoring-k8s` | Infrastructure Monitoring - Kubernetes |
|
||||
| `messaging-queues-kafka` | Messaging Queues - Kafka |
|
||||
| `messaging-queues-celery` | Messaging Queues - Celery |
|
||||
| `integrations` | Integrations page |
|
||||
| `home` | Home page |
|
||||
| `api-monitoring` | API Monitoring |
|
||||
|
||||
## Question Object Structure
|
||||
|
||||
@@ -91,14 +91,14 @@ The `question` object enables multi-step selection flows:
|
||||
|
||||
### Question Keys
|
||||
|
||||
| Key | Type | Description |
|
||||
| -------------- | -------- | ----------------------------------------------------------- |
|
||||
| `desc` | `string` | Question text displayed to the user |
|
||||
| `type` | `string` | Currently only `"select"` is supported |
|
||||
| `helpText` | `string` | (Optional) Additional help text below the question |
|
||||
| `helpLink` | `string` | (Optional) Docs link for the help section |
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `desc` | `string` | Question text displayed to the user |
|
||||
| `type` | `string` | Currently only `"select"` is supported |
|
||||
| `helpText` | `string` | (Optional) Additional help text below the question |
|
||||
| `helpLink` | `string` | (Optional) Docs link for the help section |
|
||||
| `helpLinkText` | `string` | (Optional) Text for the help link (default: "Learn more →") |
|
||||
| `options` | `array` | Array of option objects |
|
||||
| `options` | `array` | Array of option objects |
|
||||
|
||||
## Option Object Structure
|
||||
|
||||
@@ -291,7 +291,7 @@ Options can be simple (direct link) or nested (with another question):
|
||||
|
||||
1. Add your data source object to the JSON array
|
||||
2. Ensure the logo exists in `public/Logos/`
|
||||
3. Test the flow locally with `pnpm dev`
|
||||
3. Test the flow locally with `yarn dev`
|
||||
4. Validation:
|
||||
- Navigate to the [onboarding page](http://localhost:3301/get-started-with-signoz-cloud) on your local machine
|
||||
- Data source appears in the list
|
||||
|
||||
@@ -2,39 +2,45 @@ module base
|
||||
|
||||
type organisation
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
|
||||
type user
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
type serviceaccount
|
||||
relations
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
type anonymous
|
||||
|
||||
type role
|
||||
relations
|
||||
define assignee: [user, anonymous]
|
||||
define assignee: [user, serviceaccount, anonymous]
|
||||
|
||||
define read: [user, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
type metaresources
|
||||
relations
|
||||
define create: [user, role#assignee]
|
||||
define list: [user, role#assignee]
|
||||
define create: [user, serviceaccount, role#assignee]
|
||||
define list: [user, serviceaccount, role#assignee]
|
||||
|
||||
type metaresource
|
||||
relations
|
||||
define read: [user, anonymous, role#assignee]
|
||||
define update: [user, role#assignee]
|
||||
define delete: [user, role#assignee]
|
||||
define read: [user, serviceaccount, anonymous, role#assignee]
|
||||
define update: [user, serviceaccount, role#assignee]
|
||||
define delete: [user, serviceaccount, role#assignee]
|
||||
|
||||
define block: [user, role#assignee]
|
||||
define block: [user, serviceaccount, role#assignee]
|
||||
|
||||
|
||||
type telemetryresource
|
||||
relations
|
||||
define read: [user, role#assignee]
|
||||
define read: [user, serviceaccount, role#assignee]
|
||||
@@ -4,5 +4,4 @@ build
|
||||
i18-generate-hash.js
|
||||
src/parser/TraceOperatorParser/**
|
||||
|
||||
orval.config.ts
|
||||
pnpm-lock.yaml
|
||||
orval.config.ts
|
||||
@@ -41,7 +41,7 @@ module.exports = {
|
||||
'jsx-a11y', // Accessibility rules
|
||||
'import', // Import/export linting
|
||||
'sonarjs', // Code quality/complexity
|
||||
// TODO: Uncomment after running: pnpm add -D eslint-plugin-spellcheck
|
||||
// TODO: Uncomment after running: yarn add -D eslint-plugin-spellcheck
|
||||
// 'spellcheck', // Correct spellings
|
||||
],
|
||||
settings: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd frontend && pnpm run commitlint --edit $1
|
||||
cd frontend && yarn run commitlint --edit $1
|
||||
|
||||
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
cd frontend && pnpm lint-staged
|
||||
cd frontend && yarn lint-staged
|
||||
|
||||
@@ -12,6 +12,4 @@ public/
|
||||
# Ignore all files in parser folder:
|
||||
src/parser/**
|
||||
|
||||
src/TraceOperator/parser/**
|
||||
|
||||
pnpm-lock.yaml
|
||||
src/TraceOperator/parser/**
|
||||
@@ -28,8 +28,8 @@ Follow the steps below
|
||||
1. ```git clone https://github.com/SigNoz/signoz.git && cd signoz/frontend```
|
||||
1. change baseURL to ```<test environment URL>``` in file ```src/constants/env.ts```
|
||||
|
||||
1. ```pnpm install```
|
||||
1. ```pnpm dev```
|
||||
1. ```yarn install```
|
||||
1. ```yarn dev```
|
||||
|
||||
```Note: Please ping us in #contributing channel in our slack community and we will DM you with <test environment URL>```
|
||||
|
||||
@@ -41,7 +41,7 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `pnpm start`
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3301](http://localhost:3301) to view it in the browser.
|
||||
@@ -49,12 +49,12 @@ Open [http://localhost:3301](http://localhost:3301) to view it in the browser.
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `pnpm test`
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `pnpm build`
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
@@ -64,7 +64,7 @@ Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `pnpm eject`
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
@@ -100,6 +100,6 @@ This section has moved here: [https://facebook.github.io/create-react-app/docs/a
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `pnpm build` fails to minify
|
||||
### `yarn build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* Adds custom matchers from the react testing library to all tests
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import 'jest-styled-components';
|
||||
|
||||
import { server } from './src/mocks-server/server';
|
||||
|
||||
@@ -80,7 +80,7 @@ export default defineConfig({
|
||||
header: (info: { title: string; version: string }): string[] => [
|
||||
`! Do not edit manually`,
|
||||
`* The file has been auto-generated using Orval for SigNoz`,
|
||||
`* regenerate with 'pnpm generate:api'`,
|
||||
`* regenerate with 'yarn generate:api'`,
|
||||
...(info.title ? [info.title] : []),
|
||||
...(info.version ? [`OpenAPI spec version: ${info.version}`] : []),
|
||||
],
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"@ant-design/icons": "4.8.0",
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/lang-javascript": "6.2.3",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@dnd-kit/core": "6.1.0",
|
||||
"@dnd-kit/modifiers": "7.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
@@ -41,7 +40,7 @@
|
||||
"@grafana/data": "^11.2.3",
|
||||
"@mdx-js/loader": "2.3.0",
|
||||
"@mdx-js/react": "2.3.0",
|
||||
"@monaco-editor/react": "~4.3.1",
|
||||
"@monaco-editor/react": "^4.3.1",
|
||||
"@playwright/test": "1.55.1",
|
||||
"@radix-ui/react-tabs": "1.0.4",
|
||||
"@radix-ui/react-tooltip": "1.0.7",
|
||||
@@ -98,7 +97,7 @@
|
||||
"color-alpha": "2.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"crypto-js": "4.2.0",
|
||||
"d3-hierarchy": "1.1.9",
|
||||
"d3-hierarchy": "3.1.2",
|
||||
"dayjs": "^1.10.7",
|
||||
"dompurify": "3.2.4",
|
||||
"dotenv": "8.2.0",
|
||||
@@ -123,7 +122,6 @@
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"papaparse": "5.4.1",
|
||||
"posthog-js": "1.298.0",
|
||||
"rc-select": "~14.10.0",
|
||||
"rc-tween-one": "3.0.6",
|
||||
"react": "18.2.0",
|
||||
"react-addons-update": "15.6.3",
|
||||
@@ -184,22 +182,18 @@
|
||||
"@babel/preset-env": "^7.22.14",
|
||||
"@babel/preset-react": "^7.12.13",
|
||||
"@babel/preset-typescript": "^7.21.4",
|
||||
"@codemirror/view": "~6.5.1",
|
||||
"@commitlint/cli": "^20.4.2",
|
||||
"@commitlint/config-conventional": "^20.4.2",
|
||||
"@faker-js/faker": "9.3.0",
|
||||
"@jest/globals": "30.2.0",
|
||||
"@jest/types": "^30.3.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
"@testing-library/user-event": "14.4.3",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
"@types/event-source-polyfill": "^1.0.0",
|
||||
"@types/fontfaceobserver": "2.1.0",
|
||||
"@types/history": "^4.7.11",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/mini-css-extract-plugin": "^2.5.1",
|
||||
@@ -218,7 +212,6 @@
|
||||
"@types/react-syntax-highlighter": "15.5.13",
|
||||
"@types/redux-mock-store": "1.0.4",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
"@typescript-eslint/parser": "^4.33.0",
|
||||
@@ -234,7 +227,6 @@
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"eslint-plugin-sonarjs": "^0.12.0",
|
||||
"glob": "^13.0.6",
|
||||
"husky": "^7.0.4",
|
||||
"imagemin": "^8.0.1",
|
||||
"imagemin-svgo": "^10.0.1",
|
||||
@@ -256,7 +248,6 @@
|
||||
"sass": "1.97.3",
|
||||
"sharp": "0.34.5",
|
||||
"svgo": "4.0.0",
|
||||
"tailwindcss": "3",
|
||||
"ts-api-utils": "2.4.0",
|
||||
"ts-jest": "29.4.6",
|
||||
"ts-node": "^10.2.1",
|
||||
@@ -290,32 +281,6 @@
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"@codemirror/view": "~6.5.1",
|
||||
"@types/d3-hierarchy": "1.1.11"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "18.0.26",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"debug": "4.3.4",
|
||||
"semver": "7.5.4",
|
||||
"xml2js": "0.5.0",
|
||||
"phin": "^3.7.1",
|
||||
"body-parser": "1.20.3",
|
||||
"http-proxy-middleware": "3.0.5",
|
||||
"cross-spawn": "7.0.5",
|
||||
"cookie": "^0.7.1",
|
||||
"serialize-javascript": "6.0.2",
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.4",
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"@codemirror/view": "~6.5.1",
|
||||
"@types/d3-hierarchy": "1.1.11"
|
||||
}
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Read from ".env" file.
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(dirname, '.env') });
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
|
||||
23271
frontend/pnpm-lock.yaml
generated
23271
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -15,5 +15,6 @@
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members"
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,8 @@
|
||||
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
|
||||
"METER_EXPLORER": "SigNoz | Meter Explorer",
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter"
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members"
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
}
|
||||
|
||||
@@ -75,5 +75,6 @@
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members"
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ async function main() {
|
||||
PERMISSIONS_TYPE_FILE,
|
||||
);
|
||||
log('Linting generated file...');
|
||||
execSync(`cd frontend && pnpm eslint --fix ${relativePath}`, {
|
||||
execSync(`cd frontend && yarn eslint --fix ${relativePath}`, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ echo "\n✅ Prettier formatting successful"
|
||||
|
||||
# Fix linting issues
|
||||
echo "\n\n---\nRunning eslint...\n"
|
||||
if ! pnpm lint --fix --quiet src/api/generated; then
|
||||
if ! yarn lint --fix --quiet src/api/generated; then
|
||||
echo "ESLint check failed! Please fix linting errors before proceeding."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -128,6 +128,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
isAdmin &&
|
||||
(path === ROUTES.SETTINGS ||
|
||||
path === ROUTES.ORG_SETTINGS ||
|
||||
path === ROUTES.MEMBERS_SETTINGS ||
|
||||
path === ROUTES.BILLING ||
|
||||
path === ROUTES.MY_SETTINGS);
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import posthog from 'posthog-js';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
@@ -321,6 +320,19 @@ function App(): JSX.Element {
|
||||
// Session Replay
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
beforeSend(event) {
|
||||
const sessionReplayUrl = posthog.get_session_replay_url?.({
|
||||
withTimestamp: true,
|
||||
});
|
||||
if (sessionReplayUrl) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.contexts = {
|
||||
...event.contexts,
|
||||
posthog: { session_replay_url: sessionReplayUrl },
|
||||
};
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
setIsSentryInitialized(true);
|
||||
@@ -371,28 +383,26 @@ function App(): JSX.Element {
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</QueryBuilderProvider>
|
||||
</ResourceProvider>
|
||||
</PrivateRoute>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
export interface AuthtypesAttributeMappingDTO {
|
||||
@@ -2100,7 +2100,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2113,7 +2113,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
last_used: Date;
|
||||
lastObservedAt: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2121,7 +2121,7 @@ export interface ServiceaccounttypesFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
service_account_id: string;
|
||||
serviceAccountId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -2145,7 +2145,7 @@ export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2173,6 +2173,11 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
deletedAt: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2188,7 +2193,7 @@ export interface ServiceaccounttypesServiceAccountDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgID: string;
|
||||
orgId: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -2209,7 +2214,7 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
expires_at: number;
|
||||
expiresAt: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
.announcement-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
height: 40px;
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
font-size: var(--label-base-500-font-size);
|
||||
line-height: var(--label-base-500-line-height);
|
||||
font-weight: var(--label-base-500-font-weight);
|
||||
letter-spacing: -0.065px;
|
||||
|
||||
&--warning {
|
||||
background-color: var(--callout-warning-background);
|
||||
color: var(--callout-warning-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-warning-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
background-color: var(--callout-primary-background);
|
||||
color: var(--callout-primary-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-primary-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: var(--callout-error-background);
|
||||
color: var(--callout-error-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-error-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
background-color: var(--callout-success-background);
|
||||
color: var(--callout-success-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-success-border);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: var(--line-height-normal);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
height: 24px;
|
||||
padding: 0 var(--padding-2);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
font-size: var(--label-small-500-font-size);
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
font-weight: var(--label-small-500-font-weight);
|
||||
color: currentColor;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__dismiss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: currentColor;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AnnouncementBanner, {
|
||||
AnnouncementBannerProps,
|
||||
} from './AnnouncementBanner';
|
||||
|
||||
const STORAGE_KEY = 'test-banner-dismissed';
|
||||
|
||||
function renderBanner(props: Partial<AnnouncementBannerProps> = {}): void {
|
||||
render(<AnnouncementBanner message="Test message" {...props} />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('AnnouncementBanner', () => {
|
||||
it('renders message and default warning variant', () => {
|
||||
renderBanner({ message: <strong>Heads up</strong> });
|
||||
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass('announcement-banner--warning');
|
||||
expect(alert).toHaveTextContent('Heads up');
|
||||
});
|
||||
|
||||
it.each(['warning', 'info', 'success', 'error'] as const)(
|
||||
'renders %s variant correctly',
|
||||
(type) => {
|
||||
renderBanner({ type, message: 'Test message' });
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass(`announcement-banner--${type}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('dismisses on click, calls onDismiss, and persists to localStorage', async () => {
|
||||
const onDismiss = jest.fn() as jest.MockedFunction<() => void>;
|
||||
renderBanner({ storageKey: STORAGE_KEY, onDismiss });
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
|
||||
});
|
||||
|
||||
it('does not render when storageKey is already set in localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
renderBanner({ storageKey: STORAGE_KEY });
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls action onClick when action button is clicked', async () => {
|
||||
const onClick = jest.fn() as jest.MockedFunction<() => void>;
|
||||
renderBanner({ action: { label: 'Go to Settings', onClick } });
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /go to settings/i }));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides dismiss button when dismissible is false and hides icon when icon is null', () => {
|
||||
renderBanner({ dismissible: false, icon: null });
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /dismiss/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleCheckBig,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import './AnnouncementBanner.styles.scss';
|
||||
|
||||
export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success';
|
||||
|
||||
export interface AnnouncementBannerAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface AnnouncementBannerProps {
|
||||
message: ReactNode;
|
||||
type?: AnnouncementBannerType;
|
||||
icon?: ReactNode | null;
|
||||
action?: AnnouncementBannerAction;
|
||||
dismissible?: boolean;
|
||||
storageKey?: string;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ICONS: Record<AnnouncementBannerType, ReactNode> = {
|
||||
warning: <TriangleAlert size={14} />,
|
||||
info: <Info size={14} />,
|
||||
error: <CircleAlert size={14} />,
|
||||
success: <CircleCheckBig size={14} />,
|
||||
};
|
||||
|
||||
function isDismissed(storageKey?: string): boolean {
|
||||
if (!storageKey) {
|
||||
return false;
|
||||
}
|
||||
return localStorage.getItem(storageKey) === 'true';
|
||||
}
|
||||
|
||||
export default function AnnouncementBanner({
|
||||
message,
|
||||
type = 'warning',
|
||||
icon,
|
||||
action,
|
||||
dismissible = true,
|
||||
storageKey,
|
||||
onDismiss,
|
||||
className,
|
||||
}: AnnouncementBannerProps): JSX.Element | null {
|
||||
const [visible, setVisible] = useState(() => !isDismissed(storageKey));
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismiss = (): void => {
|
||||
if (storageKey) {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
}
|
||||
setVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cx(
|
||||
'announcement-banner',
|
||||
`announcement-banner--${type}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="announcement-banner__body">
|
||||
{resolvedIcon && (
|
||||
<span className="announcement-banner__icon">{resolvedIcon}</span>
|
||||
)}
|
||||
{typeof message === 'string' ? (
|
||||
<span
|
||||
className="announcement-banner__message"
|
||||
dangerouslySetInnerHTML={{ __html: message }}
|
||||
/>
|
||||
) : (
|
||||
<span className="announcement-banner__message">{message}</span>
|
||||
)}
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
className="announcement-banner__action"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dismissible && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
className="announcement-banner__dismiss"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
frontend/src/components/AnnouncementBanner/index.ts
Normal file
6
frontend/src/components/AnnouncementBanner/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
AnnouncementBannerAction,
|
||||
AnnouncementBannerProps,
|
||||
AnnouncementBannerType,
|
||||
} from './AnnouncementBanner';
|
||||
export { default } from './AnnouncementBanner';
|
||||
@@ -0,0 +1,142 @@
|
||||
.create-sa-modal {
|
||||
max-width: 530px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--bg-base-white);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.create-sa-modal__content {
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ant-form-item-label > label {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
min-height: 32px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
padding: 0 var(--padding-2) !important;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.4;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--bg-base-white);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__helper {
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
color: var(--l3-foreground);
|
||||
margin: calc(var(--spacing-2) * -1) 0 var(--spacing-4) 0;
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.create-sa-modal {
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-form {
|
||||
&__select {
|
||||
.ant-select-selector {
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Form } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useCreateServiceAccount } from 'api/generated/services/serviceaccount';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
interface CreateServiceAccountModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
function CreateServiceAccountModal({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateServiceAccountModalProps): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submittable, setSubmittable] = useState(false);
|
||||
|
||||
const values = Form.useWatch([], form);
|
||||
|
||||
useEffect(() => {
|
||||
form
|
||||
.validateFields({ validateOnly: true })
|
||||
.then(() => setSubmittable(true))
|
||||
.catch(() => setSubmittable(false));
|
||||
}, [values, form]);
|
||||
|
||||
const { mutateAsync: createServiceAccount } = useCreateServiceAccount();
|
||||
const {
|
||||
roles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setIsSubmitting(true);
|
||||
await createServiceAccount({
|
||||
data: {
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
roles: values.roles,
|
||||
},
|
||||
});
|
||||
toast.success('Service account created successfully', { richColors: true });
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) {
|
||||
return;
|
||||
}
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
toast.error(`Failed to create service account: ${errMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [form, createServiceAccount, onSuccess, onClose]);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="New Service Account"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="narrow"
|
||||
className="create-sa-modal"
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<div className="create-sa-modal__content">
|
||||
<Form form={form} layout="vertical" className="create-sa-form">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true, message: 'Name is required' }]}
|
||||
className="create-sa-form__item"
|
||||
>
|
||||
<Input placeholder="Enter a name" className="create-sa-form__input" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="Email Address"
|
||||
rules={[
|
||||
{ required: true, message: 'Email Address is required' },
|
||||
{ type: 'email', message: 'Please enter a valid email address' },
|
||||
]}
|
||||
className="create-sa-form__item"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
className="create-sa-form__input"
|
||||
/>
|
||||
</Form.Item>
|
||||
<p className="create-sa-form__helper">
|
||||
Used only for notifications about this service account. It is not used for
|
||||
authentication.
|
||||
</p>
|
||||
|
||||
<Form.Item
|
||||
name="roles"
|
||||
label="Roles"
|
||||
rules={[{ required: true, message: 'At least one role is required' }]}
|
||||
className="create-sa-form__item"
|
||||
>
|
||||
<RolesSelect
|
||||
mode="multiple"
|
||||
roles={roles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
placeholder="Select roles"
|
||||
className="create-sa-form__select"
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.create-sa-modal') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="create-sa-modal__footer">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !submittable}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Service Account'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateServiceAccountModal;
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { useCreateServiceAccount } from 'api/generated/services/serviceaccount';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { managedRoles } from 'mocks-server/__mockdata__/roles';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
jest.mock('api/generated/services/serviceaccount');
|
||||
|
||||
jest.mock('components/RolesSelect', () => {
|
||||
function MockRolesSelect({
|
||||
value = [],
|
||||
onChange,
|
||||
roles = [],
|
||||
}: {
|
||||
value?: string[];
|
||||
onChange?: (val: string[]) => void;
|
||||
roles?: Array<{ id: string; name: string }>;
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: unknown;
|
||||
onRefetch?: () => void;
|
||||
mode?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (el: HTMLElement) => HTMLElement;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<select
|
||||
multiple
|
||||
data-testid="roles-select"
|
||||
value={value}
|
||||
onChange={(e): void => {
|
||||
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
|
||||
onChange?.(selected);
|
||||
}}
|
||||
>
|
||||
{roles.map((r: { id: string; name: string }) => (
|
||||
<option key={r.id} value={r.name}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockRolesSelect,
|
||||
useRoles: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@signozhq/dialog', () => ({
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockCreateServiceAccount = jest.fn();
|
||||
const mockUseRoles = jest.mocked(useRoles);
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
describe('CreateServiceAccountModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(useCreateServiceAccount).mockReturnValue(({
|
||||
mutateAsync: mockCreateServiceAccount,
|
||||
} as unknown) as ReturnType<typeof useCreateServiceAccount>);
|
||||
mockUseRoles.mockReturnValue({
|
||||
roles: managedRoles,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('submit button is disabled when form is empty', () => {
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Service Account/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('submit button remains disabled when email is invalid', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'My Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'not-an-email',
|
||||
);
|
||||
await user.selectOptions(screen.getByTestId('roles-select'), [
|
||||
'signoz-admin',
|
||||
]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Service Account/i }),
|
||||
).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful submit calls mutation, shows toast.success, and calls onSuccess + onClose', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
mockCreateServiceAccount.mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={onClose} onSuccess={onSuccess} />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'deploy@acme.io',
|
||||
);
|
||||
await user.selectOptions(screen.getByTestId('roles-select'), [
|
||||
'signoz-admin',
|
||||
]);
|
||||
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
});
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateServiceAccount).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: 'Deploy Bot',
|
||||
email: 'deploy@acme.io',
|
||||
roles: ['signoz-admin'],
|
||||
},
|
||||
});
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows toast.error and does not call onSuccess on API error', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
mockCreateServiceAccount.mockRejectedValue(new Error('Already exists'));
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={onSuccess} />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'dupe@acme.io',
|
||||
);
|
||||
await user.selectOptions(screen.getByTestId('roles-select'), [
|
||||
'signoz-admin',
|
||||
]);
|
||||
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
});
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Failed to create service account/i),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('Cancel button calls onClose without submitting', async () => {
|
||||
const onClose = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={onClose} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateServiceAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows "Name is required" after clearing the name field', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter a name');
|
||||
await user.type(nameInput, 'Bot');
|
||||
await user.clear(nameInput);
|
||||
|
||||
await screen.findByText('Name is required');
|
||||
});
|
||||
|
||||
it('shows "Please enter a valid email address" for a malformed email', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateServiceAccountModal open onClose={jest.fn()} onSuccess={jest.fn()} />,
|
||||
);
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'not-an-email',
|
||||
);
|
||||
|
||||
await screen.findByText('Please enter a valid email address');
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,6 @@
|
||||
.custom-time-picker {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.zoom-out-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
padding: 10px;
|
||||
height: 33px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--bg-vanilla-100);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
flex-direction: column;
|
||||
|
||||
.timeSelection-input {
|
||||
&:hover {
|
||||
|
||||
@@ -16,15 +16,6 @@ jest.mock('react-router-dom', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: jest.fn(() => jest.fn()),
|
||||
useSelector: jest.fn(() => ({
|
||||
minTime: 0,
|
||||
maxTime: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => {
|
||||
const actual = jest.requireActual('providers/Timezone');
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import {
|
||||
FixedDurationSuggestionOptions,
|
||||
@@ -19,11 +17,9 @@ import {
|
||||
RelativeDurationSuggestionOptions,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useZoomOut } from 'hooks/useZoomOut';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import { isZoomOutDisabled } from 'lib/zoomOutUtils';
|
||||
import { defaultTo, isFunction, noop } from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp, ZoomOut } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { getTimeDifference, validateEpochRange } from 'utils/epochUtils';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -70,8 +66,6 @@ interface CustomTimePickerProps {
|
||||
showRecentlyUsed?: boolean;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
||||
isModalTimeSelection?: boolean;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -94,7 +88,6 @@ function CustomTimePicker({
|
||||
showRecentlyUsed = true,
|
||||
minTime,
|
||||
maxTime,
|
||||
isModalTimeSelection = false,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [
|
||||
selectedTimePlaceholderValue,
|
||||
@@ -123,14 +116,6 @@ function CustomTimePicker({
|
||||
|
||||
const [isOpenedFromFooter, setIsOpenedFromFooter] = useState(false);
|
||||
|
||||
const durationMs = (maxTime - minTime) / 1e6;
|
||||
const zoomOutDisabled = showLiveLogs || isZoomOutDisabled(durationMs);
|
||||
|
||||
const handleZoomOut = useZoomOut({
|
||||
isDisabled: zoomOutDisabled,
|
||||
urlParamsToDelete: [QueryParams.activeLogId],
|
||||
});
|
||||
|
||||
// function to get selected time in Last 1m, Last 2h, Last 3d, Last 4w format
|
||||
// 1m, 2h, 3d, 4w -> Last 1 minute, Last 2 hours, Last 3 days, Last 4 weeks
|
||||
const getSelectedTimeRangeLabelInRelativeFormat = (
|
||||
@@ -646,23 +631,6 @@ function CustomTimePicker({
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
{!showLiveLogs && !isModalTimeSelection && (
|
||||
<Tooltip
|
||||
title={
|
||||
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
className="zoom-out-btn"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomOutDisabled}
|
||||
data-testid="zoom-out-btn"
|
||||
prefixIcon={<ZoomOut size={14} />}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import CustomTimePicker from '../CustomTimePicker';
|
||||
|
||||
const MS_PER_MIN = 60 * 1000;
|
||||
const NOW_MS = 1705312800000;
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockUrlQueryDelete = jest.fn();
|
||||
const mockUrlQuerySet = jest.fn();
|
||||
|
||||
interface MockAppState {
|
||||
globalTime: Pick<GlobalReducer, 'minTime' | 'maxTime'>;
|
||||
}
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: (selector: (state: MockAppState) => unknown): unknown => {
|
||||
const mockState: MockAppState = {
|
||||
globalTime: {
|
||||
minTime: (NOW_MS - 15 * MS_PER_MIN) * 1e6,
|
||||
maxTime: NOW_MS * 1e6,
|
||||
},
|
||||
};
|
||||
return selector(mockState);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
interface MockUrlQuery {
|
||||
delete: typeof mockUrlQueryDelete;
|
||||
set: typeof mockUrlQuerySet;
|
||||
get: () => null;
|
||||
toString: () => string;
|
||||
}
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): MockUrlQuery => ({
|
||||
delete: mockUrlQueryDelete,
|
||||
set: mockUrlQuerySet,
|
||||
get: (): null => null,
|
||||
toString: (): string => 'relativeTime=45m',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): { timezone: { value: string; offset: string } } => ({
|
||||
timezone: { value: 'UTC', offset: 'UTC' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/logs-explorer' }),
|
||||
}));
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const defaultProps = {
|
||||
onSelect: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
selectedValue: '15m',
|
||||
selectedTime: '15m',
|
||||
onValidCustomDateChange: jest.fn(),
|
||||
open: false,
|
||||
setOpen: jest.fn(),
|
||||
items: [
|
||||
{ value: '15m', label: 'Last 15 minutes' },
|
||||
{ value: '1h', label: 'Last 1 hour' },
|
||||
],
|
||||
minTime: (now - 15 * 60 * 1000) * 1e6,
|
||||
maxTime: now * 1e6,
|
||||
};
|
||||
|
||||
describe('CustomTimePicker - zoom out button', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW_MS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render zoom out button when showLiveLogs is false', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
expect(screen.getByTestId('zoom-out-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when showLiveLogs is true', () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={true} />);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render zoom out button when isModalTimeSelection is true', () => {
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
showLiveLogs={false}
|
||||
isModalTimeSelection={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('zoom-out-btn')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleZoomOut when zoom out button is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use real ladder logic: 15m range zooms to 45m preset and updates URL', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.startTime);
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.endTime);
|
||||
expect(mockUrlQuerySet).toHaveBeenCalledWith(QueryParams.relativeTime, '45m');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/logs-explorer\?relativeTime=45m/),
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete activeLogId when zoom out is clicked', async () => {
|
||||
render(<CustomTimePicker {...defaultProps} showLiveLogs={false} />);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
await userEvent.click(zoomOutBtn);
|
||||
|
||||
expect(mockUrlQueryDelete).toHaveBeenCalledWith(QueryParams.activeLogId);
|
||||
});
|
||||
|
||||
it('should disable zoom button when time range is >= 1 month', () => {
|
||||
const now = Date.now();
|
||||
render(
|
||||
<CustomTimePicker
|
||||
{...defaultProps}
|
||||
minTime={(now - 31 * MS_PER_DAY) * 1e6}
|
||||
maxTime={now * 1e6}
|
||||
showLiveLogs={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
expect(zoomOutBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -243,56 +243,60 @@ function InviteMembersModal({
|
||||
<div className="table-header-cell role-header">Roles</div>
|
||||
<div className="table-header-cell action-header" />
|
||||
</div>
|
||||
<div className="invite-members-modal__container">
|
||||
{rows.map(
|
||||
(row): JSX.Element => (
|
||||
<div key={row.id} className="team-member-row">
|
||||
<div className="team-member-cell email-cell">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="john@signoz.io"
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
className="team-member-email-input"
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<span className="email-error-message">Invalid email address</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team-member-cell role-cell">
|
||||
<Select
|
||||
value={row.role || undefined}
|
||||
onChange={(role): void => updateRole(row.id, role as ROLES)}
|
||||
className="team-member-role-select"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.invite-members-modal') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
>
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="team-member-cell action-cell">
|
||||
{rows.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="remove-team-member-button"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
<form noValidate onSubmit={(e): void => e.preventDefault()}>
|
||||
<div className="invite-members-modal__container">
|
||||
{rows.map(
|
||||
(row): JSX.Element => (
|
||||
<div key={row.id} className="team-member-row">
|
||||
<div className="team-member-cell email-cell">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="john@signoz.io"
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
className="team-member-email-input"
|
||||
name={`invite-email-${row.id}`}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<span className="email-error-message">Invalid email address</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team-member-cell role-cell">
|
||||
<Select
|
||||
value={row.role || undefined}
|
||||
onChange={(role): void => updateRole(row.id, role as ROLES)}
|
||||
className="team-member-role-select"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.invite-members-modal') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="team-member-cell action-cell">
|
||||
{rows.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="remove-team-member-button"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: var(--paragraph-base-400-font-height);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
25
frontend/src/components/RolesSelect/RolesSelect.styles.scss
Normal file
25
frontend/src/components/RolesSelect/RolesSelect.styles.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
.roles-select-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
color: var(--destructive);
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&__msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
&__retry-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
171
frontend/src/components/RolesSelect/RolesSelect.tsx
Normal file
171
frontend/src/components/RolesSelect/RolesSelect.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { CircleAlert, RefreshCw } from '@signozhq/icons';
|
||||
import { Checkbox, Select } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import type { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './RolesSelect.styles.scss';
|
||||
|
||||
export interface RoleOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useRoles(): {
|
||||
roles: RoletypesRoleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | undefined;
|
||||
refetch: () => void;
|
||||
} {
|
||||
const { data, isLoading, isError, error, refetch } = useListRoles();
|
||||
return {
|
||||
roles: data?.data ?? [],
|
||||
isLoading,
|
||||
isError,
|
||||
error: convertToApiError(error),
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(roles: RoletypesRoleDTO[]): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role.name ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
function ErrorContent({
|
||||
error,
|
||||
onRefetch,
|
||||
}: {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
}): JSX.Element {
|
||||
const errorMessage = error?.message || 'Failed to load roles';
|
||||
|
||||
return (
|
||||
<div className="roles-select-error">
|
||||
<span className="roles-select-error__msg">
|
||||
<CircleAlert size={12} />
|
||||
{errorMessage}
|
||||
</span>
|
||||
{onRefetch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRefetch();
|
||||
}}
|
||||
className="roles-select-error__retry-btn"
|
||||
title="Retry"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
roles?: RoletypesRoleDTO[];
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
mode?: 'single';
|
||||
value?: string;
|
||||
onChange?: (role: string) => void;
|
||||
}
|
||||
|
||||
interface MultipleProps extends BaseProps {
|
||||
mode: 'multiple';
|
||||
value?: string[];
|
||||
onChange?: (roles: string[]) => void;
|
||||
}
|
||||
|
||||
export type RolesSelectProps = SingleProps | MultipleProps;
|
||||
|
||||
function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
const externalRoles = props.roles;
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: internalLoading,
|
||||
isError: internalError,
|
||||
error: internalErrorObj,
|
||||
refetch: internalRefetch,
|
||||
} = useListRoles({
|
||||
query: { enabled: externalRoles === undefined },
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles);
|
||||
|
||||
const {
|
||||
mode,
|
||||
id,
|
||||
placeholder = 'Select role',
|
||||
className,
|
||||
getPopupContainer,
|
||||
loading = internalLoading,
|
||||
isError = internalError,
|
||||
error = convertToApiError(internalErrorObj),
|
||||
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
|
||||
} = props;
|
||||
|
||||
const notFoundContent = isError ? (
|
||||
<ErrorContent error={error} onRefetch={onRefetch} />
|
||||
) : undefined;
|
||||
|
||||
if (mode === 'multiple') {
|
||||
const { value = [], onChange } = props as MultipleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
mode="multiple"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<Checkbox
|
||||
checked={value.includes(option.value as string)}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
)}
|
||||
getPopupContainer={getPopupContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { value, onChange } = props as SingleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
getPopupContainer={getPopupContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RolesSelect;
|
||||
2
frontend/src/components/RolesSelect/index.ts
Normal file
2
frontend/src/components/RolesSelect/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { RoleOption, RolesSelectProps } from './RolesSelect';
|
||||
export { default, getRoleOptions, useRoles } from './RolesSelect';
|
||||
@@ -0,0 +1,179 @@
|
||||
.add-key-modal {
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__expiry-toggle {
|
||||
width: 60%;
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
|
||||
[data-slot='toggle-group'] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
line-height: var(--label-small-400-line-height);
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__datepicker {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-picker {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.add-key-modal-datepicker-popup {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
background: var(--popover);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__key-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 var(--padding-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-left: 1px solid var(--border);
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
&__expiry-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__expiry-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
267
frontend/src/components/ServiceAccountDrawer/AddKeyModal.tsx
Normal file
267
frontend/src/components/ServiceAccountDrawer/AddKeyModal.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useCreateServiceAccountKey } from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate } from './utils';
|
||||
|
||||
import './AddKeyModal.styles.scss';
|
||||
|
||||
interface AddKeyModalProps {
|
||||
open: boolean;
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type Phase = 'form' | 'created';
|
||||
type ExpiryMode = 'none' | 'date';
|
||||
|
||||
function AddKeyModal({
|
||||
open,
|
||||
accountId,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AddKeyModalProps): JSX.Element {
|
||||
const [phase, setPhase] = useState<Phase>('form');
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
|
||||
const [expiryDate, setExpiryDate] = useState<Dayjs | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [
|
||||
createdKey,
|
||||
setCreatedKey,
|
||||
] = useState<ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO | null>(null);
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPhase('form');
|
||||
setKeyName('');
|
||||
setExpiryMode('none');
|
||||
setExpiryDate(null);
|
||||
setIsSubmitting(false);
|
||||
setCreatedKey(null);
|
||||
setHasCopied(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { mutateAsync: createKey } = useCreateServiceAccountKey();
|
||||
|
||||
const handleCreate = useCallback(async (): Promise<void> => {
|
||||
if (!keyName.trim()) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const expiresAt =
|
||||
expiryMode === 'date' && expiryDate ? expiryDate.endOf('day').unix() : 0;
|
||||
const response = await createKey({
|
||||
pathParams: { id: accountId },
|
||||
data: { name: keyName.trim(), expiresAt },
|
||||
});
|
||||
const keyData = response?.data;
|
||||
if (keyData) {
|
||||
setCreatedKey(keyData);
|
||||
setPhase('created');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to create key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [keyName, expiryMode, expiryDate, accountId, createKey]);
|
||||
|
||||
const handleCopy = useCallback(async (): Promise<void> => {
|
||||
if (!createdKey?.key) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.key);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
toast.success('Key copied to clipboard', { richColors: true });
|
||||
} catch {
|
||||
toast.error('Failed to copy key', { richColors: true });
|
||||
}
|
||||
}, [createdKey]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
if (phase === 'created') {
|
||||
onSuccess();
|
||||
}
|
||||
onClose();
|
||||
}, [phase, onSuccess, onClose]);
|
||||
|
||||
const expiryLabel = (): string => {
|
||||
if (expiryMode === 'none' || !expiryDate) {
|
||||
return 'Never';
|
||||
}
|
||||
try {
|
||||
return expiryDate.format('MMM D, YYYY');
|
||||
} catch {
|
||||
return 'Never';
|
||||
}
|
||||
};
|
||||
|
||||
const title = phase === 'form' ? 'Add a New Key' : 'Key Created Successfully';
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
title={title}
|
||||
width="base"
|
||||
className="add-key-modal"
|
||||
showCloseButton
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
{phase === 'form' && (
|
||||
<>
|
||||
<div className="add-key-modal__form">
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="key-name">
|
||||
Name <span style={{ color: 'var(--destructive)' }}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="key-name"
|
||||
value={keyName}
|
||||
onChange={(e): void => setKeyName(e.target.value)}
|
||||
placeholder="Enter key name e.g.: Service Owner"
|
||||
className="add-key-modal__input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__field">
|
||||
<span className="add-key-modal__label">Expiration</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={expiryMode}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setExpiryMode(val as ExpiryMode);
|
||||
if (val === 'none') {
|
||||
setExpiryDate(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="add-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="none"
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="date"
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="expiry-date">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div className="add-key-modal__datepicker">
|
||||
<DatePicker
|
||||
id="expiry-date"
|
||||
value={expiryDate}
|
||||
onChange={(date): void => setExpiryDate(date)}
|
||||
popupClassName="add-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__footer">
|
||||
<div className="add-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!keyName.trim() || isSubmitting}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Key'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'created' && createdKey && (
|
||||
<>
|
||||
<div className="add-key-modal__form">
|
||||
<div className="add-key-modal__field">
|
||||
<span className="add-key-modal__label">Key</span>
|
||||
<div className="add-key-modal__key-display">
|
||||
<span className="add-key-modal__key-text">{createdKey.key}</span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="add-key-modal__copy-btn"
|
||||
>
|
||||
{hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__expiry-meta">
|
||||
<span className="add-key-modal__expiry-label">Expiration</span>
|
||||
<Badge color="vanilla">{expiryLabel()}</Badge>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
message="Store the key securely. This is the only time it will be displayed."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddKeyModal;
|
||||
@@ -0,0 +1,188 @@
|
||||
.edit-key-modal {
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__key-text {
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__expiry-toggle {
|
||||
width: 60%;
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
|
||||
[data-slot='toggle-group'] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
line-height: var(--label-small-400-line-height);
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__datepicker {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-picker {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-key-modal-datepicker-popup {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
background: var(--popover);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__footer-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--destructive);
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
301
frontend/src/components/ServiceAccountDrawer/EditKeyModal.tsx
Normal file
301
frontend/src/components/ServiceAccountDrawer/EditKeyModal.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { LockKeyhole, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
useRevokeServiceAccountKey,
|
||||
useUpdateServiceAccountKey,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate, formatLastObservedAt } from './utils';
|
||||
|
||||
import './EditKeyModal.styles.scss';
|
||||
|
||||
interface EditKeyModalProps {
|
||||
open: boolean;
|
||||
accountId: string;
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type ExpiryMode = 'none' | 'date';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function EditKeyModal({
|
||||
open,
|
||||
accountId,
|
||||
keyItem,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: EditKeyModalProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
|
||||
const [localDate, setLocalDate] = useState<Dayjs | null>(null);
|
||||
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyItem) {
|
||||
setLocalName(keyItem.name ?? '');
|
||||
if (keyItem.expiresAt === 0) {
|
||||
setExpiryMode('none');
|
||||
setLocalDate(null);
|
||||
} else {
|
||||
setExpiryMode('date');
|
||||
setLocalDate(dayjs.unix(keyItem.expiresAt));
|
||||
}
|
||||
}
|
||||
}, [keyItem]);
|
||||
|
||||
const originalExpiresAt = keyItem?.expiresAt ?? 0;
|
||||
const currentExpiresAt =
|
||||
expiryMode === 'none' || !localDate ? 0 : localDate.endOf('day').unix();
|
||||
const isDirty =
|
||||
keyItem !== null &&
|
||||
(localName !== (keyItem.name ?? '') ||
|
||||
currentExpiresAt !== originalExpiresAt);
|
||||
|
||||
const { mutateAsync: updateKey } = useUpdateServiceAccountKey();
|
||||
const { mutateAsync: revokeKey } = useRevokeServiceAccountKey();
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!keyItem || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateKey({
|
||||
pathParams: { id: accountId, fid: keyItem.id },
|
||||
data: { name: localName, expiresAt: currentExpiresAt },
|
||||
});
|
||||
toast.success('Key updated successfully', { richColors: true });
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to update key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
keyItem,
|
||||
isDirty,
|
||||
localName,
|
||||
currentExpiresAt,
|
||||
accountId,
|
||||
updateKey,
|
||||
onSuccess,
|
||||
]);
|
||||
|
||||
const handleRevoke = useCallback(async (): Promise<void> => {
|
||||
if (!keyItem) {
|
||||
return;
|
||||
}
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
await revokeKey({
|
||||
pathParams: { id: accountId, fid: keyItem.id },
|
||||
});
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
setIsRevokeConfirmOpen(false);
|
||||
onSuccess();
|
||||
} catch (error: unknown) {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
} finally {
|
||||
setIsRevoking(false);
|
||||
}
|
||||
}, [keyItem, accountId, revokeKey, onSuccess]);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
if (isRevokeConfirmOpen) {
|
||||
setIsRevokeConfirmOpen(false);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={
|
||||
isRevokeConfirmOpen
|
||||
? `Revoke ${keyItem?.name ?? 'key'}?`
|
||||
: 'Edit Key Details'
|
||||
}
|
||||
width={isRevokeConfirmOpen ? 'narrow' : 'base'}
|
||||
className={
|
||||
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
|
||||
}
|
||||
showCloseButton={!isRevokeConfirmOpen}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
{isRevokeConfirmOpen ? (
|
||||
<>
|
||||
<p className="delete-dialog__body">
|
||||
Revoking this key will permanently invalidate it. Any systems using this
|
||||
key will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsRevokeConfirmOpen(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isRevoking}
|
||||
onClick={handleRevoke}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isRevoking ? 'Revoking...' : 'Revoke Key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="edit-key-modal__form">
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="edit-key-name"
|
||||
value={localName}
|
||||
onChange={(e): void => setLocalName(e.target.value)}
|
||||
className="edit-key-modal__input"
|
||||
placeholder="Enter key name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-display">
|
||||
Key
|
||||
</label>
|
||||
<div id="edit-key-display" className="edit-key-modal__key-display">
|
||||
<span className="edit-key-modal__key-text">********************</span>
|
||||
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__field">
|
||||
<span className="edit-key-modal__label">Expiration</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={expiryMode}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setExpiryMode(val as ExpiryMode);
|
||||
if (val === 'none') {
|
||||
setLocalDate(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="edit-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="none"
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="date"
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-datepicker">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div className="edit-key-modal__datepicker">
|
||||
<DatePicker
|
||||
value={localDate}
|
||||
id="edit-key-datepicker"
|
||||
onChange={(date): void => setLocalDate(date)}
|
||||
popupClassName="edit-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="edit-key-modal__meta">
|
||||
<span className="edit-key-modal__meta-label">Last Observed At</span>
|
||||
<Badge color="vanilla">
|
||||
{formatLastObservedAt(
|
||||
keyItem?.lastObservedAt ?? null,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__footer">
|
||||
<Button
|
||||
type="button"
|
||||
className="edit-key-modal__footer-danger"
|
||||
onClick={(): void => setIsRevokeConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditKeyModal;
|
||||
291
frontend/src/components/ServiceAccountDrawer/KeysTab.tsx
Normal file
291
frontend/src/components/ServiceAccountDrawer/KeysTab.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useRevokeServiceAccountKey } from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import EditKeyModal from './EditKeyModal';
|
||||
import { formatLastObservedAt } from './utils';
|
||||
|
||||
interface KeysTabProps {
|
||||
accountId: string;
|
||||
keys: ServiceaccounttypesFactorAPIKeyDTO[];
|
||||
isLoading: boolean;
|
||||
isDisabled?: boolean;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
onRefetch: () => void;
|
||||
onAddKeyClick: () => void;
|
||||
}
|
||||
|
||||
function formatExpiry(expiresAt: number): JSX.Element {
|
||||
if (expiresAt === 0) {
|
||||
return <span className="keys-tab__expiry--never">Never</span>;
|
||||
}
|
||||
const expiryDate = dayjs.unix(expiresAt);
|
||||
if (expiryDate.isBefore(dayjs())) {
|
||||
return <span className="keys-tab__expiry--expired">Expired</span>;
|
||||
}
|
||||
return <span>{expiryDate.format('MMM D, YYYY')}</span>;
|
||||
}
|
||||
|
||||
function KeysTab({
|
||||
accountId,
|
||||
keys,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
currentPage,
|
||||
pageSize,
|
||||
onRefetch,
|
||||
onAddKeyClick,
|
||||
}: KeysTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [
|
||||
editKey,
|
||||
setEditKey,
|
||||
] = useState<ServiceaccounttypesFactorAPIKeyDTO | null>(null);
|
||||
const [
|
||||
revokeTarget,
|
||||
setRevokeTarget,
|
||||
] = useState<ServiceaccounttypesFactorAPIKeyDTO | null>(null);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
|
||||
const { mutateAsync: revokeKey } = useRevokeServiceAccountKey();
|
||||
|
||||
const handleRevoke = useCallback(async (): Promise<void> => {
|
||||
if (!revokeTarget) {
|
||||
return;
|
||||
}
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
await revokeKey({
|
||||
pathParams: { id: accountId, fid: revokeTarget.id },
|
||||
});
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
setRevokeTarget(null);
|
||||
onRefetch();
|
||||
} catch (error: unknown) {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
} finally {
|
||||
setIsRevoking(false);
|
||||
}
|
||||
}, [revokeTarget, revokeKey, accountId, onRefetch]);
|
||||
|
||||
const handleKeySuccess = useCallback((): void => {
|
||||
setEditKey(null);
|
||||
onRefetch();
|
||||
}, [onRefetch]);
|
||||
|
||||
const handleformatLastObservedAt = useCallback(
|
||||
(lastObservedAt: Date | null | undefined): string =>
|
||||
formatLastObservedAt(lastObservedAt, formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<ServiceaccounttypesFactorAPIKeyDTO> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
className: 'keys-tab__name-column',
|
||||
sorter: (a, b): number => (a.name ?? '').localeCompare(b.name ?? ''),
|
||||
render: (_, record): JSX.Element => (
|
||||
<span className="keys-tab__name-text">{record.name ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Expiry',
|
||||
dataIndex: 'expiresAt',
|
||||
key: 'expiry',
|
||||
width: 160,
|
||||
align: 'right' as const,
|
||||
sorter: (a, b): number => {
|
||||
const aVal = a.expiresAt === 0 ? Infinity : a.expiresAt;
|
||||
const bVal = b.expiresAt === 0 ? Infinity : b.expiresAt;
|
||||
return aVal - bVal;
|
||||
},
|
||||
render: (expiresAt: number): JSX.Element => formatExpiry(expiresAt),
|
||||
},
|
||||
{
|
||||
title: 'Last Observed At',
|
||||
dataIndex: 'lastObservedAt',
|
||||
key: 'lastObservedAt',
|
||||
width: 220,
|
||||
align: 'right' as const,
|
||||
sorter: (a, b): number => {
|
||||
const aVal = a.lastObservedAt
|
||||
? new Date(a.lastObservedAt).getTime()
|
||||
: -Infinity;
|
||||
const bVal = b.lastObservedAt
|
||||
? new Date(b.lastObservedAt).getTime()
|
||||
: -Infinity;
|
||||
return aVal - bVal;
|
||||
},
|
||||
render: (lastObservedAt: Date | null | undefined): string =>
|
||||
handleformatLastObservedAt(lastObservedAt),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 48,
|
||||
align: 'right' as const,
|
||||
render: (_, record): JSX.Element => (
|
||||
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
setRevokeTarget(record);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="keys-tab__loading">
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<div className="keys-tab__empty">
|
||||
<span className="keys-tab__empty-emoji" role="img" aria-label="searching">
|
||||
🧐
|
||||
</span>
|
||||
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="keys-tab__learn-more"
|
||||
onClick={onAddKeyClick}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table<ServiceaccounttypesFactorAPIKeyDTO>
|
||||
columns={columns}
|
||||
dataSource={keys}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
style: { display: 'none' },
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
}}
|
||||
showSorterTooltip={false}
|
||||
className={`keys-tab__table${
|
||||
isDisabled ? ' keys-tab__table--disabled' : ''
|
||||
}`}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'keys-tab__table-row--alt' : ''
|
||||
}
|
||||
onRow={(
|
||||
record,
|
||||
): {
|
||||
onClick: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
role: string;
|
||||
tabIndex: number;
|
||||
'aria-label': string;
|
||||
} => ({
|
||||
onClick: (): void => {
|
||||
if (!isDisabled) {
|
||||
setEditKey(record);
|
||||
}
|
||||
},
|
||||
onKeyDown: (e: React.KeyboardEvent): void => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) {
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
}
|
||||
setEditKey(record);
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-label': `Edit key ${record.name || 'options'}`,
|
||||
})}
|
||||
/>
|
||||
|
||||
<DialogWrapper
|
||||
open={revokeTarget !== null}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setRevokeTarget(null);
|
||||
}
|
||||
}}
|
||||
title={`Revoke ${revokeTarget?.name ?? 'key'}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="delete-dialog__body">
|
||||
Revoking this key will permanently invalidate it. Any systems using this
|
||||
key will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setRevokeTarget(null)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isRevoking}
|
||||
onClick={handleRevoke}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isRevoking ? 'Revoking...' : 'Revoke Key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
|
||||
<EditKeyModal
|
||||
open={editKey !== null}
|
||||
accountId={accountId}
|
||||
keyItem={editKey}
|
||||
onClose={(): void => setEditKey(null)}
|
||||
onSuccess={handleKeySuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeysTab;
|
||||
154
frontend/src/components/ServiceAccountDrawer/OverviewTab.tsx
Normal file
154
frontend/src/components/ServiceAccountDrawer/OverviewTab.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { LockKeyhole } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
localName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: RoletypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
onRefetchRoles?: () => void;
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
account,
|
||||
localName,
|
||||
onNameChange,
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
isDisabled,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
rolesError,
|
||||
rolesErrorObj,
|
||||
onRefetchRoles,
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-name">
|
||||
Name
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{localName || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id="sa-name"
|
||||
value={localName}
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
className="sa-drawer__input"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{account.email || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-roles">
|
||||
Roles
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<div className="sa-drawer__disabled-roles">
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((r) => (
|
||||
<Badge key={r} color="vanilla">
|
||||
{r}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="sa-drawer__input-text">—</span>
|
||||
)}
|
||||
</div>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={onRefetchRoles}
|
||||
value={localRoles}
|
||||
onChange={onRolesChange}
|
||||
placeholder="Select roles"
|
||||
className="sa-drawer__role-select"
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.sa-drawer') as HTMLElement) || document.body
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta">
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Status</span>
|
||||
{account.status?.toUpperCase() === 'ACTIVE' ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
DISABLED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Created At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(account.createdAt)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Updated At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverviewTab;
|
||||
@@ -0,0 +1,523 @@
|
||||
.sa-drawer {
|
||||
[data-slot='drawer-close'] + div {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
padding-left: var(--padding-4);
|
||||
margin-left: var(--margin-2);
|
||||
}
|
||||
|
||||
&__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--padding-3) var(--padding-4) var(--padding-2) var(--padding-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__tab-group {
|
||||
[data-slot='toggle-group'] {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-slot='toggle-group-item'] {
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-family: Inter, sans-serif;
|
||||
padding: 0 var(--padding-7);
|
||||
gap: var(--spacing-3);
|
||||
box-shadow: none;
|
||||
|
||||
&:first-child {
|
||||
border-left: none;
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
&__tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--secondary);
|
||||
font-size: var(--code-small-400-font-size);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-5) var(--padding-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__footer {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
&__keys-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
padding: var(--padding-2) 0;
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__pagination-range {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
&__pagination-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__footer-btn {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__input-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__role-select {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 2px var(--padding-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 0 var(--padding-1) 0 6px;
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--foreground);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__disabled-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--margin-1);
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.keys-tab {
|
||||
&__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-8) var(--padding-4);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-8) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
text-align: center;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
&__empty-emoji {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&__empty-text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__table {
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-thead > tr > td {
|
||||
height: 38px;
|
||||
padding: 0 var(--padding-4);
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-table-column-sorter {
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.ant-table-column-sort {
|
||||
background: transparent !important;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-table-column-sorter {
|
||||
color: var(--l1-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-table-column-sorter {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
height: 38px;
|
||||
padding: 0 var(--padding-4);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: -0.07px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: none;
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
|
||||
slashed-zero;
|
||||
|
||||
&.ant-table-column-sort {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
background: transparent;
|
||||
|
||||
&:hover > td {
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
&.keys-tab__table-row--alt > td {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
.ant-table-tbody > tr {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover > td {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-cell-row-hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__name-column {
|
||||
.ant-table-column-sorters {
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ant-table-column-title {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__name-text {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__expiry--never {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__expiry--expired {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__revoke-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-disable-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
margin: 0;
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--margin-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import { Key, LayoutGrid, Plus, PowerOff, Trash2, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Pagination } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
useListServiceAccountKeys,
|
||||
useUpdateServiceAccount,
|
||||
useUpdateServiceAccountStatus,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import KeysTab from './KeysTab';
|
||||
import OverviewTab from './OverviewTab';
|
||||
import { ServiceAccountDrawerTab } from './utils';
|
||||
|
||||
import './ServiceAccountDrawer.styles.scss';
|
||||
|
||||
export interface ServiceAccountDrawerProps {
|
||||
account: ServiceAccountRow | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (options?: { closeDrawer?: boolean }) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function ServiceAccountDrawer({
|
||||
account,
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ServiceAccountDrawerProps): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState<ServiceAccountDrawerTab>(
|
||||
ServiceAccountDrawerTab.Overview,
|
||||
);
|
||||
const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false);
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [isAddKeyOpen, setIsAddKeyOpen] = useState(false);
|
||||
const [keysPage, setKeysPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
setLocalName(account.name ?? '');
|
||||
setLocalRoles(account.roles ?? []);
|
||||
setActiveTab(ServiceAccountDrawerTab.Overview);
|
||||
setKeysPage(1);
|
||||
}
|
||||
}, [account]);
|
||||
|
||||
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
|
||||
|
||||
const isDirty =
|
||||
account !== null &&
|
||||
(localName !== (account.name ?? '') ||
|
||||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const {
|
||||
data: keysData,
|
||||
isLoading: keysLoading,
|
||||
refetch: refetchKeys,
|
||||
} = useListServiceAccountKeys(
|
||||
{ id: account?.id ?? '' },
|
||||
{ query: { enabled: !!account?.id } },
|
||||
);
|
||||
const keys = keysData?.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (keysLoading) {
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(1, Math.ceil(keys.length / PAGE_SIZE));
|
||||
if (keysPage > maxPage) {
|
||||
setKeysPage(maxPage);
|
||||
}
|
||||
}, [keysLoading, keys.length, keysPage]);
|
||||
|
||||
const { mutateAsync: updateAccount } = useUpdateServiceAccount();
|
||||
const { mutateAsync: updateStatus } = useUpdateServiceAccountStatus();
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!account || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateAccount({
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName, email: account.email, roles: localRoles },
|
||||
});
|
||||
toast.success('Service account updated successfully', { richColors: true });
|
||||
onSuccess({ closeDrawer: false });
|
||||
} catch (error: unknown) {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to update service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [account, isDirty, localName, localRoles, updateAccount, onSuccess]);
|
||||
|
||||
const handleDisable = useCallback(async (): Promise<void> => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
setIsDisabling(true);
|
||||
try {
|
||||
await updateStatus({
|
||||
pathParams: { id: account.id },
|
||||
data: { status: 'DISABLED' },
|
||||
});
|
||||
toast.success('Service account disabled', { richColors: true });
|
||||
setIsDisableConfirmOpen(false);
|
||||
onSuccess({ closeDrawer: true });
|
||||
} catch (error: unknown) {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to disable service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
}, [account, updateStatus, onSuccess]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setIsDisableConfirmOpen(false);
|
||||
setIsAddKeyOpen(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleKeySuccess = useCallback((): void => {
|
||||
setIsAddKeyOpen(false);
|
||||
refetchKeys();
|
||||
}, [refetchKeys]);
|
||||
|
||||
const drawerContent = (
|
||||
<div className="sa-drawer__layout">
|
||||
<div className="sa-drawer__tabs">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={activeTab}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setActiveTab(val as ServiceAccountDrawerTab);
|
||||
}
|
||||
}}
|
||||
className="sa-drawer__tab-group"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={ServiceAccountDrawerTab.Overview}
|
||||
className="sa-drawer__tab"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={ServiceAccountDrawerTab.Keys}
|
||||
className="sa-drawer__tab"
|
||||
>
|
||||
<Key size={14} />
|
||||
Keys
|
||||
{keys.length > 0 && (
|
||||
<span className="sa-drawer__tab-count">{keys.length}</span>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDisabled}
|
||||
onClick={(): void => setIsAddKeyOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`sa-drawer__body${
|
||||
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
|
||||
}`}
|
||||
>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={setLocalName}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
isDisabled={isDisabled}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && account && (
|
||||
<KeysTab
|
||||
accountId={account.id}
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDisabled}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
onRefetch={refetchKeys}
|
||||
onAddKeyClick={(): void => setIsAddKeyOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__footer">
|
||||
{activeTab === ServiceAccountDrawerTab.Keys ? (
|
||||
<Pagination
|
||||
current={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={keys.length}
|
||||
showTotal={(total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-drawer__pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-drawer__pagination-total"> of {total}</span>
|
||||
</>
|
||||
)}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={(page): void => setKeysPage(page)}
|
||||
className="sa-drawer__keys-pagination"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="sa-drawer__footer-btn"
|
||||
onClick={(): void => setIsDisableConfirmOpen(true)}
|
||||
>
|
||||
<PowerOff size={12} />
|
||||
Disable Service Account
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
header={{ title: 'Service Account Details' }}
|
||||
content={drawerContent}
|
||||
className="sa-drawer"
|
||||
/>
|
||||
|
||||
<DialogWrapper
|
||||
open={isDisableConfirmOpen}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setIsDisableConfirmOpen(false);
|
||||
}
|
||||
}}
|
||||
title={`Disable service account ${account?.name ?? ''}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog sa-disable-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="sa-disable-dialog__body">
|
||||
Disabling this service account will revoke access for all its keys. Any
|
||||
systems using this account will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="sa-disable-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsDisableConfirmOpen(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isDisabling}
|
||||
onClick={handleDisable}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDisabling ? 'Disabling...' : 'Disable'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
|
||||
{account && (
|
||||
<AddKeyModal
|
||||
open={isAddKeyOpen}
|
||||
accountId={account.id}
|
||||
onClose={(): void => setIsAddKeyOpen(false)}
|
||||
onSuccess={handleKeySuccess}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountDrawer;
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { useCreateServiceAccountKey } from 'api/generated/services/serviceaccount';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import AddKeyModal from '../AddKeyModal';
|
||||
|
||||
jest.mock('@signozhq/toggle-group', () => ({
|
||||
ToggleGroup: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onValueChange?: (val: string) => void;
|
||||
value?: string;
|
||||
type?: string;
|
||||
className?: string;
|
||||
}): JSX.Element => <div className={className}>{children}</div>,
|
||||
ToggleGroupItem: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
className?: string;
|
||||
}): JSX.Element => <span className={className}>{children}</span>,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/dialog', () => ({
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/serviceaccount');
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockCreateKey = jest.fn();
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
accountId: 'sa-1',
|
||||
onClose: jest.fn(),
|
||||
onSuccess: jest.fn(),
|
||||
};
|
||||
|
||||
const createdKeyResponse = {
|
||||
data: {
|
||||
id: 'key-1',
|
||||
name: 'Deploy Key',
|
||||
key: 'snz_abc123xyz456secret',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe('AddKeyModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(useCreateServiceAccountKey).mockReturnValue(({
|
||||
mutateAsync: mockCreateKey,
|
||||
} as unknown) as ReturnType<typeof useCreateServiceAccountKey>);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('"Create Key" is disabled when name is empty; enabled after typing a name', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<AddKeyModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful creation transitions to phase 2 with key displayed and security callout', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
mockCreateKey.mockResolvedValue(createdKeyResponse);
|
||||
|
||||
render(<AddKeyModal {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Create Key/i }));
|
||||
|
||||
await screen.findByText('snz_abc123xyz456secret');
|
||||
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /Key Created Successfully/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copy button writes key to clipboard and shows toast.success', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const writeTextSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
mockCreateKey.mockResolvedValue(createdKeyResponse);
|
||||
|
||||
render(<AddKeyModal {...defaultProps} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Create Key/i }));
|
||||
|
||||
await screen.findByText('snz_abc123xyz456secret');
|
||||
|
||||
const copyBtn = screen
|
||||
.getAllByRole('button')
|
||||
.find((btn) => btn.querySelector('svg'));
|
||||
if (!copyBtn) {
|
||||
throw new Error('Copy button not found');
|
||||
}
|
||||
await user.click(copyBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('snz_abc123xyz456secret');
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
writeTextSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('onSuccess called only when closing from phase 2, not from phase 1 (Cancel)', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<AddKeyModal {...defaultProps} onSuccess={onSuccess} onClose={onClose} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import {
|
||||
useRevokeServiceAccountKey,
|
||||
useUpdateServiceAccountKey,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import EditKeyModal from '../EditKeyModal';
|
||||
|
||||
jest.mock('@signozhq/toggle-group', () => ({
|
||||
ToggleGroup: ({
|
||||
children,
|
||||
onValueChange,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onValueChange?: (val: string) => void;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<div
|
||||
className={className}
|
||||
data-testid="toggle-group"
|
||||
data-value={value}
|
||||
onClick={(e): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
const toggleItem = target.closest('[data-toggle-value]');
|
||||
if (toggleItem) {
|
||||
onValueChange?.(toggleItem.getAttribute('data-toggle-value') || '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
ToggleGroupItem: ({
|
||||
children,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
data-toggle-value={value}
|
||||
data-testid={`toggle-item-${value}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/dialog', () => ({
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title} data-testid="dialog-wrapper">
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
|
||||
<div data-testid="dialog-footer">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/serviceaccount');
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockUpdateKey = jest.fn();
|
||||
const mockRevokeKey = jest.fn();
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const keyItem: ServiceaccounttypesFactorAPIKeyDTO = {
|
||||
id: 'key-1',
|
||||
name: 'Original Key Name',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_abc123',
|
||||
serviceAccountId: 'sa-1',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
accountId: 'sa-1',
|
||||
keyItem,
|
||||
onClose: jest.fn(),
|
||||
onSuccess: jest.fn(),
|
||||
};
|
||||
|
||||
describe('EditKeyModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(useUpdateServiceAccountKey).mockReturnValue(({
|
||||
mutateAsync: mockUpdateKey,
|
||||
} as unknown) as ReturnType<typeof useUpdateServiceAccountKey>);
|
||||
jest.mocked(useRevokeServiceAccountKey).mockReturnValue(({
|
||||
mutateAsync: mockRevokeKey,
|
||||
} as unknown) as ReturnType<typeof useRevokeServiceAccountKey>);
|
||||
});
|
||||
|
||||
it('renders correctly with initial values', () => {
|
||||
render(<EditKeyModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByDisplayValue('Original Key Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('No Expiration')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables save button when name is changed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<EditKeyModal {...defaultProps} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/Enter key name/i);
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'New Key Name');
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Save Changes/i }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls updateKey API and onSuccess on save', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
mockUpdateKey.mockResolvedValue({});
|
||||
|
||||
render(<EditKeyModal {...defaultProps} onSuccess={onSuccess} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/Enter key name/i);
|
||||
await user.type(nameInput, ' Updated');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateKey).toHaveBeenCalled();
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key updated successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens revoke confirmation and handles revocation', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSuccess = jest.fn();
|
||||
mockRevokeKey.mockResolvedValue({});
|
||||
|
||||
render(<EditKeyModal {...defaultProps} onSuccess={onSuccess} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/Revoking this key will permanently invalidate it/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const confirmRevokeBtn = screen.getByRole('button', {
|
||||
name: (content, element) =>
|
||||
content === 'Revoke Key' && element?.tagName === 'BUTTON',
|
||||
});
|
||||
await user.click(confirmRevokeBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRevokeKey).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'sa-1', fid: 'key-1' },
|
||||
});
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key revoked successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes modal when clicking cancel', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onClose = jest.fn();
|
||||
render(<EditKeyModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { useRevokeServiceAccountKey } from 'api/generated/services/serviceaccount';
|
||||
import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import KeysTab from '../KeysTab';
|
||||
|
||||
jest.mock('@signozhq/dialog', () => ({
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('antd', () => {
|
||||
const original = jest.requireActual('antd');
|
||||
return {
|
||||
...original,
|
||||
Skeleton: ({ active }: { active?: boolean }): JSX.Element | null =>
|
||||
active ? <div data-testid="skeleton">Loading...</div> : null,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../EditKeyModal', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
open,
|
||||
keyItem,
|
||||
}: {
|
||||
open: boolean;
|
||||
keyItem: any;
|
||||
}): JSX.Element | null =>
|
||||
open ? <div data-testid="edit-key-modal">Editing {keyItem?.name}</div> : null,
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/serviceaccount');
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockRevokeKey = jest.fn();
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
|
||||
{
|
||||
id: 'key-1',
|
||||
name: 'Production Key',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_prod_123',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
{
|
||||
id: 'key-2',
|
||||
name: 'Staging Key',
|
||||
expiresAt: 1924905600, // 2030-12-31
|
||||
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
|
||||
key: 'snz_stag_456',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
accountId: 'sa-1',
|
||||
keys,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
onRefetch: jest.fn(),
|
||||
onAddKeyClick: jest.fn(),
|
||||
};
|
||||
|
||||
describe('KeysTab', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(useRevokeServiceAccountKey).mockReturnValue(({
|
||||
mutateAsync: mockRevokeKey,
|
||||
} as unknown) as ReturnType<typeof useRevokeServiceAccountKey>);
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
render(<KeysTab {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no keys', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onAddKeyClick = jest.fn();
|
||||
render(<KeysTab {...defaultProps} keys={[]} onAddKeyClick={onAddKeyClick} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/No keys. Start by creating one./i),
|
||||
).toBeInTheDocument();
|
||||
const addBtn = screen.getByRole('button', { name: /\+ Add your first key/i });
|
||||
await user.click(addBtn);
|
||||
expect(onAddKeyClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders table with keys', () => {
|
||||
render(<KeysTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Production Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Staging Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Never')).toBeInTheDocument(); // Expiry for Prod Key
|
||||
expect(screen.getByText('Dec 31, 2030')).toBeInTheDocument(); // Expiry for Staging Key
|
||||
});
|
||||
|
||||
it('clicking a row opens EditKeyModal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<KeysTab {...defaultProps} />);
|
||||
|
||||
const row = screen.getByText('Production Key').closest('tr');
|
||||
if (!row) {
|
||||
throw new Error('Row not found');
|
||||
}
|
||||
|
||||
await user.click(row);
|
||||
|
||||
expect(screen.getByTestId('edit-key-modal')).toHaveTextContent(
|
||||
'Editing Production Key',
|
||||
);
|
||||
});
|
||||
|
||||
it('clicking revoke icon opens confirmation dialog', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<KeysTab {...defaultProps} />);
|
||||
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
await user.click(revokeBtns[0]);
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /Revoke Production Key\?/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles successful key revocation', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onRefetch = jest.fn();
|
||||
mockRevokeKey.mockResolvedValue({});
|
||||
|
||||
render(<KeysTab {...defaultProps} onRefetch={onRefetch} />);
|
||||
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
await user.click(revokeBtns[0]);
|
||||
|
||||
const confirmBtn = screen.getByRole('button', { name: /Revoke Key/i });
|
||||
await user.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRevokeKey).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'sa-1', fid: 'key-1' },
|
||||
});
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key revoked successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(onRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables actions when isDisabled is true', () => {
|
||||
render(<KeysTab {...defaultProps} isDisabled={true} />);
|
||||
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
revokeBtns.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import {
|
||||
useCreateServiceAccountKey,
|
||||
useListServiceAccountKeys,
|
||||
useRevokeServiceAccountKey,
|
||||
useUpdateServiceAccount,
|
||||
useUpdateServiceAccountKey,
|
||||
useUpdateServiceAccountStatus,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { managedRoles } from 'mocks-server/__mockdata__/roles';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountDrawer, {
|
||||
ServiceAccountDrawerProps,
|
||||
} from '../ServiceAccountDrawer';
|
||||
|
||||
let mockOnToggleGroupChange: ((val: string) => void) | undefined;
|
||||
|
||||
jest.mock('@signozhq/toggle-group', () => ({
|
||||
ToggleGroup: ({
|
||||
children,
|
||||
onValueChange,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onValueChange?: (val: string) => void;
|
||||
value?: string;
|
||||
type?: string;
|
||||
className?: string;
|
||||
}): JSX.Element => {
|
||||
mockOnToggleGroupChange = onValueChange;
|
||||
return <div className={className}>{children}</div>;
|
||||
},
|
||||
ToggleGroupItem: ({
|
||||
children,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(): void => mockOnToggleGroupChange?.(value)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/drawer', () => ({
|
||||
DrawerWrapper: ({
|
||||
content,
|
||||
open,
|
||||
}: {
|
||||
content?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null => (open ? <div>{content}</div> : null),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/dialog', () => ({
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/serviceaccount');
|
||||
|
||||
jest.mock('components/RolesSelect', () => {
|
||||
function MockRolesSelect({
|
||||
value = [],
|
||||
onChange,
|
||||
roles = [],
|
||||
}: {
|
||||
value?: string[];
|
||||
onChange?: (val: string[]) => void;
|
||||
roles?: Array<{ id: string; name: string }>;
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: unknown;
|
||||
onRefetch?: () => void;
|
||||
mode?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (el: HTMLElement) => HTMLElement;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<select
|
||||
multiple
|
||||
data-testid="roles-select"
|
||||
value={value}
|
||||
onChange={(e): void => {
|
||||
const selected = Array.from(e.target.selectedOptions).map((o) => o.value);
|
||||
onChange?.(selected);
|
||||
}}
|
||||
>
|
||||
{roles.map((r: { id: string; name: string }) => (
|
||||
<option key={r.id} value={r.name}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockRolesSelect,
|
||||
useRoles: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockUpdate = jest.fn();
|
||||
const mockUpdateStatus = jest.fn();
|
||||
const mockToast = jest.mocked(toast);
|
||||
const mockUseRoles = jest.mocked(useRoles);
|
||||
|
||||
const activeAccount: ServiceAccountRow = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const disabledAccount: ServiceAccountRow = {
|
||||
...activeAccount,
|
||||
id: 'sa-2',
|
||||
status: 'DISABLED',
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
props: Partial<ServiceAccountDrawerProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<ServiceAccountDrawer
|
||||
account={activeAccount}
|
||||
open
|
||||
onClose={jest.fn()}
|
||||
onSuccess={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ServiceAccountDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockOnToggleGroupChange = undefined;
|
||||
|
||||
jest.mocked(useListServiceAccountKeys).mockReturnValue(({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
} as unknown) as ReturnType<typeof useListServiceAccountKeys>);
|
||||
|
||||
jest.mocked(useUpdateServiceAccount).mockReturnValue(({
|
||||
mutateAsync: mockUpdate,
|
||||
} as unknown) as ReturnType<typeof useUpdateServiceAccount>);
|
||||
|
||||
jest.mocked(useUpdateServiceAccountStatus).mockReturnValue(({
|
||||
mutateAsync: mockUpdateStatus,
|
||||
} as unknown) as ReturnType<typeof useUpdateServiceAccountStatus>);
|
||||
|
||||
jest.mocked(useCreateServiceAccountKey).mockReturnValue(({
|
||||
mutateAsync: jest.fn(),
|
||||
} as unknown) as ReturnType<typeof useCreateServiceAccountKey>);
|
||||
|
||||
jest.mocked(useRevokeServiceAccountKey).mockReturnValue(({
|
||||
mutateAsync: jest.fn(),
|
||||
} as unknown) as ReturnType<typeof useRevokeServiceAccountKey>);
|
||||
|
||||
jest.mocked(useUpdateServiceAccountKey).mockReturnValue(({
|
||||
mutateAsync: jest.fn(),
|
||||
} as unknown) as ReturnType<typeof useUpdateServiceAccountKey>);
|
||||
|
||||
mockUseRoles.mockReturnValue({
|
||||
roles: managedRoles,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Overview tab by default: editable name input, locked email, Save disabled when not dirty', () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(screen.getByDisplayValue('CI Bot')).toBeInTheDocument();
|
||||
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('editing name enables Save; clicking Save calls updateAccount with correct payload', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
mockUpdate.mockResolvedValue({});
|
||||
|
||||
renderDrawer({ onSuccess });
|
||||
|
||||
const nameInput = screen.getByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'CI Bot Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'sa-1' },
|
||||
data: {
|
||||
name: 'CI Bot Updated',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
},
|
||||
});
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: false });
|
||||
});
|
||||
});
|
||||
|
||||
it('changing roles enables Save; clicking Save calls updateAccount with updated roles', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
mockUpdate.mockResolvedValue({});
|
||||
|
||||
renderDrawer({ onSuccess });
|
||||
|
||||
const rolesSelect = screen.getByTestId('roles-select');
|
||||
await user.selectOptions(rolesSelect, ['signoz-viewer']);
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
roles: expect.arrayContaining(['signoz-admin', 'signoz-viewer']),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('"Disable Service Account" opens confirm dialog; confirming calls updateStatus and onSuccess({ closeDrawer: true })', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
mockUpdateStatus.mockResolvedValue({});
|
||||
|
||||
renderDrawer({ onSuccess });
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Disable Service Account/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /Disable service account CI Bot/i,
|
||||
});
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /^Disable$/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateStatus).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'sa-1' },
|
||||
data: { status: 'DISABLED' },
|
||||
});
|
||||
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('disabled account shows read-only name, no Save button, no Disable button', () => {
|
||||
renderDrawer({ account: disabledAccount });
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Save Changes/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Disable Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('CI Bot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switching to Keys tab shows "No keys" empty state', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Keys/i }));
|
||||
|
||||
await screen.findByText(/No keys/i);
|
||||
});
|
||||
});
|
||||
33
frontend/src/components/ServiceAccountDrawer/utils.ts
Normal file
33
frontend/src/components/ServiceAccountDrawer/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export enum ServiceAccountDrawerTab {
|
||||
Overview = 'overview',
|
||||
Keys = 'keys',
|
||||
}
|
||||
|
||||
export function formatLastObservedAt(
|
||||
lastObservedAt: string | Date | null | undefined,
|
||||
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string,
|
||||
): string {
|
||||
if (!lastObservedAt) {
|
||||
return '—';
|
||||
}
|
||||
const str =
|
||||
typeof lastObservedAt === 'string'
|
||||
? lastObservedAt
|
||||
: lastObservedAt.toISOString();
|
||||
// Go zero time means the key has never been used
|
||||
if (str.startsWith('0001-01-01')) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(str);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(str, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
}
|
||||
|
||||
export const disabledDate = (current: Dayjs): boolean =>
|
||||
!!current && current < dayjs().startOf('day');
|
||||
@@ -0,0 +1,218 @@
|
||||
.sa-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sa-table {
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-table-thead {
|
||||
> tr > th,
|
||||
> tr > td {
|
||||
background: var(--background);
|
||||
font-size: var(--paragraph-small-600-font-size);
|
||||
font-weight: var(--paragraph-small-600-font-weight);
|
||||
line-height: var(--paragraph-small-600-line-height);
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
border-bottom: none !important;
|
||||
border-top: none !important;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
> tr > td {
|
||||
border-bottom: none !important;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
background: transparent;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
> tr.sa-table-row--tinted > td {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-wrapper,
|
||||
.ant-table-container,
|
||||
.ant-spin-nested-loading,
|
||||
.ant-spin-container {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.sa-name-column {
|
||||
.ant-table-column-sorters {
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ant-table-column-title {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-status-cell {
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-name-email-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
|
||||
.sa-name {
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sa-email {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l3-foreground-hover);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-roles-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sa-dash {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
color: var(--l3-foreground-hover);
|
||||
}
|
||||
|
||||
.sa-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-12) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
color: var(--foreground);
|
||||
|
||||
&__emoji {
|
||||
font-size: var(--font-size-2xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--bg-base-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.sa-pagination-range {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.sa-pagination-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-tooltip {
|
||||
.ant-tooltip-inner {
|
||||
background-color: var(--bg-slate-500);
|
||||
color: var(--foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: normal;
|
||||
padding: var(--padding-2) var(--padding-3);
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ant-tooltip-arrow-content {
|
||||
background-color: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.sa-table {
|
||||
.ant-table-tbody {
|
||||
> tr.sa-table-row--tinted > td {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(0, 0, 0, 0.03) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-empty-state {
|
||||
&__text {
|
||||
strong {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Pagination, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
|
||||
import './ServiceAccountsTable.styles.scss';
|
||||
|
||||
interface ServiceAccountsTableProps {
|
||||
data: ServiceAccountRow[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
searchQuery: string;
|
||||
onPageChange: (page: number) => void;
|
||||
onRowClick?: (row: ServiceAccountRow) => void;
|
||||
}
|
||||
|
||||
function NameEmailCell({
|
||||
name,
|
||||
email,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="sa-name-email-cell">
|
||||
{name && (
|
||||
<span className="sa-name" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip title={email} overlayClassName="sa-tooltip">
|
||||
<span className="sa-email">{email}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesCell({ roles }: { roles: string[] }): JSX.Element {
|
||||
if (!roles || roles.length === 0) {
|
||||
return <span className="sa-dash">—</span>;
|
||||
}
|
||||
const first = roles[0];
|
||||
const overflow = roles.length - 1;
|
||||
const tooltipContent = roles.slice(1).join(', ');
|
||||
|
||||
return (
|
||||
<div className="sa-roles-cell">
|
||||
<Badge color="vanilla">{first}</Badge>
|
||||
{overflow > 0 && (
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
overlayClassName="sa-tooltip"
|
||||
overlayStyle={{ maxWidth: '600px' }}
|
||||
>
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
+{overflow}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
if (status?.toUpperCase() === 'ACTIVE') {
|
||||
return (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
DISABLED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceAccountsEmptyState({
|
||||
searchQuery,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="sa-empty-state">
|
||||
<span className="sa-empty-state__emoji" role="img" aria-label="monocle face">
|
||||
🧐
|
||||
</span>
|
||||
{searchQuery ? (
|
||||
<p className="sa-empty-state__text">
|
||||
No results for <strong>{searchQuery}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="sa-empty-state__text">
|
||||
No service accounts. Start by creating one to manage keys.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceAccountsTable({
|
||||
data,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
searchQuery,
|
||||
onPageChange,
|
||||
onRowClick,
|
||||
}: ServiceAccountsTableProps): JSX.Element {
|
||||
const columns: ColumnsType<ServiceAccountRow> = [
|
||||
{
|
||||
title: 'Name / Email',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
className: 'sa-name-column',
|
||||
sorter: (a, b): number => a.email.localeCompare(b.email),
|
||||
render: (_, record): JSX.Element => (
|
||||
<NameEmailCell name={record.name} email={record.email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
width: 420,
|
||||
render: (roles: string[]): JSX.Element => <RolesCell roles={roles} />,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
className: 'sa-status-cell',
|
||||
sorter: (a, b): number =>
|
||||
(a.status?.toUpperCase() === 'ACTIVE' ? 0 : 1) -
|
||||
(b.status?.toUpperCase() === 'ACTIVE' ? 0 : 1),
|
||||
render: (status: string): JSX.Element => <StatusBadge status={status} />,
|
||||
},
|
||||
];
|
||||
|
||||
const showPaginationTotal = (_total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-pagination-total"> of {_total}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sa-table-wrapper">
|
||||
<Table<ServiceAccountRow>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'sa-table-row--tinted' : ''
|
||||
}
|
||||
showSorterTooltip={false}
|
||||
locale={{
|
||||
emptyText: <ServiceAccountsEmptyState searchQuery={searchQuery} />,
|
||||
}}
|
||||
className="sa-table"
|
||||
onRow={(
|
||||
record,
|
||||
): {
|
||||
onClick?: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void;
|
||||
style?: React.CSSProperties;
|
||||
tabIndex?: number;
|
||||
role?: string;
|
||||
'aria-label'?: string;
|
||||
} => {
|
||||
if (!onRowClick) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
onClick: (): void => onRowClick(record),
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLElement>): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onRowClick(record);
|
||||
}
|
||||
},
|
||||
style: { cursor: 'pointer' },
|
||||
tabIndex: 0,
|
||||
role: 'button',
|
||||
'aria-label': `View service account ${record.name || record.email}`,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
{total > pageSize && (
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showTotal={showPaginationTotal}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
className="sa-table-pagination"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountsTable;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsTable from '../ServiceAccountsTable';
|
||||
|
||||
const mockActiveAccount: ServiceAccountRow = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockDisabledAccount: ServiceAccountRow = {
|
||||
id: 'sa-2',
|
||||
name: 'Legacy Bot',
|
||||
email: 'legacy@signoz.io',
|
||||
roles: ['signoz-viewer', 'signoz-editor', 'billing-manager'],
|
||||
status: 'DISABLED',
|
||||
createdAt: '2025-06-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
loading: false,
|
||||
total: 1,
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
searchQuery: '',
|
||||
onPageChange: jest.fn(),
|
||||
onRowClick: jest.fn(),
|
||||
};
|
||||
|
||||
describe('ServiceAccountsTable', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders name, email, role badge, and ACTIVE status badge', () => {
|
||||
render(<ServiceAccountsTable {...defaultProps} data={[mockActiveAccount]} />);
|
||||
|
||||
expect(screen.getByText('CI Bot')).toBeInTheDocument();
|
||||
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows DISABLED badge and +2 overflow badge for multi-role accounts', () => {
|
||||
render(
|
||||
<ServiceAccountsTable {...defaultProps} data={[mockDisabledAccount]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('DISABLED')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
|
||||
expect(screen.getByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRowClick with the correct account when a row is clicked', async () => {
|
||||
const onRowClick = jest.fn() as jest.MockedFunction<
|
||||
(row: ServiceAccountRow) => void
|
||||
>;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<ServiceAccountsTable
|
||||
{...defaultProps}
|
||||
data={[mockActiveAccount]}
|
||||
onRowClick={onRowClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /View service account CI Bot/i }),
|
||||
);
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'sa-1', email: 'ci-bot@signoz.io' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "No service accounts" empty state when data is empty and no search query', () => {
|
||||
render(
|
||||
<ServiceAccountsTable
|
||||
{...defaultProps}
|
||||
data={[]}
|
||||
total={0}
|
||||
searchQuery=""
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No service accounts/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No results for {query}" empty state when search is active', () => {
|
||||
render(
|
||||
<ServiceAccountsTable
|
||||
{...defaultProps}
|
||||
data={[]}
|
||||
total={0}
|
||||
searchQuery="ghost"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No results for/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('ghost')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -35,4 +35,5 @@ export enum LOCALSTORAGE {
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ const ROUTES = {
|
||||
METER_EXPLORER_VIEWS: '/meter/explorer/views',
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -30,14 +30,15 @@ export default function CustomDomainEditModal({
|
||||
onClearError,
|
||||
onSubmit,
|
||||
}: CustomDomainEditModalProps): JSX.Element {
|
||||
const [value, setValue] = useState(customDomainSubdomain ?? '');
|
||||
const initialSubdomain = customDomainSubdomain ?? '';
|
||||
const [value, setValue] = useState(initialSubdomain);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(customDomainSubdomain ?? '');
|
||||
setValue(initialSubdomain);
|
||||
}
|
||||
}, [isOpen, customDomainSubdomain]);
|
||||
}, [isOpen, initialSubdomain]);
|
||||
|
||||
const handleClose = (): void => {
|
||||
setValidationError(null);
|
||||
@@ -58,6 +59,11 @@ export default function CustomDomainEditModal({
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (value === initialSubdomain) {
|
||||
setValidationError('Input is unchanged');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
setValidationError('This field is required');
|
||||
return;
|
||||
@@ -84,7 +90,7 @@ export default function CustomDomainEditModal({
|
||||
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
const statusIcon = ((): JSX.Element => {
|
||||
const statusIcon = ((): JSX.Element | null => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoaderCircle size={16} className="animate-spin edit-modal-status-icon" />
|
||||
@@ -95,7 +101,9 @@ export default function CustomDomainEditModal({
|
||||
return <CircleAlert size={16} color={Color.BG_CHERRY_500} />;
|
||||
}
|
||||
|
||||
return <CircleCheck size={16} color={Color.BG_FOREST_500} />;
|
||||
return value && value.length >= 3 ? (
|
||||
<CircleCheck size={16} color={Color.BG_FOREST_500} />
|
||||
) : null;
|
||||
})();
|
||||
|
||||
return (
|
||||
@@ -189,7 +197,7 @@ export default function CustomDomainEditModal({
|
||||
color="primary"
|
||||
className="edit-modal-apply-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || value === initialSubdomain}
|
||||
loading={isLoading}
|
||||
>
|
||||
Apply Changes
|
||||
|
||||
@@ -81,6 +81,10 @@
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-row.workspace-name-hidden {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-timezone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -117,32 +121,6 @@
|
||||
background: var(--l2-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-license {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-plan-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-trigger {
|
||||
|
||||
@@ -69,8 +69,9 @@ function DomainUpdateToast({
|
||||
}
|
||||
|
||||
export default function CustomDomainSettings(): JSX.Element {
|
||||
const { org, activeLicense } = useAppContext();
|
||||
const { org } = useAppContext();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isPollingEnabled, setIsPollingEnabled] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
|
||||
@@ -175,7 +176,8 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
[hosts, activeHost],
|
||||
);
|
||||
|
||||
const planName = activeLicense?.plan?.name;
|
||||
const workspaceName =
|
||||
org?.[0]?.displayName || customDomainSubdomain || activeHost?.name;
|
||||
|
||||
if (isLoadingHosts) {
|
||||
return (
|
||||
@@ -191,106 +193,98 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="custom-domain-card">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
{!!workspaceName && (
|
||||
<div className="custom-domain-card-name-row">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-org-name">
|
||||
{org?.[0]?.displayName ? org?.[0]?.displayName : customDomainSubdomain}
|
||||
</span>
|
||||
<span className="custom-domain-card-org-name">{workspaceName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="custom-domain-card-meta-row">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
<div
|
||||
className={`custom-domain-card-meta-row ${
|
||||
!workspaceName ? 'workspace-name-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="custom-domain-card-divider" />
|
||||
|
||||
<div className="custom-domain-card-bottom">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-license">
|
||||
{planName && <code className="custom-domain-plan-badge">{planName}</code>}{' '}
|
||||
license is currently active
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomDomainEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={(): void => setIsEditModalOpen(false)}
|
||||
|
||||
@@ -239,4 +239,87 @@ describe('CustomDomainSettings', () => {
|
||||
const { container } = render(toastRenderer('test-id'));
|
||||
expect(container).toHaveTextContent(/myteam\.test\.cloud/i);
|
||||
});
|
||||
|
||||
describe('Workspace Name rendering', () => {
|
||||
it('renders org displayName when available from appContext', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: {
|
||||
org: [{ id: 'xyz', displayName: 'My Org Name', createdAt: 0 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('My Org Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to customDomainSubdomain when org displayName is missing', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
expect(await screen.findByText('custom-host')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to activeHost.name when neither org name nor custom domain exists', async () => {
|
||||
const onlyDefaultHostResponse = {
|
||||
...mockHostsResponse,
|
||||
data: {
|
||||
...mockHostsResponse.data,
|
||||
hosts: mockHostsResponse.data.hosts
|
||||
? [mockHostsResponse.data.hosts[0]]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(onlyDefaultHostResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
// 'accepted-starfish' is the default host's name
|
||||
expect(await screen.findByText('accepted-starfish')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the card name row if workspaceName is totally falsy', async () => {
|
||||
const emptyHostsResponse = {
|
||||
...mockHostsResponse,
|
||||
data: {
|
||||
...mockHostsResponse.data,
|
||||
hosts: [],
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(emptyHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const { container } = render(<CustomDomainSettings />, undefined, {
|
||||
appContextOverrides: { org: [] },
|
||||
});
|
||||
|
||||
await screen.findByRole('button', { name: /edit workspace link/i });
|
||||
|
||||
expect(
|
||||
container.querySelector('.custom-domain-card-name-row'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,11 +34,6 @@ const mockSafeNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
useRouteMatch: jest.fn().mockReturnValue({
|
||||
params: {
|
||||
dashboardId: 4,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
@@ -69,7 +64,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -110,7 +105,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -149,7 +144,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardProvider dashboardId="4">
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
@@ -199,8 +194,6 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
setLayouts: jest.fn(),
|
||||
setSelectedDashboard: jest.fn(),
|
||||
updatedTimeRef: { current: null },
|
||||
toScrollWidgetId: '',
|
||||
setToScrollWidgetId: jest.fn(),
|
||||
updateLocalStorageDashboardVariables: jest.fn(),
|
||||
dashboardQueryRangeCalled: false,
|
||||
setDashboardQueryRangeCalled: jest.fn(),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
|
||||
import { useScrollWidgetIntoView } from '../useScrollWidgetIntoView';
|
||||
|
||||
jest.mock('providers/Dashboard/Dashboard');
|
||||
jest.mock('providers/Dashboard/helpers/scrollToWidgetIdHelper');
|
||||
|
||||
type MockHTMLElement = {
|
||||
scrollIntoView: jest.Mock;
|
||||
@@ -18,25 +18,35 @@ function createMockElement(): MockHTMLElement {
|
||||
}
|
||||
|
||||
describe('useScrollWidgetIntoView', () => {
|
||||
const mockedUseDashboard = useDashboard as jest.MockedFunction<
|
||||
typeof useDashboard
|
||||
const mockedUseScrollToWidgetIdStore = useScrollToWidgetIdStore as jest.MockedFunction<
|
||||
typeof useScrollToWidgetIdStore
|
||||
>;
|
||||
|
||||
let mockElement: MockHTMLElement;
|
||||
let ref: React.RefObject<HTMLDivElement>;
|
||||
let setToScrollWidgetId: jest.Mock;
|
||||
|
||||
function mockStore(toScrollWidgetId: string): void {
|
||||
const storeState = { toScrollWidgetId, setToScrollWidgetId };
|
||||
mockedUseScrollToWidgetIdStore.mockImplementation(
|
||||
(selector) =>
|
||||
selector(
|
||||
(storeState as unknown) as Parameters<typeof selector>[0],
|
||||
) as ReturnType<typeof useScrollToWidgetIdStore>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockElement = createMockElement();
|
||||
ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
setToScrollWidgetId = jest.fn();
|
||||
});
|
||||
|
||||
it('scrolls into view and focuses when toScrollWidgetId matches widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'widget-id',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
mockStore('widget-id');
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
@@ -49,16 +59,7 @@ describe('useScrollWidgetIntoView', () => {
|
||||
});
|
||||
|
||||
it('does nothing when toScrollWidgetId does not match widget id', () => {
|
||||
const setToScrollWidgetId = jest.fn();
|
||||
const mockElement = createMockElement();
|
||||
const ref = ({
|
||||
current: mockElement,
|
||||
} as unknown) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
mockedUseDashboard.mockReturnValue(({
|
||||
toScrollWidgetId: 'other-widget',
|
||||
setToScrollWidgetId,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
mockStore('other-widget');
|
||||
|
||||
renderHook(() => useScrollWidgetIntoView('widget-id', ref));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useScrollToWidgetIdStore } from 'providers/Dashboard/helpers/scrollToWidgetIdHelper';
|
||||
|
||||
/**
|
||||
* Scrolls the given widget container into view when the dashboard
|
||||
@@ -11,7 +11,10 @@ export function useScrollWidgetIntoView<T extends HTMLElement>(
|
||||
widgetId: string,
|
||||
widgetContainerRef: RefObject<T>,
|
||||
): void {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const toScrollWidgetId = useScrollToWidgetIdStore((s) => s.toScrollWidgetId);
|
||||
const setToScrollWidgetId = useScrollToWidgetIdStore(
|
||||
(s) => s.setToScrollWidgetId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widgetId) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -34,8 +33,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -32,8 +31,6 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
const config = useMemo(() => {
|
||||
return prepareHistogramPanelConfig({
|
||||
widget,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -33,8 +32,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useScrollWidgetIntoView(widget.id, graphRef);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import setRetentionApi from 'api/settings/setRetention';
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import CustomDomainSettings from 'container/CustomDomainSettings';
|
||||
import LicenseKeyRow from 'container/GeneralSettings/LicenseKeyRow/LicenseKeyRow';
|
||||
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
@@ -81,7 +82,7 @@ function GeneralSettings({
|
||||
logsTtlValuesPayload,
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { user, activeLicense } = useAppContext();
|
||||
|
||||
const [setRetentionPermission] = useComponentPermission(
|
||||
['set_retention_period'],
|
||||
@@ -680,7 +681,15 @@ function GeneralSettings({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
{(showCustomDomainSettings || activeLicense?.key) && (
|
||||
<div className="custom-domain-card">
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
{showCustomDomainSettings && activeLicense?.key && (
|
||||
<div className="custom-domain-card-divider" />
|
||||
)}
|
||||
{activeLicense?.key && <LicenseKeyRow />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="retention-controls-container">
|
||||
<div className="retention-controls-header">
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
.license-key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-2) var(--padding-3);
|
||||
gap: var(--spacing-5);
|
||||
|
||||
&__left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&__code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px 0 0 2px;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
margin-right: -1px;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
padding: 1px 2px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
height: 24px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Copy, KeyRound } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getMaskedKey } from 'utils/maskedKey';
|
||||
|
||||
import './LicenseKeyRow.styles.scss';
|
||||
|
||||
function LicenseKeyRow(): JSX.Element | null {
|
||||
const { activeLicense } = useAppContext();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
if (!activeLicense?.key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCopyLicenseKey = (text: string): void => {
|
||||
copyToClipboard(text);
|
||||
toast.success('License key copied to clipboard.', { richColors: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="license-key-row">
|
||||
<span className="license-key-row__left">
|
||||
<KeyRound size={14} />
|
||||
<span className="license-key-row__label">SigNoz License Key</span>
|
||||
</span>
|
||||
<span className="license-key-row__value">
|
||||
<code className="license-key-row__code">
|
||||
{getMaskedKey(activeLicense.key)}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
aria-label="Copy license key"
|
||||
data-testid="license-key-row-copy-btn"
|
||||
className="license-key-row__copy-btn"
|
||||
onClick={(): void => handleCopyLicenseKey(activeLicense.key)}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseKeyRow;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LicenseKeyRow from '../LicenseKeyRow';
|
||||
|
||||
const mockCopyToClipboard = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
|
||||
}));
|
||||
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('LicenseKeyRow', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing when activeLicense key is absent', () => {
|
||||
const { container } = render(<LicenseKeyRow />, undefined, {
|
||||
appContextOverrides: { activeLicense: null },
|
||||
});
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders label and masked key when activeLicense key exists', () => {
|
||||
render(<LicenseKeyRow />, undefined, {
|
||||
appContextOverrides: {
|
||||
activeLicense: { key: 'abcdefghij' } as any,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('SigNoz License Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('ab·······ij')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls copyToClipboard and shows success toast when clipboard is available', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<LicenseKeyRow />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /copy license key/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('test-key');
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'License key copied to clipboard.',
|
||||
{
|
||||
richColors: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useScrollWidgetIntoView } from 'container/DashboardContainer/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
|
||||
@@ -67,11 +68,7 @@ function GridCardGraph({
|
||||
const [isInternalServerError, setIsInternalServerError] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const {
|
||||
toScrollWidgetId,
|
||||
setToScrollWidgetId,
|
||||
setDashboardQueryRangeCalled,
|
||||
} = useDashboard();
|
||||
const { setDashboardQueryRangeCalled } = useDashboard();
|
||||
|
||||
const {
|
||||
minTime,
|
||||
@@ -109,20 +106,11 @@ function GridCardGraph({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const widgetContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isVisible = useIntersectionObserver(graphRef, undefined, true);
|
||||
const isVisible = useIntersectionObserver(widgetContainerRef, undefined, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (toScrollWidgetId === widget.id) {
|
||||
graphRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
graphRef.current?.focus();
|
||||
setToScrollWidgetId('');
|
||||
}
|
||||
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
|
||||
useScrollWidgetIntoView(widget?.id || '', widgetContainerRef);
|
||||
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
@@ -306,7 +294,7 @@ function GridCardGraph({
|
||||
: headerMenuList;
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={widgetContainerRef}>
|
||||
{isEmptyLayout ? (
|
||||
<EmptyWidget />
|
||||
) : (
|
||||
|
||||
@@ -6,13 +6,16 @@ import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import AnnouncementBanner from 'components/AnnouncementBanner';
|
||||
import Header from 'components/Header/Header';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
|
||||
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import history from 'lib/history';
|
||||
@@ -289,6 +292,18 @@ export default function Home(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
{IS_SERVICE_ACCOUNTS_ENABLED && (
|
||||
<AnnouncementBanner
|
||||
type="warning"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
message={`<strong>API Keys</strong> have been deprecated and replaced by <strong>Service Accounts</strong>. Please migrate to Service Accounts for programmatic API access.`}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
leftComponent={
|
||||
|
||||
@@ -5,7 +5,6 @@ import NewWidget from 'container/NewWidget';
|
||||
import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import i18n from 'ReactI18';
|
||||
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
@@ -104,15 +103,13 @@ describe('LogsPanelComponent', () => {
|
||||
const renderComponent = async (): Promise<void> => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
fillSpans={undefined}
|
||||
yAxisUnit={undefined}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</DashboardProvider>
|
||||
<PreferenceContextProvider>
|
||||
<NewWidget
|
||||
dashboardId=""
|
||||
selectedDashboard={undefined}
|
||||
selectedGraph={PANEL_TYPES.LIST}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user