mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-30 20:00:44 +01:00
Compare commits
30 Commits
refactor/u
...
nv/dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65835394c0 | ||
|
|
f132b7e53a | ||
|
|
d4ae156dc4 | ||
|
|
d6bdf9c2b2 | ||
|
|
7ea654f1aa | ||
|
|
3fd7d013a1 | ||
|
|
fb921dd381 | ||
|
|
58020d9e00 | ||
|
|
7a5933e822 | ||
|
|
2533683de6 | ||
|
|
2670d53170 | ||
|
|
8943a9454b | ||
|
|
9a7ed5b711 | ||
|
|
2d75e3d32d | ||
|
|
1d6eabf927 | ||
|
|
082d7b1b77 | ||
|
|
5019dee2d7 | ||
|
|
216de973fb | ||
|
|
18c0eec5e2 | ||
|
|
2ccdeb3631 | ||
|
|
ad12e50bbc | ||
|
|
e247bf3864 | ||
|
|
f4651ea134 | ||
|
|
d449a2dbf2 | ||
|
|
d4b9f91062 | ||
|
|
530710b7bc | ||
|
|
4fb5eec08d | ||
|
|
f889d36f0f | ||
|
|
db12d44523 | ||
|
|
86fc0e81ba |
7
.github/workflows/build-enterprise.yaml
vendored
7
.github/workflows/build-enterprise.yaml
vendored
@@ -61,6 +61,13 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> frontend/.env
|
||||
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
|
||||
6
.github/workflows/build-staging.yaml
vendored
6
.github/workflows/build-staging.yaml
vendored
@@ -67,6 +67,12 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
|
||||
7
.github/workflows/gor-signoz.yaml
vendored
7
.github/workflows/gor-signoz.yaml
vendored
@@ -27,6 +27,13 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> .env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> .env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> .env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> .env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> .env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> .env
|
||||
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> .env
|
||||
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env
|
||||
|
||||
@@ -65,31 +65,15 @@ web:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: false
|
||||
# The PostHog project API key.
|
||||
key: ""
|
||||
# The PostHog API host. Defaults to https://us.i.posthog.com when empty.
|
||||
api_host: ""
|
||||
# The PostHog UI host. Used when api_host points at a reverse proxy.
|
||||
ui_host: ""
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: false
|
||||
# The Appcues account/app ID.
|
||||
app_id: ""
|
||||
sentry:
|
||||
# Whether to enable Sentry in web.
|
||||
enabled: false
|
||||
# The Sentry DSN.
|
||||
dsn: ""
|
||||
# The Sentry tunnel URL.
|
||||
tunnel: ""
|
||||
pylon:
|
||||
# Whether to enable Pylon in web.
|
||||
enabled: false
|
||||
# The Pylon app ID.
|
||||
app_id: ""
|
||||
# The Pylon identity verification secret.
|
||||
identity_secret: ""
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
|
||||
@@ -2626,7 +2626,6 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -3568,7 +3567,6 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -3605,7 +3603,6 @@ components:
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
@@ -6212,25 +6209,6 @@ components:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
Querybuildertypesv5PreviewStatement:
|
||||
properties:
|
||||
db.statement.args:
|
||||
items: {}
|
||||
type: array
|
||||
db.statement.query:
|
||||
type: string
|
||||
estimate:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesEstimateEntry'
|
||||
type: array
|
||||
granules:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesGranules'
|
||||
required:
|
||||
- db.statement.query
|
||||
- db.statement.args
|
||||
- estimate
|
||||
- granules
|
||||
type: object
|
||||
Querybuildertypesv5PromQuery:
|
||||
properties:
|
||||
disabled:
|
||||
@@ -6551,40 +6529,6 @@ components:
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
Querybuildertypesv5QueryPreview:
|
||||
properties:
|
||||
error: {}
|
||||
statements:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5PreviewStatement'
|
||||
type: array
|
||||
valid:
|
||||
type: boolean
|
||||
warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- valid
|
||||
- error
|
||||
- warnings
|
||||
- statements
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangePreviewResponse:
|
||||
description: Response from the v5 query range preview (dry-run) endpoint. For
|
||||
each query in the composite query, returns the underlying ClickHouse statement(s)
|
||||
it renders to without executing them (one per PromQL metric selector; exactly
|
||||
one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN
|
||||
ESTIMATE and granule analysis attached per statement when requested.
|
||||
properties:
|
||||
compositeQuery:
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryPreview'
|
||||
nullable: true
|
||||
type: object
|
||||
required:
|
||||
- compositeQuery
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangeRequest:
|
||||
description: Request body for the v5 query range endpoint. Supports builder
|
||||
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
|
||||
@@ -7935,96 +7879,6 @@ components:
|
||||
- key
|
||||
- value
|
||||
type: object
|
||||
TelemetrystoretypesEstimateEntry:
|
||||
properties:
|
||||
database:
|
||||
type: string
|
||||
marks:
|
||||
format: int64
|
||||
type: integer
|
||||
parts:
|
||||
format: int64
|
||||
type: integer
|
||||
rows:
|
||||
format: int64
|
||||
type: integer
|
||||
table:
|
||||
type: string
|
||||
required:
|
||||
- database
|
||||
- table
|
||||
- parts
|
||||
- rows
|
||||
- marks
|
||||
type: object
|
||||
TelemetrystoretypesGranules:
|
||||
nullable: true
|
||||
properties:
|
||||
initial:
|
||||
format: int64
|
||||
type: integer
|
||||
reads:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesMergeTreeRead'
|
||||
type: array
|
||||
selected:
|
||||
format: int64
|
||||
type: integer
|
||||
skipped:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- initial
|
||||
- selected
|
||||
- skipped
|
||||
- reads
|
||||
type: object
|
||||
TelemetrystoretypesIndexStep:
|
||||
properties:
|
||||
condition:
|
||||
type: string
|
||||
initialGranules:
|
||||
format: int64
|
||||
type: integer
|
||||
initialParts:
|
||||
format: int64
|
||||
type: integer
|
||||
keys:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
name:
|
||||
type: string
|
||||
selectedGranules:
|
||||
format: int64
|
||||
type: integer
|
||||
selectedParts:
|
||||
format: int64
|
||||
type: integer
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- keys
|
||||
- condition
|
||||
- initialParts
|
||||
- selectedParts
|
||||
- initialGranules
|
||||
- selectedGranules
|
||||
type: object
|
||||
TelemetrystoretypesMergeTreeRead:
|
||||
properties:
|
||||
steps:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrystoretypesIndexStep'
|
||||
type: array
|
||||
table:
|
||||
type: string
|
||||
required:
|
||||
- table
|
||||
- steps
|
||||
type: object
|
||||
TelemetrytypesFieldContext:
|
||||
enum:
|
||||
- metric
|
||||
@@ -23556,75 +23410,6 @@ paths:
|
||||
summary: Query range
|
||||
tags:
|
||||
- querier
|
||||
/api/v5/query_range/preview:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Validate a composite query without executing it. Accepts the same
|
||||
payload as the query range endpoint. By default (verbose=true) returns, for
|
||||
each query, the rendered underlying ClickHouse statement(s) with each statement''s
|
||||
EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving
|
||||
granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight
|
||||
per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse
|
||||
round trips. Intended for agentic/dry-run consumption: per-query errors are
|
||||
reported in the response rather than failing the whole request.'
|
||||
operationId: QueryRangePreviewV5
|
||||
parameters:
|
||||
- in: query
|
||||
name: verbose
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangePreviewResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Query range preview
|
||||
tags:
|
||||
- querier
|
||||
/api/v5/substitute_vars:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -101,10 +101,6 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRange(rw, req)
|
||||
}
|
||||
|
||||
func (h *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRangePreview(rw, req)
|
||||
}
|
||||
|
||||
func (h *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
|
||||
h.community.QueryRawStream(rw, req)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,8 @@
|
||||
NODE_ENV="development"
|
||||
BUNDLE_ANALYSER="true"
|
||||
CI="1"
|
||||
|
||||
# API
|
||||
VITE_BASE_PATH=""
|
||||
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
|
||||
VITE_WEBSOCKET_API_ENDPOINT=""
|
||||
VITE_PYLON_APP_ID="pylon-app-id"
|
||||
VITE_APPCUES_APP_ID="appcess-app-id"
|
||||
VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret"
|
||||
|
||||
# Pylon
|
||||
VITE_PYLON_ENABLED="false"
|
||||
VITE_PYLON_APP_ID=""
|
||||
VITE_PYLON_IDENTITY_SECRET=""
|
||||
|
||||
# Appcues
|
||||
VITE_APPCUES_ENABLED="false"
|
||||
VITE_APPCUES_APP_ID=""
|
||||
|
||||
# PostHog
|
||||
VITE_POSTHOG_ENABLED="false"
|
||||
VITE_POSTHOG_API_HOST=""
|
||||
VITE_POSTHOG_KEY=""
|
||||
VITE_POSTHOG_UI_HOST=""
|
||||
|
||||
# Sentry
|
||||
VITE_SENTRY_ENABLED="false"
|
||||
VITE_SENTRY_AUTH_TOKEN=""
|
||||
VITE_SENTRY_ORG=""
|
||||
VITE_SENTRY_PROJECT_ID=""
|
||||
VITE_SENTRY_TUNNEL=""
|
||||
VITE_SENTRY_DSN=""
|
||||
|
||||
# Docs
|
||||
VITE_DOCS_BASE_URL="https://signoz.io"
|
||||
|
||||
# Build info
|
||||
VITE_ENVIRONMENT="development"
|
||||
VITE_VERSION=""
|
||||
CI="1"
|
||||
|
||||
@@ -111,10 +111,11 @@
|
||||
<div id="root"></div>
|
||||
|
||||
<script>
|
||||
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
|
||||
var pylonSettings =
|
||||
((window.signozBootData || {}).settings || {}).pylon || {};
|
||||
var pylonEnabled = pylonSettings.enabled === true;
|
||||
if (pylonSettings.appId && pylonEnabled) {
|
||||
var pylonEnabled = pylonSettings.enabled !== false;
|
||||
if (PYLON_APP_ID && pylonEnabled) {
|
||||
(function () {
|
||||
var e = window;
|
||||
var t = document;
|
||||
@@ -132,7 +133,7 @@
|
||||
e.setAttribute('async', 'true');
|
||||
e.setAttribute(
|
||||
'src',
|
||||
'https://widget.usepylon.com/widget/' + pylonSettings.appId,
|
||||
'https://widget.usepylon.com/widget/' + PYLON_APP_ID,
|
||||
);
|
||||
var n = t.getElementsByTagName('script')[0];
|
||||
n.parentNode.insertBefore(e, n);
|
||||
@@ -149,14 +150,15 @@
|
||||
window.AppcuesSettings = { enableURLDetection: true };
|
||||
</script>
|
||||
<script>
|
||||
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
|
||||
var appcuesSettings =
|
||||
((window.signozBootData || {}).settings || {}).appcues || {};
|
||||
var appcuesEnabled = appcuesSettings.enabled === true;
|
||||
if (appcuesSettings.appId && appcuesEnabled) {
|
||||
var appcuesEnabled = appcuesSettings.enabled !== false;
|
||||
if (APPCUES_APP_ID && appcuesEnabled) {
|
||||
(function (d, t) {
|
||||
var a = d.createElement(t);
|
||||
a.async = 1;
|
||||
a.src = '//fast.appcues.com/' + appcuesSettings.appId + '.js';
|
||||
a.src = '//fast.appcues.com/' + APPCUES_APP_ID + '.js';
|
||||
var s = d.getElementsByTagName(t)[0];
|
||||
s.parentNode.insertBefore(a, s);
|
||||
})(document, 'script');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"project": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"ignore": ["src/api/generated/**/*.ts", "src/typings/*.ts"],
|
||||
"ignoreDependencies": [
|
||||
"http-proxy-middleware",
|
||||
"@typescript/native-preview"
|
||||
]
|
||||
}
|
||||
@@ -79,6 +79,7 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
|
||||
22
frontend/pnpm-lock.yaml
generated
22
frontend/pnpm-lock.yaml
generated
@@ -164,6 +164,9 @@ importers:
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
http-proxy-middleware:
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -5458,6 +5461,10 @@ packages:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
http-proxy-middleware@4.1.1:
|
||||
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
|
||||
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
|
||||
|
||||
http-status-codes@2.3.0:
|
||||
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
|
||||
|
||||
@@ -5465,6 +5472,9 @@ packages:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
httpxy@0.5.3:
|
||||
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
|
||||
|
||||
human-signals@2.1.0:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
engines: {node: '>=10.17.0'}
|
||||
@@ -14505,6 +14515,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-proxy-middleware@4.1.1:
|
||||
dependencies:
|
||||
debug: 4.3.4(supports-color@5.5.0)
|
||||
httpxy: 0.5.3
|
||||
is-glob: 4.0.3
|
||||
is-plain-obj: 4.1.0
|
||||
micromatch: 4.0.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-status-codes@2.3.0: {}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
@@ -14514,6 +14534,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
httpxy@0.5.3: {}
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
@@ -292,10 +292,10 @@ function App(): JSX.Element {
|
||||
isChatSupportEnabled &&
|
||||
!showAddCreditCardModal &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) &&
|
||||
window.signozBootData?.settings?.pylon?.enabled
|
||||
(window.signozBootData?.settings?.pylon.enabled ?? true)
|
||||
) {
|
||||
const email = user.email || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
const secret = process.env.PYLON_IDENTITY_SECRET || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
@@ -304,7 +304,7 @@ function App(): JSX.Element {
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
app_id: process.env.PYLON_APP_ID,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
@@ -335,23 +335,22 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (isCloudUser || isEnterpriseSelfHostedUser) {
|
||||
if (
|
||||
window.signozBootData?.settings?.posthog?.enabled &&
|
||||
window.signozBootData?.settings?.posthog?.key
|
||||
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
|
||||
process.env.POSTHOG_KEY
|
||||
) {
|
||||
posthog.init(window.signozBootData.settings.posthog.key, {
|
||||
api_host: window.signozBootData.settings.posthog.apiHost,
|
||||
ui_host: window.signozBootData.settings.posthog.uiHost,
|
||||
posthog.init(process.env.POSTHOG_KEY, {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isSentryInitialized &&
|
||||
window.signozBootData?.settings?.sentry?.enabled
|
||||
(window.signozBootData?.settings?.sentry.enabled ?? true)
|
||||
) {
|
||||
Sentry.init({
|
||||
dsn: window.signozBootData.settings.sentry.dsn,
|
||||
tunnel: window.signozBootData.settings.sentry.tunnel,
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tunnel: process.env.TUNNEL_URL,
|
||||
environment: process.env.ENVIRONMENT,
|
||||
release: process.env.VERSION,
|
||||
integrations: [
|
||||
|
||||
@@ -12,8 +12,6 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
QueryRangePreviewV5200,
|
||||
QueryRangePreviewV5Params,
|
||||
QueryRangeV5200,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -106,107 +104,6 @@ export const useQueryRangeV5 = <
|
||||
> => {
|
||||
return useMutation(getQueryRangeV5MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.
|
||||
* @summary Query range preview
|
||||
*/
|
||||
export const queryRangePreviewV5 = (
|
||||
querybuildertypesv5QueryRangeRequestDTO?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
|
||||
params?: QueryRangePreviewV5Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<QueryRangePreviewV5200>({
|
||||
url: `/api/v5/query_range/preview`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: querybuildertypesv5QueryRangeRequestDTO,
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getQueryRangePreviewV5MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['queryRangePreviewV5'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
}
|
||||
> = (props) => {
|
||||
const { data, params } = props ?? {};
|
||||
|
||||
return queryRangePreviewV5(data, params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>
|
||||
>;
|
||||
export type QueryRangePreviewV5MutationBody =
|
||||
| BodyType<Querybuildertypesv5QueryRangeRequestDTO>
|
||||
| undefined;
|
||||
export type QueryRangePreviewV5MutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Query range preview
|
||||
*/
|
||||
export const useQueryRangePreviewV5 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof queryRangePreviewV5>>,
|
||||
TError,
|
||||
{
|
||||
data?: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
|
||||
params?: QueryRangePreviewV5Params;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getQueryRangePreviewV5MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Replace variables in a query
|
||||
* @summary Replace variables
|
||||
|
||||
@@ -3338,7 +3338,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -3866,7 +3865,6 @@ export interface DashboardtypesComparisonThresholdDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -4147,7 +4145,6 @@ export interface DashboardtypesTableThresholdDTO {
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
@@ -7555,126 +7552,6 @@ export interface Querybuildertypesv5FormatOptionsDTO {
|
||||
formatTableResultForUI?: boolean;
|
||||
}
|
||||
|
||||
export interface TelemetrystoretypesEstimateEntryDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
database: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
marks: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
parts: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
rows: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
table: string;
|
||||
}
|
||||
|
||||
export interface TelemetrystoretypesIndexStepDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
condition: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initialGranules: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initialParts: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
keys: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selectedGranules: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selectedParts: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TelemetrystoretypesMergeTreeReadDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
steps: TelemetrystoretypesIndexStepDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
table: string;
|
||||
}
|
||||
|
||||
export type TelemetrystoretypesGranulesDTOAnyOf = {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
initial: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reads: TelemetrystoretypesMergeTreeReadDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
selected: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
skipped: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TelemetrystoretypesGranulesDTO =
|
||||
TelemetrystoretypesGranulesDTOAnyOf | null;
|
||||
|
||||
export interface Querybuildertypesv5PreviewStatementDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
'db.statement.args': unknown[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
'db.statement.query': string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
estimate: TelemetrystoretypesEstimateEntryDTO[];
|
||||
granules: TelemetrystoretypesGranulesDTO | null;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -7756,41 +7633,6 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
|
||||
export interface Querybuildertypesv5QueryPreviewDTO {
|
||||
error: unknown;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
statements: Querybuildertypesv5PreviewStatementDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
valid: boolean;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf =
|
||||
{ [key: string]: Querybuildertypesv5QueryPreviewDTO };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery =
|
||||
Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQueryAnyOf | null;
|
||||
|
||||
/**
|
||||
* Response from the v5 query range preview (dry-run) endpoint. For each query in the composite query, returns the underlying ClickHouse statement(s) it renders to without executing them (one per PromQL metric selector; exactly one for builder/ClickHouse/trace-operator queries), with the optional EXPLAIN ESTIMATE and granule analysis attached per statement when requested.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangePreviewResponseDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
compositeQuery: Querybuildertypesv5QueryRangePreviewResponseDTOCompositeQuery;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5VariableTypeDTO {
|
||||
query = 'query',
|
||||
dynamic = 'dynamic',
|
||||
@@ -11665,22 +11507,6 @@ export type QueryRangeV5200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
verbose?: string;
|
||||
};
|
||||
|
||||
export type QueryRangePreviewV5200 = {
|
||||
data: Querybuildertypesv5QueryRangePreviewResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ReplaceVariables200 = {
|
||||
data: Querybuildertypesv5QueryRangeRequestDTO;
|
||||
/**
|
||||
|
||||
@@ -167,7 +167,6 @@ describe('InviteMembers - Submission', () => {
|
||||
success: false,
|
||||
}),
|
||||
]),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -244,7 +243,6 @@ describe('InviteMembers - Submission', () => {
|
||||
error: 'User already exists',
|
||||
}),
|
||||
]),
|
||||
expect.any(Array),
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -22,9 +22,9 @@ export interface FooterRenderProps {
|
||||
|
||||
export interface UseInviteMembersOptions {
|
||||
initialRowCount?: number;
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
}
|
||||
|
||||
export interface UseInviteMembersReturn {
|
||||
@@ -56,9 +56,9 @@ export interface InviteMembersProps {
|
||||
showHeader?: boolean;
|
||||
showAddButton?: boolean;
|
||||
|
||||
onSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onPartialSuccess?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onAllFailed?: (results: InviteResult[], rows: InviteMemberRow[]) => void;
|
||||
onSuccess?: () => void;
|
||||
onPartialSuccess?: (results: InviteResult[]) => void;
|
||||
onAllFailed?: (results: InviteResult[]) => void;
|
||||
|
||||
renderFooter?: (props: FooterRenderProps) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -207,11 +207,11 @@ export function useInviteMembers(
|
||||
const successes = results.filter((r) => r.success);
|
||||
|
||||
if (failures.length === 0) {
|
||||
onSuccess?.(results, touched);
|
||||
onSuccess?.();
|
||||
} else if (successes.length > 0) {
|
||||
onPartialSuccess?.(results, touched);
|
||||
onPartialSuccess?.(results);
|
||||
} else {
|
||||
onAllFailed?.(results, touched);
|
||||
onAllFailed?.(results);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
.invite-members-modal {
|
||||
max-width: 700px;
|
||||
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(--l1-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.invite-members-modal__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.invite-members-modal__table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
.email-header {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
.role-header {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-header {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
.table-header-cell {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.team-member-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
|
||||
> .email-cell {
|
||||
flex: 0 0 240px;
|
||||
}
|
||||
|
||||
> .role-cell {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
> .action-cell {
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
&.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-email-input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--l1-border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.team-member-role-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
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(--l1-foreground);
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.remove-team-member-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--destructive);
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
opacity 0.2s;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.email-error-message {
|
||||
display: block;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.invite-team-members-error-callout {
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
border-radius: 4px;
|
||||
animation: horizontal-shaking 300ms ease-out;
|
||||
}
|
||||
|
||||
.invite-members-modal__error-callout {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes horizontal-shaking {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.invite-members-modal__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.add-another-member-button {
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
border-style: dashed;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { ChevronDown, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Select } from 'antd';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './InviteMembersModal.styles.scss';
|
||||
|
||||
interface InviteRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: ROLES | '';
|
||||
}
|
||||
|
||||
export interface InviteMembersModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
|
||||
|
||||
const isRowTouched = (row: InviteRow): boolean =>
|
||||
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
function InviteMembersModal({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const { showErrorModal, isErrorModalVisible } = useErrorModal();
|
||||
|
||||
const [rows, setRows] = useState<InviteRow[]>(() => [
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
EMPTY_ROW(),
|
||||
]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
|
||||
|
||||
const resetAndClose = useCallback((): void => {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
setEmailValidity({});
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const getValidationErrorMessage = (): string => {
|
||||
if (hasInvalidEmails && hasInvalidRoles) {
|
||||
return 'Please enter valid emails and select roles for team members';
|
||||
}
|
||||
if (hasInvalidEmails) {
|
||||
return 'Please enter valid emails for team members';
|
||||
}
|
||||
return 'Please select roles for team members';
|
||||
};
|
||||
|
||||
const validateAllUsers = useCallback((): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
|
||||
touchedRows.forEach((row) => {
|
||||
const emailValid = EMAIL_REGEX.test(row.email);
|
||||
const roleValid = Boolean(row.role && row.role.trim() !== '');
|
||||
|
||||
if (!emailValid || !row.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
if (row.id) {
|
||||
updatedEmailValidity[row.id] = emailValid;
|
||||
}
|
||||
});
|
||||
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
return isValid;
|
||||
}, [rows]);
|
||||
|
||||
const debouncedValidateEmail = useMemo(
|
||||
() =>
|
||||
debounce((email: string, rowId: string) => {
|
||||
const isValid = EMAIL_REGEX.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
debouncedValidateEmail.cancel();
|
||||
}
|
||||
return (): void => {
|
||||
debouncedValidateEmail.cancel();
|
||||
};
|
||||
}, [open, debouncedValidateEmail]);
|
||||
|
||||
const updateEmail = (id: string, email: string): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.email = email;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidEmails) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
if (emailValidity[id] === false) {
|
||||
setEmailValidity((prev) => ({ ...prev, [id]: true }));
|
||||
}
|
||||
|
||||
debouncedValidateEmail(email, id);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRole = (id: string, role: ROLES): void => {
|
||||
const updatedRows = cloneDeep(rows);
|
||||
const rowToUpdate = updatedRows.find((r) => r.id === id);
|
||||
if (rowToUpdate) {
|
||||
rowToUpdate.role = role;
|
||||
setRows(updatedRows);
|
||||
|
||||
if (hasInvalidRoles) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addRow = (): void => {
|
||||
setRows((prev) => [...prev, EMPTY_ROW()]);
|
||||
};
|
||||
|
||||
const removeRow = (id: string): void => {
|
||||
setRows((prev) => prev.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
if (!validateAllUsers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
if (touchedRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (touchedRows.length === 1) {
|
||||
const row = touchedRows[0];
|
||||
await sendInvite({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role as ROLES,
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
});
|
||||
} else {
|
||||
await inviteUsers({
|
||||
invites: touchedRows.map((row) => ({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role,
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
toast.success('Invites sent successfully', { position: 'top-right' });
|
||||
resetAndClose();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
showErrorModal(err as APIError);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [validateAllUsers, rows, resetAndClose, onComplete, showErrorModal]);
|
||||
|
||||
const touchedRows = rows.filter(isRowTouched);
|
||||
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="Invite Team Members"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
resetAndClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
>
|
||||
<div className="invite-members-modal__content">
|
||||
<div className="invite-members-modal__table">
|
||||
<div className="invite-members-modal__table-header">
|
||||
<div className="table-header-cell email-header">Email address</div>
|
||||
<div className="table-header-cell role-header">Roles</div>
|
||||
<div className="table-header-cell action-header" />
|
||||
</div>
|
||||
<div className="invite-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={popupContainer}
|
||||
>
|
||||
<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"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
<div className="invite-members-modal__error-callout">
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
title={getValidationErrorMessage()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="invite-members-modal__footer">
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
className="add-another-member-button"
|
||||
prefix={<Plus size={12} color={Style.L1_FOREGROUND} />}
|
||||
onClick={addRow}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
|
||||
<div className="invite-members-modal__footer-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={resetAndClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembersModal;
|
||||
@@ -0,0 +1,276 @@
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import InviteMembersModal from '../InviteMembersModal';
|
||||
|
||||
const makeApiError = (message: string, code = StatusCodes.CONFLICT): APIError =>
|
||||
new APIError({
|
||||
httpStatusCode: code,
|
||||
error: { code: 'already_exists', message, url: '', errors: [] },
|
||||
});
|
||||
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/invite/bulk/create');
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const showErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('providers/ErrorModalProvider'),
|
||||
useErrorModal: jest.fn(() => ({
|
||||
showErrorModal,
|
||||
isErrorModalVisible: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSendInvite = jest.mocked(sendInvite);
|
||||
const mockInviteUsers = jest.mocked(inviteUsers);
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: jest.fn(),
|
||||
onComplete: jest.fn(),
|
||||
};
|
||||
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
showErrorModal.mockClear();
|
||||
mockSendInvite.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { data: 'test', status: 'success' },
|
||||
});
|
||||
mockInviteUsers.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
});
|
||||
|
||||
it('renders 3 initial empty rows and disables the submit button', () => {
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
expect(emailInputs).toHaveLength(3);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('adds a row when "Add another" is clicked and removes a row via trash button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(4);
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove row/i });
|
||||
await user.click(removeButtons[0]);
|
||||
expect(screen.getAllByPlaceholderText('john@signoz.io')).toHaveLength(3);
|
||||
});
|
||||
|
||||
describe('validation callout messages', () => {
|
||||
it('shows combined message when email is invalid and role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'not-an-email',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Please enter valid emails and select roles for team members',
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows email-only message when email is invalid but role is selected', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'not-an-email');
|
||||
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please enter valid emails for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows role-only message when email is valid but role is missing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'valid@signoz.io',
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByText('Please select roles for team members'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses sendInvite (single) when only one row is filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'single@signoz.io');
|
||||
|
||||
const roleSelects = screen.getAllByText('Select roles');
|
||||
await user.click(roleSelects[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'single@signoz.io', role: 'VIEWER' }),
|
||||
);
|
||||
expect(mockInviteUsers).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('shows BE message on single invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: single@signoz.io',
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'single@signoz.io',
|
||||
);
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on bulk invite 409', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'An invite already exists for this email: alice@signoz.io',
|
||||
);
|
||||
mockInviteUsers.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows BE message on generic error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const error = makeApiError(
|
||||
'Internal server error',
|
||||
StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
mockSendInvite.mockRejectedValue(error);
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} />);
|
||||
|
||||
await user.type(
|
||||
screen.getAllByPlaceholderText('john@signoz.io')[0],
|
||||
'single@signoz.io',
|
||||
);
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorModal).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onComplete = jest.fn();
|
||||
|
||||
render(<InviteMembersModal {...defaultProps} onComplete={onComplete} />);
|
||||
|
||||
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
|
||||
|
||||
await user.type(emailInputs[0], 'alice@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
await user.click(await screen.findByText('Viewer'));
|
||||
|
||||
await user.type(emailInputs[1], 'bob@signoz.io');
|
||||
await user.click(screen.getAllByText('Select roles')[0]);
|
||||
const editorOptions = await screen.findAllByText('Editor');
|
||||
await user.click(editorOptions[editorOptions.length - 1]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInviteUsers).toHaveBeenCalledWith({
|
||||
invites: expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'alice@signoz.io', role: 'VIEWER' }),
|
||||
expect.objectContaining({ email: 'bob@signoz.io', role: 'EDITOR' }),
|
||||
]),
|
||||
});
|
||||
expect(mockSendInvite).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,13 +32,10 @@ export function useRoles(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role[valueField] ?? '',
|
||||
value: role.id ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -85,7 +82,6 @@ interface BaseProps {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
disabled?: boolean;
|
||||
valueField?: 'id' | 'name';
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
@@ -117,7 +113,7 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles, props.valueField || 'id');
|
||||
const options = getRoleOptions(roles);
|
||||
|
||||
const {
|
||||
mode,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Sienna chip — matches the dashboard list-row tag badge.
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 240px;
|
||||
height: 24px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tagLabel {
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 0;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: inherit;
|
||||
--button-variant-ghost-hover-color: inherit;
|
||||
overflow: hidden;
|
||||
max-width: 200px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.remove {
|
||||
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
|
||||
// default ghost hover is a grey that clashes with the chip. Resting color is
|
||||
// left at the Button default.
|
||||
--button-height: 16px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 50%;
|
||||
--button-variant-ghost-hover-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bg-sienna-500) 22%,
|
||||
transparent
|
||||
);
|
||||
--button-variant-ghost-hover-color: var(--bg-sienna-400);
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.editInput {
|
||||
width: 160px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--bg-cherry-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
import styles from './TagKeyValueInput.module.scss';
|
||||
|
||||
interface TagKeyValueInputProps {
|
||||
// Tags as `key:value` strings.
|
||||
tags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
// Override the outer container styling per host (e.g. the create modal).
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
// Strict key:value tag editor. A tag is committed only on Enter and only when
|
||||
// it parses to a valid `key:value` pair — bare values are rejected with an
|
||||
// inline error. Existing chips can be edited inline (double-click), and removed.
|
||||
function TagKeyValueInput({
|
||||
tags,
|
||||
onTagsChange,
|
||||
placeholder = 'key:value',
|
||||
className,
|
||||
testId = 'tag-key-value-input',
|
||||
}: TagKeyValueInputProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [editIndex, setEditIndex] = useState(-1);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
const removeTag = (tag: string): void => {
|
||||
onTagsChange(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const commit = (): void => {
|
||||
const raw = inputValue.trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const normalized = parseKeyValueTag(raw);
|
||||
if (!normalized) {
|
||||
setError('Tags must be in key:value format (both sides required).');
|
||||
return;
|
||||
}
|
||||
if (tags.includes(normalized)) {
|
||||
setError('This tag already exists.');
|
||||
return;
|
||||
}
|
||||
onTagsChange([...tags, normalized]);
|
||||
setInputValue('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setInputValue(e.target.value);
|
||||
if (error) {
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (index: number): void => {
|
||||
setEditIndex(index);
|
||||
setEditValue(tags[index]);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const cancelEdit = (): void => {
|
||||
setEditIndex(-1);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
const commitEdit = (): void => {
|
||||
const normalized = parseKeyValueTag(editValue);
|
||||
// Drop into a no-op (revert) on invalid or duplicate edits rather than
|
||||
// stranding the user in an un-exitable edit box.
|
||||
if (normalized && !tags.some((t, i) => t === normalized && i !== editIndex)) {
|
||||
onTagsChange(tags.map((t, i) => (i === editIndex ? normalized : t)));
|
||||
}
|
||||
cancelEdit();
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)}>
|
||||
<div className={styles.field}>
|
||||
{tags.map((tag, index) =>
|
||||
index === editIndex ? (
|
||||
<Input
|
||||
key={tag}
|
||||
className={styles.editInput}
|
||||
value={editValue}
|
||||
autoFocus
|
||||
testId={`${testId}-edit`}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setEditValue(e.target.value)
|
||||
}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={commitEdit}
|
||||
/>
|
||||
) : (
|
||||
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.tagLabel}
|
||||
title="Double-click to edit"
|
||||
onDoubleClick={(): void => startEdit(index)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={(): void => removeTag(tag)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
testId={testId}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<Typography className={styles.error} data-testid={`${testId}-error`}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagKeyValueInput;
|
||||
@@ -1,31 +0,0 @@
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
describe('parseKeyValueTag', () => {
|
||||
it('normalizes a valid key:value pair', () => {
|
||||
expect(parseKeyValueTag('env:prod')).toBe('env:prod');
|
||||
});
|
||||
|
||||
it('trims whitespace around key and value', () => {
|
||||
expect(parseKeyValueTag(' env : prod ')).toBe('env:prod');
|
||||
});
|
||||
|
||||
it('keeps colons inside the value', () => {
|
||||
expect(parseKeyValueTag('url:http://x')).toBe('url:http://x');
|
||||
});
|
||||
|
||||
it('rejects a bare value with no colon', () => {
|
||||
expect(parseKeyValueTag('prod')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty key', () => {
|
||||
expect(parseKeyValueTag(':prod')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty value', () => {
|
||||
expect(parseKeyValueTag('env:')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects blank input', () => {
|
||||
expect(parseKeyValueTag(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
// Tags are strictly key:value. Parse a raw input into a normalized `key:value`
|
||||
// string, or null if it isn't a valid pair (both sides non-empty). The first
|
||||
// colon separates key from value, so values may themselves contain colons
|
||||
// (e.g. `url:http://x`).
|
||||
export function parseKeyValueTag(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx <= 0) {
|
||||
return null;
|
||||
}
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!key || !value) {
|
||||
return null;
|
||||
}
|
||||
return `${key}:${value}`;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'container/MembersSettings/components/InviteMembersModal/InviteMembersModal';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
|
||||
@@ -110,9 +110,7 @@ describe('MembersSettings (integration)', () => {
|
||||
|
||||
fireEvent.click(await screen.findByText('Alice Smith'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Member Details'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
@@ -129,7 +127,7 @@ describe('MembersSettings (integration)', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /invite member/i }));
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
).resolves.toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -139,7 +137,7 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findAllByPlaceholderText('e.g. john@signoz.io'),
|
||||
screen.findAllByPlaceholderText('john@signoz.io'),
|
||||
).resolves.toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
.invite-members-modal {
|
||||
max-width: 700px;
|
||||
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(--l1-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
|
||||
.invite-members-modal__footer {
|
||||
padding-top: var(--padding-4);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import InviteMembers from 'components/InviteMembers/InviteMembers';
|
||||
|
||||
import './InviteMembersModal.styles.scss';
|
||||
|
||||
export interface InviteMembersModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
function InviteMembersModal({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: InviteMembersModalProps): JSX.Element {
|
||||
const handleSuccess = useCallback((): void => {
|
||||
toast.success('Invites sent successfully', { position: 'top-right' });
|
||||
onClose();
|
||||
onComplete?.();
|
||||
}, [onClose, onComplete]);
|
||||
|
||||
const handlePartialSuccess = useCallback((): void => {
|
||||
toast.warning('Some invites failed', { position: 'top-right' });
|
||||
onComplete?.();
|
||||
}, [onComplete]);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="Invite Team Members"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="wide"
|
||||
className="invite-members-modal"
|
||||
>
|
||||
<InviteMembers
|
||||
onSuccess={handleSuccess}
|
||||
onPartialSuccess={handlePartialSuccess}
|
||||
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
|
||||
<div className="invite-members-modal__footer">
|
||||
<Button type="button" variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={submit}
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMembersModal;
|
||||
@@ -1,210 +0,0 @@
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import InviteMembersModal from '../InviteMembersModal';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
interface MockInviteMembersProps {
|
||||
onSuccess: () => void;
|
||||
onPartialSuccess: () => void;
|
||||
onAllFailed?: () => void;
|
||||
renderFooter: (props: {
|
||||
submit: () => void;
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
}) => JSX.Element;
|
||||
}
|
||||
|
||||
let mockInviteMembersProps: MockInviteMembersProps | null = null;
|
||||
|
||||
jest.mock('components/InviteMembers/InviteMembers', () => {
|
||||
return function MockInviteMembers(props: MockInviteMembersProps): JSX.Element {
|
||||
mockInviteMembersProps = props;
|
||||
return (
|
||||
<div data-testid="mock-invite-members">
|
||||
{props.renderFooter({
|
||||
submit: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: jest.fn(),
|
||||
onComplete: jest.fn(),
|
||||
};
|
||||
|
||||
function renderComponent(
|
||||
props: Partial<typeof defaultProps> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(<InviteMembersModal {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
describe('InviteMembersModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockInviteMembersProps = null;
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders modal with title and InviteMembers component', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
|
||||
'Invite Team Members',
|
||||
);
|
||||
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when open=false', () => {
|
||||
renderComponent({ open: false });
|
||||
|
||||
expect(screen.queryByText('Invite Team Members')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer buttons', () => {
|
||||
it('renders Cancel and Invite buttons', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /invite team members/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Invite button when canSubmit=false', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByRole } = render(
|
||||
mockInviteMembersProps?.renderFooter({
|
||||
submit: jest.fn(),
|
||||
canSubmit: false,
|
||||
isSubmitting: false,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByRole('button', { name: /invite team members/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows loading state when isSubmitting=true', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByRole } = render(
|
||||
mockInviteMembersProps?.renderFooter({
|
||||
submit: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: true,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByRole('button', { name: /inviting/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when Cancel is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls submit when Invite button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSubmit = jest.fn();
|
||||
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByRole } = render(
|
||||
mockInviteMembersProps?.renderFooter({
|
||||
submit: mockSubmit,
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
await user.click(getByRole('button', { name: /invite team members/i }));
|
||||
|
||||
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSuccess callback', () => {
|
||||
it('shows success toast, calls onClose and onComplete', () => {
|
||||
const onClose = jest.fn();
|
||||
const onComplete = jest.fn();
|
||||
renderComponent({ onClose, onComplete });
|
||||
|
||||
mockInviteMembersProps?.onSuccess();
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith('Invites sent successfully', {
|
||||
position: 'top-right',
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works without onComplete prop', () => {
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose, onComplete: undefined });
|
||||
|
||||
mockInviteMembersProps?.onSuccess();
|
||||
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePartialSuccess callback', () => {
|
||||
it('shows warning toast and calls onComplete', () => {
|
||||
const onComplete = jest.fn();
|
||||
renderComponent({ onComplete });
|
||||
|
||||
mockInviteMembersProps?.onPartialSuccess();
|
||||
|
||||
expect(toast.warning).toHaveBeenCalledWith('Some invites failed', {
|
||||
position: 'top-right',
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClose on partial success', () => {
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose });
|
||||
|
||||
mockInviteMembersProps?.onPartialSuccess();
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dialog close behavior', () => {
|
||||
it('calls onClose when dialog is closed via close button', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = jest.fn();
|
||||
renderComponent({ onClose });
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ArrowRight, LoaderCircle } from '@signozhq/icons';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import InviteMembers from 'components/InviteMembers/InviteMembers';
|
||||
import { InviteMemberRow, InviteResult } from 'components/InviteMembers/types';
|
||||
import { useRoles } from 'components/RolesSelect/RolesSelect';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep, debounce } from 'lodash-es';
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
|
||||
|
||||
@@ -22,41 +36,101 @@ interface TeamMember {
|
||||
|
||||
interface InviteTeamMembersProps {
|
||||
isLoading: boolean;
|
||||
teamMembers: TeamMember[] | null;
|
||||
setTeamMembers: (teamMembers: TeamMember[]) => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
function InviteTeamMembers({
|
||||
isLoading,
|
||||
teamMembers,
|
||||
setTeamMembers,
|
||||
onNext,
|
||||
}: InviteTeamMembersProps): JSX.Element {
|
||||
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
|
||||
TeamMember[] | null
|
||||
>(teamMembers);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
|
||||
const [inviteError, setInviteError] = useState<APIError | null>(null);
|
||||
const { notifications } = useNotifications();
|
||||
const { roles } = useRoles();
|
||||
|
||||
const roleIdToName = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
roles.forEach((role) => {
|
||||
if (role.id && role.name) {
|
||||
map[role.id] = role.name;
|
||||
const defaultTeamMember: TeamMember = {
|
||||
email: '',
|
||||
role: '',
|
||||
name: '',
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
id: '',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (teamMembers === null) {
|
||||
const initialTeamMembers = Array.from({ length: 3 }, () => ({
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
}));
|
||||
|
||||
setTeamMembersToInvite(initialTeamMembers);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [teamMembers]);
|
||||
|
||||
const handleAddTeamMember = (): void => {
|
||||
const newTeamMember = {
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
};
|
||||
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
|
||||
};
|
||||
|
||||
const handleRemoveTeamMember = (id: string): void => {
|
||||
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
|
||||
};
|
||||
|
||||
const isMemberTouched = (member: TeamMember): boolean =>
|
||||
member.email.trim() !== '' ||
|
||||
Boolean(member.role && member.role.trim() !== '');
|
||||
|
||||
const validateAllUsers = (): boolean => {
|
||||
let isValid = true;
|
||||
let hasEmailErrors = false;
|
||||
let hasRoleErrors = false;
|
||||
|
||||
const updatedEmailValidity: Record<string, boolean> = {};
|
||||
|
||||
const touchedMembers = teamMembersToInvite?.filter(isMemberTouched) ?? [];
|
||||
|
||||
touchedMembers?.forEach((member) => {
|
||||
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
|
||||
const roleValid = Boolean(member.role && member.role.trim() !== '');
|
||||
|
||||
if (!emailValid || !member.email) {
|
||||
isValid = false;
|
||||
hasEmailErrors = true;
|
||||
}
|
||||
if (!roleValid) {
|
||||
isValid = false;
|
||||
hasRoleErrors = true;
|
||||
}
|
||||
|
||||
if (member.id) {
|
||||
updatedEmailValidity[member.id] = emailValid;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [roles]);
|
||||
|
||||
const toTeamMembers = (rows: InviteMemberRow[]): TeamMember[] =>
|
||||
rows.map((row) => ({
|
||||
email: row.email,
|
||||
role: roleIdToName[row.roleId] ?? row.roleId,
|
||||
name: '',
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
id: row.id,
|
||||
}));
|
||||
setEmailValidity(updatedEmailValidity);
|
||||
setHasInvalidEmails(hasEmailErrors);
|
||||
setHasInvalidRoles(hasRoleErrors);
|
||||
|
||||
const handleSuccess = (
|
||||
_results: InviteResult[],
|
||||
rows: InviteMemberRow[],
|
||||
): void => {
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleInviteUsersSuccess = (): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Success', {
|
||||
teamMembers: toTeamMembers(rows),
|
||||
teamMembers: teamMembersToInvite,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Invites sent successfully!',
|
||||
@@ -66,34 +140,125 @@ function InviteTeamMembers({
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handlePartialSuccess = (
|
||||
_results: InviteResult[],
|
||||
rows: InviteMemberRow[],
|
||||
): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Partial Success', {
|
||||
teamMembers: toTeamMembers(rows),
|
||||
});
|
||||
notifications.warning({
|
||||
message: 'Some invites failed. Check the errors above.',
|
||||
});
|
||||
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
|
||||
inviteUsers,
|
||||
{
|
||||
onSuccess: (): void => {
|
||||
handleInviteUsersSuccess();
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Failed', {
|
||||
teamMembers: teamMembersToInvite,
|
||||
});
|
||||
setInviteError(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleNext = (): void => {
|
||||
if (validateAllUsers()) {
|
||||
setTeamMembers(teamMembersToInvite?.filter(isMemberTouched) ?? []);
|
||||
setHasInvalidEmails(false);
|
||||
setHasInvalidRoles(false);
|
||||
setInviteError(null);
|
||||
sendInvites({
|
||||
invites: teamMembersToInvite?.filter(isMemberTouched) ?? [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllFailed = (
|
||||
_results: InviteResult[],
|
||||
rows: InviteMemberRow[],
|
||||
): void => {
|
||||
logEvent('Org Onboarding: Invite Team Members Failed', {
|
||||
teamMembers: toTeamMembers(rows),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedValidateEmail = useCallback(
|
||||
debounce((email: string, memberId: string, updatedMembers: TeamMember[]) => {
|
||||
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
|
||||
|
||||
// Clear hasInvalidEmails only when ALL emails are valid
|
||||
if (hasInvalidEmails) {
|
||||
const allEmailsValid = updatedMembers.every(
|
||||
(m) => m.email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.email),
|
||||
);
|
||||
if (allEmailsValid) {
|
||||
setHasInvalidEmails(false);
|
||||
}
|
||||
}
|
||||
}, 500),
|
||||
[hasInvalidEmails],
|
||||
);
|
||||
|
||||
const handleEmailChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>, member: TeamMember): void => {
|
||||
const { value } = e.target;
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate && member.id) {
|
||||
memberToUpdate.email = value;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
debouncedValidateEmail(value, member.id, updatedMembers);
|
||||
// Clear API error when user starts typing
|
||||
if (inviteError) {
|
||||
setInviteError(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[debouncedValidateEmail, inviteError, teamMembersToInvite],
|
||||
);
|
||||
|
||||
const createEmailChangeHandler = useCallback(
|
||||
(member: TeamMember) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
handleEmailChange(e, member);
|
||||
},
|
||||
[handleEmailChange],
|
||||
);
|
||||
|
||||
const handleRoleChange = (role: string, member: TeamMember): void => {
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate && member.id) {
|
||||
memberToUpdate.role = role;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
|
||||
// Clear errors when user selects a role
|
||||
if (hasInvalidRoles) {
|
||||
// Check if all roles are now valid
|
||||
const allRolesValid = updatedMembers.every(
|
||||
(m) => m.role && m.role.trim() !== '',
|
||||
);
|
||||
if (allRolesValid) {
|
||||
setHasInvalidRoles(false);
|
||||
}
|
||||
}
|
||||
if (inviteError) {
|
||||
setInviteError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getValidationErrorMessage = (): string => {
|
||||
if (hasInvalidEmails && hasInvalidRoles) {
|
||||
return 'Please enter valid emails and select roles for team members';
|
||||
}
|
||||
if (hasInvalidEmails) {
|
||||
return 'Please enter valid emails for team members';
|
||||
}
|
||||
return 'Please select roles for team members';
|
||||
};
|
||||
|
||||
const handleDoLater = (): void => {
|
||||
logEvent('Org Onboarding: Clicked Do Later', {
|
||||
currentPageID: 4,
|
||||
});
|
||||
|
||||
onNext();
|
||||
};
|
||||
|
||||
const hasInvites =
|
||||
(teamMembersToInvite?.filter(isMemberTouched) ?? []).length > 0;
|
||||
const isButtonDisabled = isSendingInvites || isLoading;
|
||||
const isInviteButtonDisabled = isButtonDisabled || !hasInvites;
|
||||
|
||||
return (
|
||||
<div className="questions-container">
|
||||
<OnboardingQuestionHeader
|
||||
@@ -108,52 +273,126 @@ function InviteTeamMembers({
|
||||
Invite your team to the SigNoz workspace
|
||||
</div>
|
||||
|
||||
<InviteMembers
|
||||
onSuccess={handleSuccess}
|
||||
onPartialSuccess={handlePartialSuccess}
|
||||
onAllFailed={handleAllFailed}
|
||||
showHeader
|
||||
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => {
|
||||
const isButtonDisabled = isSubmitting || isLoading;
|
||||
const isInviteButtonDisabled = isButtonDisabled || !canSubmit;
|
||||
<div className="invite-team-members-table">
|
||||
<div className="invite-team-members-table-header">
|
||||
<div className="table-header-cell email-header">Email address</div>
|
||||
<div className="table-header-cell role-header">Roles</div>
|
||||
<div className="table-header-cell action-header" />
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div className="onboarding-buttons-container">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${
|
||||
isInviteButtonDisabled ? 'disabled' : ''
|
||||
}`}
|
||||
onClick={submit}
|
||||
disabled={isInviteButtonDisabled}
|
||||
data-testid="send-invites-button"
|
||||
suffix={
|
||||
isButtonDisabled ? (
|
||||
<LoaderCircle className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Send Invites
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="onboarding-do-later-button"
|
||||
onClick={handleDoLater}
|
||||
disabled={isButtonDisabled}
|
||||
data-testid="do-later-button"
|
||||
>
|
||||
I'll do this later
|
||||
</Button>
|
||||
<div className="invite-team-members-container">
|
||||
{teamMembersToInvite?.map((member) => (
|
||||
<div className="team-member-row" key={member.id}>
|
||||
<div className="team-member-cell email-cell">
|
||||
<Input
|
||||
placeholder="e.g. john@signoz.io"
|
||||
value={member.email}
|
||||
type="email"
|
||||
id={`email-input-${member.id}`}
|
||||
name={`email-input-${member.id}`}
|
||||
required
|
||||
autoComplete="off"
|
||||
className="team-member-email-input"
|
||||
onChange={createEmailChangeHandler(member)}
|
||||
/>
|
||||
{member.id &&
|
||||
emailValidity[member.id] === false &&
|
||||
member.email.trim() !== '' && (
|
||||
<Typography.Text className="email-error-message">
|
||||
Invalid email address
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="team-member-cell role-cell">
|
||||
<Select
|
||||
value={member.role || undefined}
|
||||
onChange={(value): void => handleRoleChange(value, member)}
|
||||
className="team-member-role-select"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
>
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="team-member-cell action-cell">
|
||||
{teamMembersToInvite && teamMembersToInvite.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="remove-team-member-button"
|
||||
onClick={(): void => handleRemoveTeamMember(member.id)}
|
||||
aria-label="Remove team member"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="invite-team-members-add-another-member-container">
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
className="add-another-member-button"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={handleAddTeamMember}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className="invite-team-members-error-callout"
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{inviteError && !hasInvalidEmails && !hasInvalidRoles && (
|
||||
<AuthError error={inviteError} />
|
||||
)}
|
||||
|
||||
<div className="onboarding-buttons-container">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`onboarding-next-button ${
|
||||
isInviteButtonDisabled ? 'disabled' : ''
|
||||
}`}
|
||||
onClick={handleNext}
|
||||
disabled={isInviteButtonDisabled}
|
||||
suffix={
|
||||
isButtonDisabled ? (
|
||||
<LoaderCircle className="animate-spin" size={12} />
|
||||
) : (
|
||||
<ArrowRight size={12} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Send Invites
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="onboarding-do-later-button"
|
||||
onClick={handleDoLater}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
I'll do this later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,86 +1,97 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
InviteMemberRow,
|
||||
InviteMembersProps,
|
||||
InviteResult,
|
||||
} from 'components/InviteMembers/types';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import InviteTeamMembers from '../InviteTeamMembers';
|
||||
|
||||
const mockNotificationSuccess = jest.fn();
|
||||
const mockNotificationWarning = jest.fn();
|
||||
const mockNotificationSuccess = jest.fn() as jest.MockedFunction<
|
||||
(args: { message: string }) => void
|
||||
>;
|
||||
const mockNotificationError = jest.fn() as jest.MockedFunction<
|
||||
(args: { message: string }) => void
|
||||
>;
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
success: mockNotificationSuccess,
|
||||
warning: mockNotificationWarning,
|
||||
error: mockNotificationError,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk';
|
||||
|
||||
jest.mock('components/RolesSelect/RolesSelect', () => ({
|
||||
useRoles: (): any => ({
|
||||
roles: [
|
||||
{ id: 'role-viewer-id', name: 'VIEWER' },
|
||||
{ id: 'role-editor-id', name: 'EDITOR' },
|
||||
{ id: 'role-admin-id', name: 'ADMIN' },
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
interface TeamMember {
|
||||
email: string;
|
||||
role: string;
|
||||
name: string;
|
||||
frontendBaseUrl: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
jest.mock('utils/basePath', () => ({
|
||||
...jest.requireActual('utils/basePath'),
|
||||
getBaseUrl: (): string => 'http://localhost:3301',
|
||||
}));
|
||||
interface InviteRequestBody {
|
||||
invites: { email: string; role: string }[];
|
||||
}
|
||||
|
||||
let mockInviteMembersProps: InviteMembersProps | null = null;
|
||||
interface RenderProps {
|
||||
isLoading?: boolean;
|
||||
teamMembers?: TeamMember[] | null;
|
||||
}
|
||||
|
||||
jest.mock('components/InviteMembers/InviteMembers', () => {
|
||||
return function MockInviteMembers(props: InviteMembersProps): JSX.Element {
|
||||
mockInviteMembersProps = props;
|
||||
return (
|
||||
<div data-testid="mock-invite-members">
|
||||
{props.renderFooter?.({
|
||||
submit: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: false,
|
||||
touchedCount: 0,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const mockOnNext = jest.fn();
|
||||
const mockOnNext = jest.fn() as jest.MockedFunction<() => void>;
|
||||
const mockSetTeamMembers = jest.fn() as jest.MockedFunction<
|
||||
(members: TeamMember[]) => void
|
||||
>;
|
||||
|
||||
function renderComponent({
|
||||
isLoading = false,
|
||||
}: { isLoading?: boolean } = {}): ReturnType<typeof render> {
|
||||
return render(<InviteTeamMembers isLoading={isLoading} onNext={mockOnNext} />);
|
||||
teamMembers = null,
|
||||
}: RenderProps = {}): ReturnType<typeof render> {
|
||||
return render(
|
||||
<InviteTeamMembers
|
||||
isLoading={isLoading}
|
||||
teamMembers={teamMembers}
|
||||
setTeamMembers={mockSetTeamMembers}
|
||||
onNext={mockOnNext}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
async function selectRole(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
selectIndex: number,
|
||||
optionLabel: string,
|
||||
): Promise<void> {
|
||||
const placeholders = screen.getAllByText(/select roles/i);
|
||||
await user.click(placeholders[selectIndex]);
|
||||
const optionContent = await screen.findByText(optionLabel);
|
||||
fireEvent.click(optionContent);
|
||||
}
|
||||
|
||||
describe('InviteTeamMembers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
mockInviteMembersProps = null;
|
||||
|
||||
server.use(
|
||||
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders header and InviteMembers component', () => {
|
||||
describe('Initial rendering', () => {
|
||||
it('renders the page header, column labels, default rows, and action buttons', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
@@ -89,20 +100,11 @@ describe('InviteTeamMembers', () => {
|
||||
expect(
|
||||
screen.getByText(/signoz is a lot more useful with collaborators/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-invite-members')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes showHeader=true to InviteMembers', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(mockInviteMembersProps?.showHeader).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer buttons', () => {
|
||||
it('renders Send Invites and Do Later buttons', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(3);
|
||||
expect(screen.getByText('Email address')).toBeInTheDocument();
|
||||
expect(screen.getByText('Roles')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /send invites/i }),
|
||||
).toBeInTheDocument();
|
||||
@@ -111,7 +113,7 @@ describe('InviteTeamMembers', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables buttons when isLoading=true', () => {
|
||||
it('disables both action buttons while isLoading is true', () => {
|
||||
renderComponent({ isLoading: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: /send invites/i })).toBeDisabled();
|
||||
@@ -119,181 +121,355 @@ describe('InviteTeamMembers', () => {
|
||||
screen.getByRole('button', { name: /i'll do this later/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Send Invites when canSubmit=false from InviteMembers', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByTestId } = render(
|
||||
mockInviteMembersProps?.renderFooter?.({
|
||||
submit: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn(),
|
||||
canSubmit: false,
|
||||
isSubmitting: false,
|
||||
touchedCount: 0,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByTestId('send-invites-button')).toBeDisabled();
|
||||
expect(getByTestId('do-later-button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables buttons when isSubmitting=true from InviteMembers', () => {
|
||||
const { unmount } = renderComponent();
|
||||
unmount();
|
||||
|
||||
const { getByTestId } = render(
|
||||
mockInviteMembersProps?.renderFooter?.({
|
||||
submit: jest.fn().mockResolvedValue([]),
|
||||
reset: jest.fn(),
|
||||
canSubmit: true,
|
||||
isSubmitting: true,
|
||||
touchedCount: 0,
|
||||
}) as JSX.Element,
|
||||
);
|
||||
|
||||
expect(getByTestId('send-invites-button')).toBeDisabled();
|
||||
expect(getByTestId('do-later-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSuccess callback', () => {
|
||||
it('logs event with teamMembers in correct shape, shows success notification, and calls onNext after delay', () => {
|
||||
describe('Row management', () => {
|
||||
it('adds a new empty row when "Add another" is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const mockResults: InviteResult[] = [
|
||||
{ email: 'user1@test.com', success: true },
|
||||
{ email: 'user2@test.com', success: true },
|
||||
];
|
||||
const mockRows: InviteMemberRow[] = [
|
||||
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
|
||||
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-editor-id' },
|
||||
];
|
||||
mockInviteMembersProps?.onSuccess?.(mockResults, mockRows);
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(3);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Org Onboarding: Invite Team Members Success',
|
||||
{
|
||||
teamMembers: [
|
||||
{
|
||||
email: 'user1@test.com',
|
||||
role: 'VIEWER',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-1',
|
||||
},
|
||||
{
|
||||
email: 'user2@test.com',
|
||||
role: 'EDITOR',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(mockNotificationSuccess).toHaveBeenCalledWith({
|
||||
message: 'Invites sent successfully!',
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
|
||||
expect(mockOnNext).not.toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(mockOnNext).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePartialSuccess callback', () => {
|
||||
it('logs event with teamMembers in correct shape and shows warning notification', () => {
|
||||
it('removes the correct row when its trash icon is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const mockResults: InviteResult[] = [
|
||||
{ email: 'user1@test.com', success: true },
|
||||
{ email: 'user2@test.com', success: false, error: 'Already exists' },
|
||||
];
|
||||
const mockRows: InviteMemberRow[] = [
|
||||
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-viewer-id' },
|
||||
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-admin-id' },
|
||||
];
|
||||
mockInviteMembersProps?.onPartialSuccess?.(mockResults, mockRows);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Org Onboarding: Invite Team Members Partial Success',
|
||||
{
|
||||
teamMembers: [
|
||||
{
|
||||
email: 'user1@test.com',
|
||||
role: 'VIEWER',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-1',
|
||||
},
|
||||
{
|
||||
email: 'user2@test.com',
|
||||
role: 'ADMIN',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
const emailInputs = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
expect(mockNotificationWarning).toHaveBeenCalledWith({
|
||||
message: 'Some invites failed. Check the errors above.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAllFailed callback', () => {
|
||||
it('logs event with teamMembers in correct shape', () => {
|
||||
renderComponent();
|
||||
|
||||
const mockResults: InviteResult[] = [
|
||||
{ email: 'user1@test.com', success: false, error: 'Error 1' },
|
||||
{ email: 'user2@test.com', success: false, error: 'Error 2' },
|
||||
];
|
||||
const mockRows: InviteMemberRow[] = [
|
||||
{ id: 'row-1', email: 'user1@test.com', roleId: 'role-editor-id' },
|
||||
{ id: 'row-2', email: 'user2@test.com', roleId: 'role-viewer-id' },
|
||||
];
|
||||
mockInviteMembersProps?.onAllFailed?.(mockResults, mockRows);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith(
|
||||
'Org Onboarding: Invite Team Members Failed',
|
||||
{
|
||||
teamMembers: [
|
||||
{
|
||||
email: 'user1@test.com',
|
||||
role: 'EDITOR',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-1',
|
||||
},
|
||||
{
|
||||
email: 'user2@test.com',
|
||||
role: 'VIEWER',
|
||||
name: '',
|
||||
frontendBaseUrl: 'http://localhost:3301',
|
||||
id: 'row-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDoLater', () => {
|
||||
it('logs event and calls onNext immediately', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
renderComponent();
|
||||
await user.type(emailInputs[0], 'first@example.com');
|
||||
await screen.findByDisplayValue('first@example.com');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /i'll do this later/i }),
|
||||
screen.getAllByRole('button', { name: /remove team member/i })[0],
|
||||
);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('Org Onboarding: Clicked Do Later', {
|
||||
currentPageID: 4,
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByDisplayValue('first@example.com'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides remove buttons when only one row remains', async () => {
|
||||
renderComponent();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
let removeButtons = screen.getAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
while (removeButtons.length > 0) {
|
||||
await user.click(removeButtons[0]);
|
||||
removeButtons = screen.queryAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
}
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /remove team member/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inline email validation', () => {
|
||||
it('shows an inline error after typing an invalid email and clears it when a valid email is entered', async () => {
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
|
||||
await user.type(firstInput, 'not-an-email');
|
||||
jest.advanceTimersByTime(600);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'good@example.com');
|
||||
jest.advanceTimersByTime(600);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/invalid email address/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show an inline error when the field is cleared back to empty', async () => {
|
||||
jest.useFakeTimers();
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'a');
|
||||
await user.clear(firstInput);
|
||||
jest.advanceTimersByTime(600);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/invalid email address/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation callout on Complete', () => {
|
||||
it('shows the correct callout message for each combination of email/role validity', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
|
||||
renderComponent();
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
await user.click(removeButtons[0]);
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: /remove team member/i })[0],
|
||||
);
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
|
||||
await user.type(firstInput, 'bad-email');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/please enter valid emails and select roles for team members/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectRole(user, 0, 'Viewer');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/please enter valid emails for team members/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails and select roles/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'valid@example.com');
|
||||
await user.click(screen.getByRole('button', { name: /add another/i }));
|
||||
const allInputs = screen.getAllByPlaceholderText(/e\.g\. john@signoz\.io/i);
|
||||
await user.type(allInputs[1], 'norole@example.com');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/please select roles for team members/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails and select roles/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('treats whitespace as untouched, clears the callout on fix-and-resubmit, and clears role error on role select', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0, delay: null });
|
||||
renderComponent();
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', {
|
||||
name: /remove team member/i,
|
||||
});
|
||||
await user.click(removeButtons[0]);
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: /remove team member/i })[0],
|
||||
);
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
|
||||
await user.type(firstInput, ' ');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/please select roles/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'bad-email');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/please enter valid emails and select roles for team members/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(firstInput);
|
||||
await user.type(firstInput, 'good@example.com');
|
||||
await selectRole(user, 0, 'Admin');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails and select roles/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please enter valid emails for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/please select roles for team members/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockOnNext).toHaveBeenCalledTimes(1), {
|
||||
timeout: 1200,
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('disables the Send Invites button when all rows are untouched (empty)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const sendInvitesBtn = screen.getByRole('button', { name: /send invites/i });
|
||||
expect(sendInvitesBtn).toBeDisabled();
|
||||
|
||||
// Type something to make a row touched
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'a');
|
||||
|
||||
expect(sendInvitesBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API integration', () => {
|
||||
it('only sends touched (non-empty) rows — empty rows are excluded from the invite payload', async () => {
|
||||
let capturedBody: InviteRequestBody | null = null;
|
||||
|
||||
server.use(
|
||||
rest.post(INVITE_USERS_ENDPOINT, async (req, res, ctx) => {
|
||||
capturedBody = await req.json<InviteRequestBody>();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'only@example.com');
|
||||
await selectRole(user, 0, 'Admin');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).not.toBeNull();
|
||||
expect(capturedBody?.invites).toHaveLength(1);
|
||||
expect(capturedBody?.invites[0]).toMatchObject({
|
||||
email: 'only@example.com',
|
||||
role: 'ADMIN',
|
||||
});
|
||||
});
|
||||
await waitFor(() => expect(mockOnNext).toHaveBeenCalled(), {
|
||||
timeout: 1200,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the invite API, shows a success notification, and calls onNext after the 1 s delay', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'alice@example.com');
|
||||
await selectRole(user, 0, 'Admin');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Invites sent successfully!' }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockOnNext).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{ timeout: 1200 },
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an API error container when the invite request fails', async () => {
|
||||
server.use(
|
||||
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
errors: [{ code: 'INTERNAL_ERROR', msg: 'Something went wrong' }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderComponent();
|
||||
|
||||
const [firstInput] = screen.getAllByPlaceholderText(
|
||||
/e\.g\. john@signoz\.io/i,
|
||||
);
|
||||
await user.type(firstInput, 'fail@example.com');
|
||||
await selectRole(user, 0, 'Viewer');
|
||||
await user.click(screen.getByRole('button', { name: /send invites/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.auth-error-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(firstInput, 'x');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.auth-error-container'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(mockOnNext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,8 +143,6 @@
|
||||
}
|
||||
|
||||
&.invite-team-members-form {
|
||||
--invite-members-field-background: var(--l3-background);
|
||||
|
||||
padding-right: 12px;
|
||||
|
||||
.form-group {
|
||||
|
||||
@@ -22,8 +22,7 @@ const ORG_PREFERENCES_ENDPOINT = '*/api/v1/org/preferences/list';
|
||||
const UPDATE_ORG_PREFERENCE_ENDPOINT = '*/api/v1/org/preferences/name/update';
|
||||
const UPDATE_PROFILE_ENDPOINT = '*/api/v2/zeus/profiles';
|
||||
const EDIT_ORG_ENDPOINT = '*/api/v2/orgs/me';
|
||||
const CREATE_USER_ENDPOINT = '*/api/v2/users';
|
||||
const LIST_ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const INVITE_USERS_ENDPOINT = '*/api/v1/invite/bulk/create';
|
||||
|
||||
const mockOrgPreferences = {
|
||||
data: {
|
||||
@@ -32,12 +31,6 @@ const mockOrgPreferences = {
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
const MOCK_ROLES = [
|
||||
{ id: 'role-admin', name: 'Admin', description: 'Admin role' },
|
||||
{ id: 'role-editor', name: 'Editor', description: 'Editor role' },
|
||||
{ id: 'role-viewer', name: 'Viewer', description: 'Viewer role' },
|
||||
];
|
||||
|
||||
describe('OnboardingQuestionaire Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -55,11 +48,8 @@ describe('OnboardingQuestionaire Component', () => {
|
||||
rest.post(UPDATE_ORG_PREFERENCE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
rest.get(LIST_ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: MOCK_ROLES })),
|
||||
),
|
||||
rest.post(CREATE_USER_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ data: { id: 'user-123' } })),
|
||||
rest.post(INVITE_USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AxiosError } from 'axios';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { InviteTeamMembersProps } from 'container/OrganizationSettings/utils';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -70,6 +71,9 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
|
||||
const [optimiseSignozDetails, setOptimiseSignozDetails] =
|
||||
useState<OptimiseSignozDetails>(INITIAL_OPTIMISE_SIGNOZ_DETAILS);
|
||||
const [teamMembers, setTeamMembers] = useState<
|
||||
InviteTeamMembersProps[] | null
|
||||
>(null);
|
||||
|
||||
const [updatingOrgOnboardingStatus, setUpdatingOrgOnboardingStatus] =
|
||||
useState<boolean>(false);
|
||||
@@ -228,6 +232,8 @@ function OnboardingQuestionaire(): JSX.Element {
|
||||
{currentStep === 4 && (
|
||||
<InviteTeamMembers
|
||||
isLoading={updatingOrgOnboardingStatus}
|
||||
teamMembers={teamMembers}
|
||||
setTeamMembers={setTeamMembers}
|
||||
onNext={handleOnboardingComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowRight, Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
|
||||
import { Check, Goal, Search, UserPlus, X } from '@signozhq/icons';
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
Space,
|
||||
Steps,
|
||||
} from 'antd';
|
||||
import { Button as SignozButton } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
@@ -29,7 +27,7 @@ import { isModifierKeyPressed } from 'utils/app';
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
|
||||
import OnboardingIngestionDetails from '../IngestionDetails/IngestionDetails';
|
||||
import InviteMembers from 'components/InviteMembers/InviteMembers';
|
||||
import InviteTeamMembers from '../InviteTeamMembers/InviteTeamMembers';
|
||||
import onboardingConfigWithLinks from '../onboarding-configs/onboarding-config-with-links';
|
||||
|
||||
import '../OnboardingV2.styles.scss';
|
||||
@@ -121,10 +119,6 @@ const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
|
||||
GET_HELP_BUTTON_CLICKED: 'Get help clicked',
|
||||
GET_EXPERT_ASSISTANCE_BUTTON_CLICKED: 'Get expert assistance clicked',
|
||||
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Invite team member clicked',
|
||||
INVITE_TEAM_MEMBER_SEND_CLICKED: 'Send invites clicked',
|
||||
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
|
||||
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
|
||||
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
|
||||
CLOSE_ONBOARDING_CLICKED: 'Close onboarding clicked',
|
||||
DATA_SOURCE_REQUESTED: 'Datasource requested',
|
||||
DATA_SOURCE_SEARCHED: 'Searched',
|
||||
@@ -1153,54 +1147,12 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="invite-team-member-modal-content">
|
||||
<InviteMembers
|
||||
onSuccess={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
|
||||
{},
|
||||
);
|
||||
setShowInviteTeamMembersModal(false);
|
||||
|
||||
toast.success('Invites sent successfully', { position: 'top-center' });
|
||||
}}
|
||||
onPartialSuccess={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_PARTIAL_SUCCESS}`,
|
||||
{},
|
||||
);
|
||||
}}
|
||||
onAllFailed={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
|
||||
{},
|
||||
);
|
||||
}}
|
||||
renderFooter={({ submit, canSubmit, isSubmitting }): JSX.Element => (
|
||||
<div className="invite-team-member-modal-footer">
|
||||
<SignozButton
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={(): void => setShowInviteTeamMembersModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</SignozButton>
|
||||
<SignozButton
|
||||
variant="solid"
|
||||
onClick={(): void => {
|
||||
void logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SEND_CLICKED}`,
|
||||
{},
|
||||
);
|
||||
void submit();
|
||||
}}
|
||||
disabled={!canSubmit}
|
||||
loading={isSubmitting}
|
||||
suffix={<ArrowRight size={14} />}
|
||||
>
|
||||
Send Invites
|
||||
</SignozButton>
|
||||
</div>
|
||||
)}
|
||||
<InviteTeamMembers
|
||||
isLoading={false}
|
||||
teamMembers={null}
|
||||
setTeamMembers={(): void => {}}
|
||||
onNext={(): void => setShowInviteTeamMembersModal(false)}
|
||||
onClose={(): void => setShowInviteTeamMembersModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
.team-member-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.invite-team-members-form {
|
||||
padding: 16px 0px;
|
||||
}
|
||||
|
||||
.team-member-email-input {
|
||||
width: 80%;
|
||||
background-color: var(--l1-background);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
|
||||
.ant-input,
|
||||
.ant-input-group-addon {
|
||||
background-color: var(--l1-background) !important;
|
||||
border-right: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member-role-select {
|
||||
width: 20%;
|
||||
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-team-member-button {
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-team-members-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.invite-team-members-add-another-member-container {
|
||||
margin: 16px 0px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.next-prev-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.error-message-container,
|
||||
.success-message-container,
|
||||
.partially-sent-invites-container {
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.error-message,
|
||||
.success-message {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-users-error-message-container,
|
||||
.invite-users-success-message-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.success-message {
|
||||
color: var(--bg-success-500, #00b37e);
|
||||
}
|
||||
}
|
||||
|
||||
.partially-sent-invites-container {
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.partially-sent-invites-message {
|
||||
color: var(--bg-warning-500, #fbbd23);
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep, debounce, isEmpty } from 'lodash-es';
|
||||
import {
|
||||
ArrowRight,
|
||||
CircleCheck,
|
||||
Plus,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './InviteTeamMembers.styles.scss';
|
||||
|
||||
interface TeamMember {
|
||||
email: string;
|
||||
role: string;
|
||||
name: string;
|
||||
frontendBaseUrl: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface InviteTeamMembersProps {
|
||||
isLoading: boolean;
|
||||
teamMembers: TeamMember[] | null;
|
||||
setTeamMembers: (teamMembers: TeamMember[]) => void;
|
||||
onNext: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ONBOARDING_V3_ANALYTICS_EVENTS_MAP = {
|
||||
BASE: 'Onboarding V3',
|
||||
INVITE_TEAM_MEMBER_BUTTON_CLICKED: 'Send invites clicked',
|
||||
INVITE_TEAM_MEMBER_SUCCESS: 'Invite team members success',
|
||||
INVITE_TEAM_MEMBER_PARTIAL_SUCCESS: 'Invite team members partial success',
|
||||
INVITE_TEAM_MEMBER_FAILED: 'Invite team members failed',
|
||||
};
|
||||
|
||||
function InviteTeamMembers({
|
||||
isLoading,
|
||||
teamMembers,
|
||||
setTeamMembers,
|
||||
onNext,
|
||||
onClose,
|
||||
}: InviteTeamMembersProps): JSX.Element {
|
||||
const [teamMembersToInvite, setTeamMembersToInvite] = useState<
|
||||
TeamMember[] | null
|
||||
>(teamMembers);
|
||||
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const defaultTeamMember: TeamMember = {
|
||||
email: '',
|
||||
role: 'EDITOR',
|
||||
name: '',
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
id: '',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(teamMembers)) {
|
||||
const teamMember = {
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
};
|
||||
|
||||
setTeamMembersToInvite([teamMember]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [teamMembers]);
|
||||
|
||||
const handleAddTeamMember = (): void => {
|
||||
const newTeamMember = {
|
||||
...defaultTeamMember,
|
||||
id: uuid(),
|
||||
};
|
||||
setTeamMembersToInvite((prev) => [...(prev || []), newTeamMember]);
|
||||
};
|
||||
|
||||
const handleRemoveTeamMember = (id: string): void => {
|
||||
setTeamMembersToInvite((prev) => (prev || []).filter((m) => m.id !== id));
|
||||
};
|
||||
|
||||
// Validation function to check all users
|
||||
const validateAllUsers = (): boolean => {
|
||||
let isValid = true;
|
||||
|
||||
const updatedValidity: Record<string, boolean> = {};
|
||||
|
||||
teamMembersToInvite?.forEach((member) => {
|
||||
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(member.email);
|
||||
if (!emailValid || !member.email) {
|
||||
isValid = false;
|
||||
setHasInvalidEmails(true);
|
||||
}
|
||||
updatedValidity[member.id!] = emailValid;
|
||||
});
|
||||
|
||||
setEmailValidity(updatedValidity);
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleInviteUsersSuccess = (): void => {
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_SUCCESS}`,
|
||||
{
|
||||
teamMembers: teamMembersToInvite,
|
||||
},
|
||||
);
|
||||
setTimeout(() => {
|
||||
onNext();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const { mutate: sendInvites, isLoading: isSendingInvites } = useMutation(
|
||||
inviteUsers,
|
||||
{
|
||||
onSuccess: (): void => {
|
||||
handleInviteUsersSuccess();
|
||||
notifications.success({
|
||||
message: 'Invites sent successfully!',
|
||||
});
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_FAILED}`,
|
||||
{
|
||||
teamMembers: teamMembersToInvite,
|
||||
error,
|
||||
},
|
||||
);
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleNext = (): void => {
|
||||
if (validateAllUsers()) {
|
||||
setTeamMembers(teamMembersToInvite || []);
|
||||
|
||||
logEvent(
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INVITE_TEAM_MEMBER_BUTTON_CLICKED}`,
|
||||
{
|
||||
teamMembers: teamMembersToInvite,
|
||||
},
|
||||
);
|
||||
|
||||
setHasInvalidEmails(false);
|
||||
sendInvites({
|
||||
invites: teamMembersToInvite || [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedValidateEmail = useCallback(
|
||||
debounce((email: string, memberId: string) => {
|
||||
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
setEmailValidity((prev) => ({ ...prev, [memberId]: isValid }));
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEmailChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
member: TeamMember,
|
||||
): void => {
|
||||
const { value } = e.target;
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate) {
|
||||
memberToUpdate.email = value;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
debouncedValidateEmail(value, member.id!);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = (role: string, member: TeamMember): void => {
|
||||
const updatedMembers = cloneDeep(teamMembersToInvite || []);
|
||||
const memberToUpdate = updatedMembers.find((m) => m.id === member.id);
|
||||
if (memberToUpdate) {
|
||||
memberToUpdate.role = role;
|
||||
setTeamMembersToInvite(updatedMembers);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="invite-team-members-container">
|
||||
<div className="invite-team-members-form">
|
||||
<div className="form-group">
|
||||
<div className="invite-team-members-container">
|
||||
{teamMembersToInvite?.map((member) => (
|
||||
<div className="team-member-container" key={member.id}>
|
||||
<Input
|
||||
placeholder="your-teammate@org.com"
|
||||
value={member.email}
|
||||
type="email"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
className="team-member-email-input"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
|
||||
handleEmailChange(e, member)
|
||||
}
|
||||
addonAfter={
|
||||
emailValidity[member.id!] === undefined ? null : emailValidity[
|
||||
member.id!
|
||||
] ? (
|
||||
<CircleCheck size={14} color={Color.BG_FOREST_500} />
|
||||
) : (
|
||||
<TriangleAlert size={14} color={Color.BG_SIENNA_500} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
defaultValue={member.role}
|
||||
onChange={(value): void => handleRoleChange(value, member)}
|
||||
className="team-member-role-select"
|
||||
>
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
|
||||
{teamMembersToInvite?.length > 1 && (
|
||||
<Button
|
||||
type="default"
|
||||
className="remove-team-member-button periscope-btn"
|
||||
icon={<X size={14} />}
|
||||
onClick={(): void => handleRemoveTeamMember(member.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="invite-team-members-add-another-member-container">
|
||||
<Button
|
||||
type="primary"
|
||||
className="add-another-member-button periscope-btn"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={handleAddTeamMember}
|
||||
>
|
||||
Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasInvalidEmails && (
|
||||
<div className="error-message-container">
|
||||
<Typography.Text className="error-message" color="danger">
|
||||
<TriangleAlert size={14} /> Please enter valid emails for all team
|
||||
members
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="next-prev-container">
|
||||
<Button
|
||||
type="default"
|
||||
className="next-button periscope-btn"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="next-button periscope-btn primary"
|
||||
onClick={handleNext}
|
||||
loading={isSendingInvites || isLoading}
|
||||
>
|
||||
Send Invites
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteTeamMembers;
|
||||
@@ -1220,14 +1220,6 @@
|
||||
.request-data-source-modal-input {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.invite-team-member-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
padding-top: var(--spacing-6);
|
||||
}
|
||||
}
|
||||
|
||||
.request-data-source-modal {
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
input:not(.ant-select-selection-search-input),
|
||||
input,
|
||||
textarea {
|
||||
height: 32px;
|
||||
background: var(--l2-background) !important;
|
||||
|
||||
@@ -111,9 +111,31 @@
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selection-search {
|
||||
inset-inline-start: var(--padding-2) !important;
|
||||
inset-inline-end: var(--padding-2) !important;
|
||||
&.ant-select {
|
||||
.ant-select-selector {
|
||||
height: 32px;
|
||||
background: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l2-foreground) !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--l2-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--primary) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +185,7 @@
|
||||
|
||||
&--role {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +272,7 @@
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input:not(.ant-select-selection-search-input) {
|
||||
input {
|
||||
height: 32px;
|
||||
background: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
|
||||
@@ -11,20 +11,23 @@ import {
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
import { Collapse, Form, Select, Tooltip } from 'antd';
|
||||
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
|
||||
|
||||
import './RoleMappingSection.styles.scss';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'VIEWER', label: 'VIEWER' },
|
||||
{ value: 'EDITOR', label: 'EDITOR' },
|
||||
{ value: 'ADMIN', label: 'ADMIN' },
|
||||
];
|
||||
|
||||
interface RoleMappingSectionProps {
|
||||
fieldNamePrefix: string[];
|
||||
isExpanded?: boolean;
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
const SIGNOZ_VIEWER_ROLE = 'signoz-viewer';
|
||||
|
||||
function RoleMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
@@ -35,7 +38,6 @@ function RoleMappingSection({
|
||||
[...fieldNamePrefix, 'useRoleAttribute'],
|
||||
form,
|
||||
);
|
||||
const { roles, isLoading, isError, error, refetch } = useRoles();
|
||||
|
||||
// Support both controlled and uncontrolled modes
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
@@ -106,26 +108,19 @@ function RoleMappingSection({
|
||||
<div className="role-mapping-section__field-group">
|
||||
<label className="role-mapping-section__label" htmlFor="default-role">
|
||||
Default Role
|
||||
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "signoz-viewer"'>
|
||||
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'defaultRole']}
|
||||
className="role-mapping-section__form-item"
|
||||
initialValue={SIGNOZ_VIEWER_ROLE}
|
||||
initialValue="VIEWER"
|
||||
>
|
||||
<RolesSelect
|
||||
<Select
|
||||
id="default-role"
|
||||
valueField="name"
|
||||
roles={roles}
|
||||
loading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={refetch}
|
||||
options={ROLE_OPTIONS}
|
||||
className="role-mapping-section__select"
|
||||
allowClear={false}
|
||||
getPopupContainer={(): HTMLElement => document.body}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
@@ -145,7 +140,7 @@ function RoleMappingSection({
|
||||
Use Role Attribute Directly
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role name (e.g. signoz-viewer, signoz-editor, signoz-admin, or a custom role).">
|
||||
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
|
||||
<CircleHelp size={14} color={Style.L3_FOREGROUND} cursor="help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -179,17 +174,11 @@ function RoleMappingSection({
|
||||
name={[field.name, 'role']}
|
||||
className="role-mapping-section__field role-mapping-section__field--role"
|
||||
rules={[{ required: true, message: 'Role is required' }]}
|
||||
initialValue={SIGNOZ_VIEWER_ROLE}
|
||||
initialValue="VIEWER"
|
||||
>
|
||||
<RolesSelect
|
||||
valueField="name"
|
||||
roles={roles}
|
||||
loading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRefetch={refetch}
|
||||
allowClear={false}
|
||||
getPopupContainer={(): HTMLElement => document.body}
|
||||
<Select
|
||||
options={ROLE_OPTIONS}
|
||||
className="role-mapping-section__select"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -208,9 +197,7 @@ function RoleMappingSection({
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={(): void =>
|
||||
add({ groupName: '', role: SIGNOZ_VIEWER_ROLE })
|
||||
}
|
||||
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
|
||||
prefix={<Plus size={14} />}
|
||||
>
|
||||
Add Group Mapping
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
|
||||
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
|
||||
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
|
||||
// which mocks @signozhq/ui/switch for the same reason.
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
allRoles,
|
||||
listRolesSuccessResponse,
|
||||
managedRoles,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
|
||||
import CreateEdit from '../CreateEdit/CreateEdit';
|
||||
import {
|
||||
AUTH_DOMAINS_UPDATE_ENDPOINT,
|
||||
mockDomainWithDirectRoleAttribute,
|
||||
mockDomainWithRoleMapping,
|
||||
mockSamlAuthDomain,
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
|
||||
// The @signozhq/ui Button uses Radix Slot and has CSS infinite animations that
|
||||
// prevent form.validateFields() from resolving inside act(). Replacing with a
|
||||
// simple native button avoids the issue.
|
||||
jest.mock('@signozhq/ui/button', () => ({
|
||||
...jest.requireActual('@signozhq/ui/button'),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
'aria-label': ariaLabel,
|
||||
prefix,
|
||||
suffix,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{prefix}
|
||||
{children}
|
||||
{suffix}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// These are heavy real-timer integration tests (antd Select dropdown render +
|
||||
// form.validateFields() + a react-query mutation, all driven through userEvent).
|
||||
// Under a CPU-saturated parallel `jest` run the wall-clock roughly triples, which
|
||||
// pushes the longest tests past the 5000ms default and makes them flaky. Give the
|
||||
// whole file a wider budget (matches LogsPanelComponent.test.tsx).
|
||||
jest.setTimeout(20000);
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
type User = ReturnType<typeof userEvent.setup>;
|
||||
|
||||
// antd renders pointer-events:none on parts of its Select, so disable the
|
||||
// userEvent pointer-events guard (mirrors CreateEdit.test.tsx).
|
||||
const setupUser = (): User => userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
function getRole(name: string): (typeof managedRoles)[number] {
|
||||
const role = managedRoles.find((r) => r.name === name);
|
||||
if (!role) {
|
||||
throw new Error(`missing mock role: ${name}`);
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
const viewerRole = getRole('signoz-viewer');
|
||||
const editorRole = getRole('signoz-editor');
|
||||
|
||||
function mockRoles(
|
||||
response: Record<string, unknown> = listRolesSuccessResponse,
|
||||
status = 200,
|
||||
): { count: () => number } {
|
||||
let requested = 0;
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_req, res, ctx) => {
|
||||
requested += 1;
|
||||
return res(ctx.status(status), ctx.json(response));
|
||||
}),
|
||||
);
|
||||
return { count: (): number => requested };
|
||||
}
|
||||
|
||||
function captureUpdatePayload(): { get: () => any } {
|
||||
let payload: unknown = null;
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
return { get: (): any => payload };
|
||||
}
|
||||
|
||||
const expandRoleMapping = (user: User): Promise<void> =>
|
||||
user.click(screen.getByText(/role mapping \(advanced\)/i));
|
||||
|
||||
const openDefaultRoleSelect = (user: User): Promise<void> =>
|
||||
user.click(screen.getByLabelText(/default role/i));
|
||||
|
||||
const saveChanges = (user: User): Promise<void> =>
|
||||
user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
describe('CreateEdit — role mapping uses API roles', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('fetches the roles list from the API when the form mounts', async () => {
|
||||
const roles = mockRoles();
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithDirectRoleAttribute}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
|
||||
});
|
||||
|
||||
it('renders the default-role options from the API (managed + custom), not the old hardcoded VIEWER/EDITOR/ADMIN', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles();
|
||||
|
||||
// mockSamlAuthDomain has no stored defaultRole, so nothing stale (e.g.
|
||||
// "VIEWER") is rendered as a selected tag to pollute the title lookups.
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
// Open the Select and wait for the async roles fetch to populate it.
|
||||
await openDefaultRoleSelect(user);
|
||||
await screen.findByTitle(allRoles[0].name);
|
||||
|
||||
// Every role returned by the API is offered as an option, including the
|
||||
// custom (non-managed) roles — the whole point of the refactor. Use
|
||||
// getAllByTitle: the preselected default role also renders its name on
|
||||
// the selection item, so a role may legitimately appear more than once.
|
||||
allRoles.forEach((role) => {
|
||||
expect(screen.getAllByTitle(role.name).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The old hardcoded uppercase role values must NOT appear as options.
|
||||
expect(screen.queryByTitle('VIEWER')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('EDITOR')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('ADMIN')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the selected role name (not the role id) as defaultRole', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles();
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithDirectRoleAttribute}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
await openDefaultRoleSelect(user);
|
||||
await user.click(await screen.findByTitle(editorRole.name));
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
// SSO role mapping matches roles by name, so the payload carries the
|
||||
// role *name*, not the opaque id.
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
|
||||
expect(payload.get().config.roleMapping.defaultRole).not.toBe(editorRole.id);
|
||||
});
|
||||
|
||||
it('defaults a fresh role mapping to the signoz-viewer role name', async () => {
|
||||
const user = setupUser();
|
||||
const roles = mockRoles();
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
// mockSamlAuthDomain has no roleMapping, so the defaultRole field falls
|
||||
// back to the Form.Item initialValue (viewerRole.name). That initialValue
|
||||
// is only applied when the field mounts, so the roles fetch MUST resolve
|
||||
// before the panel is expanded — otherwise viewerRole is still undefined.
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(roles.count()).toBeGreaterThan(0));
|
||||
// Flush the react-query commit so `useRoles` exposes the loaded roles
|
||||
// before the collapse panel (and thus the default-role field) mounts.
|
||||
await screen.findByText(/edit saml authentication/i);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
await screen.findByText(/default role/i);
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
|
||||
expect(payload.get().config.roleMapping.defaultRole).not.toBe(viewerRole.id);
|
||||
});
|
||||
|
||||
it('still defaults to signoz-viewer when the roles fetch returns empty', async () => {
|
||||
const user = setupUser();
|
||||
// signoz-viewer is a managed role that always exists server-side, so even
|
||||
// a degenerate/empty roles response must not strip the hardcoded default.
|
||||
mockRoles({ status: 'success', data: [] });
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Section still renders without crashing even though the fetch was empty.
|
||||
await expandRoleMapping(user);
|
||||
await expect(screen.findByText(/default role/i)).resolves.toBeInTheDocument();
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
// The Form.Item initialValue (signoz-viewer) survives an empty roles list.
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
|
||||
});
|
||||
|
||||
it('loads a stored role mapping by role name and round-trips it on save', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles();
|
||||
const payload = captureUpdatePayload();
|
||||
|
||||
// mockDomainWithRoleMapping stores defaultRole "signoz-editor" plus three
|
||||
// group mappings, all keyed by role *name*. Editing must surface each
|
||||
// stored value as the matching option and submit it unchanged — the
|
||||
// backward-compatible read path for already-saved SSO domains.
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithRoleMapping}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
// The stored default role renders as a real selection, not a raw token.
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTitle(editorRole.name).length).toBeGreaterThan(0),
|
||||
);
|
||||
|
||||
await saveChanges(user);
|
||||
|
||||
await waitFor(() => expect(payload.get()).not.toBeNull());
|
||||
|
||||
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
|
||||
expect(payload.get().config.roleMapping.groupMappings).toStrictEqual({
|
||||
'admin-group': 'signoz-admin',
|
||||
'dev-team': 'signoz-editor',
|
||||
viewers: 'signoz-viewer',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error state in the default-role select when the roles request fails', async () => {
|
||||
const user = setupUser();
|
||||
mockRoles(
|
||||
{ error: { code: 'internal_error', message: 'boom', url: '' } },
|
||||
500,
|
||||
);
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expandRoleMapping(user);
|
||||
|
||||
// Open the select and confirm the error UI (with retry) is surfaced
|
||||
// instead of crashing the form. The error message comes straight from
|
||||
// the failed request; the Retry affordance is always present.
|
||||
await openDefaultRoleSelect(user);
|
||||
|
||||
await expect(screen.findByTitle('Retry')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('boom')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -186,9 +186,9 @@ describe('CreateEdit — payload sanitization', () => {
|
||||
|
||||
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
|
||||
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
|
||||
'admin-group': 'signoz-admin',
|
||||
'dev-team': 'signoz-editor',
|
||||
viewers: 'signoz-viewer',
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,12 +75,12 @@ export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'signoz-editor',
|
||||
defaultRole: 'EDITOR',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: {
|
||||
'admin-group': 'signoz-admin',
|
||||
'dev-team': 'signoz-editor',
|
||||
viewers: 'signoz-viewer',
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -103,7 +103,7 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
|
||||
clientSecret: 'direct-role-client-secret',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'signoz-viewer',
|
||||
defaultRole: 'VIEWER',
|
||||
useRoleAttribute: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -68,3 +68,13 @@
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// the V1 tags input ships borderless; give the field a visible box to match
|
||||
.tagsField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
// background: var(--l3-background);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
@@ -89,7 +89,9 @@ function DashboardInfoForm({
|
||||
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Tags</Typography>
|
||||
<TagKeyValueInput tags={tags} onTagsChange={onTagsChange} />
|
||||
<div className={styles.tagsField}>
|
||||
<AddTags tags={tags} setTags={onTagsChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
|
||||
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { AnnouncementBanner } from '@signozhq/ui/announcement-banner';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
@@ -7,8 +9,19 @@ import styles from './DashboardsListPageV2.module.scss';
|
||||
import { BreadcrumbLink } from '@signozhq/ui/breadcrumb';
|
||||
|
||||
function DashboardsListPageV2(): JSX.Element {
|
||||
const [showBanner, setShowBanner] = useState(true);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{showBanner && (
|
||||
<AnnouncementBanner
|
||||
type="warning"
|
||||
onClose={(): void => setShowBanner(false)}
|
||||
>
|
||||
You're on the V2 dashboards page. If you landed here unintentionally,
|
||||
please reach out to Ashwin.
|
||||
</AnnouncementBanner>
|
||||
)}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<BreadcrumbLink icon={<LayoutGrid size={14} />}>Dashboard</BreadcrumbLink>
|
||||
|
||||
@@ -78,11 +78,6 @@ function DeleteActionItem({
|
||||
runDelete(undefined, { onSettled: () => destroy() });
|
||||
},
|
||||
},
|
||||
cancelButtonProps: {
|
||||
onClick: (e): void => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
centered: true,
|
||||
});
|
||||
}, [modal, dashboardName, runDelete]);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pinBtn {
|
||||
.favBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -79,16 +79,16 @@
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.row:hover .pinBtn {
|
||||
.row:hover .favBtn {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pinBtn:hover {
|
||||
.favBtn:hover {
|
||||
background: var(--l1-background);
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
.pinBtnOn {
|
||||
.favBtnOn {
|
||||
color: var(--bg-amber-500);
|
||||
|
||||
svg {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { CalendarClock, Pin, PinOff } from '@signozhq/icons';
|
||||
import { CalendarClock, Star } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
@@ -12,10 +12,9 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { usePinDashboard } from '../../hooks/usePinDashboard';
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import { lastUpdatedLabel, tagsToStrings } from '../../utils/helpers';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { lastUpdatedLabel, tagsToStrings } from '../../utils';
|
||||
import ActionsPopover from '../ActionsPopover/ActionsPopover';
|
||||
|
||||
import styles from './DashboardRow.module.scss';
|
||||
@@ -38,10 +37,12 @@ function DashboardRow({
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const isFavorite = useDashboardViewsStore((s) =>
|
||||
s.favorites.includes(dashboard.id),
|
||||
);
|
||||
const toggleFavorite = useDashboardViewsStore((s) => s.toggleFavorite);
|
||||
const markViewed = useDashboardViewsStore((s) => s.markViewed);
|
||||
const { togglePin, isUpdating } = usePinDashboard();
|
||||
|
||||
const isPinned = !!dashboard.pinned;
|
||||
const id = dashboard.id;
|
||||
const name = dashboard.spec?.display?.name ?? '';
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
@@ -68,9 +69,9 @@ function DashboardRow({
|
||||
});
|
||||
};
|
||||
|
||||
const onTogglePin = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
const onToggleFavorite = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
event.stopPropagation();
|
||||
togglePin(id, isPinned);
|
||||
toggleFavorite(id);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -104,7 +105,7 @@ function DashboardRow({
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Badge className={styles.tag} key={tags[3]}>
|
||||
+ <Typography.Text> {tags.length - 3} </Typography.Text>
|
||||
+ <span> {tags.length - 3} </span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -113,14 +114,13 @@ function DashboardRow({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.pinBtn, { [styles.pinBtnOn]: isPinned })}
|
||||
aria-label={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
|
||||
title={isPinned ? 'Unpin dashboard' : 'Pin dashboard'}
|
||||
data-testid={`dashboard-pin-${index}`}
|
||||
disabled={isUpdating}
|
||||
onClick={onTogglePin}
|
||||
className={cx(styles.favBtn, { [styles.favBtnOn]: isFavorite })}
|
||||
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
data-testid={`dashboard-favorite-${index}`}
|
||||
onClick={onToggleFavorite}
|
||||
>
|
||||
{isPinned ? <PinOff size={14} /> : <Pin size={14} />}
|
||||
<Star size={14} />
|
||||
</button>
|
||||
|
||||
{canAct && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import { useListDashboardsV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
@@ -10,8 +10,7 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { combineQueries } from '../../utils/filterQuery';
|
||||
import { useAccumulatedTags } from '../../hooks/useAccumulatedTags';
|
||||
import { combineQueries } from '../../filterQuery';
|
||||
import { useActiveView } from '../../hooks/useActiveView';
|
||||
import { useDashboardFilters } from '../../hooks/useDashboardFilters';
|
||||
import {
|
||||
@@ -21,10 +20,9 @@ import {
|
||||
} from '../../hooks/useDashboardsListQueryParams';
|
||||
import { useDashboardViewsStore } from '../../store/useDashboardViewsStore';
|
||||
import { useDashboardsListVisibleColumnsStore } from '../../store/useVisibleColumnsStore';
|
||||
import { BuiltinViewId } from '../../types';
|
||||
import type { SelectedTag, UpdatedWindow } from '../../types';
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import { applyClientView } from '../../utils/views';
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { applyClientView } from '../../views';
|
||||
import type { CreatorOption } from '../FilterZone/FilterChips';
|
||||
import FilterZone from '../FilterZone/FilterZone';
|
||||
import NewDashboardModal from '../NewDashboardModal/NewDashboardModal';
|
||||
@@ -57,7 +55,6 @@ function DashboardsList(): JSX.Element {
|
||||
setSearch,
|
||||
setCreatedBy,
|
||||
setUpdated,
|
||||
setTags,
|
||||
applyFilters,
|
||||
clearAll,
|
||||
} = useDashboardFilters();
|
||||
@@ -69,7 +66,6 @@ function DashboardsList(): JSX.Element {
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
customViewsLoading,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
viewQuery,
|
||||
@@ -79,18 +75,11 @@ function DashboardsList(): JSX.Element {
|
||||
saveActiveView,
|
||||
resetView,
|
||||
removeView,
|
||||
} = useActiveView({
|
||||
filters,
|
||||
applyFilters,
|
||||
userEmail: user.email,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
setSortColumn,
|
||||
setSortOrder,
|
||||
});
|
||||
} = useActiveView({ filters, applyFilters, userEmail: user.email });
|
||||
|
||||
const railCollapsed = useDashboardViewsStore((s) => s.railCollapsed);
|
||||
const setRailCollapsed = useDashboardViewsStore((s) => s.setRailCollapsed);
|
||||
const favorites = useDashboardViewsStore((s) => s.favorites);
|
||||
const recent = useDashboardViewsStore((s) => s.recent);
|
||||
|
||||
// Any filter change resets to the first page so the user isn't stranded on a
|
||||
@@ -116,13 +105,6 @@ function DashboardsList(): JSX.Element {
|
||||
},
|
||||
[setUpdated, setPage],
|
||||
);
|
||||
const handleTagsChange = useCallback(
|
||||
(tags: SelectedTag[]): void => {
|
||||
setTags(tags);
|
||||
void setPage(1);
|
||||
},
|
||||
[setTags, setPage],
|
||||
);
|
||||
const handleClearAll = useCallback((): void => {
|
||||
clearAll();
|
||||
void setPage(1);
|
||||
@@ -168,9 +150,7 @@ function DashboardsList(): JSX.Element {
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useListDashboardsForUserV2(listParams, {
|
||||
query: { keepPreviousData: true },
|
||||
});
|
||||
} = useListDashboardsV2(listParams, { query: { keepPreviousData: true } });
|
||||
|
||||
const apiError = useMemo(
|
||||
() => (error ? toAPIError(error) : undefined),
|
||||
@@ -189,9 +169,9 @@ function DashboardsList(): JSX.Element {
|
||||
const dashboards = useMemo<DashboardListItem[]>(
|
||||
() =>
|
||||
clientView
|
||||
? applyClientView(rawDashboards, activeViewId, recent)
|
||||
? applyClientView(rawDashboards, activeViewId, favorites, recent)
|
||||
: rawDashboards,
|
||||
[clientView, rawDashboards, activeViewId, recent],
|
||||
[clientView, rawDashboards, activeViewId, favorites, recent],
|
||||
);
|
||||
const total = clientView ? dashboards.length : (response?.data?.total ?? 0);
|
||||
|
||||
@@ -214,16 +194,6 @@ function DashboardsList(): JSX.Element {
|
||||
}));
|
||||
}, [rawDashboards, user.email]);
|
||||
|
||||
// All key:value tags the API reports for the org's dashboards, powering the
|
||||
// Tags filter chip and DSL key suggestions. Accumulated across refetches so
|
||||
// previously-seen tags stay selectable even when a filtered page omits them.
|
||||
const responseTags = useMemo<SelectedTag[]>(
|
||||
() =>
|
||||
(response?.data?.tags ?? []).map((t) => ({ key: t.key, value: t.value })),
|
||||
[response],
|
||||
);
|
||||
const availableTags = useAccumulatedTags(responseTags);
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const visibleColumns = useDashboardsListVisibleColumnsStore(
|
||||
(s) => s.visibleColumns,
|
||||
@@ -269,7 +239,7 @@ function DashboardsList(): JSX.Element {
|
||||
const showWorkspaceEmpty =
|
||||
!error &&
|
||||
dashboards.length === 0 &&
|
||||
activeViewId === BuiltinViewId.All &&
|
||||
activeViewId === 'all' &&
|
||||
filtersEmpty &&
|
||||
page === 1;
|
||||
|
||||
@@ -281,7 +251,6 @@ function DashboardsList(): JSX.Element {
|
||||
activeViewId={activeViewId}
|
||||
builtinViews={builtinViews}
|
||||
customViews={customViews}
|
||||
customViewsLoading={customViewsLoading}
|
||||
isCustomActive={isCustomActive}
|
||||
isModified={isModified}
|
||||
collapsed={railCollapsed}
|
||||
@@ -312,14 +281,11 @@ function DashboardsList(): JSX.Element {
|
||||
search={filters.search}
|
||||
createdBy={filters.createdBy}
|
||||
updated={filters.updated}
|
||||
tags={filters.tags}
|
||||
availableTags={availableTags}
|
||||
creatorOptions={creatorOptions}
|
||||
isEmpty={filtersEmpty}
|
||||
onSearchChange={handleSearchChange}
|
||||
onCreatedByChange={handleCreatedByChange}
|
||||
onUpdatedChange={handleUpdatedChange}
|
||||
onTagsChange={handleTagsChange}
|
||||
onClearAll={handleClearAll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { TableProps } from 'antd/lib';
|
||||
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import DashboardRow from '../DashboardRow/DashboardRow';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardListItem } from '../../utils/helpers';
|
||||
import { noResultsCopy } from '../../utils/views';
|
||||
import type { DashboardListItem } from '../../utils';
|
||||
import { noResultsCopy } from '../../views';
|
||||
import ListHeader from '../ListHeader/ListHeader';
|
||||
import ErrorState from '../states/ErrorState/ErrorState';
|
||||
import LoadingState from '../states/LoadingState/LoadingState';
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import { buildSuggestionKeys } from '../../utils/dslSuggestions';
|
||||
import type { SelectedTag, UpdatedWindow } from '../../types';
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import FilterChips, { type CreatorOption } from './FilterChips';
|
||||
import TagsFilterChip from './TagsFilterChip';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
@@ -21,14 +12,11 @@ interface Props {
|
||||
search: string;
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
tags: SelectedTag[];
|
||||
availableTags: SelectedTag[];
|
||||
creatorOptions: CreatorOption[];
|
||||
isEmpty: boolean;
|
||||
onSearchChange: (value: string) => void;
|
||||
onCreatedByChange: (emails: string[]) => void;
|
||||
onUpdatedChange: (window: UpdatedWindow) => void;
|
||||
onTagsChange: (tags: SelectedTag[]) => void;
|
||||
onClearAll: () => void;
|
||||
// Rendered at the end of the search row (e.g. the New Dashboard action).
|
||||
rightSlot?: ReactNode;
|
||||
@@ -41,24 +29,16 @@ function FilterZone({
|
||||
search,
|
||||
createdBy,
|
||||
updated,
|
||||
tags,
|
||||
availableTags,
|
||||
creatorOptions,
|
||||
isEmpty,
|
||||
onSearchChange,
|
||||
onCreatedByChange,
|
||||
onUpdatedChange,
|
||||
onTagsChange,
|
||||
onClearAll,
|
||||
rightSlot,
|
||||
}: Props): JSX.Element {
|
||||
const [searchInput, setSearchInput] = useState(search);
|
||||
|
||||
const suggestionKeys = useMemo(
|
||||
() => buildSuggestionKeys(availableTags),
|
||||
[availableTags],
|
||||
);
|
||||
|
||||
// Keep the local input in sync with external search changes (applying a view,
|
||||
// clear-all, back/forward). User typing only mutates the local copy.
|
||||
useEffect(() => {
|
||||
@@ -78,8 +58,7 @@ function FilterZone({
|
||||
<div className={styles.searchInput}>
|
||||
<SearchBar
|
||||
value={searchInput}
|
||||
placeholder={`Search with DSL — e.g. name contains "prod" AND env = "staging"`}
|
||||
suggestionKeys={suggestionKeys}
|
||||
placeholder="Search dashboards by name"
|
||||
onChange={setSearchInput}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
@@ -87,7 +66,7 @@ function FilterZone({
|
||||
{rightSlot}
|
||||
</div>
|
||||
<div className={styles.filtersRow}>
|
||||
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
|
||||
<span className={styles.filtersLabel}>Filters</span>
|
||||
<FilterChips
|
||||
createdBy={createdBy}
|
||||
updated={updated}
|
||||
@@ -95,11 +74,6 @@ function FilterZone({
|
||||
onCreatedByChange={onCreatedByChange}
|
||||
onUpdatedChange={onUpdatedChange}
|
||||
/>
|
||||
<TagsFilterChip
|
||||
availableTags={availableTags}
|
||||
tags={tags}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { ChevronDown, Tag } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { SelectedTag } from '../../types';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
interface Props {
|
||||
// All key:value tags the list API reports across the org's dashboards.
|
||||
availableTags: SelectedTag[];
|
||||
tags: SelectedTag[];
|
||||
onTagsChange: (tags: SelectedTag[]) => void;
|
||||
}
|
||||
|
||||
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
|
||||
|
||||
function TagsFilterChip({
|
||||
availableTags,
|
||||
tags,
|
||||
onTagsChange,
|
||||
}: Props): JSX.Element {
|
||||
const selectedIds = useMemo(() => new Set(tags.map(tagId)), [tags]);
|
||||
|
||||
const label = useMemo((): string => {
|
||||
if (tags.length === 0) {
|
||||
return 'Any';
|
||||
}
|
||||
if (tags.length === 1) {
|
||||
return tagId(tags[0]);
|
||||
}
|
||||
return `${tags.length} tags`;
|
||||
}, [tags]);
|
||||
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const options: MenuItem[] = availableTags.map((tag) => {
|
||||
const id = tagId(tag);
|
||||
return {
|
||||
type: 'checkbox',
|
||||
key: id,
|
||||
label: id,
|
||||
checked: selectedIds.has(id),
|
||||
onCheckedChange: (checked: boolean): void =>
|
||||
onTagsChange(
|
||||
checked ? [...tags, tag] : tags.filter((t) => tagId(t) !== id),
|
||||
),
|
||||
};
|
||||
});
|
||||
if (tags.length > 0) {
|
||||
options.push({ type: 'divider', key: 'sep' });
|
||||
options.push({
|
||||
key: 'clear',
|
||||
label: 'Clear selection',
|
||||
onClick: (): void => onTagsChange([]),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [availableTags, selectedIds, tags, onTagsChange]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }} align="start">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Tag size={12} />}
|
||||
suffix={<ChevronDown size={12} />}
|
||||
className={cx(styles.chip, { [styles.chipActive]: tags.length > 0 })}
|
||||
disabled={availableTags.length === 0}
|
||||
testId="dashboards-filter-tags"
|
||||
>
|
||||
Tags: {label}
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagsFilterChip;
|
||||
@@ -54,7 +54,7 @@ function ListHeader({
|
||||
|
||||
const metadataContent = (
|
||||
<div className={styles.metaPanel}>
|
||||
<Typography.Text className={styles.sortHeading}>Columns</Typography.Text>
|
||||
<Typography.Text className={styles.sortHeading}>Metadata</Typography.Text>
|
||||
{METADATA_COLUMNS.map((col) => (
|
||||
<div key={col.key} className={styles.metaRow}>
|
||||
<Typography.Text className={styles.metaLabel}>{col.label}</Typography.Text>
|
||||
@@ -171,7 +171,7 @@ function ListHeader({
|
||||
)
|
||||
}
|
||||
>
|
||||
<Typography.Text className={styles.sortPrefix}>Sort:</Typography.Text>{' '}
|
||||
<span className={styles.sortPrefix}>Sort:</span>{' '}
|
||||
{SORT_LABELS[sortColumn]}{' '}
|
||||
</Button>
|
||||
</Popover>
|
||||
@@ -183,13 +183,13 @@ function ListHeader({
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
>
|
||||
<Tooltip title="Columns">
|
||||
<Tooltip title="Metadata">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Columns"
|
||||
testId="configure-columns-trigger"
|
||||
aria-label="Metadata"
|
||||
testId="configure-metadata-trigger"
|
||||
>
|
||||
<HdmiPort size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -13,9 +13,8 @@ import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import TagKeyValueInput from 'components/TagKeyValueInput/TagKeyValueInput';
|
||||
|
||||
import { keyValueStringsToTags } from '../../utils/helpers';
|
||||
import { toPostableTags } from '../../utils';
|
||||
|
||||
import styles from './NewDashboardModal.module.scss';
|
||||
|
||||
@@ -31,7 +30,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
|
||||
const [name, setName] = useState(DEFAULT_NAME);
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = name.trim().length > 0 && !submitting;
|
||||
@@ -43,7 +42,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
logEvent('Dashboard List: Create dashboard clicked', {});
|
||||
const postableTags = keyValueStringsToTags(tags);
|
||||
const postableTags = toPostableTags(tags);
|
||||
const created = await createDashboardV2({
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
@@ -73,7 +72,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Title <Typography.Text className={styles.required}>*</Typography.Text>
|
||||
Title <span className={styles.required}>*</span>
|
||||
</Typography.Text>
|
||||
<Input
|
||||
value={name}
|
||||
@@ -105,14 +104,16 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>Tags</Typography.Text>
|
||||
<TagKeyValueInput
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder="team:jarvis (press Enter)"
|
||||
<Input
|
||||
value={tags}
|
||||
placeholder="team:jarvis, prod"
|
||||
testId="create-dashboard-tags"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setTags(e.target.value)
|
||||
}
|
||||
/>
|
||||
<Typography.Text className={styles.hint}>
|
||||
Use key:value (e.g. team:jarvis) and press Enter to add.
|
||||
Comma-separated. Use key:value (e.g. team:jarvis) or a single label.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,58 +1,24 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// Flatten the input's bottom corners while the dropdown is attached below it.
|
||||
.inputOpen {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
background: var(--l1-background);
|
||||
/* stylelint-disable-next-line local/prefer-css-variables -- matches the V2 dashboard dropdown shadow */
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.suggestionActive {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.submit {
|
||||
color: var(--bg-vanilla-400);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: color-mix(in srgb, var(--l1-foreground) 12%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: color-mix(in srgb, var(--l1-foreground) 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
import {
|
||||
ChangeEvent,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ChangeEvent, KeyboardEvent, MouseEvent } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { CornerDownLeft, Search } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import {
|
||||
applyKeySuggestion,
|
||||
getActiveKeyToken,
|
||||
matchKeys,
|
||||
} from '../../utils/dslSuggestions';
|
||||
|
||||
import styles from './SearchBar.module.scss';
|
||||
|
||||
@@ -24,8 +10,6 @@ interface Props {
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
// Keys offered as you type (reserved DSL columns + tag keys from the API).
|
||||
suggestionKeys?: string[];
|
||||
}
|
||||
|
||||
function SearchBar({
|
||||
@@ -33,116 +17,38 @@ function SearchBar({
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Search with DSL (e.g. name CONTAINS 'foo')",
|
||||
suggestionKeys = [],
|
||||
}: Props): JSX.Element {
|
||||
const [focused, setFocused] = useState(false);
|
||||
// -1 means nothing is highlighted, so Enter submits the typed query rather
|
||||
// than picking a suggestion (arrow keys engage selection).
|
||||
const [highlighted, setHighlighted] = useState(-1);
|
||||
|
||||
const active = useMemo(() => getActiveKeyToken(value), [value]);
|
||||
const suggestions = useMemo(
|
||||
() => (active ? matchKeys(suggestionKeys, active.token) : []),
|
||||
[active, suggestionKeys],
|
||||
);
|
||||
const showSuggestions = focused && suggestions.length > 0;
|
||||
|
||||
const pickSuggestion = (key: string): void => {
|
||||
if (active) {
|
||||
onChange(applyKeySuggestion(value, active, key));
|
||||
}
|
||||
setHighlighted(-1);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (showSuggestions && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => Math.min(h + 1, suggestions.length - 1));
|
||||
return;
|
||||
}
|
||||
if (showSuggestions && e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => Math.max(h - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
if (showSuggestions && highlighted >= 0) {
|
||||
e.preventDefault();
|
||||
pickSuggestion(suggestions[highlighted]);
|
||||
} else {
|
||||
onSubmit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setFocused(false);
|
||||
setHighlighted(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Input
|
||||
className={cx(styles.input, { [styles.inputOpen]: showSuggestions })}
|
||||
placeholder={placeholder}
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
suffix={
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.submit}
|
||||
aria-label="Run search"
|
||||
testId="dashboards-list-search-submit"
|
||||
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
// Prevent the input's blur from firing first and double-submitting.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
|
||||
</Button>
|
||||
}
|
||||
value={value}
|
||||
testId="dashboards-list-search"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
onChange(e.target.value);
|
||||
setHighlighted(-1);
|
||||
}}
|
||||
onFocus={(): void => setFocused(true)}
|
||||
onBlur={(): void => {
|
||||
setFocused(false);
|
||||
setHighlighted(-1);
|
||||
onSubmit();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{showSuggestions && (
|
||||
<div
|
||||
className={styles.suggestions}
|
||||
data-testid="dashboards-list-search-suggestions"
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
|
||||
suffix={
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submit}
|
||||
aria-label="Run search"
|
||||
data-testid="dashboards-list-search-submit"
|
||||
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
// Prevent the input's blur from firing first and double-submitting.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{suggestions.map((key, index) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={cx(styles.suggestion, {
|
||||
[styles.suggestionActive]: index === highlighted,
|
||||
})}
|
||||
data-testid={`dashboards-list-search-suggestion-${key}`}
|
||||
onMouseEnter={(): void => setHighlighted(index)}
|
||||
onMouseDown={(e: MouseEvent<HTMLButtonElement>): void => {
|
||||
// Keep focus on the input so blur doesn't submit before we update.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={(): void => pickSuggestion(key)}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CornerDownLeft size={12} color={Color.BG_VANILLA_400} />
|
||||
</button>
|
||||
}
|
||||
value={value}
|
||||
testId="dashboards-list-search"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
onChange(e.target.value)
|
||||
}
|
||||
onBlur={onSubmit}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,21 @@ import { type ChangeEvent, type ReactNode, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { PopoverSimple } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { VIEW_ICON_OPTIONS } from '../../views';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (name: string) => void;
|
||||
onSave: (name: string, icon: string) => void;
|
||||
trigger: ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_ICON = VIEW_ICON_OPTIONS[0].name;
|
||||
|
||||
function SaveViewPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -20,10 +24,12 @@ function SaveViewPopover({
|
||||
trigger,
|
||||
}: Props): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const [icon, setIcon] = useState(DEFAULT_ICON);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setIcon(DEFAULT_ICON);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -31,7 +37,7 @@ function SaveViewPopover({
|
||||
|
||||
const handleSave = (): void => {
|
||||
if (canSave) {
|
||||
onSave(name);
|
||||
onSave(name, icon);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -45,7 +51,7 @@ function SaveViewPopover({
|
||||
>
|
||||
<div className={styles.savePopover}>
|
||||
<div className={styles.saveTitle}>Save as view</div>
|
||||
<Typography.Text className={styles.saveLabel}>Name</Typography.Text>
|
||||
<span className={styles.saveLabel}>Name</span>
|
||||
<Input
|
||||
value={name}
|
||||
autoFocus
|
||||
@@ -60,6 +66,22 @@ function SaveViewPopover({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className={styles.saveLabel}>Icon</span>
|
||||
<div className={styles.iconGrid}>
|
||||
{VIEW_ICON_OPTIONS.map(({ name: iconName, Icon }) => (
|
||||
<button
|
||||
key={iconName}
|
||||
type="button"
|
||||
aria-label={iconName}
|
||||
className={cx(styles.iconCell, {
|
||||
[styles.iconCellOn]: icon === iconName,
|
||||
})}
|
||||
onClick={(): void => setIcon(iconName)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.saveActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l1-foreground);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.search {
|
||||
@@ -77,13 +76,7 @@
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.deleteName {
|
||||
color: var(--danger-background);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 3px 0;
|
||||
@@ -99,27 +92,27 @@
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
// A left accent bar reads the active row more clearly than background alone.
|
||||
.rowActive::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 18px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--primary-background);
|
||||
}
|
||||
|
||||
.item {
|
||||
// Neutralise the signoz Button defaults so it reads as a full-width,
|
||||
// left-aligned list row; the row coordinates hover/active colours below.
|
||||
--button-display: flex;
|
||||
--button-justify-content: flex-start;
|
||||
--button-height: auto;
|
||||
--button-padding: 9px 10px;
|
||||
--button-gap: 10px;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: var(--l2-foreground);
|
||||
--button-variant-ghost-hover-color: var(--l1-foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-size: var(--font-size-base);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.row:hover .item,
|
||||
.rowActive .item {
|
||||
--button-variant-ghost-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
@@ -132,6 +125,7 @@
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -146,31 +140,24 @@
|
||||
}
|
||||
|
||||
.itemAction {
|
||||
// Blended ghost icon overlaid on the row's right edge — absolutely positioned
|
||||
// so it never reserves layout space or affects the row height. Transparent so
|
||||
// the row's hover background shows through; turns red only on its own hover.
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
--button-height: 20px;
|
||||
// Square icon button that surfaces on row hover and turns red on its own
|
||||
// hover; colours flow through the signoz Button tokens.
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 4px;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-color: var(--l3-foreground);
|
||||
--button-variant-ghost-hover-background-color: var(--danger-background);
|
||||
--button-variant-ghost-hover-color: var(--danger-color, #fff);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
opacity: 0;
|
||||
// Hidden until row hover, and inert while hidden so it can't intercept clicks
|
||||
// meant for the row.
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.row:hover .itemAction {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -230,6 +217,37 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.iconGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.iconCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.12s,
|
||||
color 0.12s;
|
||||
}
|
||||
|
||||
.iconCell:hover {
|
||||
color: var(--l1-foreground);
|
||||
border-color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.iconCellOn {
|
||||
border-color: var(--primary-background);
|
||||
color: var(--primary-background);
|
||||
}
|
||||
|
||||
.saveActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Modal } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Bookmark, CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
|
||||
import { CircleAlert, Plus, Search, Trash2 } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { SavedView } from '../../types';
|
||||
import { type BuiltinView } from '../../utils/views';
|
||||
import { type BuiltinView, iconByName } from '../../views';
|
||||
import SaveViewPopover from './SaveViewPopover';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
@@ -16,12 +16,11 @@ interface Props {
|
||||
activeViewId: string;
|
||||
builtinViews: BuiltinView[];
|
||||
customViews: SavedView[];
|
||||
customViewsLoading: boolean;
|
||||
isCustomActive: boolean;
|
||||
isModified: boolean;
|
||||
collapsed?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onSave: (name: string) => void;
|
||||
onSave: (name: string, icon: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onClearFilters: () => void;
|
||||
@@ -41,7 +40,6 @@ function ViewsRail({
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
customViewsLoading,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
collapsed = false,
|
||||
@@ -75,8 +73,11 @@ function ViewsRail({
|
||||
const { destroy } = modal.confirm({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Delete the{' '}
|
||||
<Typography.Text className={styles.deleteName}>{label}</Typography.Text>{' '}
|
||||
Delete the
|
||||
<span style={{ color: 'var(--danger-background)', fontWeight: 500 }}>
|
||||
{' '}
|
||||
{label}{' '}
|
||||
</span>
|
||||
view?
|
||||
</Typography.Title>
|
||||
),
|
||||
@@ -115,10 +116,12 @@ function ViewsRail({
|
||||
onClick={(): void => onSelect(row.id)}
|
||||
testId={`dashboards-view-${row.id}`}
|
||||
>
|
||||
<Icon size={16} className={styles.itemIcon} />
|
||||
<Typography.Text className={styles.itemLabel}>{row.label}</Typography.Text>
|
||||
<span className={styles.itemIcon}>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<span className={styles.itemLabel}>{row.label}</span>
|
||||
{active && isModified && (
|
||||
<div className={styles.dirtyDot} title="Unsaved changes" />
|
||||
<span className={styles.dirtyDot} title="Unsaved changes" />
|
||||
)}
|
||||
</Button>
|
||||
{row.deletable && (
|
||||
@@ -129,10 +132,7 @@ function ViewsRail({
|
||||
className={styles.itemAction}
|
||||
aria-label="Delete view"
|
||||
title="Delete view"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
confirmDelete(row.id, row.label);
|
||||
}}
|
||||
onClick={(): void => confirmDelete(row.id, row.label)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
@@ -166,7 +166,7 @@ function ViewsRail({
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={query}
|
||||
placeholder="Filter views by name"
|
||||
placeholder="Search views"
|
||||
prefix={<Search size={12} />}
|
||||
testId="dashboards-view-search"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
@@ -196,13 +196,9 @@ function ViewsRail({
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
My views
|
||||
<Typography.Text className={styles.groupCount}>
|
||||
{customViews.length}
|
||||
</Typography.Text>
|
||||
<span className={styles.groupCount}>{customViews.length}</span>
|
||||
</div>
|
||||
{customViewsLoading ? (
|
||||
<div className={styles.empty}>Loading views…</div>
|
||||
) : customViews.length === 0 ? (
|
||||
{customViews.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
No saved views yet. Filter the list, then save it as a view.
|
||||
</div>
|
||||
@@ -211,7 +207,7 @@ function ViewsRail({
|
||||
renderItem({
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
icon: Bookmark,
|
||||
icon: iconByName(v.icon),
|
||||
deletable: true,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { handleContactSupport } from 'container/Integrations/utils';
|
||||
|
||||
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
|
||||
|
||||
import { formatQueryErrorMessage } from '../../../utils/helpers';
|
||||
import { formatQueryErrorMessage } from '../../../utils';
|
||||
import styles from './ErrorState.module.scss';
|
||||
|
||||
interface Props {
|
||||
|
||||
Binary file not shown.
@@ -1,32 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { SelectedTag } from '../types';
|
||||
|
||||
const tagId = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
|
||||
|
||||
// The list response only reports the tags present in the current (filtered) page,
|
||||
// so tags vanish from the filter options as results narrow. Accumulate every tag
|
||||
// we've ever seen so previously-surfaced tags stay selectable across refetches.
|
||||
export function useAccumulatedTags(responseTags: SelectedTag[]): SelectedTag[] {
|
||||
const [tags, setTags] = useState<SelectedTag[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (responseTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
setTags((prev) => {
|
||||
const merged = new Map(prev.map((t) => [tagId(t), t]));
|
||||
let changed = false;
|
||||
responseTags.forEach((t) => {
|
||||
const id = tagId(t);
|
||||
if (!merged.has(id)) {
|
||||
merged.set(id, t);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? Array.from(merged.values()) : prev;
|
||||
});
|
||||
}, [responseTags]);
|
||||
|
||||
return tags;
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { parseAsString, useQueryState, type Options } from 'nuqs';
|
||||
import type {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
areFilterStatesEqual,
|
||||
combineQueries,
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
} from '../utils/filterQuery';
|
||||
import { BuiltinViewId } from '../types';
|
||||
import { DEFAULT_FILTER_STATE, areFilterStatesEqual } from '../filterQuery';
|
||||
import { useDashboardViewsStore } from '../store/useDashboardViewsStore';
|
||||
import type { DashboardFilterState, SavedView } from '../types';
|
||||
import {
|
||||
BUILTIN_VIEWS,
|
||||
@@ -19,8 +10,7 @@ import {
|
||||
builtinViewSnapshot,
|
||||
type BuiltinView,
|
||||
isClientView,
|
||||
} from '../utils/views';
|
||||
import { useSavedViews } from './useSavedViews';
|
||||
} from '../views';
|
||||
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
@@ -28,62 +18,43 @@ interface UseActiveViewArgs {
|
||||
filters: DashboardFilterState;
|
||||
applyFilters: (next: DashboardFilterState) => void;
|
||||
userEmail: string;
|
||||
sortColumn: DashboardtypesListSortDTO;
|
||||
sortOrder: DashboardtypesListOrderDTO;
|
||||
setSortColumn: (column: DashboardtypesListSortDTO) => void;
|
||||
setSortOrder: (order: DashboardtypesListOrderDTO) => void;
|
||||
}
|
||||
|
||||
export interface UseActiveViewResult {
|
||||
activeViewId: string;
|
||||
builtinViews: BuiltinView[];
|
||||
customViews: SavedView[];
|
||||
customViewsLoading: boolean;
|
||||
isCustomActive: boolean;
|
||||
// Current filters diverge from the active view's canonical snapshot.
|
||||
isModified: boolean;
|
||||
// Extra server-query fragment the active view contributes, and whether it
|
||||
// constrains the list client-side (pinned/recent).
|
||||
// constrains the list client-side (favorites/recent).
|
||||
viewQuery: string;
|
||||
clientView: boolean;
|
||||
selectView: (id: string) => void;
|
||||
saveView: (name: string) => void;
|
||||
saveView: (name: string, icon: string) => void;
|
||||
saveActiveView: () => void;
|
||||
resetView: () => void;
|
||||
removeView: (id: string) => void;
|
||||
}
|
||||
|
||||
// The canonical filter snapshot a saved view "is": the backend stores a flat
|
||||
// query, so a view folds entirely into the search box with empty chips.
|
||||
const customSnapshot = (view: SavedView): DashboardFilterState => ({
|
||||
...DEFAULT_FILTER_STATE,
|
||||
search: view.query,
|
||||
});
|
||||
|
||||
// Orchestrates the active view: which view is selected (URL `view` param),
|
||||
// merging built-in + org-shared saved views, applying a view's snapshot on
|
||||
// select, dirty detection, and save/reset/delete via the Views API.
|
||||
// merging built-in + persisted custom views, applying a view's snapshot on
|
||||
// select, dirty detection, and save/reset/delete.
|
||||
export function useActiveView({
|
||||
filters,
|
||||
applyFilters,
|
||||
userEmail,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
setSortColumn,
|
||||
setSortOrder,
|
||||
}: UseActiveViewArgs): UseActiveViewResult {
|
||||
const [activeViewId, setActiveViewId] = useQueryState(
|
||||
'view',
|
||||
parseAsString.withDefault(BuiltinViewId.All).withOptions(opts),
|
||||
parseAsString.withDefault('all').withOptions(opts),
|
||||
);
|
||||
|
||||
const {
|
||||
views: customViews,
|
||||
isLoading: customViewsLoading,
|
||||
createView,
|
||||
updateView,
|
||||
deleteView,
|
||||
} = useSavedViews();
|
||||
const customViews = useDashboardViewsStore((s) => s.customViews);
|
||||
const addView = useDashboardViewsStore((s) => s.addView);
|
||||
const updateView = useDashboardViewsStore((s) => s.updateView);
|
||||
const deleteView = useDashboardViewsStore((s) => s.deleteView);
|
||||
|
||||
const activeCustom = useMemo(
|
||||
() => customViews.find((v) => v.id === activeViewId),
|
||||
@@ -94,7 +65,7 @@ export function useActiveView({
|
||||
const canonicalSnapshot = useMemo<DashboardFilterState | null>(
|
||||
() =>
|
||||
activeCustom
|
||||
? customSnapshot(activeCustom)
|
||||
? activeCustom.filters
|
||||
: builtinViewSnapshot(activeViewId, userEmail),
|
||||
[activeCustom, activeViewId, userEmail],
|
||||
);
|
||||
@@ -107,93 +78,47 @@ export function useActiveView({
|
||||
(id: string): void => {
|
||||
void setActiveViewId(id);
|
||||
const custom = customViews.find((v) => v.id === id);
|
||||
if (custom) {
|
||||
applyFilters(customSnapshot(custom));
|
||||
setSortColumn(custom.sort);
|
||||
setSortOrder(custom.order);
|
||||
return;
|
||||
}
|
||||
applyFilters(builtinViewSnapshot(id, userEmail) ?? DEFAULT_FILTER_STATE);
|
||||
applyFilters(
|
||||
custom?.filters ??
|
||||
builtinViewSnapshot(id, userEmail) ??
|
||||
DEFAULT_FILTER_STATE,
|
||||
);
|
||||
},
|
||||
[
|
||||
setActiveViewId,
|
||||
customViews,
|
||||
applyFilters,
|
||||
userEmail,
|
||||
setSortColumn,
|
||||
setSortOrder,
|
||||
],
|
||||
[setActiveViewId, customViews, applyFilters, userEmail],
|
||||
);
|
||||
|
||||
const saveView = useCallback(
|
||||
(name: string): void => {
|
||||
// Fold the current built-in clause + chips into a single query string.
|
||||
const query = combineQueries(
|
||||
builtinViewQuery(activeViewId),
|
||||
filterStateToQuery(filters),
|
||||
);
|
||||
void (async (): Promise<void> => {
|
||||
const created = await createView({
|
||||
name,
|
||||
query,
|
||||
sort: sortColumn,
|
||||
order: sortOrder,
|
||||
});
|
||||
if (created) {
|
||||
void setActiveViewId(created.id);
|
||||
// Re-apply the folded representation so the new view isn't
|
||||
// immediately flagged as modified.
|
||||
applyFilters(customSnapshot(created));
|
||||
}
|
||||
})();
|
||||
(name: string, icon: string): void => {
|
||||
const id = `cv_${Date.now()}`;
|
||||
addView({
|
||||
id,
|
||||
name: name.trim(),
|
||||
icon,
|
||||
filters: { ...filters },
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
void setActiveViewId(id);
|
||||
},
|
||||
[
|
||||
activeViewId,
|
||||
filters,
|
||||
createView,
|
||||
sortColumn,
|
||||
sortOrder,
|
||||
setActiveViewId,
|
||||
applyFilters,
|
||||
],
|
||||
[addView, filters, setActiveViewId],
|
||||
);
|
||||
|
||||
const saveActiveView = useCallback((): void => {
|
||||
if (!activeCustom) {
|
||||
return;
|
||||
if (activeCustom) {
|
||||
updateView(activeCustom.id, { filters: { ...filters } });
|
||||
}
|
||||
const query = filterStateToQuery(filters);
|
||||
updateView(activeCustom.id, {
|
||||
name: activeCustom.name,
|
||||
query,
|
||||
sort: sortColumn,
|
||||
order: sortOrder,
|
||||
});
|
||||
applyFilters({ ...DEFAULT_FILTER_STATE, search: query });
|
||||
}, [activeCustom, filters, updateView, sortColumn, sortOrder, applyFilters]);
|
||||
}, [activeCustom, updateView, filters]);
|
||||
|
||||
const resetView = useCallback((): void => {
|
||||
if (!canonicalSnapshot) {
|
||||
return;
|
||||
if (canonicalSnapshot) {
|
||||
applyFilters(canonicalSnapshot);
|
||||
}
|
||||
applyFilters(canonicalSnapshot);
|
||||
if (activeCustom) {
|
||||
setSortColumn(activeCustom.sort);
|
||||
setSortOrder(activeCustom.order);
|
||||
}
|
||||
}, [
|
||||
canonicalSnapshot,
|
||||
applyFilters,
|
||||
activeCustom,
|
||||
setSortColumn,
|
||||
setSortOrder,
|
||||
]);
|
||||
}, [canonicalSnapshot, applyFilters]);
|
||||
|
||||
const removeView = useCallback(
|
||||
(id: string): void => {
|
||||
deleteView(id);
|
||||
if (activeViewId === id) {
|
||||
void setActiveViewId(BuiltinViewId.All);
|
||||
void setActiveViewId('all');
|
||||
applyFilters(DEFAULT_FILTER_STATE);
|
||||
}
|
||||
},
|
||||
@@ -204,7 +129,6 @@ export function useActiveView({
|
||||
activeViewId,
|
||||
builtinViews: BUILTIN_VIEWS,
|
||||
customViews,
|
||||
customViewsLoading,
|
||||
isCustomActive: !!activeCustom,
|
||||
isModified,
|
||||
viewQuery: builtinViewQuery(activeViewId),
|
||||
|
||||
@@ -11,28 +11,13 @@ import {
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
isFilterStateEmpty,
|
||||
} from '../utils/filterQuery';
|
||||
import type {
|
||||
DashboardFilterState,
|
||||
SelectedTag,
|
||||
UpdatedWindow,
|
||||
} from '../types';
|
||||
} from '../filterQuery';
|
||||
import type { DashboardFilterState, UpdatedWindow } from '../types';
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
|
||||
const opts: Options = { history: 'push' };
|
||||
|
||||
// Tags are carried in the URL as `key:value` strings; split on the first colon.
|
||||
const parseTag = (raw: string): SelectedTag | null => {
|
||||
const idx = raw.indexOf(':');
|
||||
if (idx <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { key: raw.slice(0, idx), value: raw.slice(idx + 1) };
|
||||
};
|
||||
|
||||
const serializeTag = (tag: SelectedTag): string => `${tag.key}:${tag.value}`;
|
||||
|
||||
export interface UseDashboardFiltersResult {
|
||||
filters: DashboardFilterState;
|
||||
// The backend list-filter `query` string derived from the current filters.
|
||||
@@ -41,7 +26,6 @@ export interface UseDashboardFiltersResult {
|
||||
setSearch: (value: string) => void;
|
||||
setCreatedBy: (emails: string[]) => void;
|
||||
setUpdated: (window: UpdatedWindow) => void;
|
||||
setTags: (tags: SelectedTag[]) => void;
|
||||
// Replace the whole filter state at once — used when applying a saved view.
|
||||
applyFilters: (next: DashboardFilterState) => void;
|
||||
clearAll: () => void;
|
||||
@@ -63,19 +47,10 @@ export function useDashboardFilters(): UseDashboardFiltersResult {
|
||||
'updated',
|
||||
parseAsStringLiteral(UPDATED_WINDOWS).withDefault('any').withOptions(opts),
|
||||
);
|
||||
const [tagStrings, setTagStringsState] = useQueryState(
|
||||
'tags',
|
||||
parseAsArrayOf(parseAsString).withDefault([]).withOptions(opts),
|
||||
);
|
||||
|
||||
const tags = useMemo<SelectedTag[]>(
|
||||
() => tagStrings.map(parseTag).filter((t): t is SelectedTag => t !== null),
|
||||
[tagStrings],
|
||||
);
|
||||
|
||||
const filters = useMemo<DashboardFilterState>(
|
||||
() => ({ search, createdBy, updated, tags }),
|
||||
[search, createdBy, updated, tags],
|
||||
() => ({ search, createdBy, updated }),
|
||||
[search, createdBy, updated],
|
||||
);
|
||||
|
||||
const query = useMemo(() => filterStateToQuery(filters), [filters]);
|
||||
@@ -101,23 +76,13 @@ export function useDashboardFilters(): UseDashboardFiltersResult {
|
||||
[setUpdatedState],
|
||||
);
|
||||
|
||||
const setTags = useCallback(
|
||||
(next: SelectedTag[]): void => {
|
||||
void setTagStringsState(next.length ? next.map(serializeTag) : null);
|
||||
},
|
||||
[setTagStringsState],
|
||||
);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: DashboardFilterState): void => {
|
||||
void setSearchState(next.search || null);
|
||||
void setCreatedByState(next.createdBy.length ? next.createdBy : null);
|
||||
void setUpdatedState(next.updated);
|
||||
void setTagStringsState(
|
||||
next.tags.length ? next.tags.map(serializeTag) : null,
|
||||
);
|
||||
},
|
||||
[setSearchState, setCreatedByState, setUpdatedState, setTagStringsState],
|
||||
[setSearchState, setCreatedByState, setUpdatedState],
|
||||
);
|
||||
|
||||
const clearAll = useCallback((): void => {
|
||||
@@ -131,7 +96,6 @@ export function useDashboardFilters(): UseDashboardFiltersResult {
|
||||
setSearch,
|
||||
setCreatedBy,
|
||||
setUpdated,
|
||||
setTags,
|
||||
applyFilters,
|
||||
clearAll,
|
||||
};
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
usePinDashboardV2,
|
||||
useUnpinDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { getHttpStatusCode } from 'utils/errorUtils';
|
||||
|
||||
const PIN_LIMIT_MESSAGE =
|
||||
'You can pin up to 10 dashboards. Unpin one to add another.';
|
||||
|
||||
export interface UsePinDashboardResult {
|
||||
// Toggle the pin for a dashboard given its current pinned state.
|
||||
togglePin: (id: string, pinned: boolean) => void;
|
||||
isUpdating: boolean;
|
||||
}
|
||||
|
||||
// Wraps the per-user pin/unpin mutations: refreshes the personalized list on
|
||||
// success and surfaces the 10-pin limit (HTTP 409) as a toast.
|
||||
export function usePinDashboard(): UsePinDashboardResult {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidate = useCallback((): void => {
|
||||
void invalidateListDashboardsForUserV2(queryClient);
|
||||
}, [queryClient]);
|
||||
|
||||
const pin = usePinDashboardV2({
|
||||
mutation: {
|
||||
onSuccess: invalidate,
|
||||
onError: (error): void => {
|
||||
toast.error(
|
||||
getHttpStatusCode(error) === 409
|
||||
? PIN_LIMIT_MESSAGE
|
||||
: 'Failed to pin dashboard.',
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const unpin = useUnpinDashboardV2({
|
||||
mutation: {
|
||||
onSuccess: invalidate,
|
||||
onError: (): void => {
|
||||
toast.error('Failed to unpin dashboard.');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const togglePin = useCallback(
|
||||
(id: string, pinned: boolean): void => {
|
||||
if (pinned) {
|
||||
unpin.mutate({ pathParams: { id } });
|
||||
} else {
|
||||
pin.mutate({ pathParams: { id } });
|
||||
}
|
||||
},
|
||||
[pin, unpin],
|
||||
);
|
||||
|
||||
return { togglePin, isUpdating: pin.isLoading || unpin.isLoading };
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateListDashboardViews,
|
||||
useCreateDashboardView,
|
||||
useDeleteDashboardView,
|
||||
useListDashboardViews,
|
||||
useUpdateDashboardView,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
type DashboardtypesDashboardViewDTO,
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { SavedView, SavedViewInput } from '../types';
|
||||
|
||||
// Schema version stamped on the view's data envelope (the backend requires it).
|
||||
const VIEW_DATA_VERSION = 'v1';
|
||||
|
||||
const toSavedView = (dto: DashboardtypesDashboardViewDTO): SavedView => ({
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
query: dto.data.query ?? '',
|
||||
sort: dto.data.sort ?? DashboardtypesListSortDTO.updated_at,
|
||||
order: dto.data.order ?? DashboardtypesListOrderDTO.desc,
|
||||
});
|
||||
|
||||
const toPostable = (
|
||||
input: SavedViewInput,
|
||||
): { name: string; data: DashboardtypesDashboardViewDTO['data'] } => ({
|
||||
name: input.name.trim(),
|
||||
data: {
|
||||
version: VIEW_DATA_VERSION,
|
||||
query: input.query,
|
||||
sort: input.sort,
|
||||
order: input.order,
|
||||
},
|
||||
});
|
||||
|
||||
export interface UseSavedViewsResult {
|
||||
views: SavedView[];
|
||||
isLoading: boolean;
|
||||
createView: (input: SavedViewInput) => Promise<SavedView | null>;
|
||||
updateView: (id: string, input: SavedViewInput) => void;
|
||||
deleteView: (id: string) => void;
|
||||
}
|
||||
|
||||
// Org-shared saved views, backed by the Views API. Exposes the list plus
|
||||
// create/update/delete that invalidate the list on success.
|
||||
export function useSavedViews(): UseSavedViewsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useListDashboardViews();
|
||||
|
||||
const views = useMemo<SavedView[]>(
|
||||
() => (data?.data?.views ?? []).map(toSavedView),
|
||||
[data],
|
||||
);
|
||||
|
||||
const invalidate = useCallback((): void => {
|
||||
void invalidateListDashboardViews(queryClient);
|
||||
}, [queryClient]);
|
||||
|
||||
const createMutation = useCreateDashboardView({
|
||||
mutation: {
|
||||
onSuccess: invalidate,
|
||||
onError: (): void => {
|
||||
toast.error('Failed to save view.');
|
||||
},
|
||||
},
|
||||
});
|
||||
const updateMutation = useUpdateDashboardView({
|
||||
mutation: {
|
||||
onSuccess: invalidate,
|
||||
onError: (): void => {
|
||||
toast.error('Failed to update view.');
|
||||
},
|
||||
},
|
||||
});
|
||||
const deleteMutation = useDeleteDashboardView({
|
||||
mutation: {
|
||||
onSuccess: invalidate,
|
||||
onError: (): void => {
|
||||
toast.error('Failed to delete view.');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createView = useCallback(
|
||||
async (input: SavedViewInput): Promise<SavedView | null> => {
|
||||
try {
|
||||
const res = await createMutation.mutateAsync({ data: toPostable(input) });
|
||||
return res?.data ? toSavedView(res.data) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[createMutation],
|
||||
);
|
||||
|
||||
const updateView = useCallback(
|
||||
(id: string, input: SavedViewInput): void => {
|
||||
updateMutation.mutate({ pathParams: { id }, data: toPostable(input) });
|
||||
},
|
||||
[updateMutation],
|
||||
);
|
||||
|
||||
const deleteView = useCallback(
|
||||
(id: string): void => {
|
||||
deleteMutation.mutate({ pathParams: { id } });
|
||||
},
|
||||
[deleteMutation],
|
||||
);
|
||||
|
||||
return { views, isLoading, createView, updateView, deleteView };
|
||||
}
|
||||
@@ -2,21 +2,30 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import type { SavedView } from '../types';
|
||||
|
||||
// Most-recently-viewed list is capped so it stays a useful shortlist.
|
||||
const RECENT_LIMIT = 20;
|
||||
|
||||
// Client-side persistence for the parts of the views feature that aren't backed
|
||||
// by an API: recently-viewed dashboard ids and the rail collapse preference.
|
||||
// (Saved views are org-shared via the Views API — see `useSavedViews`; pinning
|
||||
// is server-side per-user — see `usePinDashboard`.)
|
||||
// Client-side persistence for everything the views feature owns until the views
|
||||
// API lands: user-saved views, favorite/recently-viewed dashboard ids, and the
|
||||
// rail collapse preference. Mirrors `useDashboardsListVisibleColumnsStore`.
|
||||
interface DashboardViewsState {
|
||||
customViews: SavedView[];
|
||||
favorites: string[]; // dashboard ids
|
||||
recent: string[]; // dashboard ids, most-recent first
|
||||
railCollapsed: boolean;
|
||||
addView: (view: SavedView) => void;
|
||||
updateView: (id: string, patch: Partial<Omit<SavedView, 'id'>>) => void;
|
||||
deleteView: (id: string) => void;
|
||||
toggleFavorite: (id: string) => void;
|
||||
markViewed: (id: string) => void;
|
||||
setRailCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
customViews: [] as SavedView[],
|
||||
favorites: [] as string[],
|
||||
recent: [] as string[],
|
||||
railCollapsed: false,
|
||||
};
|
||||
@@ -25,6 +34,26 @@ export const useDashboardViewsStore = create<DashboardViewsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULT_STATE,
|
||||
addView: (view): void => {
|
||||
set((s) => ({ customViews: [...s.customViews, view] }));
|
||||
},
|
||||
updateView: (id, patch): void => {
|
||||
set((s) => ({
|
||||
customViews: s.customViews.map((v) =>
|
||||
v.id === id ? { ...v, ...patch } : v,
|
||||
),
|
||||
}));
|
||||
},
|
||||
deleteView: (id): void => {
|
||||
set((s) => ({ customViews: s.customViews.filter((v) => v.id !== id) }));
|
||||
},
|
||||
toggleFavorite: (id): void => {
|
||||
set((s) => ({
|
||||
favorites: s.favorites.includes(id)
|
||||
? s.favorites.filter((f) => f !== id)
|
||||
: [...s.favorites, id],
|
||||
}));
|
||||
},
|
||||
markViewed: (id): void => {
|
||||
set((s) => ({
|
||||
recent: [id, ...s.recent.filter((r) => r !== id)].slice(0, RECENT_LIMIT),
|
||||
|
||||
@@ -1,52 +1,27 @@
|
||||
import type {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Relative "updated within" windows offered by the Updated filter chip.
|
||||
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
|
||||
|
||||
// A tag selected in the Tags filter chip — a concrete key:value pair drawn from
|
||||
// the tags the list API reports across the org's dashboards.
|
||||
export interface SelectedTag {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// The user-controllable filter state a view captures. `search` is a raw filter
|
||||
// DSL fragment the user types; the structured chips (created-by, updated, tags)
|
||||
// are AND-ed onto it. Sort/order are handled separately via URL query params and
|
||||
// are not part of a view snapshot.
|
||||
// The user-controllable filter state a view captures. (Tags are intentionally
|
||||
// excluded for now — the tag filter UI is deferred.) Sort/order are handled
|
||||
// separately via URL query params and are not part of a view snapshot.
|
||||
export interface DashboardFilterState {
|
||||
search: string;
|
||||
createdBy: string[]; // emails (created_by)
|
||||
updated: UpdatedWindow;
|
||||
tags: SelectedTag[];
|
||||
}
|
||||
|
||||
// A saved view: a named filter the org shares, persisted via the backend Views
|
||||
// API. The backend stores a flat `{ query, sort, order }` (no structured chips),
|
||||
// so a view captures the fully-combined DSL query plus the sort/order to apply.
|
||||
// A saved view: a named, iconed snapshot of filter state. Persisted client-side
|
||||
// (localStorage) until the views API lands.
|
||||
export interface SavedView {
|
||||
id: string;
|
||||
name: string;
|
||||
query: string;
|
||||
sort: DashboardtypesListSortDTO;
|
||||
order: DashboardtypesListOrderDTO;
|
||||
icon: string; // @signozhq/icons icon name
|
||||
filters: DashboardFilterState;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// The payload for creating or updating a saved view (everything but the id).
|
||||
export type SavedViewInput = Omit<SavedView, 'id'>;
|
||||
|
||||
// Built-in views rendered above the user's saved views. Their result set is
|
||||
// derived (a fixed query fragment or a client-side id set), never persisted.
|
||||
// String values double as the URL `view` param, so they must stay stable.
|
||||
export enum BuiltinViewId {
|
||||
Mine = 'mine',
|
||||
Pinned = 'pinned',
|
||||
Recent = 'recent',
|
||||
All = 'all',
|
||||
Locked = 'locked',
|
||||
}
|
||||
export type BuiltinViewId = 'mine' | 'favorites' | 'recent' | 'all' | 'locked';
|
||||
|
||||
export type ViewSection = 'personal' | 'system' | 'custom';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
DashboardtypesListedDashboardV2DTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// The list is fetched via the per-user endpoint, so every row carries `pinned`.
|
||||
export type DashboardListItem = DashboardtypesListedDashboardForUserV2DTO;
|
||||
export type DashboardListItem = DashboardtypesListedDashboardV2DTO;
|
||||
|
||||
export const tagsToStrings = (
|
||||
tags: { key: string; value: string }[] | null | undefined,
|
||||
@@ -15,23 +14,23 @@ export const tagsToStrings = (
|
||||
tag.key === tag.value ? tag.key : `${tag.key}:${tag.value}`,
|
||||
);
|
||||
|
||||
// Convert validated `key:value` tag strings (from TagKeyValueInput) into the
|
||||
// postable tag DTO shape. The first colon separates key from value.
|
||||
export const keyValueStringsToTags = (
|
||||
tags: string[],
|
||||
): TagtypesPostableTagDTO[] =>
|
||||
tags
|
||||
.map((tag) => {
|
||||
const idx = tag.indexOf(':');
|
||||
if (idx <= 0) {
|
||||
return null;
|
||||
// Inverse of `tagsToStrings`: each comma-separated tag is "key:value" or a bare
|
||||
// label (key === value).
|
||||
export const toPostableTags = (raw: string): TagtypesPostableTagDTO[] =>
|
||||
raw
|
||||
.split(',')
|
||||
.map((label) => label.trim())
|
||||
.filter(Boolean)
|
||||
.map((label) => {
|
||||
const sep = label.indexOf(':');
|
||||
if (sep > 0) {
|
||||
return {
|
||||
key: label.slice(0, sep).trim(),
|
||||
value: label.slice(sep + 1).trim(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: tag.slice(0, idx).trim(),
|
||||
value: tag.slice(idx + 1).trim(),
|
||||
};
|
||||
})
|
||||
.filter((t): t is TagtypesPostableTagDTO => !!t?.key && !!t.value);
|
||||
return { key: label, value: label };
|
||||
});
|
||||
|
||||
export const lastUpdatedLabel = (time: string | undefined): string => {
|
||||
if (!time || isEmpty(time)) {
|
||||
@@ -1,57 +0,0 @@
|
||||
import {
|
||||
applyKeySuggestion,
|
||||
buildSuggestionKeys,
|
||||
getActiveKeyToken,
|
||||
matchKeys,
|
||||
RESERVED_DSL_KEYS,
|
||||
} from './dslSuggestions';
|
||||
|
||||
describe('getActiveKeyToken', () => {
|
||||
it('returns the partial key at the start', () => {
|
||||
expect(getActiveKeyToken('nam')).toStrictEqual({ token: 'nam', start: 0 });
|
||||
});
|
||||
|
||||
it('returns the partial key after AND', () => {
|
||||
const value = 'name = "x" AND en';
|
||||
expect(getActiveKeyToken(value)).toStrictEqual({ token: 'en', start: 15 });
|
||||
});
|
||||
|
||||
it('is null once an operator (space) has been typed', () => {
|
||||
expect(getActiveKeyToken('name contains')).toBeNull();
|
||||
});
|
||||
|
||||
it('is null for an empty trailing segment', () => {
|
||||
expect(getActiveKeyToken('name = "x" AND ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSuggestionKeys', () => {
|
||||
it('lists reserved keys plus distinct tag keys', () => {
|
||||
const keys = buildSuggestionKeys([
|
||||
{ key: 'env', value: 'prod' },
|
||||
{ key: 'env', value: 'dev' },
|
||||
{ key: 'team', value: 'core' },
|
||||
]);
|
||||
expect(keys).toStrictEqual([...RESERVED_DSL_KEYS, 'env', 'team']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchKeys', () => {
|
||||
it('matches case-insensitively and excludes exact matches', () => {
|
||||
expect(matchKeys(['name', 'created_by', 'env'], 'NAM')).toStrictEqual([
|
||||
'name',
|
||||
]);
|
||||
expect(matchKeys(['name'], 'name')).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyKeySuggestion', () => {
|
||||
it('replaces the partial key with the chosen key and a trailing space', () => {
|
||||
const value = 'name = "x" AND en';
|
||||
const active = getActiveKeyToken(value);
|
||||
if (!active) {
|
||||
throw new Error('expected an active key token');
|
||||
}
|
||||
expect(applyKeySuggestion(value, active, 'env')).toBe('name = "x" AND env ');
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
// Key-name suggestions for the dashboards-list DSL search box. The reserved keys
|
||||
// mirror the backend filter DSL (pkg/.../listfilter_visitor.go); any other key is
|
||||
// treated as a tag key, so we also surface the tag keys the list API reports.
|
||||
import type { SelectedTag } from '../types';
|
||||
|
||||
// Reserved DSL keys the backend recognises as dashboard columns.
|
||||
export const RESERVED_DSL_KEYS: string[] = [
|
||||
'name',
|
||||
'description',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'created_by',
|
||||
'locked',
|
||||
'source',
|
||||
];
|
||||
|
||||
export interface ActiveKeyToken {
|
||||
token: string;
|
||||
// Index in the value string where the partial key begins.
|
||||
start: number;
|
||||
}
|
||||
|
||||
// The partial key the user is currently typing: the trailing segment after the
|
||||
// last top-level AND/OR (or the start), provided it hasn't yet reached an
|
||||
// operator (no whitespace). Returns null once the key is complete.
|
||||
export const getActiveKeyToken = (value: string): ActiveKeyToken | null => {
|
||||
const boundaryRe = /\b(?:AND|OR)\b/gi;
|
||||
let lastEnd = 0;
|
||||
let match = boundaryRe.exec(value);
|
||||
while (match !== null) {
|
||||
lastEnd = match.index + match[0].length;
|
||||
match = boundaryRe.exec(value);
|
||||
}
|
||||
const segment = value.slice(lastEnd);
|
||||
const leading = segment.length - segment.trimStart().length;
|
||||
const partial = segment.slice(leading);
|
||||
if (partial.length === 0 || /[\s(]/.test(partial)) {
|
||||
return null;
|
||||
}
|
||||
return { token: partial, start: lastEnd + leading };
|
||||
};
|
||||
|
||||
// Build the de-duplicated, ordered list of keys to offer: reserved columns plus
|
||||
// distinct tag keys from the list response.
|
||||
export const buildSuggestionKeys = (availableTags: SelectedTag[]): string[] => {
|
||||
const tagKeys = availableTags.map((t) => t.key);
|
||||
return Array.from(new Set([...RESERVED_DSL_KEYS, ...tagKeys]));
|
||||
};
|
||||
|
||||
// Keys matching the partial token (case-insensitive), excluding an exact match.
|
||||
export const matchKeys = (
|
||||
keys: string[],
|
||||
token: string,
|
||||
limit = 8,
|
||||
): string[] => {
|
||||
const lower = token.toLowerCase();
|
||||
return keys
|
||||
.filter((k) => k.toLowerCase().includes(lower) && k.toLowerCase() !== lower)
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
// Replace the active partial key in `value` with the chosen key + a space, ready
|
||||
// for the user to type an operator.
|
||||
export const applyKeySuggestion = (
|
||||
value: string,
|
||||
active: ActiveKeyToken,
|
||||
key: string,
|
||||
): string => `${value.slice(0, active.start)}${key} `;
|
||||
@@ -1,109 +0,0 @@
|
||||
import {
|
||||
areFilterStatesEqual,
|
||||
combineQueries,
|
||||
DEFAULT_FILTER_STATE,
|
||||
filterStateToQuery,
|
||||
isFilterStateEmpty,
|
||||
} from './filterQuery';
|
||||
import type { DashboardFilterState } from '../types';
|
||||
|
||||
const state = (patch: Partial<DashboardFilterState>): DashboardFilterState => ({
|
||||
...DEFAULT_FILTER_STATE,
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe('filterStateToQuery', () => {
|
||||
it('passes the raw search through, wrapped in parentheses', () => {
|
||||
expect(filterStateToQuery(state({ search: 'name contains "prod"' }))).toBe(
|
||||
'(name contains "prod")',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an equality clause for a single creator', () => {
|
||||
expect(filterStateToQuery(state({ createdBy: ['a@b.com'] }))).toBe(
|
||||
"created_by = 'a@b.com'",
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an IN clause for multiple creators', () => {
|
||||
expect(filterStateToQuery(state({ createdBy: ['a@b.com', 'c@d.com'] }))).toBe(
|
||||
"created_by IN ['a@b.com', 'c@d.com']",
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an exact equality clause per selected tag', () => {
|
||||
expect(
|
||||
filterStateToQuery(
|
||||
state({
|
||||
tags: [
|
||||
{ key: 'env', value: 'prod' },
|
||||
{ key: 'team', value: 'core' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe("env = 'prod' AND team = 'core'");
|
||||
});
|
||||
|
||||
it('ANDs raw search with the structured chips', () => {
|
||||
expect(
|
||||
filterStateToQuery(
|
||||
state({
|
||||
search: 'name contains "x"',
|
||||
createdBy: ['a@b.com'],
|
||||
tags: [{ key: 'env', value: 'prod' }],
|
||||
}),
|
||||
),
|
||||
).toBe("(name contains \"x\") AND created_by = 'a@b.com' AND env = 'prod'");
|
||||
});
|
||||
|
||||
it('returns an empty string for the default state', () => {
|
||||
expect(filterStateToQuery(DEFAULT_FILTER_STATE)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFilterStateEmpty', () => {
|
||||
it('is true for the default state', () => {
|
||||
expect(isFilterStateEmpty(DEFAULT_FILTER_STATE)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when any tag is selected', () => {
|
||||
expect(
|
||||
isFilterStateEmpty(state({ tags: [{ key: 'env', value: 'prod' }] })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areFilterStatesEqual', () => {
|
||||
it('ignores tag ordering', () => {
|
||||
const a = state({
|
||||
tags: [
|
||||
{ key: 'env', value: 'prod' },
|
||||
{ key: 'team', value: 'core' },
|
||||
],
|
||||
});
|
||||
const b = state({
|
||||
tags: [
|
||||
{ key: 'team', value: 'core' },
|
||||
{ key: 'env', value: 'prod' },
|
||||
],
|
||||
});
|
||||
expect(areFilterStatesEqual(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('distinguishes differing tag selections', () => {
|
||||
expect(
|
||||
areFilterStatesEqual(
|
||||
state({ tags: [{ key: 'env', value: 'prod' }] }),
|
||||
state({ tags: [{ key: 'env', value: 'dev' }] }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineQueries', () => {
|
||||
it('drops empty fragments and ANDs the rest', () => {
|
||||
expect(combineQueries('locked = true', '', undefined, 'name = "x"')).toBe(
|
||||
'locked = true AND name = "x"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,26 @@
|
||||
// - snapshot: selecting applies a filter snapshot (All, My dashboards, custom)
|
||||
// - query: contributes an extra server clause AND-ed with the chips (Locked)
|
||||
// - client: constrains by a client-side id set (Favorites, Recently viewed)
|
||||
import { Clock, Layers, Lock, Pin, User } from '@signozhq/icons';
|
||||
import {
|
||||
Activity,
|
||||
Bookmark,
|
||||
Clock,
|
||||
Code,
|
||||
Flag,
|
||||
Layers,
|
||||
Lock,
|
||||
Server,
|
||||
Star,
|
||||
Tag,
|
||||
User,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { DEFAULT_FILTER_STATE } from './filterQuery';
|
||||
import { BuiltinViewId } from '../types';
|
||||
import type { DashboardFilterState, ViewSection } from '../types';
|
||||
import type { DashboardListItem } from './helpers';
|
||||
import type { BuiltinViewId, DashboardFilterState, ViewSection } from './types';
|
||||
import type { DashboardListItem } from './utils';
|
||||
|
||||
// All @signozhq icons share this component type.
|
||||
export type ViewIcon = typeof Pin;
|
||||
export type ViewIcon = typeof Star;
|
||||
|
||||
export interface BuiltinView {
|
||||
id: BuiltinViewId;
|
||||
@@ -21,37 +32,40 @@ export interface BuiltinView {
|
||||
}
|
||||
|
||||
export const BUILTIN_VIEWS: BuiltinView[] = [
|
||||
{
|
||||
id: BuiltinViewId.Mine,
|
||||
label: 'My dashboards',
|
||||
icon: User,
|
||||
section: 'personal',
|
||||
},
|
||||
{ id: BuiltinViewId.Pinned, label: 'Pinned', icon: Pin, section: 'personal' },
|
||||
{
|
||||
id: BuiltinViewId.Recent,
|
||||
label: 'Recently viewed',
|
||||
icon: Clock,
|
||||
section: 'personal',
|
||||
},
|
||||
{
|
||||
id: BuiltinViewId.All,
|
||||
label: 'All dashboards',
|
||||
icon: Layers,
|
||||
section: 'system',
|
||||
},
|
||||
{ id: BuiltinViewId.Locked, label: 'Locked', icon: Lock, section: 'system' },
|
||||
{ id: 'mine', label: 'My dashboards', icon: User, section: 'personal' },
|
||||
{ id: 'favorites', label: 'Favorites', icon: Star, section: 'personal' },
|
||||
{ id: 'recent', label: 'Recently viewed', icon: Clock, section: 'personal' },
|
||||
{ id: 'all', label: 'All dashboards', icon: Layers, section: 'system' },
|
||||
{ id: 'locked', label: 'Locked', icon: Lock, section: 'system' },
|
||||
];
|
||||
|
||||
// Pinned/Recently-viewed constrain client-side — Pinned by the per-row `pinned`
|
||||
// flag, Recently-viewed by a localStorage id set — so they filter the fetched
|
||||
// rows rather than adding a server clause.
|
||||
// Icons offered when naming a saved view; stored by name on the view.
|
||||
export const VIEW_ICON_OPTIONS: { name: string; Icon: ViewIcon }[] = [
|
||||
{ name: 'bookmark', Icon: Bookmark },
|
||||
{ name: 'star', Icon: Star },
|
||||
{ name: 'layers', Icon: Layers },
|
||||
{ name: 'activity', Icon: Activity },
|
||||
{ name: 'server', Icon: Server },
|
||||
{ name: 'code', Icon: Code },
|
||||
{ name: 'flag', Icon: Flag },
|
||||
{ name: 'tag', Icon: Tag },
|
||||
{ name: 'lock', Icon: Lock },
|
||||
{ name: 'clock', Icon: Clock },
|
||||
];
|
||||
|
||||
const ICON_BY_NAME = new Map(VIEW_ICON_OPTIONS.map((o) => [o.name, o.Icon]));
|
||||
|
||||
export const iconByName = (name: string): ViewIcon =>
|
||||
ICON_BY_NAME.get(name) ?? Bookmark;
|
||||
|
||||
// Favorites/Recently-viewed constrain by a client-side id set — the backend has
|
||||
// no id filter, so these are filtered on the fetched rows.
|
||||
export const isClientView = (id: string): boolean =>
|
||||
id === BuiltinViewId.Pinned || id === BuiltinViewId.Recent;
|
||||
id === 'favorites' || id === 'recent';
|
||||
|
||||
// Extra server query fragment a built-in view contributes (AND-ed with chips).
|
||||
export const builtinViewQuery = (id: string): string =>
|
||||
id === BuiltinViewId.Locked ? 'locked = true' : '';
|
||||
id === 'locked' ? 'locked = true' : '';
|
||||
|
||||
// The canonical filter snapshot a built-in view applies when selected. `null`
|
||||
// for ids that aren't built-in (custom views carry their own snapshot).
|
||||
@@ -60,15 +74,15 @@ export const builtinViewSnapshot = (
|
||||
userEmail: string,
|
||||
): DashboardFilterState | null => {
|
||||
switch (id) {
|
||||
case BuiltinViewId.Mine:
|
||||
case 'mine':
|
||||
return {
|
||||
...DEFAULT_FILTER_STATE,
|
||||
createdBy: userEmail ? [userEmail] : [],
|
||||
};
|
||||
case BuiltinViewId.All:
|
||||
case BuiltinViewId.Pinned:
|
||||
case BuiltinViewId.Recent:
|
||||
case BuiltinViewId.Locked:
|
||||
case 'all':
|
||||
case 'favorites':
|
||||
case 'recent':
|
||||
case 'locked':
|
||||
return { ...DEFAULT_FILTER_STATE };
|
||||
default:
|
||||
return null;
|
||||
@@ -95,22 +109,22 @@ export const noResultsCopy = (
|
||||
};
|
||||
}
|
||||
switch (activeViewId) {
|
||||
case BuiltinViewId.Pinned:
|
||||
case 'favorites':
|
||||
return {
|
||||
title: 'No pinned dashboards yet',
|
||||
description: 'Pin a dashboard to keep it handy here.',
|
||||
title: 'No favorite dashboards yet',
|
||||
description: 'Star a dashboard to pin it here.',
|
||||
};
|
||||
case BuiltinViewId.Recent:
|
||||
case 'recent':
|
||||
return {
|
||||
title: 'No recently viewed dashboards',
|
||||
description: 'Dashboards you open will appear here.',
|
||||
};
|
||||
case BuiltinViewId.Locked:
|
||||
case 'locked':
|
||||
return {
|
||||
title: 'No locked dashboards',
|
||||
description: 'Dashboards locked for editing will appear here.',
|
||||
};
|
||||
case BuiltinViewId.Mine:
|
||||
case 'mine':
|
||||
return {
|
||||
title: "You haven't created any dashboards",
|
||||
description: 'Dashboards you create will appear here.',
|
||||
@@ -128,18 +142,19 @@ export const noResultsCopy = (
|
||||
}
|
||||
};
|
||||
|
||||
// Apply a client-side view's constraint to already-fetched rows. Pinned filters
|
||||
// by the per-row `pinned` flag; Recently-viewed filters by a localStorage id set
|
||||
// and preserves visit order regardless of the active sort.
|
||||
// Apply a client-side view's id-set constraint to already-fetched rows.
|
||||
// Recently-viewed preserves visit order regardless of the active sort.
|
||||
export const applyClientView = (
|
||||
items: DashboardListItem[],
|
||||
id: string,
|
||||
favorites: string[],
|
||||
recent: string[],
|
||||
): DashboardListItem[] => {
|
||||
if (id === BuiltinViewId.Pinned) {
|
||||
return items.filter((d) => d.pinned);
|
||||
if (id === 'favorites') {
|
||||
const set = new Set(favorites);
|
||||
return items.filter((d) => set.has(d.id));
|
||||
}
|
||||
if (id === BuiltinViewId.Recent) {
|
||||
if (id === 'recent') {
|
||||
const order = new Map(recent.map((rid, index) => [rid, index]));
|
||||
return items
|
||||
.filter((d) => order.has(d.id))
|
||||
@@ -14,9 +14,6 @@
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"appId": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -29,17 +26,8 @@
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiHost": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"uiHost": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -50,14 +38,8 @@
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"appId": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"identitySecret": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -68,14 +50,8 @@
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"dsn": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tunnel": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
14
frontend/src/setupProxy.js
Normal file
14
frontend/src/setupProxy.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable */
|
||||
// @ts-ignore
|
||||
// @ts-nocheck
|
||||
import { legacyCreateProxyMiddleware } from 'http-proxy-middleware';
|
||||
|
||||
export default function (app) {
|
||||
app.use(
|
||||
'/tunnel',
|
||||
legacyCreateProxyMiddleware({
|
||||
target: `${process.env.TUNNEL_DOMAIN}/tunnel`,
|
||||
changeOrigin: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -7,22 +7,14 @@ export interface WebSettings {
|
||||
sentry: Sentry;
|
||||
}
|
||||
export interface Appcues {
|
||||
appId?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface Posthog {
|
||||
apiHost?: string;
|
||||
enabled: boolean;
|
||||
key?: string;
|
||||
uiHost?: string;
|
||||
}
|
||||
export interface Pylon {
|
||||
appId?: string;
|
||||
enabled: boolean;
|
||||
identitySecret?: string;
|
||||
}
|
||||
export interface Sentry {
|
||||
dsn?: string;
|
||||
enabled: boolean;
|
||||
tunnel?: string;
|
||||
}
|
||||
|
||||
10
frontend/src/vite-env.d.ts
vendored
10
frontend/src/vite-env.d.ts
vendored
@@ -11,24 +11,18 @@ declare module '*.md?raw' {
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BASE_PATH: string;
|
||||
readonly VITE_FRONTEND_API_ENDPOINT: string;
|
||||
readonly VITE_WEBSOCKET_API_ENDPOINT: string;
|
||||
readonly VITE_PYLON_ENABLED: string;
|
||||
readonly VITE_PYLON_APP_ID: string;
|
||||
readonly VITE_PYLON_IDENTITY_SECRET: string;
|
||||
readonly VITE_APPCUES_ENABLED: string;
|
||||
readonly VITE_APPCUES_APP_ID: string;
|
||||
readonly VITE_POSTHOG_ENABLED: string;
|
||||
readonly VITE_POSTHOG_API_HOST: string;
|
||||
readonly VITE_POSTHOG_KEY: string;
|
||||
readonly VITE_POSTHOG_UI_HOST: string;
|
||||
readonly VITE_SENTRY_AUTH_TOKEN: string;
|
||||
readonly VITE_SENTRY_ORG: string;
|
||||
readonly VITE_SENTRY_PROJECT_ID: string;
|
||||
readonly VITE_SENTRY_ENABLED: string;
|
||||
readonly VITE_SENTRY_TUNNEL: string;
|
||||
readonly VITE_SENTRY_DSN: string;
|
||||
readonly VITE_TUNNEL_URL: string;
|
||||
readonly VITE_TUNNEL_DOMAIN: string;
|
||||
readonly VITE_DOCS_BASE_URL: string;
|
||||
readonly VITE_ENVIRONMENT: string;
|
||||
readonly VITE_VERSION: string;
|
||||
|
||||
@@ -6,15 +6,9 @@ import type { Plugin, TransformResult, UserConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import vitePluginChecker from 'vite-plugin-checker';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
import { createHtmlPlugin } from 'vite-plugin-html';
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import type {
|
||||
Appcues,
|
||||
Posthog,
|
||||
Pylon,
|
||||
Sentry,
|
||||
WebSettings,
|
||||
} from 'types/generated/webSettings';
|
||||
|
||||
// In dev the Go backend is not involved, so replace the [[.BaseHref]] placeholder
|
||||
// with the configured base path so relative assets resolve correctly from the Vite dev server.
|
||||
@@ -29,33 +23,17 @@ function devBasePathPlugin(basePath: string): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
function devBootDataPlugin(env: ImportMetaEnv): Plugin {
|
||||
function devBootDataPlugin(env: Record<string, string>): Plugin {
|
||||
return {
|
||||
name: 'dev-boot-data',
|
||||
apply: 'serve',
|
||||
transformIndexHtml(html): string {
|
||||
const settings = {
|
||||
posthog: {
|
||||
enabled: env.VITE_POSTHOG_ENABLED === 'true',
|
||||
apiHost: env.VITE_POSTHOG_API_HOST || '',
|
||||
key: env.VITE_POSTHOG_KEY || '',
|
||||
uiHost: env.VITE_POSTHOG_UI_HOST || '',
|
||||
} satisfies Required<Posthog>,
|
||||
appcues: {
|
||||
enabled: env.VITE_APPCUES_ENABLED === 'true',
|
||||
appId: env.VITE_APPCUES_APP_ID || '',
|
||||
} satisfies Required<Appcues>,
|
||||
sentry: {
|
||||
enabled: env.VITE_SENTRY_ENABLED === 'true',
|
||||
dsn: env.VITE_SENTRY_DSN || '',
|
||||
tunnel: env.VITE_SENTRY_TUNNEL || '',
|
||||
} satisfies Required<Sentry>,
|
||||
pylon: {
|
||||
enabled: env.VITE_PYLON_ENABLED === 'true',
|
||||
appId: env.VITE_PYLON_APP_ID || '',
|
||||
identitySecret: env.VITE_PYLON_IDENTITY_SECRET || '',
|
||||
} satisfies Required<Pylon>,
|
||||
} satisfies Required<WebSettings>;
|
||||
posthog: { enabled: env.VITE_POSTHOG_ENABLED !== 'false' },
|
||||
appcues: { enabled: env.VITE_APPCUES_ENABLED !== 'false' },
|
||||
sentry: { enabled: env.VITE_SENTRY_ENABLED !== 'false' },
|
||||
pylon: { enabled: env.VITE_PYLON_ENABLED !== 'false' },
|
||||
};
|
||||
return html.replaceAll('[[.Settings]]', JSON.stringify(settings));
|
||||
},
|
||||
};
|
||||
@@ -77,7 +55,7 @@ function rawMarkdownPlugin(): Plugin {
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }): UserConfig => {
|
||||
const env = loadEnv(mode, process.cwd(), '') as ImportMetaEnv;
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
// Base path for serving the app (e.g., '/signoz/'). Defaults to '/'.
|
||||
const basePath = env.VITE_BASE_PATH || '/';
|
||||
|
||||
@@ -87,6 +65,14 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
devBasePathPlugin(basePath),
|
||||
devBootDataPlugin(env),
|
||||
react(),
|
||||
createHtmlPlugin({
|
||||
inject: {
|
||||
data: {
|
||||
PYLON_APP_ID: env.VITE_PYLON_APP_ID || '',
|
||||
APPCUES_APP_ID: env.VITE_APPCUES_APP_ID || '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
vitePluginChecker({
|
||||
typescript: true,
|
||||
// this doubles the build tim
|
||||
@@ -171,6 +157,17 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
'process.env.WEBSOCKET_API_ENDPOINT': JSON.stringify(
|
||||
env.VITE_WEBSOCKET_API_ENDPOINT,
|
||||
),
|
||||
'process.env.PYLON_APP_ID': JSON.stringify(env.VITE_PYLON_APP_ID),
|
||||
'process.env.PYLON_IDENTITY_SECRET': JSON.stringify(
|
||||
env.VITE_PYLON_IDENTITY_SECRET,
|
||||
),
|
||||
'process.env.APPCUES_APP_ID': JSON.stringify(env.VITE_APPCUES_APP_ID),
|
||||
'process.env.POSTHOG_KEY': JSON.stringify(env.VITE_POSTHOG_KEY),
|
||||
'process.env.SENTRY_ORG': JSON.stringify(env.VITE_SENTRY_ORG),
|
||||
'process.env.SENTRY_PROJECT_ID': JSON.stringify(env.VITE_SENTRY_PROJECT_ID),
|
||||
'process.env.SENTRY_DSN': JSON.stringify(env.VITE_SENTRY_DSN),
|
||||
'process.env.TUNNEL_URL': JSON.stringify(env.VITE_TUNNEL_URL),
|
||||
'process.env.TUNNEL_DOMAIN': JSON.stringify(env.VITE_TUNNEL_DOMAIN),
|
||||
'process.env.DOCS_BASE_URL': JSON.stringify(env.VITE_DOCS_BASE_URL),
|
||||
'process.env.ENVIRONMENT': JSON.stringify(env.VITE_ENVIRONMENT),
|
||||
'process.env.VERSION': JSON.stringify(env.VITE_VERSION),
|
||||
|
||||
@@ -451,23 +451,6 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
|
||||
ID: "QueryRangePreviewV5",
|
||||
Tags: []string{"querier"},
|
||||
Summary: "Query range preview",
|
||||
Description: "Validate a composite query without executing it. Accepts the same payload as the query range endpoint. By default (verbose=true) returns, for each query, the rendered underlying ClickHouse statement(s) with each statement's EXPLAIN ESTIMATE (per-table parts/rows/marks) and granule index analysis (candidate/surviving granules and the per-index pruning funnel). Pass ?verbose=false for the lightweight per-query verdict (valid/error/warnings) with no rendered SQL and no ClickHouse round trips. Intended for agentic/dry-run consumption: per-query errors are reported in the response rather than failing the whole request.",
|
||||
Request: new(qbtypes.QueryRangeRequest),
|
||||
RequestQuery: new(qbtypes.QueryRangePreviewParams),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(qbtypes.QueryRangePreviewResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v5/substitute_vars", handler.New(provider.authzMiddleware.ViewAccess(provider.querierHandler.ReplaceVariables), handler.OpenAPIDef{
|
||||
ID: "ReplaceVariables",
|
||||
Tags: []string{"querier"},
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package clickhouseprometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/prometheus/prometheus/prompb"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
)
|
||||
|
||||
// statementRecorder collects the statements a PromQL evaluation would run.
|
||||
// Safe for concurrent use: the engine may Select selectors concurrently.
|
||||
type statementRecorder struct {
|
||||
mu sync.Mutex
|
||||
statements []prometheus.CapturedStatement
|
||||
}
|
||||
|
||||
func (r *statementRecorder) record(query string, args []any) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
|
||||
}
|
||||
|
||||
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]prometheus.CapturedStatement, len(r.statements))
|
||||
copy(out, r.statements)
|
||||
return out
|
||||
}
|
||||
|
||||
// captureClient builds the same SQL as the real client but records it and
|
||||
// returns an empty result instead of executing.
|
||||
type captureClient struct {
|
||||
*client
|
||||
recorder *statementRecorder
|
||||
}
|
||||
|
||||
func (c *captureClient) Read(ctx context.Context, query *prompb.Query, _ bool) (storage.SeriesSet, error) {
|
||||
// Raw-SQL passthrough ({job="rawsql", query="..."}): record the raw query.
|
||||
if len(query.Matchers) == 2 {
|
||||
var hasJob bool
|
||||
var queryString string
|
||||
for _, m := range query.Matchers {
|
||||
if m.Type == prompb.LabelMatcher_EQ && m.Name == "job" && m.Value == "rawsql" {
|
||||
hasJob = true
|
||||
}
|
||||
if m.Type == prompb.LabelMatcher_EQ && m.Name == "query" {
|
||||
queryString = m.Value
|
||||
}
|
||||
}
|
||||
if hasJob && queryString != "" {
|
||||
c.recorder.record(queryString, nil)
|
||||
return storage.EmptySeriesSet(), nil
|
||||
}
|
||||
}
|
||||
|
||||
var metricName string
|
||||
for _, matcher := range query.Matchers {
|
||||
if matcher.Name == "__name__" {
|
||||
metricName = matcher.Value
|
||||
}
|
||||
}
|
||||
|
||||
// Build the executing path's queries, but only record them.
|
||||
subQuery, args, err := c.queryToClickhouseQuery(ctx, query, metricName, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
samplesQuery, samplesArgs := buildSamplesQuery(int64(query.StartTimestampMs), int64(query.EndTimestampMs), metricName, subQuery, args)
|
||||
c.recorder.record(samplesQuery, samplesArgs)
|
||||
|
||||
return storage.EmptySeriesSet(), nil
|
||||
}
|
||||
|
||||
// captureQueryable adapts the capturing read client to storage.Queryable.
|
||||
type captureQueryable struct {
|
||||
inner storage.SampleAndChunkQueryable
|
||||
}
|
||||
|
||||
func (c captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
querier, err := c.inner.Querier(mint, maxt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
|
||||
}
|
||||
@@ -204,9 +204,8 @@ func (client *client) getFingerprintsFromClickhouseQuery(ctx context.Context, qu
|
||||
return fingerprints, nil
|
||||
}
|
||||
|
||||
// buildSamplesQuery renders the samples SQL (and args) that fetches data
|
||||
// points for the series selected by subQuery.
|
||||
func buildSamplesQuery(start int64, end int64, metricName string, subQuery string, args []any) (string, []any) {
|
||||
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
|
||||
argCount := len(args)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
@@ -218,13 +217,6 @@ func buildSamplesQuery(start int64, end int64, metricName string, subQuery strin
|
||||
|
||||
allArgs := append([]any{metricName}, args...)
|
||||
allArgs = append(allArgs, start, end)
|
||||
return query, allArgs
|
||||
}
|
||||
|
||||
func (client *client) querySamples(ctx context.Context, start int64, end int64, fingerprints map[uint64][]prompb.Label, metricName string, subQuery string, args []any) ([]*prompb.TimeSeries, error) {
|
||||
ctx = client.withClickhousePrometheusContext(ctx, "querySamples")
|
||||
|
||||
query, allArgs := buildSamplesQuery(start, end, metricName, subQuery, args)
|
||||
|
||||
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, allArgs...)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
@@ -64,15 +64,3 @@ func (provider *provider) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
|
||||
return storage.NewMergeQuerier(nil, []storage.Querier{querier}, storage.ChainedSeriesMerge), nil
|
||||
}
|
||||
|
||||
// CapturingStorage implements prometheus.StatementCapturer. Uses a fresh
|
||||
// recorder per call so concurrent dry-runs don't share state.
|
||||
func (provider *provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
|
||||
recorder := &statementRecorder{}
|
||||
capture := &captureClient{
|
||||
client: &client{settings: provider.settings, telemetryStore: provider.telemetryStore},
|
||||
recorder: recorder,
|
||||
}
|
||||
queryable := remote.NewSampleAndChunkQueryableClient(capture, labels.EmptyLabels(), []*labels.Matcher{}, false, stCallback)
|
||||
return captureQueryable{inner: queryable}, recorder
|
||||
}
|
||||
|
||||
@@ -15,23 +15,3 @@ type Prometheus interface {
|
||||
Storage() storage.Queryable
|
||||
Parser() Parser
|
||||
}
|
||||
|
||||
// CapturedStatement is one datastore statement a PromQL query would run,
|
||||
// captured without executing.
|
||||
type CapturedStatement struct {
|
||||
Query string
|
||||
Args []any
|
||||
}
|
||||
|
||||
// StatementRecorder reads back the statements captured against a capturing
|
||||
// Storage (see StatementCapturer).
|
||||
type StatementRecorder interface {
|
||||
Statements() []CapturedStatement
|
||||
}
|
||||
|
||||
// StatementCapturer is an optional Prometheus-provider capability, discovered
|
||||
// via type assertion: it returns a Storage that records each Select's statement
|
||||
// without executing it, plus a recorder to read them back.
|
||||
type StatementCapturer interface {
|
||||
CapturingStorage() (storage.Queryable, StatementRecorder)
|
||||
}
|
||||
|
||||
@@ -73,53 +73,6 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, queryRangeResponse)
|
||||
}
|
||||
|
||||
// QueryRangePreview is the dry-run counterpart of QueryRange: it validates and
|
||||
// renders each query without executing it.
|
||||
func (handler *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "querier",
|
||||
instrumentationtypes.CodeFunctionName: "QueryRangePreview",
|
||||
})
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validation is deferred to QueryRangePreview, which reports per-query
|
||||
// errors instead of failing fast.
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
previewParams := qbtypes.QueryRangePreviewParams{Verbose: req.URL.Query().Get("verbose")}
|
||||
previewOpts, err := previewParams.Validate()
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := handler.querier.QueryRangePreview(ctx, orgID, &queryRangeRequest, previewOpts)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, preview)
|
||||
}
|
||||
|
||||
func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ type builderQuery[T any] struct {
|
||||
}
|
||||
|
||||
var _ qbtypes.Query = (*builderQuery[any])(nil)
|
||||
var _ qbtypes.StatementProvider = (*builderQuery[any])(nil)
|
||||
|
||||
type builderConfig struct {
|
||||
logTraceIDWindowPaddingMS uint64
|
||||
@@ -212,11 +211,6 @@ func (q *builderQuery[T]) isWindowList() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Statement renders the SQL without executing it, for the preview path.
|
||||
func (q *builderQuery[T]) Statement(ctx context.Context) (*qbtypes.Statement, error) {
|
||||
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
|
||||
}
|
||||
|
||||
func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
|
||||
// can we do window based pagination?
|
||||
|
||||
@@ -32,7 +32,6 @@ type chSQLQuery struct {
|
||||
}
|
||||
|
||||
var _ qbtypes.Query = (*chSQLQuery)(nil)
|
||||
var _ qbtypes.StatementProvider = (*chSQLQuery)(nil)
|
||||
|
||||
func newchSQLQuery(
|
||||
logger *slog.Logger,
|
||||
@@ -100,15 +99,6 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
|
||||
return newQuery.String(), nil
|
||||
}
|
||||
|
||||
// Statement renders the SQL without executing it, for the preview path.
|
||||
func (q *chSQLQuery) Statement(_ context.Context) (*qbtypes.Statement, error) {
|
||||
rendered, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &qbtypes.Statement{Query: rendered, Args: q.args}, nil
|
||||
}
|
||||
|
||||
func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),
|
||||
|
||||
@@ -14,8 +14,6 @@ type Querier interface {
|
||||
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
|
||||
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
|
||||
statsreporter.StatsCollector
|
||||
// QueryRangePreview validates and renders the queries without executing them.
|
||||
QueryRangePreview(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, opts qbtypes.QueryRangePreviewOptions) (*qbtypes.QueryRangePreviewResponse, error)
|
||||
}
|
||||
|
||||
// BucketCache is the interface for bucket-based caching.
|
||||
@@ -28,8 +26,6 @@ type BucketCache interface {
|
||||
|
||||
type Handler interface {
|
||||
QueryRange(rw http.ResponseWriter, req *http.Request)
|
||||
// QueryRangePreview is the dry-run endpoint: validate and render without executing.
|
||||
QueryRangePreview(rw http.ResponseWriter, req *http.Request)
|
||||
QueryRawStream(rw http.ResponseWriter, req *http.Request)
|
||||
ReplaceVariables(rw http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// QueryRangePreview validates and renders each query without executing it.
|
||||
// When opts.Verbose, it also attaches each statement's EXPLAIN ESTIMATE and
|
||||
// granule analysis.
|
||||
func (q *querier) QueryRangePreview(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
opts qbtypes.QueryRangePreviewOptions,
|
||||
) (*qbtypes.QueryRangePreviewResponse, error) {
|
||||
|
||||
validationOpts, err := req.ValidateRequestScope()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dependencyQueries, err := q.constructTraceOperatorDependencyMap(req.CompositeQuery.Queries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
|
||||
|
||||
prepared := make(map[string]qbtypes.QueryPreview, len(req.CompositeQuery.Queries))
|
||||
missingMetricQuerySet := make(map[string]bool)
|
||||
for idx := range req.CompositeQuery.Queries {
|
||||
name := req.CompositeQuery.Queries[idx].GetQueryName()
|
||||
ps := qbtypes.QueryPreview{Warnings: []string{}, Statements: []qbtypes.PreviewStatement{}}
|
||||
|
||||
if vErr := req.CompositeQuery.Queries[idx].Validate(validationOpts...); vErr != nil {
|
||||
ps.Error = vErr
|
||||
prepared[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
env := []qbtypes.QueryEnvelope{req.CompositeQuery.Queries[idx]}
|
||||
ps.Warnings = append(ps.Warnings, q.adjustStepInterval(env, req.Start, req.End)...)
|
||||
|
||||
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, orgID, env, req.Start, req.End)
|
||||
if mErr != nil {
|
||||
// Report this query's error but keep previewing the rest.
|
||||
ps.Error = mErr
|
||||
} else {
|
||||
ps.Warnings = append(ps.Warnings, metricWarnings...)
|
||||
if len(missingMetricQueries) > 0 {
|
||||
missingMetricQuerySet[name] = true
|
||||
if len(metricWarnings) == 0 {
|
||||
if metricNames := missingMetricNames(env[0]); len(metricNames) > 0 {
|
||||
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
|
||||
"query %q references metric(s) %s with no data available; it will return an empty result",
|
||||
name, strings.Join(metricNames, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req.CompositeQuery.Queries[idx] = env[0]
|
||||
prepared[name] = ps
|
||||
}
|
||||
|
||||
skip := make(map[string]bool, len(prepared))
|
||||
for name, ps := range prepared {
|
||||
if ps.Error != nil || missingMetricQuerySet[name] {
|
||||
skip[name] = true
|
||||
}
|
||||
}
|
||||
providers, buildErrs := q.buildPreviewProviders(req, dependencyQueries, missingMetricQuerySet, skip)
|
||||
|
||||
// Render each executing query's statement and collect the ClickHouse-bound
|
||||
// analysis work to run concurrently.
|
||||
var previewTasks []qbtypes.PreviewTask
|
||||
for _, query := range req.CompositeQuery.Queries {
|
||||
name := query.GetQueryName()
|
||||
ps := prepared[name]
|
||||
|
||||
if ps.Error != nil {
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
if missingMetricQuerySet[name] {
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
if bErr := buildErrs[name]; bErr != nil {
|
||||
ps.Error = bErr
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
provider, ok := providers[name]
|
||||
if !ok {
|
||||
if !rendersStandaloneStatement(query.Type) {
|
||||
ps.Warnings = append(ps.Warnings, fmt.Sprintf(
|
||||
"query type %q has no standalone statement to preview; it is evaluated from the queries it references", query.Type.StringValue()))
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
ps.Error = errors.NewInternalf(errors.CodeInternal, "query produced no provider")
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
stmtProvider, ok := provider.(qbtypes.StatementProvider)
|
||||
if !ok {
|
||||
ps.Error = errors.NewInternalf(errors.CodeInternal, "query does not support preview")
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
stmt, sErr := stmtProvider.Statement(ctx)
|
||||
if sErr != nil {
|
||||
ps.Error = sErr
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
ps.Warnings = append(ps.Warnings, stmt.Warnings...)
|
||||
|
||||
if query.Type == qbtypes.QueryTypeClickHouseSQL {
|
||||
if bindErr := q.telemetryStore.Plan(ctx, stmt.Query, stmt.Args...); bindErr != nil {
|
||||
if errors.Ast(bindErr, errors.TypeInvalidInput) || errors.Ast(bindErr, errors.TypeNotFound) {
|
||||
ps.Error = bindErr
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
ps.Warnings = append(ps.Warnings, "could not validate ClickHouse SQL: "+bindErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.Verbose {
|
||||
results[name] = ps
|
||||
continue
|
||||
}
|
||||
|
||||
if query.Type == qbtypes.QueryTypePromQL {
|
||||
if pq, ok := provider.(*promqlQuery); ok {
|
||||
sqlStmts, pErr := pq.PreviewStatements(ctx)
|
||||
if pErr != nil {
|
||||
ps.Warnings = append(ps.Warnings, "could not render underlying ClickHouse SQL: "+pErr.Error())
|
||||
} else {
|
||||
for _, s := range sqlStmts {
|
||||
ps.Statements = append(ps.Statements, qbtypes.PreviewStatement{Query: s.Query, Args: orEmpty(s.Args), Estimate: []telemetrystoretypes.EstimateEntry{}})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ps.Statements = []qbtypes.PreviewStatement{{Query: stmt.Query, Args: orEmpty(stmt.Args), Estimate: []telemetrystoretypes.EstimateEntry{}}}
|
||||
}
|
||||
|
||||
results[name] = ps
|
||||
|
||||
for j := range ps.Statements {
|
||||
previewTasks = append(previewTasks, qbtypes.PreviewTask{Name: name, StmtIdx: j, Query: ps.Statements[j].Query, Args: ps.Statements[j].Args})
|
||||
}
|
||||
}
|
||||
|
||||
q.runPreviewTasks(ctx, previewTasks, results)
|
||||
|
||||
return &qbtypes.QueryRangePreviewResponse{
|
||||
CompositeQuery: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// missingMetricNames returns the distinct metric names referenced by a metric
|
||||
// builder query, or nil for a non-metric query.
|
||||
func missingMetricNames(env qbtypes.QueryEnvelope) []string {
|
||||
spec, ok := env.Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(spec.Aggregations))
|
||||
for _, agg := range spec.Aggregations {
|
||||
if agg.MetricName != "" && !slices.Contains(names, agg.MetricName) {
|
||||
names = append(names, agg.MetricName)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (q *querier) buildPreviewProviders(
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
dependencyQueries map[string]bool,
|
||||
missingMetricQuerySet map[string]bool,
|
||||
skip map[string]bool,
|
||||
) (providers map[string]qbtypes.Query, errs map[string]error) {
|
||||
providers = make(map[string]qbtypes.Query)
|
||||
errs = make(map[string]error)
|
||||
|
||||
event := &qbtypes.QBEvent{} // preview emits no analytics
|
||||
|
||||
for _, query := range req.CompositeQuery.Queries {
|
||||
name := query.GetQueryName()
|
||||
if skip[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
sub := *req // shallow copy: only CompositeQuery and RequestType are swapped
|
||||
|
||||
// deps is the set buildQueries skips: empty for a standalone query, the
|
||||
// operator's referenced siblings for a trace operator.
|
||||
var deps map[string]bool
|
||||
|
||||
switch {
|
||||
case query.GetType() == qbtypes.QueryTypeTraceOperator:
|
||||
refs, rErr := q.traceOperatorPreviewComposite(req, query)
|
||||
if rErr != nil {
|
||||
errs[name] = rErr
|
||||
continue
|
||||
}
|
||||
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: refs}
|
||||
deps = dependencyQueries
|
||||
case dependencyQueries[name]:
|
||||
sub.RequestType = qbtypes.RequestTypeRaw
|
||||
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
|
||||
default:
|
||||
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
|
||||
}
|
||||
|
||||
built, _, bErr := q.buildQueries(&sub, deps, missingMetricQuerySet, event)
|
||||
if bErr != nil {
|
||||
errs[name] = bErr
|
||||
continue
|
||||
}
|
||||
|
||||
if provider, ok := built[name]; ok {
|
||||
providers[name] = provider
|
||||
}
|
||||
}
|
||||
return providers, errs
|
||||
}
|
||||
|
||||
// rendersStandaloneStatement reports whether a query type renders its own
|
||||
// statement. Formula/join/sub-query don't — they reference other queries.
|
||||
func rendersStandaloneStatement(t qbtypes.QueryType) bool {
|
||||
switch t {
|
||||
case qbtypes.QueryTypeBuilder,
|
||||
qbtypes.QueryTypePromQL,
|
||||
qbtypes.QueryTypeClickHouseSQL,
|
||||
qbtypes.QueryTypeTraceOperator:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (q *querier) traceOperatorPreviewComposite(req *qbtypes.QueryRangeRequest, operator qbtypes.QueryEnvelope) ([]qbtypes.QueryEnvelope, error) {
|
||||
spec, ok := operator.Spec.(qbtypes.QueryBuilderTraceOperator)
|
||||
if !ok {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", operator.Spec)
|
||||
}
|
||||
if err := spec.ParseExpression(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
referenced := make(map[string]bool)
|
||||
for _, name := range spec.CollectReferencedQueries(spec.ParsedExpression) {
|
||||
referenced[name] = true
|
||||
}
|
||||
|
||||
queries := make([]qbtypes.QueryEnvelope, 0, len(referenced)+1)
|
||||
for _, qe := range req.CompositeQuery.Queries {
|
||||
if referenced[qe.GetQueryName()] {
|
||||
queries = append(queries, qe)
|
||||
}
|
||||
}
|
||||
return append(queries, operator), nil
|
||||
}
|
||||
|
||||
func (q *querier) runPreviewTasks(ctx context.Context, tasks []qbtypes.PreviewTask, previews map[string]qbtypes.QueryPreview) {
|
||||
if len(tasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
granules *telemetrystoretypes.Granules
|
||||
estimate []telemetrystoretypes.EstimateEntry
|
||||
warnings []string
|
||||
}
|
||||
outcomes := make([]outcome, len(tasks))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range tasks {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
t := tasks[i]
|
||||
var out outcome
|
||||
if granules, ok, scErr := q.telemetryStore.Indexes(ctx, t.Query, t.Args...); scErr != nil {
|
||||
out.warnings = append(out.warnings, "could not compute granule stats: "+scErr.Error())
|
||||
} else if ok {
|
||||
out.granules = &granules
|
||||
}
|
||||
if estimate, eErr := q.telemetryStore.Estimate(ctx, t.Query, t.Args...); eErr != nil {
|
||||
out.warnings = append(out.warnings, "could not run EXPLAIN ESTIMATE: "+eErr.Error())
|
||||
} else {
|
||||
out.estimate = estimate
|
||||
}
|
||||
outcomes[i] = out
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := range tasks {
|
||||
ps := previews[tasks[i].Name]
|
||||
if idx := tasks[i].StmtIdx; idx >= 0 && idx < len(ps.Statements) {
|
||||
if outcomes[i].granules != nil {
|
||||
ps.Statements[idx].Granules = outcomes[i].granules
|
||||
}
|
||||
if len(outcomes[i].estimate) > 0 {
|
||||
ps.Statements[idx].Estimate = outcomes[i].estimate
|
||||
}
|
||||
}
|
||||
ps.Warnings = append(ps.Warnings, outcomes[i].warnings...)
|
||||
previews[tasks[i].Name] = ps
|
||||
}
|
||||
}
|
||||
|
||||
// orEmpty returns s, or a non-nil empty slice when s is nil.
|
||||
func orEmpty[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -101,7 +101,6 @@ type promqlQuery struct {
|
||||
}
|
||||
|
||||
var _ qbv5.Query = (*promqlQuery)(nil)
|
||||
var _ qbv5.StatementProvider = (*promqlQuery)(nil)
|
||||
|
||||
func newPromqlQuery(
|
||||
logger *slog.Logger,
|
||||
@@ -221,62 +220,6 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
|
||||
return newQuery.String(), nil
|
||||
}
|
||||
|
||||
// Statement renders the PromQL string (no SQL args) without executing it, for
|
||||
// the preview path.
|
||||
func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
|
||||
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &qbv5.Statement{Query: rendered}, nil
|
||||
}
|
||||
|
||||
// PreviewStatements returns the ClickHouse statement(s) this PromQL query would
|
||||
// run, captured by driving the engine with a Storage that records each selector's
|
||||
// SQL and returns no data. Returns nil if capture is unsupported.
|
||||
func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.CapturedStatement, error) {
|
||||
storer, ok := q.promEngine.(prometheus.StatementCapturer)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
start := int64(querybuilder.ToNanoSecs(q.tr.From))
|
||||
end := int64(querybuilder.ToNanoSecs(q.tr.To))
|
||||
|
||||
capStorage, recorder := storer.CapturingStorage()
|
||||
qry, err := q.promEngine.Engine().NewRangeQuery(
|
||||
ctx,
|
||||
capStorage,
|
||||
nil,
|
||||
rendered,
|
||||
time.Unix(0, start),
|
||||
time.Unix(0, end),
|
||||
q.query.Step.Duration,
|
||||
)
|
||||
if err != nil {
|
||||
if e := tryEnhancePromQLExecError(err); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return nil, enhancePromQLError(rendered, err)
|
||||
}
|
||||
defer qry.Close()
|
||||
|
||||
// Exec drives a Select per selector (recording SQL) but reads no data.
|
||||
if res := qry.Exec(ctx); res.Err != nil {
|
||||
if e := tryEnhancePromQLExecError(res.Err); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
|
||||
}
|
||||
|
||||
return recorder.Statements(), nil
|
||||
}
|
||||
|
||||
func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
|
||||
@@ -23,7 +23,6 @@ type traceOperatorQuery struct {
|
||||
}
|
||||
|
||||
var _ qbtypes.Query = (*traceOperatorQuery)(nil)
|
||||
var _ qbtypes.StatementProvider = (*traceOperatorQuery)(nil)
|
||||
|
||||
func (q *traceOperatorQuery) Fingerprint() string {
|
||||
return ""
|
||||
@@ -33,11 +32,6 @@ func (q *traceOperatorQuery) Window() (uint64, uint64) {
|
||||
return q.fromMS, q.toMS
|
||||
}
|
||||
|
||||
// Statement renders the SQL without executing it, for the preview path.
|
||||
func (q *traceOperatorQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
|
||||
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.compositeQuery)
|
||||
}
|
||||
|
||||
func (q *traceOperatorQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
stmt, err := q.stmtBuilder.Build(
|
||||
ctx,
|
||||
|
||||
@@ -145,7 +145,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhere
|
||||
"Found %d syntax errors while parsing the search expression.",
|
||||
len(parserErrorListener.SyntaxErrors),
|
||||
)
|
||||
additionals := make([]string, 0, len(parserErrorListener.SyntaxErrors))
|
||||
additionals := make([]string, len(parserErrorListener.SyntaxErrors))
|
||||
for _, err := range parserErrorListener.SyntaxErrors {
|
||||
if err.Error() != "" {
|
||||
additionals = append(additionals, err.Error())
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
package clickhousetelemetrystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
)
|
||||
|
||||
// ExplainPlanNode is a node in ClickHouse's `EXPLAIN json = 1, indexes = 1`
|
||||
// output, parsed to derive the granule-skip breakdown.
|
||||
type ExplainPlanNode struct {
|
||||
NodeType string `json:"Node Type"`
|
||||
Description string `json:"Description"`
|
||||
Indexes []ExplainPlanIndex `json:"Indexes"`
|
||||
Plans []ExplainPlanNode `json:"Plans"`
|
||||
}
|
||||
|
||||
// ExplainPlanIndex is one index entry under a ReadFromMergeTree node, reporting
|
||||
// the parts/granules entering and surviving the index.
|
||||
type ExplainPlanIndex struct {
|
||||
Type string `json:"Type"`
|
||||
Name string `json:"Name"`
|
||||
Keys []string `json:"Keys"`
|
||||
Condition string `json:"Condition"`
|
||||
InitialParts *int64 `json:"Initial Parts"`
|
||||
SelectedParts *int64 `json:"Selected Parts"`
|
||||
InitialGranules *int64 `json:"Initial Granules"`
|
||||
SelectedGranules *int64 `json:"Selected Granules"`
|
||||
}
|
||||
|
||||
// RunExplainEstimate backs TelemetryStore.Estimate.
|
||||
func RunExplainEstimate(ctx context.Context, conn clickhouse.Conn, stmt string, args ...any) ([]telemetrystoretypes.EstimateEntry, error) {
|
||||
if err := ValidateExplainStatement(stmt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, "EXPLAIN ESTIMATE "+stmt, args...)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN ESTIMATE")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
colTypes := rows.ColumnTypes()
|
||||
var entries []telemetrystoretypes.EstimateEntry
|
||||
for rows.Next() {
|
||||
dest := make([]any, len(colTypes))
|
||||
for i, ct := range colTypes {
|
||||
dest[i] = reflect.New(ct.ScanType()).Interface()
|
||||
}
|
||||
if err := rows.Scan(dest...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN ESTIMATE row")
|
||||
}
|
||||
var entry telemetrystoretypes.EstimateEntry
|
||||
for i, ct := range colTypes {
|
||||
val := reflect.ValueOf(dest[i]).Elem().Interface()
|
||||
switch strings.ToLower(ct.Name()) {
|
||||
case "database":
|
||||
entry.Database = fmt.Sprintf("%v", val)
|
||||
case "table":
|
||||
entry.Table = fmt.Sprintf("%v", val)
|
||||
case "parts":
|
||||
entry.Parts = toInt64(val)
|
||||
case "rows":
|
||||
entry.Rows = toInt64(val)
|
||||
case "marks":
|
||||
entry.Marks = toInt64(val)
|
||||
}
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN ESTIMATE row iteration failed")
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// RunExplainPlan backs TelemetryStore.Plan, returning the driver error when stmt
|
||||
// does not parse or bind.
|
||||
func RunExplainPlan(ctx context.Context, conn clickhouse.Conn, stmt string, args ...any) error {
|
||||
if err := ValidateExplainStatement(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, "EXPLAIN PLAN "+stmt, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunExplainIndexes backs TelemetryStore.Indexes, summing the breakdown
|
||||
// across every ReadFromMergeTree node.
|
||||
func RunExplainIndexes(ctx context.Context, conn clickhouse.Conn, stmt string, args ...any) (telemetrystoretypes.Granules, bool, error) {
|
||||
if err := ValidateExplainStatement(stmt); err != nil {
|
||||
return telemetrystoretypes.Granules{}, false, err
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, "EXPLAIN json = 1, indexes = 1 "+stmt, args...)
|
||||
if err != nil {
|
||||
return telemetrystoretypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN for granule stats")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// json=1 emits one JSON document; join rows in case the driver splits it.
|
||||
var sb strings.Builder
|
||||
for rows.Next() {
|
||||
var line string
|
||||
if err := rows.Scan(&line); err != nil {
|
||||
return telemetrystoretypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN json row")
|
||||
}
|
||||
sb.WriteString(line)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return telemetrystoretypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN json row iteration failed")
|
||||
}
|
||||
|
||||
var plans []struct {
|
||||
Plan ExplainPlanNode `json:"Plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(sb.String()), &plans); err != nil {
|
||||
return telemetrystoretypes.Granules{}, false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse EXPLAIN json")
|
||||
}
|
||||
|
||||
var totalInitial, totalSelected int64
|
||||
reads := []telemetrystoretypes.MergeTreeRead{}
|
||||
for i := range plans {
|
||||
collectMergeTreeReads(&plans[i].Plan, &reads, &totalInitial, &totalSelected)
|
||||
}
|
||||
if totalInitial <= 0 {
|
||||
// No MergeTree index analysis — nothing to report.
|
||||
return telemetrystoretypes.Granules{}, false, nil
|
||||
}
|
||||
if totalSelected < 0 {
|
||||
totalSelected = 0
|
||||
}
|
||||
skippedGranules := totalInitial - totalSelected
|
||||
if skippedGranules < 0 {
|
||||
skippedGranules = 0
|
||||
}
|
||||
return telemetrystoretypes.Granules{
|
||||
Initial: totalInitial,
|
||||
Selected: totalSelected,
|
||||
Skipped: skippedGranules,
|
||||
Reads: reads,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func collectMergeTreeReads(node *ExplainPlanNode, reads *[]telemetrystoretypes.MergeTreeRead, totalInitial, totalSelected *int64) {
|
||||
if node.NodeType == "ReadFromMergeTree" && len(node.Indexes) > 0 {
|
||||
steps := make([]telemetrystoretypes.IndexStep, 0, len(node.Indexes))
|
||||
var initial, selected *int64
|
||||
for i := range node.Indexes {
|
||||
idx := node.Indexes[i]
|
||||
if idx.InitialGranules != nil && initial == nil {
|
||||
initial = idx.InitialGranules
|
||||
}
|
||||
if idx.SelectedGranules != nil {
|
||||
selected = idx.SelectedGranules
|
||||
}
|
||||
steps = append(steps, telemetrystoretypes.IndexStep{
|
||||
Type: idx.Type,
|
||||
Name: idx.Name,
|
||||
Keys: orEmpty(idx.Keys),
|
||||
Condition: idx.Condition,
|
||||
InitialParts: derefInt64(idx.InitialParts),
|
||||
SelectedParts: derefInt64(idx.SelectedParts),
|
||||
InitialGranules: derefInt64(idx.InitialGranules),
|
||||
SelectedGranules: derefInt64(idx.SelectedGranules),
|
||||
})
|
||||
}
|
||||
if initial != nil && selected != nil {
|
||||
*totalInitial += *initial
|
||||
*totalSelected += *selected
|
||||
}
|
||||
*reads = append(*reads, telemetrystoretypes.MergeTreeRead{Table: node.Description, Steps: steps})
|
||||
}
|
||||
for i := range node.Plans {
|
||||
collectMergeTreeReads(&node.Plans[i], reads, totalInitial, totalSelected)
|
||||
}
|
||||
}
|
||||
|
||||
// toInt64 coerces a driver-scanned numeric value to int64 (0 if non-numeric).
|
||||
func toInt64(v any) int64 {
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return rv.Int()
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return int64(rv.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return int64(rv.Float())
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func derefInt64(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// orEmpty returns s, or a non-nil empty slice when s is nil.
|
||||
func orEmpty[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package clickhousetelemetrystore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// ErrCodeUnsafeStatement is returned when a statement is not a single statement
|
||||
// safe to wrap in an EXPLAIN prefix.
|
||||
var ErrCodeUnsafeStatement = errors.MustNewCode("unsafe_statement")
|
||||
|
||||
// scanState is the lexer state while scanning a statement.
|
||||
type scanState int
|
||||
|
||||
const (
|
||||
scanNormal scanState = iota
|
||||
scanSingle
|
||||
scanDouble
|
||||
scanBacktick
|
||||
scanLineComment
|
||||
scanBlockComment
|
||||
)
|
||||
|
||||
// ValidateExplainStatement rejects stacked statements (e.g. `SELECT 1; DROP TABLE t`),
|
||||
// the only injection vector left once values are bound and the EXPLAIN prefix is fixed.
|
||||
// It scans stmt as ClickHouse SQL — ignoring ';' inside string literals, quoted
|
||||
// identifiers, and comments — and rejects any content after a top-level ';'.
|
||||
func ValidateExplainStatement(stmt string) error {
|
||||
if strings.TrimSpace(stmt) == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeUnsafeStatement, "statement is empty")
|
||||
}
|
||||
|
||||
state := scanNormal
|
||||
// terminated is set at a top-level ';'; after it only whitespace, ';', and
|
||||
// comments may appear — anything else is a second statement.
|
||||
terminated := false
|
||||
|
||||
for i := 0; i < len(stmt); i++ {
|
||||
c := stmt[i]
|
||||
switch state {
|
||||
case scanNormal:
|
||||
if terminated {
|
||||
switch {
|
||||
case isSQLSpace(c) || c == ';':
|
||||
// harmless trailing whitespace / empty statements
|
||||
case c == '-' && i+1 < len(stmt) && stmt[i+1] == '-':
|
||||
state = scanLineComment
|
||||
i++
|
||||
case c == '/' && i+1 < len(stmt) && stmt[i+1] == '*':
|
||||
state = scanBlockComment
|
||||
i++
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeUnsafeStatement, "statement must be a single statement; content found after ';'")
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case c == '\'':
|
||||
state = scanSingle
|
||||
case c == '"':
|
||||
state = scanDouble
|
||||
case c == '`':
|
||||
state = scanBacktick
|
||||
case c == '-' && i+1 < len(stmt) && stmt[i+1] == '-':
|
||||
state = scanLineComment
|
||||
i++
|
||||
case c == '/' && i+1 < len(stmt) && stmt[i+1] == '*':
|
||||
state = scanBlockComment
|
||||
i++
|
||||
case c == ';':
|
||||
terminated = true
|
||||
}
|
||||
case scanSingle:
|
||||
i = skipQuoted(stmt, i, '\'', &state)
|
||||
case scanDouble:
|
||||
i = skipQuoted(stmt, i, '"', &state)
|
||||
case scanBacktick:
|
||||
i = skipQuoted(stmt, i, '`', &state)
|
||||
case scanLineComment:
|
||||
if c == '\n' {
|
||||
state = scanNormal
|
||||
}
|
||||
case scanBlockComment:
|
||||
if c == '*' && i+1 < len(stmt) && stmt[i+1] == '/' {
|
||||
state = scanNormal
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// skipQuoted advances one character within a quoted literal/identifier delimited
|
||||
// by quote. A backslash or doubled quote escapes; an unescaped quote ends the
|
||||
// literal (resetting *state). It returns the index to resume from (caller adds one).
|
||||
func skipQuoted(s string, i int, quote byte, state *scanState) int {
|
||||
c := s[i]
|
||||
switch c {
|
||||
case '\\':
|
||||
// skip the escaped character
|
||||
return i + 1
|
||||
case quote:
|
||||
if i+1 < len(s) && s[i+1] == quote {
|
||||
// doubled quote: stay inside the literal
|
||||
return i + 1
|
||||
}
|
||||
*state = scanNormal
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// isSQLSpace reports whether c is SQL statement whitespace.
|
||||
func isSQLSpace(c byte) bool {
|
||||
switch c {
|
||||
case ' ', '\t', '\n', '\r', '\v', '\f':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user