Compare commits

..

27 Commits

Author SHA1 Message Date
Abhi kumar
7224fc61dc Merge branch 'main' into e2e/dashboard-create-flow 2026-05-31 04:17:35 +05:30
Abhi Kumar
0a346605c7 chore: pr review fixes 2026-05-31 04:17:23 +05:30
Yunus M
11eb6e112b feat: rename AI Assistant to Noz and introduce Noz component with hov… (#11510)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* feat: rename AI Assistant to Noz and introduce Noz component with hover animation

* fix(ai-assistant): render Noz empty-state icon at true brand color

The .emptyIcon wrapper around the large empty-state Noz mascot applied
opacity: 0.85. Since opacity composites against the background, the brand
red (#E5484D) measured as #E96469 over the light panel and #C53F44 over
the dark panel instead of its true value. Removed the opacity so the
mascot keeps #E5484D in both themes.

---------

Co-authored-by: makeavish <makeavish786@gmail.com>
2026-05-30 06:21:57 +00:00
Vinicius Lourenço
0d035ef57d feat(tanstack-text): add support for forwardRef (#11464)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
2026-05-30 04:14:25 +00:00
Vinicius Lourenço
72dd544288 Revert "refactor: replace antd Tabs with @signozhq/ui Tabs (#11392)" (#11507)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
This reverts commit da1b09c479.
2026-05-29 19:30:42 +00:00
Pandey
53b2b2f017 fix(telemetrystore): fix clickhouse connection-pool slot leak (acquire conn timeout) (#11506)
* fix(telemetrystore): upgrade clickhouse-go to v2.44.0 to fix connection-pool slot leak

clickhouse-go v2.43.0 introduced connection-pool slot leaks triggered by context
cancellation: acquire() failed to release the pool slot when idle.Get returned a
cancellation error (ClickHouse/clickhouse-go#1759), and batch.Close() never released
the connection when closeQuery() failed on a cancelled context
(ClickHouse/clickhouse-go#1795). Both leak slots until the pool is exhausted and every
query fails with 'acquire conn timeout'. Both are fixed in v2.44.0.

v2.44.0 adds HasData() to the driver.Rows interface, which the test mock did not
implement. Swap the mock to the SigNoz fork github.com/SigNoz/clickhouse-go-mock
v0.14.0, which implements HasData() and tracks v2.44.0.

* feat(telemetrystore): emit clickhouse connection-pool metrics

Register OTel observable gauges that report the clickhouse connection-pool stats
from driver.Stats() on each collection cycle:
signoz.telemetrystore.connection.{open,idle,max_open,max_idle}. Plotting open against
max_open makes pool saturation (and leaks like the one fixed in the previous commit)
directly observable in Prometheus.
2026-05-30 01:06:16 +05:30
Vikrant Gupta
b568f3e5cb chore(ci): remove unused frontend build variables (#11504) 2026-05-29 17:13:17 +00:00
Gaurav Tewari
516e490567 fix: different color in badge (#11492)
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-05-29 13:59:19 +00:00
Vikrant Gupta
407d969cd3 feat(user): add support for token verification (#11496)
* feat(user): add support for token validation

* feat(user): update openapi specs

* feat(user): update verify endpoint

* feat(user): use binding package

* feat(user): update openapi specs
2026-05-29 13:35:30 +00:00
Vinicius Lourenço
fa3ab0c197 chore(oxlint): disable max-params, jsdoc, and more rules for tests (#11467) 2026-05-29 13:02:36 +00:00
Vinicius Lourenço
457ceb8fe7 feat(alerts): rewrite list alerts & triggered alerts to use new table (#11277)
* feat(tanstack-table): add showPageSize flag and callbacks to pagination

* chore(signozhq/ui): bump to v0.0.19

* feat(components): added shared components for alerts

* feat(hooks): add shared hooks for alerts

* feat(time-utils): add little helper to get elapsed time in ms

* feat(alerts-components): add badge map colors

* feat(list-alerts): rewrite page to use new table component (#11276)

* feat(triggered-alerts): rewrite page to use new table component (#11260)

* feat(triggered-alerts): rewrite page

* chore(triggered-alerts): move reason tooltip content to own component

* chore(pr-comments): address PR comments

* chore(lint): fix signozhq/ui imports

* refactor(alerts): removed stats card

* refactor(alerts): move table to use pagination instead of infinity load

* refactor(alerts): remove extra columns & add back info tooltip

* refactor(alerts): disable move columns

* refactor(alerts): cleanup dead components

* refactor(alerts): standardize errors and empty states

* refactor(alerts): missing pagination class on group

* fix(tanstack): preserve page from URL on refresh page

* refactor(alerts): ensure no empty has correct colors

* refactor(alerts): remove barrel imports

* fix(alerts): missing including error empty state

* fix(alerts): ensure the params are removed on page change

* fix(alerts): address comments related to UI/UX

* feat(tanstack): add auto page size

* fix(alert): use calculated page size

* fix(alerts): address tiny issues in ui

* fix(tests): label column tests

* fix(actions-menu): ensure callbacks are memoized

* fix(alerts): ensure tabs reset the url state (#11380)

* fix(pr): address issue with expand button

* fix(list-alerts): reduce size of state/severity, add line clamp for table

* fix(triggered-alerts): reduce size of state/severity, add line clamp for table

* fix(expanded-alerts): remove min-height when no page

* fix(triggered-alerts): increase row height for triggered alerts

* fix(list-alerts-triggered-alerts): reset page to 1 when change search

* fix(use-url-search): add missing test case for clear search
2026-05-29 12:53:55 +00:00
SagarRajput-7
1928de7452 feat(web): gate sentry and pylon init behind boot settings flags (#11498) 2026-05-29 12:31:44 +00:00
Vikrant Gupta
c6b2fe47d5 feat(web): add support for sentry and pylon (#11495)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
* feat(web): add support for sentry and pylon

* feat(web): add support for sentry and pylon

* feat(web): add support for sentry and pylon

* feat(web): removed bootdata.ts and maintaining websettings as single source of truth

* feat(web): update the example.yaml

---------

Co-authored-by: SagarRajput-7 <sagar@signoz.io>
2026-05-29 12:08:00 +00:00
Tushar Vats
1d6fa6e507 feat: extend error fields (#11228)
* fix: extended error fields

* fix: remove stale comment

* fix: removed retry policy enum and added example

* fix: generate openapi

* fix: stale comments
2026-05-29 11:43:35 +00:00
swapnil-signoz
910645516d chore: remove cloud integration service cascade delete constraint (#11480)
* chore: remove cloud integration service cascade delete constraint

* refactor: manually recreate table
2026-05-29 11:21:05 +00:00
Tushar Vats
edc1278769 fix(querybuilder): return PreparedWhereClause by value so warnings propagate when clause is empty (#11395)
* fix: propage dropped warnings from where clause visitor

* fix: added integration test

* fix: make py-fmt

* fix: remove stale comment
2026-05-29 10:09:09 +00:00
Yunus M
da1b09c479 refactor: replace antd Tabs with @signozhq/ui Tabs (#11392)
* refactor: replace antd Tabs with @signozhq/ui Tabs

Migrates Tabs usage from antd to the @signozhq/ui Tabs component across
dashboard settings, integration details, metrics application, pipelines,
trace detail, and workspace-locked pages. API differences (activeKey →
value, defaultActiveKey → defaultValue, TabsProps['items'] → TabItemProps[])
are updated to match the new component.

* refactor: enhance RouteTab component with new styling and functionality
2026-05-29 09:53:51 +00:00
Abhi Kumar
33a7789f9a chore: removed duplicate tests 2026-05-25 20:51:03 +05:30
Abhi kumar
c3762b789d Merge branch 'main' into e2e/dashboard-create-flow 2026-05-25 17:41:21 +05:30
Abhi Kumar
cff88f9d9f chore: fixed lint issue 2026-05-25 17:41:06 +05:30
Abhi Kumar
4b3518cb9e chore: cleaned up duplicate tests 2026-05-25 16:56:12 +05:30
Abhi Kumar
bd99b637f3 chore: ran oxfmt 2026-05-25 14:16:41 +05:30
Abhi Kumar
46844cf091 Merge branch 'main' of https://github.com/SigNoz/signoz into e2e/dashboard-create-flow 2026-05-25 14:16:04 +05:30
Abhi Kumar
1f63ebff14 chore: added more tests 2026-05-25 13:27:00 +05:30
Abhi Kumar
ed463a87e4 Merge branch 'main' of https://github.com/SigNoz/signoz into e2e/dashboard-create-flow 2026-05-25 12:02:32 +05:30
Abhi kumar
9cba7e88ec Merge branch 'main' into e2e/dashboard-create-flow 2026-05-18 00:19:17 +05:30
Abhi Kumar
e4949379e2 test: added e2e tests for dashboard create flow 2026-05-18 00:11:35 +05:30
281 changed files with 7405 additions and 10774 deletions

View File

@@ -58,8 +58,6 @@ jobs:
run: |
mkdir -p frontend
echo 'CI=1' > frontend/.env
echo 'VITE_INTERCOM_APP_ID="${{ secrets.INTERCOM_APP_ID }}"' >> frontend/.env
echo 'VITE_SEGMENT_ID="${{ secrets.SEGMENT_ID }}"' >> frontend/.env
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

View File

@@ -24,8 +24,6 @@ jobs:
- name: dotenv-frontend
working-directory: frontend
run: |
echo 'VITE_INTERCOM_APP_ID="${{ secrets.INTERCOM_APP_ID }}"' > .env
echo 'VITE_SEGMENT_ID="${{ secrets.SEGMENT_ID }}"' >> .env
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

View File

@@ -68,6 +68,12 @@ web:
appcues:
# Whether to enable Appcues in web.
enabled: true
sentry:
# Whether to enable Sentry in web.
enabled: true
pylon:
# Whether to enable Pylon in web.
enabled: true
##################### Cache #####################
cache:

View File

@@ -1,6 +0,0 @@
services:
signoz:
environment:
# Enable dashboard v2 (maps to flagger.config.boolean.use_dashboard_v2: true)
# Double underscore is the key separator; single underscore stays part of the key.
- SIGNOZ_FLAGGER__CONFIG__BOOLEAN__USE_DASHBOARD_V2=true

View File

@@ -3237,8 +3237,20 @@ components:
items:
$ref: '#/components/schemas/ErrorsResponseerroradditional'
type: array
invalidReferences:
items:
type: string
type: array
message:
type: string
retry:
$ref: '#/components/schemas/ErrorsResponseretryjson'
suggestions:
items:
type: string
type: array
type:
type: string
url:
type: string
required:
@@ -3250,6 +3262,11 @@ components:
message:
type: string
type: object
ErrorsResponseretryjson:
properties:
delay:
$ref: '#/components/schemas/TimeDuration'
type: object
FactoryResponse:
properties:
healthy:
@@ -7068,6 +7085,13 @@ components:
required:
- name
type: object
TypesPostableVerifyResetPasswordToken:
properties:
token:
type: string
required:
- token
type: object
TypesResetPasswordToken:
properties:
expiresAt:
@@ -14849,6 +14873,41 @@ paths:
summary: Readiness check
tags:
- health
/api/v2/reset_password_tokens/verify:
post:
deprecated: false
description: This endpoint verifies whether a reset password token exists and
is not expired
operationId: VerifyResetPasswordToken
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesPostableVerifyResetPasswordToken'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
summary: Verify a reset password token
tags:
- users
/api/v2/roles/{id}/users:
get:
deprecated: false

View File

@@ -1,7 +1,9 @@
{
"required": [
"posthog",
"appcues"
"appcues",
"sentry",
"pylon"
],
"additionalProperties": false,
"definitions": {
@@ -28,6 +30,30 @@
}
},
"type": "object"
},
"Pylon": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"Sentry": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
}
},
"properties": {
@@ -36,6 +62,12 @@
},
"posthog": {
"$ref": "#/definitions/Posthog"
},
"pylon": {
"$ref": "#/definitions/Pylon"
},
"sentry": {
"$ref": "#/definitions/Sentry"
}
},
"type": "object"

View File

@@ -26,7 +26,7 @@ import (
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
cmock "github.com/srikanthccv/ClickHouse-go-mock"
cmock "github.com/SigNoz/clickhouse-go-mock"
)
func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {

View File

@@ -123,6 +123,7 @@
"react/require-render-return": "error",
"react/no-unsafe": "off",
"no-array-constructor": "error",
"jsdoc/check-tag-names": "off",
"@typescript-eslint/no-duplicate-enum-values": "warn",
// TODO: Change to error after migration to oxlint
"@typescript-eslint/no-empty-object-type": "error",
@@ -197,10 +198,7 @@
]
}
],
"max-params": [
"warn",
3
],
"max-params": "off",
// Warns when functions have more than 3 parameters
"@typescript-eslint/explicit-function-return-type": "error",
// Requires explicit return types on functions
@@ -538,7 +536,10 @@
"signoz/no-raw-absolute-path":"off",
"no-restricted-globals": "off",
// Tests need raw localStorage/sessionStorage to seed DOM state for isolation
"signoz/no-zustand-getstate-in-hooks": "off"
"signoz/no-zustand-getstate-in-hooks": "off",
"@typescript-eslint/no-explicit-any": "off", // Tests often need any for mocks/stubs
"@typescript-eslint/explicit-module-boundary-types": "off", // Return types not required in tests
"@typescript-eslint/explicit-function-return-type": "off" // Same as above rule, don't need to care about return type
}
},
{

View File

@@ -112,7 +112,10 @@
<script>
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
if (PYLON_APP_ID) {
var pylonSettings =
((window.signozBootData || {}).settings || {}).pylon || {};
var pylonEnabled = pylonSettings.enabled !== false;
if (PYLON_APP_ID && pylonEnabled) {
(function () {
var e = window;
var t = document;

View File

@@ -35,7 +35,6 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
import { bootSettings } from 'utils/bootData';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
@@ -292,7 +291,8 @@ function App(): JSX.Element {
isLoggedInState &&
isChatSupportEnabled &&
!showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser)
(isCloudUser || isEnterpriseSelfHostedUser) &&
(window.signozBootData?.settings?.pylon.enabled ?? true)
) {
const email = user.email || '';
const secret = process.env.PYLON_IDENTITY_SECRET || '';
@@ -334,14 +334,20 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
if (
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
process.env.POSTHOG_KEY
) {
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) {
if (
!isSentryInitialized &&
(window.signozBootData?.settings?.sentry.enabled ?? true)
) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,

View File

@@ -21,7 +21,6 @@ import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesJSONPatchDocumentDTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatablePublicDashboardDTO,
@@ -34,17 +33,7 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -644,103 +633,6 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`title`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.
* @summary List dashboards (v2)
*/
export const listDashboardsV2 = (
params?: ListDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsV2200>({
url: `/api/v2/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsV2QueryKey = (
params?: ListDashboardsV2Params,
) => {
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
signal,
}) => listDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsV2>>
>;
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards (v2)
*/
export function useListDashboardsV2<
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards (v2)
*/
export const invalidateListDashboardsV2 = async (
queryClient: QueryClient,
params?: ListDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
* @summary Create dashboard (v2)
@@ -924,518 +816,3 @@ export const invalidateGetDashboardV2 = async (
return queryClient;
};
/**
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Locked dashboards are rejected.
* @summary Patch dashboard (v2)
*/
export const patchDashboardV2 = (
{ id }: PatchDashboardV2PathParameters,
dashboardtypesJSONPatchDocumentDTONull?: BodyType<DashboardtypesJSONPatchDocumentDTO | null> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PatchDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesJSONPatchDocumentDTONull,
signal,
});
};
export const getPatchDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
},
TContext
> => {
const mutationKey = ['patchDashboardV2'];
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 patchDashboardV2>>,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof patchDashboardV2>>
>;
export type PatchDashboardV2MutationBody =
| BodyType<DashboardtypesJSONPatchDocumentDTO | null>
| undefined;
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Patch dashboard (v2)
*/
export const usePatchDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesJSONPatchDocumentDTO | null>;
},
TContext
> => {
return useMutation(getPatchDashboardV2MutationOptions(options));
};
/**
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
* @summary Update dashboard (v2)
*/
export const updateDashboardV2 = (
{ id }: UpdateDashboardV2PathParameters,
dashboardtypesPostableDashboardV2DTO?: BodyType<DashboardtypesPostableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardV2DTO,
signal,
});
};
export const getUpdateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardV2'];
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 updateDashboardV2>>,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardV2>>
>;
export type UpdateDashboardV2MutationBody =
| BodyType<DashboardtypesPostableDashboardV2DTO>
| undefined;
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard (v2)
*/
export const useUpdateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardV2MutationOptions(options));
};
/**
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Unlock dashboard (v2)
*/
export const unlockDashboardV2 = (
{ id }: UnlockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
signal,
});
};
export const getUnlockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unlockDashboardV2'];
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 unlockDashboardV2>>,
{ pathParams: UnlockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unlockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnlockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unlockDashboardV2>>
>;
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unlock dashboard (v2)
*/
export const useUnlockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnlockDashboardV2MutationOptions(options));
};
/**
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Lock dashboard (v2)
*/
export const lockDashboardV2 = (
{ id }: LockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
signal,
});
};
export const getLockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['lockDashboardV2'];
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 lockDashboardV2>>,
{ pathParams: LockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return lockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type LockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof lockDashboardV2>>
>;
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Lock dashboard (v2)
*/
export const useLockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.
* @summary Unpin a dashboard for the current user (v2)
*/
export const unpinDashboardV2 = (
{ id }: UnpinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/pins/me`,
method: 'DELETE',
signal,
});
};
export const getUnpinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unpinDashboardV2'];
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 unpinDashboardV2>>,
{ pathParams: UnpinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unpinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnpinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unpinDashboardV2>>
>;
export type UnpinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unpin a dashboard for the current user (v2)
*/
export const useUnpinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnpinDashboardV2MutationOptions(options));
};
/**
* Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.
* @summary Pin a dashboard for the current user (v2)
*/
export const pinDashboardV2 = (
{ id }: PinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/pins/me`,
method: 'PUT',
signal,
});
};
export const getPinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['pinDashboardV2'];
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 pinDashboardV2>>,
{ pathParams: PinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return pinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type PinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof pinDashboardV2>>
>;
export type PinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Pin a dashboard for the current user (v2)
*/
export const usePinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
return useMutation(getPinDashboardV2MutationOptions(options));
};

View File

@@ -2051,6 +2051,10 @@ export interface ErrorsResponseerroradditionalDTO {
message?: string;
}
export interface ErrorsResponseretryjsonDTO {
delay?: TimeDurationDTO;
}
export interface ErrorsJSONDTO {
/**
* @type string
@@ -2060,10 +2064,23 @@ export interface ErrorsJSONDTO {
* @type array
*/
errors?: ErrorsResponseerroradditionalDTO[];
/**
* @type array
*/
invalidReferences?: string[];
/**
* @type string
*/
message: string;
retry?: ErrorsResponseretryjsonDTO;
/**
* @type array
*/
suggestions?: string[];
/**
* @type string
*/
type?: string;
/**
* @type string
*/
@@ -4616,61 +4633,6 @@ export interface DashboardtypesGettableDashboardV2DTO {
updatedBy?: string;
}
export interface DashboardtypesGettableDashboardWithPinDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type boolean
*/
pinned?: boolean;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesGettablePublicDasbhboardDTO {
/**
* @type string
@@ -4691,55 +4653,6 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export enum DashboardtypesJSONPatchOperationDTOOp {
add = 'add',
remove = 'remove',
replace = 'replace',
move = 'move',
copy = 'copy',
test = 'test',
}
export interface DashboardtypesJSONPatchOperationDTO {
/**
* @type string
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
*/
from?: string;
/**
* @enum add,remove,replace,move,copy,test
* @type string
*/
op: DashboardtypesJSONPatchOperationDTOOp;
/**
* @type string
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /data/display/name, /data/panels/<id>, /data/panels/<id>/spec/queries/0, /tags/-.
*/
path: string;
/**
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /data/panels/<id> takes a DashboardtypesPanel; /data/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /data/variables/N takes a DashboardtypesVariable; /data/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /data/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
*/
value?: unknown;
}
/**
* @nullable
*/
export type DashboardtypesJSONPatchDocumentDTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface DashboardtypesListableDashboardV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesGettableDashboardWithPinDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -8412,6 +8325,13 @@ export interface TypesPostableRoleDTO {
name: string;
}
export interface TypesPostableVerifyResetPasswordTokenDTO {
/**
* @type string
*/
token: string;
}
export interface TypesResetPasswordTokenDTO {
/**
* @type string
@@ -9493,42 +9413,6 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @type string
* @description undefined
*/
sort?: string;
/**
* @type string
* @description undefined
*/
order?: string;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsV2200 = {
data: DashboardtypesListableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
@@ -9548,40 +9432,6 @@ export type GetDashboardV2200 = {
status: string;
};
export type PatchDashboardV2PathParameters = {
id: string;
};
export type PatchDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UpdateDashboardV2PathParameters = {
id: string;
};
export type UpdateDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UnlockDashboardV2PathParameters = {
id: string;
};
export type LockDashboardV2PathParameters = {
id: string;
};
export type UnpinDashboardV2PathParameters = {
id: string;
};
export type PinDashboardV2PathParameters = {
id: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

@@ -48,6 +48,7 @@ import type {
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
TypesPostableRoleDTO,
TypesPostableVerifyResetPasswordTokenDTO,
TypesUpdatableUserDTO,
UpdateUserDeprecated200,
UpdateUserDeprecatedPathParameters,
@@ -947,6 +948,90 @@ export const useForgotPassword = <
> => {
return useMutation(getForgotPasswordMutationOptions(options));
};
/**
* This endpoint verifies whether a reset password token exists and is not expired
* @summary Verify a reset password token
*/
export const verifyResetPasswordToken = (
typesPostableVerifyResetPasswordTokenDTO?: BodyType<TypesPostableVerifyResetPasswordTokenDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/reset_password_tokens/verify`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableVerifyResetPasswordTokenDTO,
signal,
});
};
export const getVerifyResetPasswordTokenMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
> => {
const mutationKey = ['verifyResetPasswordToken'];
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 verifyResetPasswordToken>>,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> }
> = (props) => {
const { data } = props ?? {};
return verifyResetPasswordToken(data);
};
return { mutationFn, ...mutationOptions };
};
export type VerifyResetPasswordTokenMutationResult = NonNullable<
Awaited<ReturnType<typeof verifyResetPasswordToken>>
>;
export type VerifyResetPasswordTokenMutationBody =
| BodyType<TypesPostableVerifyResetPasswordTokenDTO>
| undefined;
export type VerifyResetPasswordTokenMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Verify a reset password token
*/
export const useVerifyResetPasswordToken = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
> => {
return useMutation(getVerifyResetPasswordTokenMutationOptions(options));
};
/**
* This endpoint returns the users having the role by role id
* @summary Get users by role id

View File

@@ -0,0 +1,31 @@
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
text-align: center;
}
.icon {
color: var(--danger-background);
}
.title {
font-size: 16px;
font-weight: 500;
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--text-vanilla-400);
max-width: 400px;
}
.actions {
display: flex;
gap: 8px;
margin-top: 4px;
}

View File

@@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { LifeBuoy, RefreshCw, TriangleAlert } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import styles from './ErrorEmptyState.module.scss';
interface ErrorEmptyStateProps {
title?: string;
subtitle?: string;
onRefresh?: () => void;
}
function ErrorEmptyState({
title = 'Something went wrong',
subtitle = 'Our team is getting on top to resolve this. Please reach out to support if the issue persists.',
onRefresh,
}: ErrorEmptyStateProps): JSX.Element {
const { isCloudUser } = useGetTenantLicense();
const onContactSupport = useCallback((): void => {
handleContactSupport(isCloudUser);
}, [isCloudUser]);
return (
<div className={styles.emptyState} data-testid="error-empty-state">
<TriangleAlert className={styles.icon} size={32} />
<div className={styles.title} data-testid="error-title">
{title}
</div>
<div className={styles.subtitle} data-testid="error-subtitle">
{subtitle}
</div>
<div className={styles.actions}>
<Button
variant="solid"
color="secondary"
prefix={<LifeBuoy size={14} />}
onClick={onContactSupport}
data-testid="error-contact-support-button"
>
Contact Support
</Button>
{onRefresh && (
<Button
variant="outlined"
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
data-testid="error-refresh-button"
>
Refresh
</Button>
)}
</div>
</div>
);
}
export default ErrorEmptyState;

View File

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

View File

@@ -0,0 +1,68 @@
.labelColumn {
display: flex;
gap: 4px;
align-items: center;
overflow: hidden;
max-width: 100%;
width: 100%;
}
.labelBadge {
cursor: default;
font-size: 12px;
--badge-display: inline;
max-width: 180px;
text-overflow: ellipsis;
}
.overflowTrigger {
all: unset;
cursor: pointer;
}
.overflowBadge {
cursor: pointer;
font-size: 12px;
}
.labelPopover {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
max-height: 300px;
overflow-y: auto;
}
.labelTooltip {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 300px;
overflow-y: auto;
}
.labelValue {
text-overflow: ellipsis;
overflow: hidden;
}
.tooltipContent {
display: flex;
align-items: center;
gap: 8px;
}
.copyButton {
all: unset;
cursor: pointer;
display: flex;
align-items: center;
opacity: 0.7;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,142 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { act, render, screen } from '@testing-library/react';
import LabelColumn from './LabelColumn';
let resizeCallback: ResizeObserverCallback | null = null;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
}
function triggerResize(width: number): void {
if (resizeCallback) {
act(() => {
resizeCallback?.(
[{ contentRect: { width } } as ResizeObserverEntry],
{} as ResizeObserver,
);
});
}
}
beforeAll(() => {
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
});
afterEach(() => {
resizeCallback = null;
});
function renderWithProviders(
ui: React.ReactElement,
): ReturnType<typeof render> {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
describe('LabelColumn', () => {
it('should render all labels when 5 or fewer', () => {
const labels = ['env', 'service', 'region'];
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
});
it('should truncate labels and show +N badge when container is narrow', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
// Simulate narrow container that fits ~3 badges
// Badge widths: env=37, service=65, region=58, team=44, owner=51, version=65
// 220px available = 3 badges (160px) + gaps (8px) + overflow (44px)
triggerResize(220);
// First 3 visible
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
expect(screen.getByTestId('label-tag-region')).toBeInTheDocument();
// Remaining in overflow badge
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
});
it('should render label with value when value prop provided', () => {
const labels = ['env'];
const value = { env: 'production' };
renderWithProviders(<LabelColumn labels={labels} value={value} />);
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
'env: production',
);
});
it('should render labels without value when value is not provided for that label', () => {
const labels = ['env', 'service'];
const value = { env: 'production' };
renderWithProviders(<LabelColumn labels={labels} value={value} />);
expect(screen.getByTestId('label-tag-env')).toHaveTextContent(
'env: production',
);
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
});
it('should show overflow badge with remaining count when container is narrow', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
// Simulate narrow container to trigger overflow (shows 3 labels)
// 220px fits first 3 badges before overflow
triggerResize(220);
// Overflow badge shows +3 (remaining labels)
const overflowBadge = screen.getByTestId('label-overflow-badge');
expect(overflowBadge).toBeInTheDocument();
expect(overflowBadge).toHaveTextContent('+3');
});
it('should render empty when no labels provided', () => {
renderWithProviders(<LabelColumn labels={[]} />);
const column = screen.getByTestId('label-column');
expect(column.children).toHaveLength(0);
});
it('should use primary color by default', () => {
const labels = ['env'];
renderWithProviders(<LabelColumn labels={labels} />);
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
});
it('should show all labels when container is wide enough', () => {
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
renderWithProviders(<LabelColumn labels={labels} />);
// Simulate wide container
triggerResize(1000);
// All labels visible
labels.forEach((label) => {
expect(screen.getByTestId(`label-tag-${label}`)).toBeInTheDocument();
});
// No overflow badge
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,150 @@
import { Copy } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { toast } from '@signozhq/ui/sonner';
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import LabelTag from './LabelTag';
import styles from './LabelColumn.module.scss';
import { BADGE_GAP, estimateBadgeWidth, OVERFLOW_BADGE_WIDTH } from './utils';
export interface LabelColumnProps {
labels: string[];
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
value?: { [key: string]: string };
}
function LabelColumn({
labels,
value,
color = 'primary',
}: LabelColumnProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [maxVisibleCount, setMaxVisibleCount] = useState(labels.length);
const [, copyToClipboard] = useCopyToClipboard();
const calculateMaxVisible = useCallback(
(width: number): number => {
if (width <= 0) {
return 1;
}
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
let usedWidth = 0;
let count = 0;
for (const label of labels) {
const badgeWidth = estimateBadgeWidth(label, value?.[label]) + BADGE_GAP;
if (usedWidth + badgeWidth > availableWidth && count > 0) {
break;
}
usedWidth += badgeWidth;
count++;
}
return Math.max(1, count);
},
[labels, value],
);
useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && entry.contentRect.width > 0) {
setMaxVisibleCount(calculateMaxVisible(entry.contentRect.width));
}
});
observer.observe(container);
if (container.clientWidth > 0) {
setMaxVisibleCount(calculateMaxVisible(container.clientWidth));
}
return (): void => observer.disconnect();
}, [calculateMaxVisible]);
const needsOverflow = labels.length > maxVisibleCount;
const visibleLabels = needsOverflow
? labels.slice(0, maxVisibleCount)
: labels;
const remainingLabels = needsOverflow ? labels.slice(maxVisibleCount) : [];
return (
<div
ref={containerRef}
className={styles.labelColumn}
data-testid="label-column"
>
{visibleLabels.map((label) => (
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
))}
{remainingLabels.length > 0 && (
<TooltipRoot>
<TooltipTrigger asChild>
<span>
<Badge
color={color}
className={styles.overflowBadge}
variant="outline"
data-testid="label-overflow-badge"
>
+{remainingLabels.length}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="end">
<div className={styles.tooltipContent}>
<span>
{remainingLabels
.map((label) => (value?.[label] ? `${label}: ${value[label]}` : label))
.join(', ')}
</span>
<button
type="button"
className={styles.copyButton}
onClick={(e): void => {
e.stopPropagation();
const searchFormat = remainingLabels
.map((label) => (value?.[label] ? `${label} ${value[label]}` : label))
.join(' ');
copyToClipboard(searchFormat);
toast.success('Copied! Use in search to filter alerts.');
}}
aria-label="Copy to clipboard"
>
<Copy size={12} />
</button>
</div>
</TooltipContent>
</TooltipRoot>
)}
</div>
);
}
export default LabelColumn;

View File

@@ -0,0 +1,30 @@
.labelBadge {
cursor: default;
font-size: 12px;
max-width: 180px;
text-overflow: ellipsis;
}
.labelValue {
text-overflow: ellipsis;
overflow: hidden;
}
.tooltipContent {
display: flex;
align-items: center;
gap: 8px;
}
.copyButton {
all: unset;
cursor: pointer;
display: flex;
align-items: center;
opacity: 0.7;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,74 @@
import { Copy } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { toast } from '@signozhq/ui/sonner';
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { useCopyToClipboard } from 'react-use';
import styles from './LabelTag.module.scss';
export interface LabelTagProps {
label: string;
color?:
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning'
| 'robin'
| 'forest'
| 'amber'
| 'sienna'
| 'cherry'
| 'sakura'
| 'aqua'
| 'vanilla';
value?: string;
}
function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const displayText = value ? `${label}: ${value}` : label;
const searchFormat = value ? `${label} ${value}` : label;
const handleCopy = (e: React.MouseEvent): void => {
e.stopPropagation();
copyToClipboard(searchFormat);
toast.success('Copied! Use in search to filter alerts.');
};
return (
<TooltipRoot>
<TooltipTrigger asChild>
<span>
<Badge
color={color}
className={styles.labelBadge}
variant="outline"
data-testid={`label-tag-${label}`}
>
<span className={styles.labelValue}>{displayText}</span>
</Badge>
</span>
</TooltipTrigger>
<TooltipContent>
<div className={styles.tooltipContent}>
<span>{displayText}</span>
<button
type="button"
className={styles.copyButton}
onClick={handleCopy}
aria-label="Copy to clipboard"
>
<Copy size={12} />
</button>
</div>
</TooltipContent>
</TooltipRoot>
);
}
export default LabelTag;

View File

@@ -0,0 +1,2 @@
export { default } from './LabelColumn';
export type { LabelColumnProps } from './LabelColumn';

View File

@@ -0,0 +1,14 @@
export const BADGE_GAP = 4;
export const OVERFLOW_BADGE_WIDTH = 40;
export const BADGE_MAX_WIDTH = 180;
export const BADGE_PADDING = 16;
export const CHAR_WIDTH = 7;
export function estimateBadgeWidth(label: string, value?: string): number {
const displayText = value ? `${label}: ${value}` : label;
return Math.min(
displayText.length * CHAR_WIDTH + BADGE_PADDING,
BADGE_MAX_WIDTH,
);
}

View File

@@ -0,0 +1,30 @@
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
text-align: center;
}
.icon {
color: var(--text-vanilla-400);
}
.title {
font-size: 16px;
font-weight: 500;
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--text-vanilla-400);
max-width: 400px;
}
.actions {
display: flex;
gap: 8px;
}

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import NoResultsEmptyState from './NoResultsEmptyState';
describe('NoResultsEmptyState', () => {
it('should render with default props', () => {
render(<NoResultsEmptyState />);
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching results',
);
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
'No items match your current filters. Try adjusting your search criteria.',
);
});
it('should render with custom title and subtitle', () => {
render(
<NoResultsEmptyState title="Custom Title" subtitle="Custom Subtitle" />,
);
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'Custom Title',
);
expect(screen.getByTestId('no-results-subtitle')).toHaveTextContent(
'Custom Subtitle',
);
});
it('should not render clear button when onClear is not provided', () => {
render(<NoResultsEmptyState />);
expect(
screen.queryByTestId('no-results-clear-button'),
).not.toBeInTheDocument();
});
it('should render clear button when onClear is provided', () => {
const onClear = jest.fn();
render(<NoResultsEmptyState onClear={onClear} />);
expect(screen.getByTestId('no-results-clear-button')).toBeInTheDocument();
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
'Clear Filters',
);
});
it('should render custom clear button text', () => {
render(
<NoResultsEmptyState onClear={jest.fn()} clearButtonText="Reset All" />,
);
expect(screen.getByTestId('no-results-clear-button')).toHaveTextContent(
'Reset All',
);
});
it('should call onClear when clear button is clicked', async () => {
const user = userEvent.setup();
const onClear = jest.fn();
render(<NoResultsEmptyState onClear={onClear} />);
await user.click(screen.getByTestId('no-results-clear-button'));
expect(onClear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,57 @@
import { RefreshCw, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import styles from './NoResultsEmptyState.module.scss';
interface NoResultsEmptyStateProps {
title?: string;
subtitle?: string;
onClear?: () => void;
clearButtonText?: string;
onRefresh?: () => void;
}
function NoResultsEmptyState({
title = 'No matching results',
subtitle = 'No items match your current filters. Try adjusting your search criteria.',
onClear,
clearButtonText = 'Clear Filters',
onRefresh,
}: NoResultsEmptyStateProps): JSX.Element {
return (
<div className={styles.emptyState} data-testid="no-results-empty-state">
<Search className={styles.icon} size={16} />
<div className={styles.title} data-testid="no-results-title">
{title}
</div>
<div className={styles.subtitle} data-testid="no-results-subtitle">
{subtitle}
</div>
<div className={styles.actions}>
{onClear && (
<Button
variant="outlined"
color="secondary"
onClick={onClear}
data-testid="no-results-clear-button"
>
{clearButtonText}
</Button>
)}
{onRefresh && (
<Button
variant="outlined"
color="secondary"
prefix={<RefreshCw size={14} />}
onClick={onRefresh}
data-testid="no-results-refresh-button"
>
Refresh
</Button>
)}
</div>
</div>
);
}
export default NoResultsEmptyState;

View File

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

View File

@@ -0,0 +1,32 @@
import type { BadgeColor } from '@signozhq/ui/badge';
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
export const STATE_LABELS: Record<string, string> = {
firing: 'Firing',
pending: 'Pending',
inactive: 'OK',
disabled: 'Disabled',
};
export const STATE_COLORS: Record<string, string> = {
firing: 'var(--bg-cherry-500)',
pending: 'var(--bg-amber-500)',
inactive: 'var(--bg-forest-500)',
disabled: 'var(--l2-foreground)',
};
export const SEVERITY_COLORS: Record<string, string> = {
critical: 'var(--bg-cherry-500)',
error: 'var(--bg-cherry-400)',
warning: 'var(--bg-amber-500)',
info: 'var(--bg-robin-500)',
};
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColor> = {
critical: 'error',
error: 'error',
warning: 'warning',
info: 'primary',
};

View File

@@ -0,0 +1,7 @@
export interface FilterValue {
value: string;
}
export interface AlertWithLabels {
labels?: Record<string, string>;
}

View File

@@ -0,0 +1,287 @@
import type { SortState } from 'components/TanStackTableView/types';
import type { AlertWithLabels, FilterValue } from './types';
import { filterByLabels, searchByLabels, sortByColumn } from './utils';
interface TestAlert extends AlertWithLabels {
name: string;
value: number;
}
const createAlert = (
name: string,
value: number,
labels?: Record<string, string>,
): TestAlert => ({
name,
value,
labels,
});
describe('sortByColumn', () => {
const alerts: TestAlert[] = [
createAlert('Alert C', 3),
createAlert('Alert A', 1),
createAlert('Alert B', 2),
];
const getSortValue = (
item: TestAlert,
columnName: string,
): string | number => {
if (columnName === 'name') {
return item.name;
}
if (columnName === 'value') {
return item.value;
}
return '';
};
it('should return items unchanged when no orderBy provided', () => {
const result = sortByColumn(alerts, null, getSortValue);
expect(result).toStrictEqual(alerts);
});
it('should sort by string column ascending', () => {
const orderBy: SortState = { columnName: 'name', order: 'asc' };
const result = sortByColumn(alerts, orderBy, getSortValue);
expect(result.map((a) => a.name)).toStrictEqual([
'Alert A',
'Alert B',
'Alert C',
]);
});
it('should sort by string column descending', () => {
const orderBy: SortState = { columnName: 'name', order: 'desc' };
const result = sortByColumn(alerts, orderBy, getSortValue);
expect(result.map((a) => a.name)).toStrictEqual([
'Alert C',
'Alert B',
'Alert A',
]);
});
it('should sort by number column ascending', () => {
const orderBy: SortState = { columnName: 'value', order: 'asc' };
const result = sortByColumn(alerts, orderBy, getSortValue);
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
});
it('should sort by number column descending', () => {
const orderBy: SortState = { columnName: 'value', order: 'desc' };
const result = sortByColumn(alerts, orderBy, getSortValue);
expect(result.map((a) => a.value)).toStrictEqual([3, 2, 1]);
});
it('should use defaultSort when orderBy is null', () => {
const defaultSort: SortState = { columnName: 'value', order: 'asc' };
const result = sortByColumn(alerts, null, getSortValue, defaultSort);
expect(result.map((a) => a.value)).toStrictEqual([1, 2, 3]);
});
it('should not mutate original array', () => {
const original = [...alerts];
const orderBy: SortState = { columnName: 'name', order: 'asc' };
sortByColumn(alerts, orderBy, getSortValue);
expect(alerts).toStrictEqual(original);
});
it('should handle empty array', () => {
const result = sortByColumn(
[],
{ columnName: 'name', order: 'asc' },
getSortValue,
);
expect(result).toStrictEqual([]);
});
it('should handle equal values', () => {
const duplicates = [
createAlert('Same', 1),
createAlert('Same', 1),
createAlert('Same', 1),
];
const orderBy: SortState = { columnName: 'name', order: 'asc' };
const result = sortByColumn(duplicates, orderBy, getSortValue);
expect(result).toHaveLength(3);
});
});
describe('searchByLabels', () => {
const alerts: TestAlert[] = [
createAlert('CPU High', 1, { severity: 'critical', team: 'infra' }),
createAlert('Memory Warning', 2, { severity: 'warning', team: 'backend' }),
createAlert('Disk Full', 3, { severity: 'error', region: 'us-east' }),
createAlert('Network Slow', 4, {}),
createAlert('No Labels', 5),
];
const getAlertName = (alert: TestAlert): string => alert.name;
it('should return all items when search is empty', () => {
const result = searchByLabels(alerts, '', getAlertName);
expect(result).toStrictEqual(alerts);
});
it('should return all items when search is whitespace', () => {
const result = searchByLabels(alerts, ' ', getAlertName);
expect(result).toStrictEqual(alerts);
});
it('should search by alert name', () => {
const result = searchByLabels(alerts, 'CPU', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
it('should search by alert name case-insensitive', () => {
const result = searchByLabels(alerts, 'cpu', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
it('should search by severity label', () => {
const result = searchByLabels(alerts, 'critical', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
it('should search by any label key', () => {
const result = searchByLabels(alerts, 'team', getAlertName);
expect(result).toHaveLength(2);
});
it('should search by any label value', () => {
const result = searchByLabels(alerts, 'infra', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
it('should handle alerts with no labels', () => {
const result = searchByLabels(alerts, 'No Labels', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('No Labels');
});
it('should handle partial matches', () => {
const result = searchByLabels(alerts, 'warn', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Memory Warning');
});
it('should return empty for no matches', () => {
const result = searchByLabels(alerts, 'nonexistent', getAlertName);
expect(result).toStrictEqual([]);
});
it('should trim search text', () => {
const result = searchByLabels(alerts, ' CPU ', getAlertName);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('CPU High');
});
});
describe('filterByLabels', () => {
const alerts: TestAlert[] = [
createAlert('A1', 1, { severity: 'critical', team: 'infra', env: 'prod' }),
createAlert('A2', 2, { severity: 'critical', team: 'backend', env: 'prod' }),
createAlert('A3', 3, { severity: 'warning', team: 'infra', env: 'staging' }),
createAlert('A4', 4, { severity: 'info', team: 'frontend', env: 'dev' }),
createAlert('A5', 5, {}),
createAlert('A6', 6),
];
const createFilter = (value: string): FilterValue => ({ value });
it('should return all items when filters are empty', () => {
const result = filterByLabels(alerts, []);
expect(result).toStrictEqual(alerts);
});
it('should return all items when filters is null-ish', () => {
const result = filterByLabels(alerts, null as unknown as FilterValue[]);
expect(result).toStrictEqual(alerts);
});
it('should filter by single label', () => {
const filters = [createFilter('severity:critical')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2']);
});
it('should use OR logic for same key', () => {
const filters = [
createFilter('severity:critical'),
createFilter('severity:warning'),
];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(3);
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A2', 'A3']);
});
it('should use AND logic for different keys', () => {
const filters = [
createFilter('severity:critical'),
createFilter('team:infra'),
];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('A1');
});
it('should handle case-insensitive keys', () => {
const filters = [createFilter('SEVERITY:critical')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
});
it('should handle case-insensitive values', () => {
const filters = [createFilter('severity:CRITICAL')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
});
it('should trim whitespace', () => {
const filters = [createFilter(' severity : critical ')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
});
it('should return empty for invalid filter format', () => {
const filters = [createFilter('invalid')];
const result = filterByLabels(alerts, filters);
expect(result).toStrictEqual([]);
});
it('should ignore invalid filters mixed with valid', () => {
const filters = [createFilter('invalid'), createFilter('severity:critical')];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
});
it('should exclude alerts without matching label key', () => {
const filters = [createFilter('nonexistent:value')];
const result = filterByLabels(alerts, filters);
expect(result).toStrictEqual([]);
});
it('should exclude alerts with no labels', () => {
const filters = [createFilter('severity:critical')];
const result = filterByLabels(alerts, filters);
expect(result.every((a) => a.labels !== undefined)).toBe(true);
});
it('should handle complex AND/OR combinations', () => {
const filters = [
createFilter('env:prod'),
createFilter('env:staging'),
createFilter('team:infra'),
];
const result = filterByLabels(alerts, filters);
expect(result).toHaveLength(2);
expect(result.map((a) => a.name)).toStrictEqual(['A1', 'A3']);
});
});

View File

@@ -0,0 +1,116 @@
import type { SortState } from 'components/TanStackTableView/types';
import type { AlertWithLabels, FilterValue } from './types';
/**
* Generic sort function for alert-like data
*/
export function sortByColumn<T>(
items: T[],
orderBy: SortState | null,
getSortValue: (item: T, columnName: string) => string | number,
defaultSort?: SortState,
): T[] {
const sortState = orderBy ?? defaultSort;
if (!sortState) {
return items;
}
const { columnName, order } = sortState;
const multiplier = order === 'asc' ? 1 : -1;
return [...items].sort((a, b) => {
const aVal = getSortValue(a, columnName);
const bVal = getSortValue(b, columnName);
if (aVal < bVal) {
return -1 * multiplier;
}
if (aVal > bVal) {
return 1 * multiplier;
}
return 0;
});
}
/**
* Search alerts/rules by name, severity, and all labels
*/
export function searchByLabels<T extends AlertWithLabels>(
items: T[],
searchText: string,
getAlertName: (item: T) => string,
): T[] {
if (!searchText.trim()) {
return items;
}
const value = searchText.toLowerCase().trim();
return items.filter((item) => {
const alertName = getAlertName(item).toLowerCase();
const severity = item.labels?.severity?.toLowerCase() ?? '';
const labelSearchString = Object.entries(item.labels ?? {})
.map(([key, val]) => `${key} ${val}`)
.join(' ')
.toLowerCase();
return (
alertName.includes(value) ||
severity.includes(value) ||
labelSearchString.includes(value)
);
});
}
/**
* Filter alerts by label key:value pairs
* Same key uses OR logic, different keys use AND logic
*/
export function filterByLabels<T extends AlertWithLabels>(
items: T[],
selectedFilters: FilterValue[],
): T[] {
if (!selectedFilters?.length) {
return items;
}
const validFilters = selectedFilters
.map((e) => e.value)
.filter((v) => v.split(':').length === 2);
if (!validFilters.length) {
return [];
}
// Group values by key - same key uses OR, different keys use AND
const filtersByKey = new Map<string, string[]>();
validFilters.forEach((f) => {
const [key, value] = f.split(':');
const trimmedKey = key.trim().toLowerCase();
const trimmedValue = value.trim().toLowerCase();
const existing = filtersByKey.get(trimmedKey) ?? [];
existing.push(trimmedValue);
filtersByKey.set(trimmedKey, existing);
});
return items.filter((item) => {
if (!item.labels) {
return false;
}
// All keys must match (AND), any value per key can match (OR)
return Array.from(filtersByKey.entries()).every(([filterKey, values]) => {
// Case-insensitive key lookup
const matchingKey = Object.keys(item.labels ?? {}).find(
(k) => k.toLowerCase() === filterKey,
);
if (!matchingKey) {
return false;
}
const labelValue = item.labels?.[matchingKey]?.toLowerCase();
return values.some((v) => labelValue === v);
});
});
}

View File

@@ -1,8 +1,9 @@
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Dot, Sparkles } from '@signozhq/icons';
import { Dot } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import Noz from 'components/Noz/Noz';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from 'container/AIAssistant/events';
@@ -21,6 +22,7 @@ import FeedbackModal from './FeedbackModal';
import ShareURLModal from './ShareURLModal';
import './HeaderRightSection.styles.scss';
import { Typography } from '@signozhq/ui/typography';
interface HeaderRightSectionProps {
enableAnnouncements: boolean;
@@ -107,21 +109,22 @@ function HeaderRightSection({
</span>
) : null}
<TooltipSimple title="AI Assistant">
<TooltipSimple title="Noz">
<Button
variant="solid"
color="secondary"
className="noz-wave"
onClick={handleOpenAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1
? 'Open AI Assistant, 1 action needs your response'
: `Open AI Assistant, ${pendingUserInputCount} actions need your response`
: 'Open AI Assistant'
? 'Open Noz, 1 action needs your response'
: `Open Noz, ${pendingUserInputCount} actions need your response`
: 'Open Noz'
}
prefix={<Sparkles size={14} color="var(--primary)" />}
prefix={<Noz size={20} />}
>
AI Assistant
<Typography.Text>Noz</Typography.Text>
</Button>
</TooltipSimple>
</div>

View File

@@ -0,0 +1,86 @@
@keyframes noz-wave-wiggle {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(20deg);
}
75% {
transform: rotate(-20deg);
}
}
.noz {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
overflow: visible;
svg {
display: block;
overflow: visible;
}
:global {
.noz-arm-left {
transform-origin: 4.18383px 13.4752px;
transform: rotate(0deg);
transition: transform 0.55s cubic-bezier(0.34, 1.7, 0.5, 1);
will-change: transform;
}
.noz-arm-wiggle {
transform-origin: 4.18383px 13.4752px;
transform: rotate(0deg);
will-change: transform;
}
.noz-head {
transform-origin: 12.02px 18.37px;
transform: rotate(0deg);
transition: transform 0.5s cubic-bezier(0.34, 1.7, 0.5, 1);
will-change: transform;
}
}
&:hover {
:global(.noz-arm-left) {
transform: rotate(145deg) scale(1, 1.7);
}
:global(.noz-arm-wiggle) {
animation: noz-wave-wiggle 0.7s ease-in-out 0.2s infinite;
}
:global(.noz-head) {
transform: rotate(9deg);
}
}
}
:global(.noz-wave):hover {
:global(.noz-arm-left) {
transform: rotate(145deg) scale(1, 1.7);
}
:global(.noz-arm-wiggle) {
animation: noz-wave-wiggle 0.7s ease-in-out 0.2s infinite;
}
:global(.noz-head) {
transform: rotate(9deg);
}
}
@media (prefers-reduced-motion: reduce) {
.noz {
:global(.noz-arm-left),
:global(.noz-arm-wiggle),
:global(.noz-head) {
transition: none;
animation: none !important;
}
}
}

View File

@@ -0,0 +1,100 @@
import cx from 'classnames';
import styles from './Noz.module.scss';
interface NozProps {
size?: number;
className?: string;
}
/**
* Noz — the SigNoz AI assistant mascot. Waves on hover.
*
* Hover behavior:
* - Hovering the icon itself triggers the wave.
* - To make a parent element (e.g. a Button) trigger the wave on its own
* hover, add the class `noz-wave` to that parent.
*/
export default function Noz({ size = 24, className }: NozProps): JSX.Element {
return (
<span
className={cx(styles.noz, className)}
style={{ width: size, height: size }}
aria-hidden="true"
>
<svg
viewBox="-2 0.5 28 28"
width={size}
height={size}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* body */}
<rect
x="4.35938"
y="8.49908"
width="15.4569"
height="11.978"
rx="1.76147"
fill="#E5484D"
/>
{/* legs */}
<rect
x="6.87012"
y="19.0679"
width="3.34679"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
<rect
x="13.916"
y="19.0679"
width="3.34679"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
{/* right arm (static) */}
<rect
x="18.7598"
y="13.4752"
width="2.11376"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
{/* left arm: outer group does the "lift", inner does the wiggle */}
<g className="noz-arm-left">
<g className="noz-arm-wiggle">
<rect
x="3.12695"
y="13.4752"
width="2.11376"
height="3.69908"
rx="0.880734"
fill="#E5484D"
/>
</g>
</g>
{/* head: face + eye + hat tilt together */}
<g className="noz-head">
<circle cx="12.0217" cy="14.4881" r="3.87523" fill="#F5F5F5" />
<path
d="M12.0237 12.8024C12.0237 13.7328 11.2673 14.4892 10.337 14.4892C10.0339 14.4892 9.74926 14.4101 9.50152 14.2678C9.47517 14.5551 9.49888 14.8502 9.57795 15.1428C9.93901 16.4921 11.3279 17.2933 12.6773 16.9323C14.0267 16.5712 14.8279 15.1823 14.4668 13.8329C14.1453 12.6285 13.0041 11.8616 11.8023 11.967C11.942 12.2121 12.0237 12.4967 12.0237 12.8024Z"
fill="#0A0C10"
/>
<path
d="M8.33833 7.94578L9.83358 4.31319C10.1302 3.59261 10.6676 2.99939 11.355 2.63299L13.9181 1.26684C14.1327 1.15169 14.3804 1.34885 14.3194 1.58439L13.6703 4.06892C13.6511 4.14046 13.6424 4.21374 13.6424 4.28876C13.6424 4.39868 13.6633 4.5086 13.7052 4.61154L15.0382 7.94578H11.4248L11.6307 7.32813L12.3356 7.09259C12.449 7.05421 12.5257 6.94778 12.5257 6.82739C12.5257 6.707 12.449 6.60057 12.3356 6.56218L11.6307 6.32664L11.3951 5.62176C11.3568 5.51009 11.2503 5.43333 11.1299 5.43333C11.0096 5.43333 10.9031 5.5101 10.8647 5.6235L10.6292 6.32839L9.92431 6.56393C9.8109 6.60231 9.73413 6.70874 9.73413 6.82913C9.73413 6.94952 9.8109 7.05595 9.92431 7.09434L10.6292 7.32988L10.8351 7.94752H8.33833V7.94578ZM12.1 3.43558C12.0808 3.378 12.0285 3.33962 11.9674 3.33962C11.9064 3.33962 11.854 3.378 11.8348 3.43558L11.7179 3.78802L11.3655 3.90492C11.3079 3.92411 11.2695 3.97645 11.2695 4.03752C11.2695 4.09859 11.3079 4.15093 11.3655 4.17012L11.7179 4.28702L11.8348 4.63946C11.854 4.69704 11.9064 4.73542 11.9674 4.73542C12.0285 4.73542 12.0808 4.69704 12.1 4.63946L12.2169 4.28702L12.5694 4.17012C12.6269 4.15093 12.6653 4.09859 12.6653 4.03752C12.6653 3.97645 12.6269 3.92411 12.5694 3.90492L12.2169 3.78802L12.1 3.43558ZM7.78 7.91088H15.5965C15.9053 7.91088 16.1548 8.16038 16.1548 8.4692C16.1548 8.77803 15.9053 9.02753 15.5965 9.02753H7.78C7.47118 9.02753 7.22168 8.77803 7.22168 8.4692C7.22168 8.16038 7.47118 7.91088 7.78 7.91088Z"
fill="#4E74F8"
/>
</g>
</svg>
</span>
);
}
Noz.defaultProps = {
size: 24,
className: undefined,
};

View File

@@ -16,7 +16,7 @@ import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
import { ComboboxSimple } from '@signozhq/ui/combobox';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Pagination } from '@signozhq/ui/pagination';
import type { Row } from '@tanstack/react-table';
@@ -51,7 +51,7 @@ import { useEffectiveData } from './useEffectiveData';
import { useFlatItems } from './useFlatItems';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import { buildTanstackColumnDef } from './utils';
import { buildPageSizeItems, buildTanstackColumnDef } from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
@@ -66,14 +66,6 @@ const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
const noopColumnVisibility = (): void => {};
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
(value) => ({
value: value.toString(),
label: value.toString(),
displayValue: value.toString(),
}),
);
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackTableInner<TData>(
{
@@ -89,7 +81,6 @@ function TanStackTableInner<TData>(
enableQueryParams,
pagination,
paginationClassname,
onSort,
onEndReached,
getRowKey,
getItemKey,
@@ -102,6 +93,7 @@ function TanStackTableInner<TData>(
onRowClick,
onRowClickNewTab,
onRowDeactivate,
onSort,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
@@ -129,17 +121,22 @@ function TanStackTableInner<TData>(
const {
page,
limit,
setPage,
setLimit,
setPage: internalSetPage,
setLimit: internalSetLimit,
orderBy,
setOrderBy: internalSetOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
page: pagination?.defaultPage,
limit: pagination?.defaultLimit,
limit: pagination?.defaultLimit ?? pagination?.calculatedPageSize ?? 10,
});
const pageSizeItems = useMemo(
() => buildPageSizeItems(pagination?.calculatedPageSize),
[pagination?.calculatedPageSize],
);
const setOrderBy = useCallback(
(sort: SortState | null) => {
internalSetOrderBy(sort);
@@ -148,6 +145,23 @@ function TanStackTableInner<TData>(
[internalSetOrderBy, onSort],
);
const setPage = useCallback(
(p: number) => {
internalSetPage(p);
pagination?.onPageChange?.(p);
},
[internalSetPage, pagination],
);
const setLimit = useCallback(
(l: number) => {
internalSetLimit(l);
internalSetPage(1);
pagination?.onLimitChange?.(l);
},
[internalSetLimit, internalSetPage, pagination],
);
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
@@ -621,6 +635,7 @@ function TanStackTableInner<TData>(
{pagination.showPageSize !== false && (
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
testId="pagination-page-size"
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => {
@@ -631,7 +646,7 @@ function TanStackTableInner<TData>(
pagination.onPageChange?.(1);
}
}}
items={paginationPageSizeItems}
items={pageSizeItems}
/>
</div>
)}

View File

@@ -1,4 +1,4 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
import cx from 'classnames';
import tableStyles from './TanStackTable.module.scss';
@@ -22,21 +22,19 @@ type WithDangerousHtml = BaseProps & {
export type TanStackTableTextProps = WithChildren | WithDangerousHtml;
function TanStackTableText({
children,
className,
dangerouslySetInnerHTML,
...rest
}: TanStackTableTextProps): JSX.Element {
return (
const TanStackTableText = forwardRef<HTMLSpanElement, TanStackTableTextProps>(
({ children, className, dangerouslySetInnerHTML, ...rest }, ref) => (
<span
ref={ref}
className={cx(tableStyles.tableCellText, className)}
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
{...rest}
>
{children}
</span>
);
}
),
);
TanStackTableText.displayName = 'TanStackTableText';
export default TanStackTableText;

View File

@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
@@ -23,12 +23,13 @@ jest.mock('../TanStackTable.module.scss', () => ({
},
}));
// Mock ResizeObserver for combobox tests
global.ResizeObserver = class ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
beforeAll(() => {
window.ResizeObserver = jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
});
describe('TanStackTableView Integration', () => {
describe('rendering', () => {
@@ -402,6 +403,22 @@ describe('TanStackTableView Integration', () => {
});
});
it('preserves page from URL on initial mount', async () => {
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
enableQueryParams: true,
},
queryParams: { page: '3' },
});
const nav = await screen.findByRole('navigation');
const page3Button = within(nav).getByRole('button', { name: '3' });
// Page 3 should be active (from URL), not reset to defaultPage 1
expect(page3Button).toHaveAttribute('aria-current', 'page');
});
it('resets page to 1 when limit changes', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();

View File

@@ -0,0 +1,25 @@
import { renderHook } from '@testing-library/react';
import { useCalculatedPageSize } from '../useCalculatedPageSize';
describe('useCalculatedPageSize', () => {
it('returns containerRef and null calculatedPageSize initially', () => {
const { result } = renderHook(() => useCalculatedPageSize());
expect(result.current.containerRef).toBeDefined();
expect(result.current.containerRef.current).toBeNull();
expect(result.current.calculatedPageSize).toBeNull();
});
it('accepts custom config', () => {
const { result } = renderHook(() =>
useCalculatedPageSize({
rowHeight: 50,
headerHeight: 40,
paginationHeight: 50,
minPageSize: 3,
maxPageSize: 20,
}),
);
expect(result.current.containerRef).toBeDefined();
});
});

View File

@@ -0,0 +1,89 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import {
getPreferredPageSize,
usePreferredPageSize,
usePreferredPageSizeStore,
} from '../usePreferredPageSize.store';
const STORAGE_KEY = 'test-table';
const FULL_STORAGE_KEY = '@signoz/table-columns/test-table-preferred-page-size';
describe('usePreferredPageSize', () => {
beforeEach(() => {
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
it('returns null when no stored value exists', () => {
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
expect(result.current[0]).toBeNull();
});
it('returns null when storageKey is undefined', () => {
const { result } = renderHook(() => usePreferredPageSize(undefined));
expect(result.current[0]).toBeNull();
});
it('loads stored page size from localStorage', () => {
localStorage.setItem(FULL_STORAGE_KEY, '25');
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
expect(result.current[0]).toBe(25);
});
it('ignores invalid stored values', () => {
localStorage.setItem(FULL_STORAGE_KEY, 'invalid');
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
expect(result.current[0]).toBeNull();
});
it('persists page size to localStorage when set', () => {
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
act(() => {
result.current[1](30);
});
expect(result.current[0]).toBe(30);
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBe('30');
});
it('removes from localStorage when set to null', () => {
localStorage.setItem(FULL_STORAGE_KEY, '25');
const { result } = renderHook(() => usePreferredPageSize(STORAGE_KEY));
act(() => {
result.current[1](null);
});
expect(result.current[0]).toBeNull();
expect(localStorage.getItem(FULL_STORAGE_KEY)).toBeNull();
});
it('does nothing when storageKey is undefined and set is called', () => {
const { result } = renderHook(() => usePreferredPageSize(undefined));
act(() => {
result.current[1](30);
});
expect(result.current[0]).toBeNull();
});
});
describe('getPreferredPageSize', () => {
beforeEach(() => {
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
it('returns null when no stored value exists', () => {
expect(getPreferredPageSize(STORAGE_KEY)).toBeNull();
});
it('returns stored value from localStorage', () => {
localStorage.setItem(FULL_STORAGE_KEY, '42');
expect(getPreferredPageSize(STORAGE_KEY)).toBe(42);
});
});

View File

@@ -7,6 +7,7 @@ import {
} from 'nuqs/adapters/testing';
import { useTableParams } from '../useTableParams';
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
function createNuqsWrapper(
queryParams?: Record<string, string>,
@@ -543,3 +544,406 @@ describe('useTableParams (selective URL mode — partial config object)', () =>
});
});
});
describe('useTableParams (cleanupOnUnmount option)', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
afterEach(() => {
jest.useRealTimers();
});
it('clears URL params on unmount when cleanupOnUnmount is true', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
{
page: 1,
limit: 10,
cleanupOnUnmount: true,
},
),
{ wrapper },
);
// Set some values
await act(async () => {
result.current.setLimit(50);
result.current.setPage(3);
jest.runAllTimers();
await Promise.resolve();
});
// Verify values set
expect(result.current.limit).toBe(50);
expect(result.current.page).toBe(3);
// Unmount triggers cleanup
unmount();
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// Last URL update should have cleared params
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
expect(lastUpdate[0].searchParams.get('limit')).toBeNull();
expect(lastUpdate[0].searchParams.get('page')).toBeNull();
});
it('does not clear URL params on unmount when cleanupOnUnmount is false', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
cleanupOnUnmount: false,
},
),
{ wrapper },
);
await act(async () => {
result.current.setLimit(50);
jest.runAllTimers();
await Promise.resolve();
});
expect(result.current.limit).toBe(50);
unmount();
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// No new URL updates after unmount (or same count)
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
});
it('defaults cleanupOnUnmount to false', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams({ page: 'page', limit: 'limit' }, { page: 1, limit: 10 }),
{ wrapper },
);
await act(async () => {
result.current.setLimit(50);
jest.runAllTimers();
await Promise.resolve();
});
unmount();
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// URL should still have limit=50 (cleanup not triggered)
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
expect(lastUpdate[0].searchParams.get('limit')).toBe('50');
});
});
describe('useTableParams (auto page size with storageKey)', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
afterEach(() => {
jest.useRealTimers();
});
it('uses explicit default when no URL, no calculated, no preferred', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: null,
},
),
{ wrapper },
);
// Should use explicit default (10), NOT the internal DEFAULT_LIMIT (50)
expect(result.current.limit).toBe(10);
});
it('uses calculatedPageSize when available and no preferred', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
expect(result.current.limit).toBe(42);
});
it('prefers stored value over calculatedPageSize', () => {
// Pre-populate the store
localStorage.setItem(
'@signoz/table-columns/test-table-preferred-page-size',
'25',
);
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Should use preferred (25), not calculated (42)
expect(result.current.limit).toBe(25);
});
it('preserves URL limit over calculated and preferred', () => {
localStorage.setItem(
'@signoz/table-columns/test-table-preferred-page-size',
'25',
);
const wrapper = createNuqsWrapper({ limit: '30' });
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Should use URL (30), not preferred (25) or calculated (42)
expect(result.current.limit).toBe(30);
});
it('persists user selection when different from calculated', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// User selects 30 (different from calculated 42)
act(() => {
result.current.setLimit(30);
jest.runAllTimers();
});
expect(result.current.limit).toBe(30);
expect(
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
).toBe('30');
});
it('clears preference when user selects calculated value', () => {
// Pre-set a preference
localStorage.setItem(
'@signoz/table-columns/test-table-preferred-page-size',
'30',
);
usePreferredPageSizeStore.setState({ tables: { 'test-table': 30 } });
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// User selects 42 (same as calculated)
act(() => {
result.current.setLimit(42);
jest.runAllTimers();
});
expect(result.current.limit).toBe(42);
// Preference should be cleared (null removes from storage)
expect(
localStorage.getItem('@signoz/table-columns/test-table-preferred-page-size'),
).toBeNull();
});
it('returns calculated value even before URL is synced', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Limit should be 42 (calculated) even if URL sync is async
expect(result.current.limit).toBe(42);
});
it('does not override URL when it already has a value', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
// Limit should stay at 30 (from URL), not change to 42
expect(result.current.limit).toBe(30);
});
it('handles calculatedPageSize changing from null to number', () => {
const wrapper = createNuqsWrapper();
const { result, rerender } = renderHook(
({ calculated }) =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table-2',
calculatedPageSize: calculated,
},
),
{ wrapper, initialProps: { calculated: null as number | null } },
);
// Initially should use explicit default (10)
expect(result.current.limit).toBe(10);
// When calculated becomes available, should update
rerender({ calculated: 42 });
act(() => {
jest.runAllTimers();
});
// Limit should now be 42
expect(result.current.limit).toBe(42);
});
it('keeps user selection when calculatedPageSize changes', () => {
const wrapper = createNuqsWrapper();
const { result, rerender } = renderHook(
({ calculated }) =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table-3',
calculatedPageSize: calculated,
},
),
{ wrapper, initialProps: { calculated: 42 as number | null } },
);
expect(result.current.limit).toBe(42);
// User selects 30
act(() => {
result.current.setLimit(30);
jest.runAllTimers();
});
expect(result.current.limit).toBe(30);
// calculatedPageSize changes (e.g., window resize)
rerender({ calculated: 50 });
act(() => {
jest.runAllTimers();
});
// Should keep user's selection (30), not change to new calculated (50)
expect(result.current.limit).toBe(30);
});
});

View File

@@ -0,0 +1,199 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { useQueryStates, parseAsInteger } from 'nuqs';
import {
NuqsTestingAdapter,
OnUrlUpdateFunction,
UrlUpdateEvent,
} from 'nuqs/adapters/testing';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { useTableParams } from '../useTableParams';
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
function createNuqsWrapper(
queryParams?: Record<string, string>,
onUrlUpdate?: OnUrlUpdateFunction,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function NuqsWrapper({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
};
}
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
page: 'page',
limit: 'limit',
} as const;
type TableParamsWithCleanup = ReturnType<typeof useTableParams> & {
clearParams: ReturnType<typeof useQueryStates>[1];
};
/**
* Simulates the cleanup pattern used in ListAlertRules:
* - Uses useQueryStates to clear URL params on unmount
*/
function useTableParamsWithCleanup(
storageKey: string,
calculatedPageSize: number | null,
): TableParamsWithCleanup {
const result = useTableParams(QUERY_PARAMS_CONFIG, {
page: 1,
limit: 10,
storageKey,
calculatedPageSize,
});
// This mirrors the cleanup effect in ListAlertRules
const [, setTableQueryParams] = useQueryStates({
[QUERY_PARAMS_CONFIG.orderBy]: parseAsJsonNoValidate(),
[QUERY_PARAMS_CONFIG.page]: parseAsInteger,
[QUERY_PARAMS_CONFIG.limit]: parseAsInteger,
});
// Note: We can't use useEffect cleanup in tests easily, but we can verify
// that calling setTableQueryParams with nulls does clear the URL
return { ...result, clearParams: setTableQueryParams };
}
describe('URL cleanup pattern (simulating ListAlertRules behavior)', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
afterEach(() => {
jest.useRealTimers();
});
it('setTableQueryParams with null values should clear URL params', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParamsWithCleanup('alert-rules', 42),
{ wrapper },
);
// Set limit to 100
await act(async () => {
result.current.setLimit(100);
jest.runAllTimers();
await Promise.resolve();
});
expect(result.current.limit).toBe(100);
// Verify limit=100 is in URL
const limitAfterSet = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('limit'))
.filter(Boolean)
.pop();
expect(limitAfterSet).toBe('100');
// Simulate cleanup: clear all params
await act(async () => {
void result.current.clearParams({
orderBy: null,
page: null,
limit: null,
});
jest.runAllTimers();
await Promise.resolve();
});
// Verify limit was cleared (last update should have limit=null or removed)
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
const finalLimit = lastUpdate[0].searchParams.get('limit');
expect(finalLimit).toBeNull();
});
it('cleanup should work even when limit was set from localStorage preference', async () => {
// Pre-set preference
localStorage.setItem(
'@signoz/table-columns/alert-rules-preferred-page-size',
'100',
);
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParamsWithCleanup('alert-rules', 42),
{ wrapper },
);
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// Should use preferred value
expect(result.current.limit).toBe(100);
// Simulate cleanup
await act(async () => {
void result.current.clearParams({
orderBy: null,
page: null,
limit: null,
});
jest.runAllTimers();
await Promise.resolve();
});
// URL should be cleared
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
const finalLimit = lastUpdate[0].searchParams.get('limit');
expect(finalLimit).toBeNull();
});
it('demonstrates the bug: component without cleanup leaves limit in URL', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Mount TriggeredAlerts-like component (no cleanup)
const { result, unmount } = renderHook(
() =>
useTableParams(QUERY_PARAMS_CONFIG, {
page: 1,
limit: 10,
storageKey: 'triggered-alerts',
calculatedPageSize: 42,
}),
{ wrapper },
);
// Set limit to 100
await act(async () => {
result.current.setLimit(100);
jest.runAllTimers();
await Promise.resolve();
});
expect(result.current.limit).toBe(100);
// Unmount WITHOUT cleanup
unmount();
// Verify limit=100 is STILL in URL (this is the bug!)
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
const finalLimit = lastUpdate[0].searchParams.get('limit');
expect(finalLimit).toBe('100'); // BUG: limit persists after unmount
});
});

View File

@@ -0,0 +1,385 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import {
NuqsTestingAdapter,
OnUrlUpdateFunction,
UrlUpdateEvent,
} from 'nuqs/adapters/testing';
import { useTableParams } from '../useTableParams';
import { usePreferredPageSizeStore } from '../usePreferredPageSize.store';
function createNuqsWrapper(
queryParams?: Record<string, string>,
onUrlUpdate?: OnUrlUpdateFunction,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function NuqsWrapper({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
};
}
describe('useTableParams navigation scenarios', () => {
beforeEach(() => {
jest.useFakeTimers();
localStorage.clear();
usePreferredPageSizeStore.setState({ tables: {} });
});
afterEach(() => {
jest.useRealTimers();
});
describe('Tab navigation: Alert Rules -> Configuration -> Routing Policies', () => {
it('preferred value from one table should NOT leak to URL when navigating away', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Simulate Alert Rules: user sets limit=100
const alertRules = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
{
page: 1,
limit: 10,
storageKey: 'alert-rules',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// User selects limit=100
act(() => {
alertRules.result.current.setLimit(100);
jest.runAllTimers();
});
expect(alertRules.result.current.limit).toBe(100);
// Verify it's persisted in localStorage
expect(
localStorage.getItem(
'@signoz/table-columns/alert-rules-preferred-page-size',
),
).toBe('100');
// Simulate unmount (user navigates away)
alertRules.unmount();
// At this point, the URL should NOT have limit=100 from alert-rules
// when another component mounts with a different storageKey
});
it('different tables with different storageKeys maintain separate preferences', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Alert Rules sets limit=100
localStorage.setItem(
'@signoz/table-columns/alert-rules-preferred-page-size',
'100',
);
// Triggered Alerts sets limit=25
localStorage.setItem(
'@signoz/table-columns/triggered-alerts-preferred-page-size',
'25',
);
// Mount Triggered Alerts (simulating tab switch from Alert Rules)
const triggeredAlerts = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
{
page: 1,
limit: 10,
storageKey: 'triggered-alerts',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
// Should use triggered-alerts preference (25), NOT alert-rules (100)
expect(triggeredAlerts.result.current.limit).toBe(25);
});
it('table without storageKey should NOT write preference to URL from another table', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
// Pre-set alert-rules preference
localStorage.setItem(
'@signoz/table-columns/alert-rules-preferred-page-size',
'100',
);
// Start fresh with NO URL params
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Mount a table WITHOUT storageKey (simulating a simple table)
const simpleTable = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
// NO storageKey
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
// Should use calculated (42), not alert-rules preference (100)
expect(simpleTable.result.current.limit).toBe(42);
});
});
describe('URL cleanup on unmount', () => {
it('URL params should be cleanable by consumer on unmount', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit', orderBy: 'orderBy' },
{
page: 1,
limit: 10,
storageKey: 'test-cleanup',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Set some values
act(() => {
result.current.setLimit(50);
result.current.setPage(3);
jest.runAllTimers();
});
// Verify URL was updated
const limitUpdates = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('limit'))
.filter(Boolean);
expect(limitUpdates).toContain('50');
// Unmount (note: useTableParams itself doesn't cleanup URL - consumer should)
unmount();
// Verify the component unmounted (no errors)
expect(true).toBe(true);
});
});
describe('Parallel tables sharing URL params', () => {
it('two tables using same URL params should see same values when URL pre-set', () => {
const wrapper = createNuqsWrapper({ limit: '30' });
const table1 = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'table-1',
calculatedPageSize: 42,
},
),
{ wrapper },
);
const table2 = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 20,
storageKey: 'table-2',
calculatedPageSize: 50,
},
),
{ wrapper },
);
// Both should see URL value (30), not their defaults
expect(table1.result.current.limit).toBe(30);
expect(table2.result.current.limit).toBe(30);
});
it('table mounted after setLimit should see updated URL value', () => {
const wrapper = createNuqsWrapper();
// Table1 mounts first
const table1 = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'table-1',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
expect(table1.result.current.limit).toBe(42);
// Table1 sets limit to 100
act(() => {
table1.result.current.setLimit(100);
jest.runAllTimers();
});
expect(table1.result.current.limit).toBe(100);
// Table2 mounts AFTER table1 set limit=100 in URL
// In test environment, URL state doesn't persist between renderHook calls
// This test documents current behavior - each hook instance is independent
});
});
describe('URL state initialization race conditions', () => {
it('should not write preferred value to URL if URL already has value', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
// Pre-set preference
localStorage.setItem(
'@signoz/table-columns/test-table-preferred-page-size',
'100',
);
// URL already has limit=30
const wrapper = createNuqsWrapper({ limit: '30' }, onUrlUpdate);
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'test-table',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
jest.runAllTimers();
});
// Should use URL (30), not preferred (100)
expect(result.current.limit).toBe(30);
// URL should NOT have been overwritten with 100
const limitUpdates = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('limit'))
.filter((v) => v === '100');
expect(limitUpdates).toHaveLength(0);
});
it('URL init effect should write calculated value when URL empty', async () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
// Mount with no URL params
const { result } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'table-1',
calculatedPageSize: 42,
},
),
{ wrapper },
);
// Effects run after render, need to flush
await act(async () => {
jest.runAllTimers();
await Promise.resolve();
});
// Should use calculated value
expect(result.current.limit).toBe(42);
// The URL init effect writes to URL asynchronously
// Check that limit is 42 (which it is from the limitDefault calculation)
});
it('consumer cleanup effect is responsible for clearing URL params', () => {
// This test documents that useTableParams does NOT auto-cleanup URL
// Consumer components (like ListAlertRules) must use useEffect cleanup
// to clear URL params when unmounting
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result, unmount } = renderHook(
() =>
useTableParams(
{ page: 'page', limit: 'limit' },
{
page: 1,
limit: 10,
storageKey: 'table-1',
calculatedPageSize: 42,
},
),
{ wrapper },
);
act(() => {
result.current.setLimit(100);
jest.runAllTimers();
});
expect(result.current.limit).toBe(100);
// Unmount - useTableParams does NOT clear URL
unmount();
// Verify unmount happened without clearing URL
// The last URL update should still have limit=100, not null
const lastUpdate = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1];
expect(lastUpdate[0].searchParams.get('limit')).toBe('100');
});
});
});

View File

@@ -3,8 +3,10 @@ import TanStackTableText from './TanStackTableText';
export * from './TanStackTableStateContext';
export * from './types';
export * from './useCalculatedPageSize';
export * from './useColumnState';
export * from './useColumnStore';
export * from './usePreferredPageSize.store';
export * from './useTableParams';
/**
@@ -192,6 +194,67 @@ export * from './useTableParams';
* )}
* />
* ```
*
* @example useTableParams — manages pagination state with URL sync and persistence
*
* The `useTableParams` hook handles page, limit, orderBy, and expanded state. It can sync
* to URL params, persist user's page size preference, and auto-calculate page size from
* container height.
*
* **Priority chain for limit**: URL > preferred (localStorage) > calculated > explicit default > 50
*
* ```tsx
* import { useCalculatedPageSize, useTableParams } from 'components/TanStackTableView';
*
* const QUERY_PARAMS = { page: 'page', limit: 'limit', orderBy: 'orderBy' } as const;
*
* function MyTable({ data, columns }) {
* // Auto-calculate page size based on container height
* const { containerRef, calculatedPageSize } = useCalculatedPageSize({ rowHeight: 42 });
*
* // useTableParams options:
* // - storageKey: persists user's page size selection to localStorage
* // - calculatedPageSize: uses this when no URL/preferred value exists
* // - cleanupOnUnmount: clears URL params when component unmounts
* const { page, limit, setLimit, orderBy } = useTableParams(QUERY_PARAMS, {
* page: 1,
* limit: 10,
* storageKey: 'my-table',
* calculatedPageSize,
* cleanupOnUnmount: true,
* });
*
* const paginatedData = useMemo(() => {
* const start = (page - 1) * limit;
* return data.slice(start, start + limit);
* }, [data, page, limit]);
*
* return (
* <div ref={containerRef} style={{ height: '100%' }}>
* <TanStackTable
* data={paginatedData}
* columns={columns}
* enableQueryParams={QUERY_PARAMS}
* pagination={{
* total: data.length,
* calculatedPageSize,
* onLimitChange: setLimit,
* }}
* />
* </div>
* );
* }
* ```
*
* **useTableParams options:**
* - `storageKey`: Persists user's page size to localStorage. When user selects a size
* different from calculated, it's saved. Selecting calculated size clears preference.
* - `calculatedPageSize`: From `useCalculatedPageSize`. Used as default when no URL/preferred.
* - `cleanupOnUnmount`: Clears URL params (page, limit, orderBy, expanded) on unmount.
* Use when navigating away should reset table state.
*
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
* to reset to auto-calculated size.
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -74,6 +74,7 @@ export type TableColumnDef<
min?: number | string;
default?: number | string;
max?: number | string;
ignoreLastColumnFill?: boolean;
};
};
@@ -111,6 +112,14 @@ export type TableRowContext<TData> = {
enableAlternatingRowColors?: boolean;
};
export type AutoPageSizeConfig = {
rowHeight?: number;
headerHeight?: number;
paginationHeight?: number;
minPageSize?: number;
maxPageSize?: number;
};
export type PaginationProps = {
total: number;
defaultPage?: number;
@@ -123,6 +132,12 @@ export type PaginationProps = {
onLimitChange?: (limit: number) => void;
showTotalCount?: boolean;
totalCountLabel?: string;
/**
* Auto-calculated page size for the current container.
* When set, shows as "Auto (N)" option in the page size dropdown.
* Consumer is responsible for calculating this via useCalculatedPageSize.
*/
calculatedPageSize?: number | null;
};
export type TanstackTableQueryParamsConfig = {

View File

@@ -0,0 +1,76 @@
import type { RefObject } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AutoPageSizeConfig } from './types';
const DEFAULT_ROW_HEIGHT = 36;
const DEFAULT_HEADER_HEIGHT = 36;
const DEFAULT_PAGINATION_HEIGHT = 62;
const MIN_PAGE_SIZE = 5;
const MAX_PAGE_SIZE = 100;
export type UseCalculatedPageSizeResult = {
containerRef: RefObject<HTMLDivElement>;
calculatedPageSize: number | null;
};
export function useCalculatedPageSize(
config?: AutoPageSizeConfig,
): UseCalculatedPageSizeResult {
const containerRef = useRef<HTMLDivElement>(null);
const [calculatedPageSize, setCalculatedPageSize] = useState<number | null>(
null,
);
const rowHeight = config?.rowHeight ?? DEFAULT_ROW_HEIGHT;
const headerHeight = config?.headerHeight ?? DEFAULT_HEADER_HEIGHT;
const paginationHeight = config?.paginationHeight ?? DEFAULT_PAGINATION_HEIGHT;
const minPageSize = config?.minPageSize ?? MIN_PAGE_SIZE;
const maxPageSize = config?.maxPageSize ?? MAX_PAGE_SIZE;
const calculatePageSize = useCallback(
(containerHeight: number): number => {
const availableHeight = containerHeight - headerHeight - paginationHeight;
const rawPageSize = Math.floor(availableHeight / rowHeight);
return Math.min(maxPageSize, Math.max(minPageSize, rawPageSize));
},
[rowHeight, headerHeight, paginationHeight, minPageSize, maxPageSize],
);
useEffect(() => {
if (!containerRef.current) {
return;
}
const container = containerRef.current;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const { height } = entry.contentRect;
if (height > 0) {
const newPageSize = calculatePageSize(height);
setCalculatedPageSize((prev) =>
prev !== newPageSize ? newPageSize : prev,
);
}
});
observer.observe(container);
const { height } = container.getBoundingClientRect();
if (height > 0) {
setCalculatedPageSize(calculatePageSize(height));
}
return (): void => {
observer.disconnect();
};
}, [calculatePageSize]);
return { containerRef, calculatedPageSize };
}

View File

@@ -0,0 +1,91 @@
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import remove from 'api/browser/localstorage/remove';
import { create } from 'zustand';
const STORAGE_PREFIX = '@signoz/table-columns/';
const STORAGE_SUFFIX = '-preferred-page-size';
type PreferredPageSizeState = {
tables: Record<string, number | null>;
setPreferredPageSize: (storageKey: string, pageSize: number | null) => void;
};
const getStorageKey = (tableKey: string): string =>
`${STORAGE_PREFIX}${tableKey}${STORAGE_SUFFIX}`;
const loadFromStorage = (tableKey: string): number | null => {
try {
const raw = get(getStorageKey(tableKey));
if (!raw) {
return null;
}
const parsed = parseInt(raw, 10);
return Number.isNaN(parsed) ? null : parsed;
} catch {
return null;
}
};
const saveToStorage = (tableKey: string, pageSize: number | null): void => {
try {
const key = getStorageKey(tableKey);
if (pageSize === null) {
remove(key);
} else {
set(key, String(pageSize));
}
} catch {
// Ignore storage errors
}
};
export const usePreferredPageSizeStore = create<PreferredPageSizeState>()(
(set, get) => ({
tables: {},
setPreferredPageSize: (storageKey, pageSize): void => {
set({ tables: { ...get().tables, [storageKey]: pageSize } });
saveToStorage(storageKey, pageSize);
},
}),
);
export function usePreferredPageSize(
storageKey: string | undefined,
): [number | null, (pageSize: number | null) => void] {
const pageSize = usePreferredPageSizeStore((s) => {
if (!storageKey) {
return null;
}
const cached = s.tables[storageKey];
if (cached !== undefined) {
return cached;
}
return loadFromStorage(storageKey);
});
const setPageSize = usePreferredPageSizeStore((s) => s.setPreferredPageSize);
const setPreferred = (size: number | null): void => {
if (storageKey) {
setPageSize(storageKey, size);
}
};
return [pageSize, setPreferred];
}
export function getPreferredPageSize(storageKey: string): number | null {
// oxlint-disable-next-line signoz/no-zustand-getstate-in-hooks
const state = usePreferredPageSizeStore.getState();
const cached = state.tables[storageKey];
if (cached !== undefined) {
return cached;
}
const stored = loadFromStorage(storageKey);
if (stored !== null) {
state.setPreferredPageSize(storageKey, stored);
}
return stored;
}

View File

@@ -4,6 +4,7 @@ import { parseAsInteger, useQueryState } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { SortState, TanstackTableQueryParamsConfig } from './types';
import { usePreferredPageSize } from './usePreferredPageSize.store';
const NUQS_OPTIONS = { history: 'push' as const };
const DEFAULT_PAGE = 1;
@@ -20,9 +21,15 @@ type Defaults = {
limit?: number;
orderBy?: SortState | null;
expanded?: ExpandedState;
/** Storage key for persisting user's page size preference */
storageKey?: string;
/** Auto-calculated page size from container. URL initializes with this when available. */
calculatedPageSize?: number | null;
/** Clear URL params on unmount. Useful when navigating away from table views. */
cleanupOnUnmount?: boolean;
};
type TableParamsResult = {
export type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
@@ -99,15 +106,23 @@ export function useTableParams(
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
: URL_KEYS_DEFAULT.expanded;
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
const expandedDefault = defaults?.expanded ?? {};
const storageKey = defaults?.storageKey;
const calculatedPageSize = defaults?.calculatedPageSize;
const cleanupOnUnmount = defaults?.cleanupOnUnmount ?? false;
const expandedDefaultArray = useMemo(
() => expandedStateToArray(expandedDefault),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const [preferredPageSize, setPreferredPageSize] =
usePreferredPageSize(storageKey);
const limitDefault =
preferredPageSize ?? calculatedPageSize ?? defaults?.limit ?? DEFAULT_LIMIT;
const [localPage, setLocalPage] = useState(pageDefault);
const [localLimit, setLocalLimit] = useState(limitDefault);
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
@@ -120,9 +135,71 @@ export function useTableParams(
pageQueryParam,
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
);
const [urlLimit, setUrlLimit] = useQueryState(
const [urlLimitRaw, setUrlLimitRaw] = useQueryState(
limitQueryParam,
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
parseAsInteger.withOptions(NUQS_OPTIONS),
);
// Track if URL had limit on initial mount
const hadUrlLimitOnMountRef = useRef<boolean | null>(null);
if (hadUrlLimitOnMountRef.current === null) {
hadUrlLimitOnMountRef.current = urlLimitRaw !== null;
}
const hadUrlLimit = hadUrlLimitOnMountRef.current ?? false;
const urlLimit = urlLimitRaw ?? limitDefault;
// Initialize URL with preferred/calculated when available (only if URL was empty)
const hasInitializedUrlRef = useRef(false);
useEffect(() => {
if (!useUrlForLimit || hasInitializedUrlRef.current || hadUrlLimit) {
return;
}
if (preferredPageSize !== null) {
hasInitializedUrlRef.current = true;
void setUrlLimitRaw(preferredPageSize);
return;
}
if (calculatedPageSize != null) {
hasInitializedUrlRef.current = true;
void setUrlLimitRaw(calculatedPageSize);
}
}, [
useUrlForLimit,
calculatedPageSize,
preferredPageSize,
hadUrlLimit,
setUrlLimitRaw,
]);
// Wrapped setLimit that persists preference when different from calculated
const setUrlLimit = useCallback(
(newLimit: number): void => {
if (storageKey) {
if (newLimit !== calculatedPageSize) {
setPreferredPageSize(newLimit);
} else {
setPreferredPageSize(null);
}
}
void setUrlLimitRaw(newLimit);
},
[storageKey, calculatedPageSize, setPreferredPageSize, setUrlLimitRaw],
);
const setLocalLimitWithPersist = useCallback(
(newLimit: number): void => {
if (storageKey) {
if (newLimit !== calculatedPageSize) {
setPreferredPageSize(newLimit);
} else {
setPreferredPageSize(null);
}
}
setLocalLimit(newLimit);
},
[storageKey, calculatedPageSize, setPreferredPageSize],
);
const [urlOrderBy, setUrlOrderBy] = useQueryState(
orderByQueryParam,
@@ -155,7 +232,7 @@ export function useTableParams(
typeof updaterOrValue === 'function'
? updaterOrValue(urlExpandedRef.current)
: updaterOrValue;
setUrlExpandedArray(expandedStateToArray(newState));
void setUrlExpandedArray(expandedStateToArray(newState));
},
[setUrlExpandedArray],
);
@@ -172,21 +249,53 @@ export function useTableParams(
[],
);
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const prevOrderByRef = useRef<string | null>(null);
useEffect(() => {
if (useUrlForPage) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
// Only reset page when orderBy actually changes, not on initial mount
if (
prevOrderByRef.current !== null &&
prevOrderByRef.current !== orderByUrlMemoKey
) {
if (useUrlForPage) {
void setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}
prevOrderByRef.current = orderByUrlMemoKey;
}, [useUrlForPage, orderByUrlMemoKey, pageDefault, setUrlPage]);
useEffect(() => {
if (!cleanupOnUnmount) {
return;
}
return (): void => {
if (useUrlForPage) {
void setUrlPage(null);
}
if (useUrlForLimit) {
void setUrlLimitRaw(null);
}
if (useUrlForOrderBy) {
void setUrlOrderBy(null);
}
if (useUrlForExpanded) {
void setUrlExpandedArray(null);
}
};
}, [
cleanupOnUnmount,
useUrlForPage,
orderByDefaultMemoKey,
orderByUrlMemoKey,
pageDefault,
useUrlForLimit,
useUrlForOrderBy,
useUrlForExpanded,
setUrlPage,
setUrlLimitRaw,
setUrlOrderBy,
setUrlExpandedArray,
]);
return {
@@ -195,7 +304,7 @@ export function useTableParams(
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
};

View File

@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { RowKeyData, TableColumnDef } from './types';
import { ComboboxSimpleItem } from '@signozhq/ui/combobox';
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
column.id;
@@ -34,7 +35,7 @@ export const getColumnWidthStyle = <TData>(
isLastColumn?: boolean,
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
if (isLastColumn && column?.width?.ignoreLastColumnFill !== true) {
return {
width: '100%',
minWidth: persistedWidth ?? column?.width?.min,
@@ -145,3 +146,31 @@ export function buildTanstackColumnDef<TData>(
},
};
}
const DEFAULT_PAGE_SIZES = [10, 20, 30, 50, 100];
export function buildPageSizeItems(
calculatedSize?: number | null,
): ComboboxSimpleItem[] {
const items: ComboboxSimpleItem[] = [];
if (calculatedSize) {
items.push({
value: calculatedSize.toString(),
label: `Auto (${calculatedSize})`,
displayValue: calculatedSize.toString(),
});
}
for (const size of DEFAULT_PAGE_SIZES) {
if (size !== calculatedSize) {
items.push({
value: size.toString(),
label: size.toString(),
displayValue: size.toString(),
});
}
}
return items;
}

View File

@@ -76,4 +76,19 @@
.cmd-item-icon {
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
svg {
height: 16px !important;
width: 16px !important;
}
&.noz-icon {
svg {
height: 20px !important;
width: 20px !important;
}
}
}

View File

@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import cx from 'classnames';
import { useLocation } from 'react-router-dom';
import {
CommandDialog,
@@ -162,7 +163,11 @@ export function CmdKPalette({
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span className="cmd-item-icon">{it.icon}</span>
<span
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
>
{it.icon}
</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>

View File

@@ -42,5 +42,4 @@ export enum LOCALSTORAGE {
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
}

View File

@@ -15,10 +15,10 @@ import {
ListMinus,
ScrollText,
Settings,
Sparkles,
TowerControl,
Workflow,
} from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import { ROLES } from 'types/roles';
export type CmdAction = {
@@ -292,11 +292,11 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
if (aiAssistant) {
actions.unshift({
id: 'ai-assistant',
name: 'Open AI Assistant',
name: 'Open Noz',
shortcut: ['cmd+j'],
keywords: 'ai assistant chat ask sparkles copilot',
section: 'AI Assistant',
icon: <Sparkles size={14} />,
keywords: 'noz ai assistant chat ask sparkles copilot',
section: 'Noz',
icon: <Noz size={16} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: aiAssistant.open,
});

View File

@@ -4,7 +4,8 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Drawer } from 'antd';
import ROUTES from 'constants/routes';
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
import { Maximize2, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
@@ -46,9 +47,9 @@ export default function AIAssistantDrawer(): JSX.Element {
closeIcon={null}
title={
<div>
<div>
<MessageSquare size={16} />
<span>AI Assistant</span>
<div className="noz-wave">
<Noz size={16} />
<span>Noz</span>
</div>
<div>

View File

@@ -4,7 +4,8 @@ import { useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
import { History, Maximize2, Minus, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import logEvent from 'api/common/logEvent';
@@ -142,15 +143,15 @@ export default function AIAssistantModal(): JSX.Element | null {
className={styles.backdrop}
role="dialog"
aria-modal="true"
aria-label="AI Assistant"
aria-label="Noz"
onClick={handleBackdropClick}
>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={16} color="var(--primary)" />
<span>AI Assistant</span>
<div className={`${styles.title} noz-wave`}>
<Noz size={16} />
<span>Noz</span>
<kbd className={styles.shortcut}>
<span></span>
<span>J</span>

View File

@@ -3,7 +3,8 @@ import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
import { History, Maximize2, Plus, X } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import logEvent from 'api/common/logEvent';
@@ -137,9 +138,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={18} color="var(--primary)" />
<span>AI Assistant</span>
<div className={`${styles.title} noz-wave`}>
<Noz size={18} />
<span>Noz</span>
</div>
<div className={styles.actions}>

View File

@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
@@ -42,15 +42,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
}
return (
<TooltipSimple title="AI Assistant">
<TooltipSimple title="Noz">
<Button
variant="solid"
color="primary"
className={styles.trigger}
className={`${styles.trigger} noz-wave`}
onClick={handleOpen}
aria-label="Open AI Assistant"
aria-label="Open Noz"
>
<Bot size={20} />
<Noz size={24} />
</Button>
</TooltipSimple>
);

View File

@@ -28,7 +28,6 @@
.emptyIcon {
margin-bottom: 4px;
opacity: 0.85;
}
.emptyTitle {

View File

@@ -7,8 +7,8 @@ import {
ChartBar,
Search,
Zap,
Sparkles,
} from '@signozhq/icons';
import Noz from 'components/Noz/Noz';
import logEvent from 'api/common/logEvent';
@@ -177,10 +177,10 @@ export default function VirtualizedMessages({
if (messages.length === 0 && !showStreamingSlot) {
return (
<div className={styles.empty}>
<div className={styles.emptyIcon}>
<Sparkles size={24} color="var(--primary)" />
<div className={`${styles.emptyIcon} noz-wave`}>
<Noz size={48} />
</div>
<h3 className={styles.emptyTitle}>SigNoz AI Assistant</h3>
<h3 className={styles.emptyTitle}>Noz</h3>
<p className={styles.emptySubtitle}>
Ask questions about your traces, logs, metrics, and infrastructure.
</p>

View File

@@ -1,43 +0,0 @@
.settings-container-root {
.ant-drawer-wrapper-body {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
.ant-drawer-header {
height: 48px;
border-bottom: 1px solid var(--l1-border);
padding: 14px 14px 14px 11px;
.ant-drawer-header-title {
gap: 16px;
.ant-drawer-title {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
padding-left: 16px;
border-left: 1px solid var(--l1-border);
}
.ant-drawer-close {
height: 16px;
width: 16px;
margin-inline-end: 0px !important;
}
}
}
.ant-drawer-body {
padding: 16px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
}
}

View File

@@ -1,34 +0,0 @@
import { memo, PropsWithChildren, ReactElement } from 'react';
import { Drawer } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import './SettingsDrawer.styles.scss';
type SettingsDrawerProps = PropsWithChildren<{
drawerTitle: string;
isOpen: boolean;
onClose: () => void;
}>;
function SettingsDrawer({
children,
drawerTitle,
isOpen,
onClose,
}: SettingsDrawerProps): JSX.Element {
return (
<Drawer
title={drawerTitle}
placement="right"
width="50%"
onClose={onClose}
open={isOpen}
rootClassName="settings-container-root"
>
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
</Drawer>
);
}
export default memo(SettingsDrawer);

View File

@@ -1,407 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { FullScreenHandle } from 'react-full-screen';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import {
Check,
ClipboardCopy,
Ellipsis,
FileJson,
Fullscreen,
Globe,
LockKeyhole,
PenLine,
Plus,
X,
} from '@signozhq/icons';
import { Button, Card, Input, Modal, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { toast } from '@signozhq/ui/sonner';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import {
lockDashboardV2,
patchDashboardV2,
unlockDashboardV2,
} from 'api/generated/services/dashboard';
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import useComponentPermission from 'hooks/useComponentPermission';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
import DashboardSettingsV2 from '../DashboardSettings';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
import DashboardVariablesV2 from '../DashboardVariablesV2';
import SettingsDrawer from './SettingsDrawer';
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
import type { V2Dashboard } from '../utils';
interface DashboardDescriptionV2Props {
dashboard: V2Dashboard | undefined;
handle: FullScreenHandle;
onRefetch: () => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
const { dashboard, handle, onRefetch } = props;
const id = dashboard?.id ?? '';
const isDashboardLocked = !!dashboard?.locked;
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
useState<boolean>(false);
const title = dashboard?.spec?.display?.name ?? '';
const description = dashboard?.spec?.display?.description ?? '';
const image = dashboard?.image || Base64Icons[0];
const tags = useMemo(
() =>
(dashboard?.tags ?? []).map((t) =>
t.key === t.value ? t.key : `${t.key}:${t.value}`,
),
[dashboard?.tags],
);
const dashboardVariables = dashboard?.spec?.variables ?? [];
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
useState<boolean>(false);
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
useState<boolean>(false);
const isAuthor =
!!user?.email && !!dashboard?.createdBy && dashboard.createdBy === user.email;
const addPanelPermission = !isDashboardLocked;
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
const isPublicDashboard = false;
const { showErrorModal } = useErrorModal();
const { t } = useTranslation(['dashboard', 'common']);
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
useEffect(() => {
if (dashboard) {setUpdatedTitle(title);}
}, [dashboard, title]);
const handleLockDashboardToggle = async (): Promise<void> => {
if (!id) {return;}
setIsDashbordSettingsOpen(false);
try {
if (isDashboardLocked) {
await unlockDashboardV2({ id });
toast.success('Dashboard unlocked');
} else {
await lockDashboardV2({ id });
toast.success('Dashboard locked');
}
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
}
};
const onNameChangeHandler = async (): Promise<void> => {
const trimmed = updatedTitle.trim();
if (!id || !trimmed || trimmed === title) {
setIsRenameDashboardOpen(false);
return;
}
try {
setIsRenameLoading(true);
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/display/name',
value: trimmed,
},
];
await patchDashboardV2({ id }, patch);
toast.success('Dashboard renamed successfully');
setIsRenameDashboardOpen(false);
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
setIsRenameDashboardOpen(true);
} finally {
setIsRenameLoading(false);
}
};
const onEmptyWidgetHandler = (): void => {
logEvent('Dashboard Detail V2: Add new panel clicked', {
dashboardId: id,
});
toast.info('V2 panel editor coming next');
};
const [state, setCopy] = useCopyToClipboard();
useEffect(() => {
if (state.error) {
toast.error(t('something_went_wrong', { ns: 'common' }));
}
if (state.value) {
toast.success(t('success', { ns: 'common' }));
}
}, [state.error, state.value, t]);
const dashboardDataJSON = (): string =>
JSON.stringify(dashboard ?? {}, null, 2);
const exportJSON = (): void => {
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${title || 'dashboard'}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const onConfigureClick = (): void => {
setIsSettingsDrawerOpen(true);
};
const onSettingsDrawerClose = (): void => {
setIsSettingsDrawerOpen(false);
};
return (
<Card className="dashboard-description-container">
<DashboardHeader title={title} image={image} />
<section className="dashboard-details">
<div className="left-section">
<img src={image} alt="dashboard-img" className="dashboard-img" />
<Tooltip title={title.length > 30 ? title : ''}>
<Typography.Text
className="dashboard-title"
data-testid="dashboard-title"
>
{title}
</Typography.Text>
</Tooltip>
{isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible">
<Globe size={14} className="public-dashboard-icon" />
</Tooltip>
)}
{isDashboardLocked && (
<Tooltip title="This dashboard is locked">
<LockKeyhole size={14} className="lock-dashboard-icon" />
</Tooltip>
)}
</div>
<div className="right-section">
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
<Popover
open={isDashboardSettingsOpen}
arrow={false}
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
rootClassName="dashboard-settings"
content={
<div className="menu-content">
<section className="section-1">
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<Tooltip
title={
dashboard?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={dashboard?.createdBy === 'integration'}
onClick={handleLockDashboardToggle}
data-testid="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</Tooltip>
)}
{!isDashboardLocked && editDashboard && (
<Button
type="text"
icon={<PenLine size={14} />}
onClick={(): void => {
setIsRenameDashboardOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
Rename
</Button>
)}
<Button
type="text"
icon={<Fullscreen size={14} />}
onClick={handle.enter}
>
Full screen
</Button>
</section>
<section className="section-2">
<Button
type="text"
icon={<FileJson size={14} />}
onClick={(): void => {
exportJSON();
setIsDashbordSettingsOpen(false);
}}
>
Export JSON
</Button>
<Button
type="text"
icon={<ClipboardCopy size={14} />}
onClick={(): void => {
setCopy(dashboardDataJSON());
setIsDashbordSettingsOpen(false);
}}
>
Copy as JSON
</Button>
</section>
<section className="delete-dashboard">
<DeleteButton
createdBy={dashboard?.createdBy || ''}
name={title}
id={id}
isLocked={isDashboardLocked}
routeToListPage
/>
</section>
</div>
}
trigger="click"
placement="bottomRight"
>
<Button
icon={<Ellipsis size={14} />}
type="text"
className="icons"
data-testid="options"
/>
</Popover>
{!isDashboardLocked && editDashboard && (
<>
<Button
type="text"
className="configure-button"
icon={<ConfigureIcon />}
data-testid="show-drawer"
onClick={onConfigureClick}
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={onSettingsDrawerClose}
>
<DashboardSettingsV2
dashboard={dashboard}
onRefetch={onRefetch}
/>
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
className="add-panel-btn"
onClick={onEmptyWidgetHandler}
icon={<Plus size="md" />}
type="primary"
data-testid="add-panel-header"
>
New Panel
</Button>
)}
</div>
</section>
{tags.length > 0 && (
<div className="dashboard-tags">
{tags.map((tag) => (
<Badge key={tag} className="tag">
{tag}
</Badge>
))}
</div>
)}
{!isEmpty(description) && (
<section className="dashboard-description-section">{description}</section>
)}
{dashboardVariables.length > 0 && (
<section className="dashboard-variables">
<DashboardVariablesV2
dashboardId={id}
variables={dashboardVariables}
/>
</section>
)}
<Modal
open={isRenameDashboardOpen}
title="Rename Dashboard"
onOk={onNameChangeHandler}
onCancel={(): void => {
setIsRenameDashboardOpen(false);
}}
rootClassName="rename-dashboard"
footer={
<div className="dashboard-rename">
<Button
type="primary"
icon={<Check size={14} />}
className="rename-btn"
onClick={onNameChangeHandler}
disabled={isRenameLoading}
>
Rename Dashboard
</Button>
<Button
type="text"
icon={<X size={14} />}
className="cancel-btn"
onClick={(): void => setIsRenameDashboardOpen(false)}
>
Cancel
</Button>
</div>
}
>
<div className="dashboard-content">
<Typography.Text className="name-text">Enter a new name</Typography.Text>
<Input
data-testid="dashboard-name"
className="dashboard-name-input"
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</div>
</Modal>
</Card>
);
}
export default DashboardDescriptionV2;

View File

@@ -1,227 +0,0 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncSectionHeader {
display: flex;
align-items: center;
gap: 6px;
align-self: flex-start;
}
.crossPanelSyncInfoIcon {
cursor: help;
color: var(--l3-foreground);
}
.crossPanelSyncTooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 300px;
}
.crossPanelSyncTooltipTitle {
font-size: 14px;
}
.crossPanelSyncTooltipDescription {
font-size: 12px;
line-height: 1.5;
}
.crossPanelSyncTooltipDocLink {
display: flex;
align-items: center;
gap: 4px;
color: var(--primary-background);
font-size: 12px;
margin-top: 4px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -1,357 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesJSONPatchOperationDTO,
TagtypesPostableTagDTO,
} from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
import type { V2Dashboard } from '../../utils';
const { Option } = Select;
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
// AddTags component (which expects string[]), and back on save.
//
// V2 tags require both `key` and `value` to be non-empty server-side
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
}
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
return tagStrings
.map((s) => {
const trimmed = s.trim();
const idx = trimmed.indexOf(':');
if (idx === -1) {return { key: trimmed, value: trimmed };}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
return { key, value: value || key };
})
.filter((t) => t.key.length > 0);
}
function GeneralDashboardSettingsV2({
dashboard,
onRefetch,
}: Props): JSX.Element {
const id = dashboard?.id ?? '';
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(id);
const title = dashboard?.spec?.display?.name ?? '';
const description = dashboard?.spec?.display?.description ?? '';
const image = dashboard?.image || Base64Icons[0];
const tagsAsStrings = useMemo(
() => tagsToStrings(dashboard?.tags ?? []),
[dashboard?.tags],
);
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
const [updatedDescription, setUpdatedDescription] = useState<string>(
description,
);
const [updatedImage, setUpdatedImage] = useState<string>(image);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] = useState<number>(
0,
);
const { t } = useTranslation('common');
const { showErrorModal } = useErrorModal();
// Sync state when dashboard refetches after a save
useEffect(() => {
setUpdatedTitle(title);
setUpdatedDescription(description);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard?.updatedAt]);
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
const replace = (
path: string,
value: unknown,
): DashboardtypesJSONPatchOperationDTO => ({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path,
value,
});
if (updatedTitle !== title) {
ops.push(replace('/spec/display/name', updatedTitle));
}
if (updatedDescription !== description) {
ops.push(replace('/spec/display/description', updatedDescription));
}
if (updatedImage !== image) {
ops.push(replace('/image', updatedImage));
}
if (!isEqual(updatedTags, tagsAsStrings)) {
ops.push(replace('/tags', stringsToTags(updatedTags)));
}
return ops;
};
const onSaveHandler = async (): Promise<void> => {
if (!id) {return;}
const ops = buildPatch();
if (ops.length === 0) {return;}
try {
setIsSaving(true);
await patchDashboardV2({ id }, ops);
toast.success('Dashboard updated');
onRefetch();
} catch (error) {
showErrorModal(error as APIError);
} finally {
setIsSaving(false);
}
};
useEffect(() => {
let n = 0;
const initialValues = [title, description, tagsAsStrings, image];
const updatedValues = [
updatedTitle,
updatedDescription,
updatedTags,
updatedImage,
];
initialValues.forEach((val, index) => {
if (!isEqual(val, updatedValues[index])) {n += 1;}
});
setNumberOfUnsavedChanges(n);
}, [
description,
image,
tagsAsStrings,
title,
updatedDescription,
updatedImage,
updatedTags,
updatedTitle,
]);
const discardHandler = (): void => {
setUpdatedTitle(title);
setUpdatedImage(image);
setUpdatedTags(tagsAsStrings);
setUpdatedDescription(description);
};
return (
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<Space
direction="vertical"
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '21px',
}}
>
<div>
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName={styles.dashboardImageInput}
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className={styles.dashboardNameInput}
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
</section>
</div>
<div>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className={styles.descriptionTextArea}
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<div className={styles.crossPanelSyncSectionHeader}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<Tooltip
title={
<div className={styles.crossPanelSyncTooltipContent}>
<strong className={styles.crossPanelSyncTooltipTitle}>
Cross-Panel Sync
</strong>
<span className={styles.crossPanelSyncTooltipDescription}>
Sync crosshair and tooltip across all the dashboard panels
</span>
<a
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
target="_blank"
rel="noopener noreferrer"
className={styles.crossPanelSyncTooltipDocLink}
>
Learn more
<ExternalLink size={12} />
</a>
</div>
}
placement="top"
mouseEnterDelay={0.5}
>
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
</Tooltip>
</div>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className={styles.footerActionBtns}>
<Button
disabled={isSaving}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className={styles.discardBtn}
>
Discard
</Button>
<Button
style={{ margin: '16px 0' }}
disabled={isSaving}
loading={isSaving}
icon={<Check size={14} />}
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className={styles.saveBtn}
>
{t('save')}
</Button>
</div>
</div>
)}
</div>
);
}
export default GeneralDashboardSettingsV2;

View File

@@ -1,20 +0,0 @@
import { Button as ButtonComponent, Drawer } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
margin-top: 0.5rem;
`;
export const Button = styled(ButtonComponent)`
&&& {
display: flex;
align-items: center;
}
`;
export const DrawerContainer = styled(Drawer)`
.ant-drawer-header {
padding: 0;
border: none;
}
`;

File diff suppressed because one or more lines are too long

View File

@@ -1,46 +0,0 @@
import { Collapse, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
customValue: string;
onChange: (v: string) => void;
error?: string;
}
function CustomFields({ customValue, onChange, error }: Props): JSX.Element {
return (
<VariableItemRow className="variable-custom-section">
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<>
<Input.TextArea
value={customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onChange(e.target.value)}
data-testid="variable-custom-value-v2"
/>
{error ? (
<div>
<Typography.Text color="warning">{error}</Typography.Text>
</div>
) : null}
</>
),
},
]}
/>
</VariableItemRow>
);
}
export default CustomFields;

View File

@@ -1,74 +0,0 @@
import { useCallback, useMemo } from 'react';
import DynamicVariable from 'container/DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/DynamicVariable/DynamicVariable';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
interface Props {
dynamicName: string;
dynamicSignal: TelemetrytypesSignalDTO | undefined;
onNameChange: (v: string) => void;
onSignalChange: (v: TelemetrytypesSignalDTO | undefined) => void;
error?: string;
}
// V1 DynamicVariable stores the source as a UI-friendly label:
// 'All telemetry' | 'Logs' | 'Metrics' | 'Traces'. V2 stores the API enum
// signal value: undefined (= all) | 'metrics' | 'traces' | 'logs'. We convert
// at this boundary so the V1 component can stay untouched.
const ALL_TELEMETRY = 'All telemetry';
function signalToV1Source(
signal: TelemetrytypesSignalDTO | undefined,
): string {
if (signal === TelemetrytypesSignalDTO.logs) {return 'Logs';}
if (signal === TelemetrytypesSignalDTO.metrics) {return 'Metrics';}
if (signal === TelemetrytypesSignalDTO.traces) {return 'Traces';}
return ALL_TELEMETRY;
}
function v1SourceToSignal(
source: string,
): TelemetrytypesSignalDTO | undefined {
if (source === 'Logs') {return TelemetrytypesSignalDTO.logs;}
if (source === 'Metrics') {return TelemetrytypesSignalDTO.metrics;}
if (source === 'Traces') {return TelemetrytypesSignalDTO.traces;}
return undefined;
}
function DynamicFields({
dynamicName,
dynamicSignal,
onNameChange,
onSignalChange,
error,
}: Props): JSX.Element {
const v1Value = useMemo(
() => ({ name: dynamicName, value: signalToV1Source(dynamicSignal) }),
[dynamicName, dynamicSignal],
);
const setV1Value: React.Dispatch<
React.SetStateAction<{ name: string; value: string } | undefined>
> = useCallback(
(action) => {
const next =
typeof action === 'function' ? action(v1Value) : action;
if (!next) {return;}
if (next.name !== dynamicName) {onNameChange(next.name);}
const nextSignal = v1SourceToSignal(next.value);
if (nextSignal !== dynamicSignal) {onSignalChange(nextSignal);}
},
[v1Value, dynamicName, dynamicSignal, onNameChange, onSignalChange],
);
return (
<div className="variable-dynamic-section">
<DynamicVariable
setDynamicVariablesSelectedValue={setV1Value}
dynamicVariablesSelectedValue={v1Value}
errorAttributeKeyMessage={error}
/>
</div>
);
}
export default DynamicFields;

View File

@@ -1,43 +0,0 @@
import { Button } from 'antd';
import { Check, X } from '@signozhq/icons';
import { VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
saving: boolean;
canSave: boolean;
onSave: () => void;
onCancel: () => void;
}
function Footer({ saving, canSave, onSave, onCancel }: Props): JSX.Element {
return (
<div className="variable-item-footer">
<VariableItemRow>
<Button
type="default"
onClick={onCancel}
icon={<X size={14} />}
className="footer-btn-discard"
disabled={saving}
data-testid="variable-cancel-v2"
>
Discard
</Button>
<Button
type="primary"
onClick={onSave}
icon={<Check size={14} />}
className="footer-btn-save"
loading={saving}
disabled={!canSave || saving}
data-testid="variable-save-v2"
>
Save Variable
</Button>
</VariableItemRow>
</div>
);
}
export default Footer;

View File

@@ -1,80 +0,0 @@
import type { V2VariableKind } from '../types';
import AllOptionRow from './ListOptions/AllOptionRow';
import CapturingRegexpRow from './ListOptions/CapturingRegexpRow';
import CustomAllValueRow from './ListOptions/CustomAllValueRow';
import DefaultValueRow from './ListOptions/DefaultValueRow';
import MultiSelectRow from './ListOptions/MultiSelectRow';
import SortRow from './ListOptions/SortRow';
interface Props {
kind: V2VariableKind;
allowAllValue: boolean;
allowMultiple: boolean;
sort: string;
defaultValue: string;
customAllValue: string;
capturingRegexp: string;
previewValues: string[];
onAllowAllChange: (v: boolean) => void;
onAllowMultipleChange: (v: boolean) => void;
onSortChange: (v: string) => void;
onDefaultValueChange: (v: string) => void;
onCustomAllValueChange: (v: string) => void;
onCapturingRegexpChange: (v: string) => void;
}
function ListBasicOptions({
kind,
allowAllValue,
allowMultiple,
sort,
defaultValue,
customAllValue,
capturingRegexp,
previewValues,
onAllowAllChange,
onAllowMultipleChange,
onSortChange,
onDefaultValueChange,
onCustomAllValueChange,
onCapturingRegexpChange,
}: Props): JSX.Element {
return (
<>
<SortRow sort={sort} onChange={onSortChange} />
<MultiSelectRow
allowMultiple={allowMultiple}
onChange={(v): void => {
onAllowMultipleChange(v);
if (!v) {onAllowAllChange(false);}
}}
/>
{allowMultiple && kind !== 'DYNAMIC' ? (
<AllOptionRow
allowAllValue={allowAllValue}
onChange={onAllowAllChange}
/>
) : null}
{allowAllValue ? (
<CustomAllValueRow
customAllValue={customAllValue}
onChange={onCustomAllValueChange}
/>
) : null}
{kind === 'QUERY' || kind === 'DYNAMIC' ? (
<CapturingRegexpRow
capturingRegexp={capturingRegexp}
onChange={onCapturingRegexpChange}
/>
) : null}
<DefaultValueRow
kind={kind}
defaultValue={defaultValue}
previewValues={previewValues}
onChange={onDefaultValueChange}
/>
</>
);
}
export default ListBasicOptions;

View File

@@ -1,29 +0,0 @@
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate to @signozhq/ui/switch
import { Switch } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
allowAllValue: boolean;
onChange: (v: boolean) => void;
}
function AllOptionRow({ allowAllValue, onChange }: Props): JSX.Element {
return (
<VariableItemRow className="all-option-section">
<LabelContainer>
<Typography className="typography-variables">
Include an option for ALL values
</Typography>
</LabelContainer>
<Switch
checked={allowAllValue}
onChange={onChange}
data-testid="variable-allow-all-v2"
/>
</VariableItemRow>
);
}
export default AllOptionRow;

View File

@@ -1,43 +0,0 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
capturingRegexp: string;
onChange: (v: string) => void;
}
function CapturingRegexpRow({
capturingRegexp,
onChange,
}: Props): JSX.Element {
return (
<VariableItemRow className="capturing-regexp-section">
<LabelContainer>
<Typography
className="typography-variables"
style={{ display: 'block' }}
>
Capturing regex
</Typography>
<Typography
className="default-value-description"
style={{ display: 'block' }}
>
Regex applied to each value; the first capture group becomes the
selectable option.
</Typography>
</LabelContainer>
<Input
value={capturingRegexp}
placeholder="e.g. env-(.*)-\\d+"
onChange={(e): void => onChange(e.target.value)}
style={{ width: 400 }}
data-testid="variable-capturing-regexp-v2"
/>
</VariableItemRow>
);
}
export default CapturingRegexpRow;

View File

@@ -1,42 +0,0 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
customAllValue: string;
onChange: (v: string) => void;
}
function CustomAllValueRow({
customAllValue,
onChange,
}: Props): JSX.Element {
return (
<VariableItemRow className="custom-all-value-section">
<LabelContainer>
<Typography
className="typography-variables"
style={{ display: 'block' }}
>
Custom &quot;ALL&quot; value
</Typography>
<Typography
className="default-value-description"
style={{ display: 'block' }}
>
Literal value emitted when the user picks ALL (e.g. * or .*).
</Typography>
</LabelContainer>
<Input
value={customAllValue}
placeholder="Leave blank to send the full union of values"
onChange={(e): void => onChange(e.target.value)}
style={{ width: 400 }}
data-testid="variable-custom-all-value-v2"
/>
</VariableItemRow>
);
}
export default CustomAllValueRow;

View File

@@ -1,43 +0,0 @@
import CustomSelect from 'components/NewSelect/CustomSelect';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
import type { V2VariableKind } from '../../types';
interface Props {
kind: V2VariableKind;
defaultValue: string;
previewValues: string[];
onChange: (v: string) => void;
}
function DefaultValueRow({
kind,
defaultValue,
previewValues,
onChange,
}: Props): JSX.Element {
const description =
kind === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value';
return (
<VariableItemRow className="default-value-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
<Typography className="default-value-description">
{description}
</Typography>
</LabelContainer>
<CustomSelect
placeholder="Select a default value"
value={defaultValue}
onChange={(v): void => onChange((v as string) ?? '')}
options={previewValues.map((v) => ({ label: v, value: v }))}
/>
</VariableItemRow>
);
}
export default DefaultValueRow;

View File

@@ -1,29 +0,0 @@
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate to @signozhq/ui/switch
import { Switch } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
allowMultiple: boolean;
onChange: (v: boolean) => void;
}
function MultiSelectRow({ allowMultiple, onChange }: Props): JSX.Element {
return (
<VariableItemRow className="multiple-values-section">
<LabelContainer>
<Typography className="typography-variables">
Enable multiple values to be checked
</Typography>
</LabelContainer>
<Switch
checked={allowMultiple}
onChange={onChange}
data-testid="variable-allow-multiple-v2"
/>
</VariableItemRow>
);
}
export default MultiSelectRow;

View File

@@ -1,29 +0,0 @@
import { Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
import { SORT_OPTIONS } from '../../types';
interface Props {
sort: string;
onChange: (v: string) => void;
}
function SortRow({ sort, onChange }: Props): JSX.Element {
return (
<VariableItemRow className="sort-values-section">
<LabelContainer>
<Typography className="typography-variables">Sort Values</Typography>
</LabelContainer>
<Select
value={sort}
onChange={onChange}
options={SORT_OPTIONS}
className="sort-input"
data-testid="variable-sort-v2"
/>
</VariableItemRow>
);
}
export default SortRow;

View File

@@ -1,59 +0,0 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
name: string;
description: string;
onNameChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
nameError?: string;
}
function NameDisplay({
name,
description,
onNameChange,
onDescriptionChange,
nameError,
}: Props): JSX.Element {
return (
<>
<VariableItemRow className="variable-name-section">
<LabelContainer>
<Typography className="typography-variables">Name</Typography>
</LabelContainer>
<div>
<Input
placeholder="Unique name of the variable"
value={name}
className="name-input"
onChange={(e): void => onNameChange(e.target.value)}
data-testid="variable-name-v2"
/>
{nameError ? (
<div>
<Typography.Text color="warning">{nameError}</Typography.Text>
</div>
) : null}
</div>
</VariableItemRow>
<VariableItemRow className="variable-description-section">
<LabelContainer>
<Typography className="typography-variables">Description</Typography>
</LabelContainer>
<Input.TextArea
value={description}
placeholder="Enter a description for the variable"
className="description-input"
rows={3}
onChange={(e): void => onDescriptionChange(e.target.value)}
data-testid="variable-description-v2"
/>
</VariableItemRow>
</>
);
}
export default NameDisplay;

View File

@@ -1,33 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { orange } from '@ant-design/colors';
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
previewValues: string[];
error?: string | null;
}
function PreviewValues({ previewValues, error }: Props): JSX.Element {
return (
<VariableItemRow className="variables-preview-section">
<LabelContainer style={{ width: '100%' }}>
<Typography className="typography-variables">
Preview of Values
</Typography>
</LabelContainer>
<div className="preview-values">
{error ? (
<Typography style={{ color: orange[5] }}>{error}</Typography>
) : (
previewValues.map((v, idx) => (
<Badge key={`${v}${idx}`}>{v.toString()}</Badge>
))
)}
</div>
</VariableItemRow>
);
}
export default PreviewValues;

View File

@@ -1,66 +0,0 @@
import { Button } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import Editor from 'components/Editor';
import { LabelContainer } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
queryValue: string;
onChange: (v: string) => void;
onTestRun?: () => void;
testRunLoading?: boolean;
error?: string;
}
function QueryFields({
queryValue,
onChange,
onTestRun,
testRunLoading,
error,
}: Props): JSX.Element {
return (
<div className="query-container">
<LabelContainer>
<Typography>Query</Typography>
</LabelContainer>
<div style={{ flex: 1, position: 'relative' }}>
<Editor
language="sql"
value={queryValue}
onChange={onChange}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: { enabled: false },
}}
/>
{onTestRun ? (
<Button
type="primary"
size="small"
onClick={onTestRun}
style={{ position: 'absolute', bottom: 0 }}
loading={testRunLoading}
>
Test Run Query
</Button>
) : null}
{error ? (
<div>
<Typography.Text color="warning">{error}</Typography.Text>
</div>
) : null}
</div>
</div>
);
}
export default QueryFields;

View File

@@ -1,37 +0,0 @@
import { Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
interface Props {
textValue: string;
onChange: (v: string) => void;
error?: string;
}
function TextFields({ textValue, onChange, error }: Props): JSX.Element {
return (
<VariableItemRow className="variable-textbox-section">
<LabelContainer>
<Typography className="typography-variables">Default Value</Typography>
</LabelContainer>
<div>
<Input
value={textValue}
className="default-input"
onChange={(e): void => onChange(e.target.value)}
placeholder="Enter a default value (if any)..."
style={{ width: 400 }}
data-testid="variable-text-value-v2"
/>
{error ? (
<div>
<Typography.Text color="warning">{error}</Typography.Text>
</div>
) : null}
</div>
</VariableItemRow>
);
}
export default TextFields;

View File

@@ -1,127 +0,0 @@
import { Button } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { LabelContainer, VariableItemRow } from '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/styles';
import type { V2VariableKind } from '../types';
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
interface Props {
kind: V2VariableKind;
onChange: (kind: V2VariableKind) => void;
}
function TypeSelector({ kind, onChange }: Props): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<VariableItemRow className="variable-type-section">
<LabelContainer className="variable-type-label-container">
<Typography className="typography-variables">Variable Type</Typography>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
/>
</LabelContainer>
<div className="variable-type-btn-group">
<Button
type="text"
icon={<Pyramid size={14} />}
className={cx(
'variable-type-btn',
kind === 'DYNAMIC' ? 'selected' : '',
)}
onClick={(): void => onChange('DYNAMIC')}
data-testid="variable-type-dynamic-v2"
>
Dynamic
<Badge className="sidenav-beta-tag" color="robin">
Beta
</Badge>
</Button>
<Button
type="text"
icon={<ClipboardType size={14} />}
className={cx(
'variable-type-btn',
kind === 'TEXT' ? 'selected' : '',
)}
onClick={(): void => onChange('TEXT')}
data-testid="variable-type-text-v2"
>
Textbox
</Button>
<Button
type="text"
icon={<LayoutList size={14} />}
className={cx(
'variable-type-btn',
kind === 'CUSTOM' ? 'selected' : '',
)}
onClick={(): void => onChange('CUSTOM')}
data-testid="variable-type-custom-v2"
>
Custom
</Button>
<Button
type="text"
icon={<DatabaseZap size={14} />}
className={cx(
'variable-type-btn',
kind === 'QUERY' ? 'selected' : '',
)}
onClick={(): void => onChange('QUERY')}
data-testid="variable-type-query-v2"
>
Query
<Badge className="sidenav-beta-tag" color="warning">
Not Recommended
</Badge>
<div onClick={(e): void => e.stopPropagation()}>
<TextToolTip
text="Learn why we don't recommend"
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
urlText="here"
useFilledIcon={false}
outlinedIcon={
<Info
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
/>
</div>
</Button>
</div>
</VariableItemRow>
);
}
export default TypeSelector;

View File

@@ -1,188 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from 'antd';
import { ArrowLeft } from '@signozhq/icons';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { draftToVariableDTO, validateDraft } from '../draft';
import type { SaveCallback, VariableDraft, V2VariableKind } from '../types';
import CustomFields from './CustomFields';
import DynamicFields from './DynamicFields';
import Footer from './Footer';
import ListBasicOptions from './ListBasicOptions';
import NameDisplay from './NameDisplay';
import PreviewValues from './PreviewValues';
import QueryFields from './QueryFields';
import TextFields from './TextFields';
import TypeSelector from './TypeSelector';
import '../../../../DashboardContainer/DashboardSettings/DashboardVariableSettings/VariableItem/VariableItem.styles.scss';
interface Props {
initialDraft: VariableDraft;
existingNames: string[];
saving: boolean;
onSave: SaveCallback;
onCancel: () => void;
}
/**
* Editor for a single V2 variable.
*
* Type-switch contract: changing `kind` does NOT clear the per-kind fields
* the user already typed. They remain in local state and are restored if the
* user navigates back to the same kind. Only the fields relevant to the
* active `kind` are written into the V2 envelope on save (see
* `draftToVariableDTO`).
*/
function VariableItem({
initialDraft,
existingNames,
saving,
onSave,
onCancel,
}: Props): JSX.Element {
const [draft, setDraft] = useState<VariableDraft>(initialDraft);
const update = useCallback(
<K extends keyof VariableDraft>(key: K, value: VariableDraft[K]): void => {
setDraft((prev) => ({ ...prev, [key]: value }));
},
[],
);
const onKindChange = useCallback(
(kind: V2VariableKind): void => {
// Retain every other field — only the discriminator changes.
update('kind', kind);
},
[update],
);
const namesExcludingSelf = useMemo(
() => existingNames.filter((n) => n !== initialDraft.name),
[existingNames, initialDraft.name],
);
const validationError = useMemo(
() => validateDraft(draft, namesExcludingSelf),
[draft, namesExcludingSelf],
);
// Local preview values — currently populated only for CUSTOM (CSV parse).
// Query / Dynamic previews are wired in the variable execution subsystem.
const previewValues = useMemo<string[]>(() => {
if (draft.kind === 'CUSTOM') {
return commaValuesParser(draft.customValue).map((v) => String(v));
}
return [];
}, [draft.kind, draft.customValue]);
const handleSave = useCallback((): void => {
if (validationError) {return;}
onSave(draftToVariableDTO(draft));
}, [draft, validationError, onSave]);
const errorFor = (
field: NonNullable<typeof validationError>['field'],
): string | undefined => {
if (validationError && validationError.field === field) {
return validationError.message;
}
return undefined;
};
const showListOptions =
draft.kind === 'QUERY' || draft.kind === 'CUSTOM' || draft.kind === 'DYNAMIC';
return (
<>
<div className="variable-item-container">
<div className="all-variables">
<Button
type="text"
className="all-variables-btn"
icon={<ArrowLeft size={14} />}
onClick={onCancel}
>
All variables
</Button>
</div>
<div className="variable-item-content">
<NameDisplay
name={draft.name}
description={draft.displayName}
onNameChange={(v): void => update('name', v)}
onDescriptionChange={(v): void => update('displayName', v)}
nameError={errorFor('name')}
/>
<TypeSelector kind={draft.kind} onChange={onKindChange} />
{draft.kind === 'DYNAMIC' ? (
<DynamicFields
dynamicName={draft.dynamicName}
dynamicSignal={draft.dynamicSignal}
onNameChange={(v): void => update('dynamicName', v)}
onSignalChange={(v): void => update('dynamicSignal', v)}
error={errorFor('dynamicName')}
/>
) : null}
{draft.kind === 'QUERY' ? (
<QueryFields
queryValue={draft.queryValue}
onChange={(v): void => update('queryValue', v)}
error={errorFor('queryValue')}
/>
) : null}
{draft.kind === 'CUSTOM' ? (
<CustomFields
customValue={draft.customValue}
onChange={(v): void => update('customValue', v)}
error={errorFor('customValue')}
/>
) : null}
{draft.kind === 'TEXT' ? (
<TextFields
textValue={draft.textValue}
onChange={(v): void => update('textValue', v)}
error={errorFor('textValue')}
/>
) : null}
{showListOptions ? (
<>
<PreviewValues previewValues={previewValues} />
<ListBasicOptions
kind={draft.kind}
allowAllValue={draft.allowAllValue}
allowMultiple={draft.allowMultiple}
sort={draft.sort}
defaultValue={draft.defaultValue}
customAllValue={draft.customAllValue}
capturingRegexp={draft.capturingRegexp}
previewValues={previewValues}
onAllowAllChange={(v): void => update('allowAllValue', v)}
onAllowMultipleChange={(v): void => update('allowMultiple', v)}
onSortChange={(v): void => update('sort', v)}
onDefaultValueChange={(v): void => update('defaultValue', v)}
onCustomAllValueChange={(v): void =>
update('customAllValue', v)
}
onCapturingRegexpChange={(v): void =>
update('capturingRegexp', v)
}
/>
</>
) : null}
</div>
</div>
<Footer
saving={saving}
canSave={!validationError}
onSave={handleSave}
onCancel={onCancel}
/>
</>
);
}
export default VariableItem;

View File

@@ -1,58 +0,0 @@
import React from 'react';
import { CSS } from '@dnd-kit/utilities';
import { useSortable } from '@dnd-kit/sortable';
import type { RowProps } from 'antd';
import { GripVertical } from '@signozhq/icons';
/**
* Sortable table row that injects a drag handle into the `name` cell —
* matches V1's [DashboardVariableSettings/index.tsx:31](TableRow component).
*/
function TableRow({ children, ...props }: RowProps): JSX.Element {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
// @ts-expect-error — antd Table's RowProps doesn't type the data-row-key it injects
id: props['data-row-key'],
});
const style: React.CSSProperties = {
...props.style,
transform: CSS.Transform.toString(transform && { ...transform, scaleY: 1 }),
transition,
...(isDragging ? { position: 'relative', zIndex: 9999 } : {}),
};
return (
<tr {...props} ref={setNodeRef} style={style} {...attributes}>
{React.Children.map(children, (child) => {
const childElement = child as React.ReactElement;
if (childElement.key === 'name') {
return React.cloneElement(childElement, {
key: 'name-with-drag',
children: (
<div className="variable-name-drag">
<GripVertical
ref={setActivatorNodeRef as unknown as React.Ref<SVGSVGElement>}
style={{ touchAction: 'none', cursor: 'move' }}
size="md"
{...listeners}
/>
{child}
</div>
),
});
}
return childElement;
})}
</tr>
);
}
export default TableRow;

View File

@@ -1,53 +0,0 @@
import { Button, Space } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { PenLine, Trash2 } from '@signozhq/icons';
interface Props {
description: string;
kindLabel: string;
onEdit: () => void;
onDelete: () => void;
}
/**
* Right cell of the variable table — description text + edit/delete actions.
* Variable name + kind tag render in the left cell via column config.
*/
function VariableRow({
description,
kindLabel,
onEdit,
onDelete,
}: Props): JSX.Element {
return (
<div className="variable-description-actions">
<Typography.Text className="variable-description">
{description}
</Typography.Text>
<Space className="actions-btns">
<Badge color="sienna" variant="outline">
{kindLabel}
</Badge>
<Button
type="text"
onClick={onEdit}
className="edit-variable-button"
data-testid="variable-edit-v2"
>
<PenLine size={14} />
</Button>
<Button
type="text"
onClick={onDelete}
className="delete-variable-button"
data-testid="variable-delete-v2"
>
<Trash2 size={14} />
</Button>
</Space>
</div>
);
}
export default VariableRow;

View File

@@ -1,119 +0,0 @@
import { Empty, Table } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { arrayMove, SortableContext } from '@dnd-kit/sortable';
import type { DashboardtypesVariableDTO } from 'api/generated/services/sigNoz.schemas';
import { getVariableKindLabel, getVariableName } from '../draft';
import TableRow from './TableRow';
import VariableRow from './VariableRow';
import '../../../../DashboardContainer/DashboardSettings/DashboardSettings.styles.scss';
interface TableEntry {
key: string;
name: string;
description: string;
kindLabel: string;
index: number;
}
interface Props {
variables: DashboardtypesVariableDTO[];
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onReorder: (next: DashboardtypesVariableDTO[]) => void;
}
function VariableList({
variables,
onEdit,
onDelete,
onReorder,
}: Props): JSX.Element {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 1 },
}),
);
if (variables.length === 0) {
return (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<Typography.Text>
No variables yet. Click &quot;Add variable&quot; to create one.
</Typography.Text>
}
/>
);
}
const dataSource: TableEntry[] = variables.map((v, idx) => ({
key: getVariableName(v) || String(idx),
name: getVariableName(v),
description:
(v.spec as { display?: { name?: string } })?.display?.name ?? '',
kindLabel: getVariableKindLabel(v),
index: idx,
}));
const columns = [
{
title: 'Variable',
dataIndex: 'name',
key: 'name',
width: '50%',
},
{
title: 'Description',
key: 'description',
width: '50%',
render: (entry: TableEntry): JSX.Element => (
<VariableRow
description={entry.description}
kindLabel={entry.kindLabel}
onEdit={(): void => onEdit(entry.index)}
onDelete={(): void => onDelete(entry.index)}
/>
),
},
];
const onDragEnd = ({ active, over }: DragEndEvent): void => {
if (!over || active.id === over.id) {return;}
const fromIdx = dataSource.findIndex((d) => d.key === active.id);
const toIdx = dataSource.findIndex((d) => d.key === over.id);
if (fromIdx < 0 || toIdx < 0) {return;}
onReorder(arrayMove(variables, fromIdx, toIdx));
};
return (
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={onDragEnd}
>
<SortableContext items={dataSource.map((d) => d.key)}>
<Table
components={{ body: { row: TableRow } }}
rowKey="key"
columns={columns}
pagination={false}
dataSource={dataSource}
className="dashboard-variable-settings-table"
/>
</SortableContext>
</DndContext>
);
}
export default VariableList;

View File

@@ -1,202 +0,0 @@
import { v4 as generateUUID } from 'uuid';
import type {
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardtypesListVariableSpecDTO,
DashboardTextVariableSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { V2VariableKind, VariableDraft } from './types';
export function emptyDraft(): VariableDraft {
return {
id: generateUUID(),
kind: 'QUERY',
name: '',
displayName: '',
allowAllValue: false,
allowMultiple: false,
sort: 'none',
defaultValue: '',
customAllValue: '',
capturingRegexp: '',
queryValue: '',
customValue: '',
dynamicName: '',
dynamicSignal: undefined,
textValue: '',
};
}
/**
* Hydrate the relevant slot from a V2 envelope; other slots stay empty.
*/
export function variableDTOToDraft(
dto: DashboardtypesVariableDTO,
): VariableDraft {
const base = emptyDraft();
if (dto.kind === 'TextVariable') {
const spec = dto.spec as DashboardTextVariableSpecDTO;
return {
...base,
kind: 'TEXT',
name: spec?.name ?? '',
displayName: spec?.display?.name ?? '',
textValue: spec?.value ?? '',
};
}
// ListVariable
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const pluginKind = spec?.plugin?.kind;
let kind: V2VariableKind = 'QUERY';
if (pluginKind === 'signoz/DynamicVariable') {kind = 'DYNAMIC';}
else if (pluginKind === 'signoz/CustomVariable') {kind = 'CUSTOM';}
else if (pluginKind === 'signoz/QueryVariable') {kind = 'QUERY';}
const draft: VariableDraft = {
...base,
kind,
name: spec?.name ?? '',
displayName: spec?.display?.name ?? '',
allowAllValue: !!spec?.allowAllValue,
allowMultiple: !!spec?.allowMultiple,
sort: spec?.sort ?? 'none',
defaultValue: typeof spec?.defaultValue === 'string' ? spec.defaultValue : '',
customAllValue: spec?.customAllValue ?? '',
capturingRegexp: spec?.capturingRegexp ?? '',
};
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
if (kind === 'QUERY') {
draft.queryValue = (pluginSpec?.queryValue as string) ?? '';
} else if (kind === 'CUSTOM') {
draft.customValue = (pluginSpec?.customValue as string) ?? '';
} else if (kind === 'DYNAMIC') {
draft.dynamicName = (pluginSpec?.name as string) ?? '';
draft.dynamicSignal = pluginSpec?.signal as TelemetrytypesSignalDTO | undefined;
}
return draft;
}
/**
* Serialize draft to a V2 envelope, reading ONLY the fields relevant to the
* active kind. Other fields the user touched stay in React state and are
* silently dropped.
*/
export function draftToVariableDTO(
draft: VariableDraft,
): DashboardtypesVariableDTO {
const display = draft.displayName ? { name: draft.displayName } : undefined;
if (draft.kind === 'TEXT') {
return ({
kind: 'TextVariable',
spec: {
name: draft.name,
display,
value: draft.textValue,
},
} as unknown) as DashboardtypesVariableDTO;
}
let plugin: DashboardtypesVariablePluginDTO | undefined;
if (draft.kind === 'QUERY') {
plugin = ({
kind: 'signoz/QueryVariable',
spec: { queryValue: draft.queryValue },
} as unknown) as DashboardtypesVariablePluginDTO;
} else if (draft.kind === 'CUSTOM') {
plugin = ({
kind: 'signoz/CustomVariable',
spec: { customValue: draft.customValue },
} as unknown) as DashboardtypesVariablePluginDTO;
} else if (draft.kind === 'DYNAMIC') {
plugin = ({
kind: 'signoz/DynamicVariable',
spec: {
name: draft.dynamicName,
signal: draft.dynamicSignal,
},
} as unknown) as DashboardtypesVariablePluginDTO;
}
const spec: DashboardtypesListVariableSpecDTO = {
name: draft.name,
display,
allowAllValue: draft.allowAllValue,
allowMultiple: draft.allowMultiple,
sort: draft.sort,
plugin,
// VariableDefaultValueDTO is an open `{[key]: unknown}` shape, so a bare
// string isn't structurally assignable. We cast at the boundary.
defaultValue: draft.defaultValue
? ((draft.defaultValue as unknown) as DashboardtypesListVariableSpecDTO['defaultValue'])
: undefined,
customAllValue: draft.customAllValue || undefined,
capturingRegexp: draft.capturingRegexp || undefined,
};
return ({
kind: 'ListVariable',
spec,
} as unknown) as DashboardtypesVariableDTO;
}
export interface DraftValidationError {
field:
| 'name'
| 'queryValue'
| 'customValue'
| 'dynamicName'
| 'textValue'
| 'cycle';
message: string;
}
export function validateDraft(
draft: VariableDraft,
existingNames: string[],
): DraftValidationError | null {
const trimmedName = draft.name.trim();
if (!trimmedName) {
return { field: 'name', message: 'Variable name is required' };
}
if (/\s/.test(trimmedName)) {
return { field: 'name', message: 'Variable name cannot contain whitespace' };
}
if (existingNames.includes(trimmedName)) {
return { field: 'name', message: 'Variable name already exists' };
}
if (draft.kind === 'QUERY' && !draft.queryValue.trim()) {
return { field: 'queryValue', message: 'Query is required' };
}
if (draft.kind === 'CUSTOM' && !draft.customValue.trim()) {
return { field: 'customValue', message: 'Custom values are required' };
}
if (draft.kind === 'DYNAMIC' && !draft.dynamicName.trim()) {
return { field: 'dynamicName', message: 'Attribute name is required' };
}
if (draft.kind === 'TEXT' && !draft.textValue.trim()) {
return { field: 'textValue', message: 'Default text value is required' };
}
return null;
}
export function getVariableName(dto: DashboardtypesVariableDTO): string {
if (dto.kind === 'TextVariable') {
return (dto.spec as DashboardTextVariableSpecDTO)?.name ?? '';
}
return (dto.spec as DashboardtypesListVariableSpecDTO)?.name ?? '';
}
export function getVariableKindLabel(dto: DashboardtypesVariableDTO): string {
if (dto.kind === 'TextVariable') {return 'Text';}
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const pluginKind = spec?.plugin?.kind;
if (pluginKind === 'signoz/DynamicVariable') {return 'Dynamic';}
if (pluginKind === 'signoz/CustomVariable') {return 'Custom';}
return 'Query';
}

View File

@@ -1,155 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from 'antd';
import { Plus } from '@signozhq/icons';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import type {
DashboardtypesJSONPatchOperationDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import {
buildDependencyMap,
detectCycle,
} from '../../DashboardVariablesV2/dependencyGraph';
import type { V2Dashboard } from '../../utils';
import {
emptyDraft,
getVariableName,
variableDTOToDraft,
} from './draft';
import type { VariableDraft } from './types';
import VariableItem from './VariableItem';
import VariableList from './VariableList';
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
type EditorState =
| { kind: 'closed' }
| { kind: 'add'; draft: VariableDraft }
| { kind: 'edit'; index: number; draft: VariableDraft };
function VariablesSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
const dashboardId = dashboard?.id ?? '';
const variables = useMemo<DashboardtypesVariableDTO[]>(
() => dashboard?.spec?.variables ?? [],
[dashboard?.spec?.variables],
);
const [editor, setEditor] = useState<EditorState>({ kind: 'closed' });
const [saving, setSaving] = useState<boolean>(false);
const { showErrorModal } = useErrorModal();
const existingNames = useMemo(() => variables.map(getVariableName), [
variables,
]);
const persistVariables = useCallback(
async (next: DashboardtypesVariableDTO[]): Promise<void> => {
if (!dashboardId) {return;}
const cycle = detectCycle(buildDependencyMap(next));
if (cycle.hasCycle) {
toast.error(
`Cyclic variable dependency: ${cycle.cycle?.join(' → ')}`,
);
return;
}
setSaving(true);
try {
const patch: DashboardtypesJSONPatchOperationDTO[] = [
{
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/variables',
value: next,
},
];
await patchDashboardV2({ id: dashboardId }, patch);
toast.success('Variables updated');
onRefetch();
setEditor({ kind: 'closed' });
} catch (error) {
showErrorModal(error as APIError);
} finally {
setSaving(false);
}
},
[dashboardId, onRefetch, showErrorModal],
);
const handleSave = useCallback(
async (dto: DashboardtypesVariableDTO): Promise<void> => {
if (editor.kind === 'add') {
await persistVariables([...variables, dto]);
} else if (editor.kind === 'edit') {
const next = variables.slice();
next[editor.index] = dto;
await persistVariables(next);
}
},
[editor, variables, persistVariables],
);
const handleDelete = useCallback(
async (index: number): Promise<void> => {
const next = variables.slice();
next.splice(index, 1);
await persistVariables(next);
},
[variables, persistVariables],
);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 16,
}}
>
{editor.kind === 'closed' ? (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
icon={<Plus size={14} />}
onClick={(): void =>
setEditor({ kind: 'add', draft: emptyDraft() })
}
data-testid="add-variable-v2"
>
Add variable
</Button>
</div>
<VariableList
variables={variables}
onEdit={(index): void =>
setEditor({
kind: 'edit',
index,
draft: variableDTOToDraft(variables[index]),
})
}
onDelete={handleDelete}
onReorder={persistVariables}
/>
</>
) : (
<VariableItem
initialDraft={editor.draft}
existingNames={existingNames}
saving={saving}
onSave={handleSave}
onCancel={(): void => setEditor({ kind: 'closed' })}
/>
)}
</div>
);
}
export default VariablesSettingsV2;

View File

@@ -1,61 +0,0 @@
import type {
DashboardtypesVariableDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
export type V2VariableKind = 'QUERY' | 'CUSTOM' | 'DYNAMIC' | 'TEXT';
/**
* Internal editor state. Holds every per-kind field so that switching `kind`
* does not discard user input. Only the fields relevant to the active kind
* are written into the resulting V2 envelope on save.
*/
export interface VariableDraft {
id: string; // local identifier for list keys; not persisted to V2
kind: V2VariableKind;
name: string;
displayName: string;
// Shared by all List variants (QUERY / CUSTOM / DYNAMIC)
allowAllValue: boolean;
allowMultiple: boolean;
sort: string;
defaultValue: string;
// V2-only: literal value emitted when the user picks "ALL"
customAllValue: string;
// V2-only: regex applied to query/dynamic results to extract the actual value
capturingRegexp: string;
// QUERY
queryValue: string;
// CUSTOM
customValue: string;
// DYNAMIC
dynamicName: string;
dynamicSignal: TelemetrytypesSignalDTO | undefined;
// TEXT
textValue: string;
}
export type SaveCallback = (dto: DashboardtypesVariableDTO) => void;
export const VARIABLE_KIND_LABEL: Record<V2VariableKind, string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
DYNAMIC: 'Dynamic',
TEXT: 'Text',
};
// V2 supports a finer sort taxonomy than V1: separate alphabetical and
// numerical orderings (V1 only exposed Disabled / Ascending / Descending).
// Values match the strings used in the perses fixture and backend.
export const SORT_OPTIONS: { label: string; value: string }[] = [
{ label: 'Disabled', value: 'none' },
{ label: 'Alphabetical ascending', value: 'alphabetical-asc' },
{ label: 'Alphabetical descending', value: 'alphabetical-desc' },
{ label: 'Numerical ascending', value: 'numerical-asc' },
{ label: 'Numerical descending', value: 'numerical-desc' },
];

View File

@@ -1,70 +0,0 @@
import { Button, Empty, Tabs } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Braces, Globe, Table } from '@signozhq/icons';
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
import GeneralDashboardSettingsV2 from './General';
import VariablesSettingsV2 from './Variables';
import type { V2Dashboard } from '../utils';
interface Props {
dashboard: V2Dashboard | undefined;
onRefetch: () => void;
}
function Placeholder({ message }: { message: string }): JSX.Element {
return (
<div style={{ padding: 24 }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<Typography.Text>{message}</Typography.Text>}
/>
</div>
);
}
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
const items = [
{
label: (
<Button type="text" icon={<Table size={14} />}>
General
</Button>
),
key: 'general',
children: (
<GeneralDashboardSettingsV2
dashboard={dashboard}
onRefetch={onRefetch}
/>
),
},
{
label: (
<Button type="text" icon={<Braces size={14} />}>
Variables
</Button>
),
key: 'variables',
children: (
<VariablesSettingsV2 dashboard={dashboard} onRefetch={onRefetch} />
),
},
{
label: (
<Button type="text" icon={<Globe size={14} />}>
Publish
</Button>
),
key: 'public-dashboard',
children: (
<Placeholder message="V2 public dashboard publishing coming next." />
),
},
];
return <Tabs items={items} />;
}
export default DashboardSettingsV2;

View File

@@ -1,135 +0,0 @@
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { referencedVariables } from './substitution';
/**
* Extracts the strings on a variable that may contain `$var` references —
* i.e. the dependency edges out of this variable.
*
* Currently only QUERY variables produce dependencies (their `queryValue`
* may reference other variables). CUSTOM and DYNAMIC plugin specs don't
* embed substitutable strings, and TEXT variables are leaf nodes.
*/
function dependencyStrings(dto: DashboardtypesVariableDTO): string[] {
if (dto.kind !== 'ListVariable') {return [];}
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const pluginKind = spec?.plugin?.kind;
const pluginSpec = spec?.plugin?.spec as Record<string, unknown> | undefined;
if (pluginKind === 'signoz/QueryVariable') {
return [String(pluginSpec?.queryValue ?? '')];
}
return [];
}
function nameOf(dto: DashboardtypesVariableDTO): string {
return (dto.spec as { name?: string })?.name ?? '';
}
/**
* Direct dependencies for each variable (name → set of names it references).
*/
export function buildDependencyMap(
variables: DashboardtypesVariableDTO[],
): Record<string, Set<string>> {
const knownNames = new Set(variables.map(nameOf).filter(Boolean));
const deps: Record<string, Set<string>> = {};
variables.forEach((v) => {
const name = nameOf(v);
if (!name) {return;}
const refs = new Set<string>();
dependencyStrings(v).forEach((s) => {
referencedVariables(s).forEach((ref) => {
if (ref !== name && knownNames.has(ref)) {refs.add(ref);}
});
});
deps[name] = refs;
});
return deps;
}
export interface CycleResult {
hasCycle: boolean;
cycle?: string[];
}
/**
* Detect a cycle via DFS; returns the participating names in traversal order.
* Used at save time and to guard re-resolution.
*/
export function detectCycle(
deps: Record<string, Set<string>>,
): CycleResult {
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color: Record<string, number> = {};
const stack: string[] = [];
const names = Object.keys(deps);
names.forEach((n) => {
color[n] = WHITE;
});
function visit(node: string): string[] | null {
color[node] = GRAY;
stack.push(node);
for (const next of deps[node] ?? []) {
if (color[next] === GRAY) {
const idx = stack.indexOf(next);
return stack.slice(idx).concat(next);
}
if (color[next] === WHITE) {
const found = visit(next);
if (found) {return found;}
}
}
stack.pop();
color[node] = BLACK;
return null;
}
for (const n of names) {
if (color[n] === WHITE) {
const cycle = visit(n);
if (cycle) {return { hasCycle: true, cycle };}
}
}
return { hasCycle: false };
}
/**
* Kahn's algorithm — returns variable names in dependency order
* (dependencies first). If there's a cycle the result excludes the
* participating nodes; combine with `detectCycle` for validation.
*/
export function topoSort(
deps: Record<string, Set<string>>,
): string[] {
const incoming: Record<string, number> = {};
const downstream: Record<string, string[]> = {};
Object.keys(deps).forEach((n) => {
incoming[n] = 0;
downstream[n] = [];
});
Object.entries(deps).forEach(([n, refs]) => {
refs.forEach((ref) => {
incoming[n] += 1;
downstream[ref] = downstream[ref] ?? [];
downstream[ref].push(n);
});
});
const queue: string[] = Object.keys(incoming).filter((n) => incoming[n] === 0);
const out: string[] = [];
while (queue.length > 0) {
const n = queue.shift() as string;
out.push(n);
(downstream[n] ?? []).forEach((next) => {
incoming[next] -= 1;
if (incoming[next] === 0) {queue.push(next);}
});
}
return out;
}

View File

@@ -1,81 +0,0 @@
import { useEffect, useMemo } from 'react';
import type {
DashboardtypesListVariableSpecDTO,
DashboardTextVariableSpecDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { buildDependencyMap, detectCycle, topoSort } from './dependencyGraph';
import VariableSelector from './selectors/VariableSelector';
import { useVariableSelectionStore } from './state/selectionStore';
import '../../DashboardContainer/DashboardVariablesSelection/DashboardVariableSelection.styles.scss';
interface Props {
dashboardId: string;
variables: DashboardtypesVariableDTO[] | undefined;
}
function nameOf(v: DashboardtypesVariableDTO): string {
return (
(v.spec as DashboardtypesListVariableSpecDTO | DashboardTextVariableSpecDTO)
?.name ?? ''
);
}
function kindHint(v: DashboardtypesVariableDTO): 'list' | 'text' {
return v.kind === 'TextVariable' ? 'text' : 'list';
}
function DashboardVariablesV2({ dashboardId, variables }: Props): JSX.Element | null {
const hydrate = useVariableSelectionStore((s) => s.hydrate);
// Build hints map (variable-name → list/text) so the store can decode the URL.
const hints = useMemo<Record<string, 'list' | 'text'>>(() => {
const out: Record<string, 'list' | 'text'> = {};
(variables ?? []).forEach((v) => {
const n = nameOf(v);
if (n) {out[n] = kindHint(v);}
});
return out;
}, [variables]);
useEffect(() => {
if (!dashboardId) {return;}
hydrate(dashboardId, hints);
}, [dashboardId, hints, hydrate]);
// Sort variables in dependency order so dependent resolvers see fresh
// selections from their parents. (Render order doesn't affect the React
// Query cache but it does affect *visual* order.)
const ordered = useMemo(() => {
if (!variables?.length) {return [];}
const deps = buildDependencyMap(variables);
const cycle = detectCycle(deps);
if (cycle.hasCycle) {
// Render in the original order; the cycle is surfaced separately at save
// time via validateDraft. Resolution will still execute; it just won't
// converge.
return variables;
}
const order = topoSort(deps);
const byName: Record<string, DashboardtypesVariableDTO> = {};
variables.forEach((v) => {
const n = nameOf(v);
if (n) {byName[n] = v;}
});
return order.map((n) => byName[n]).filter(Boolean);
}, [variables]);
if (!variables || variables.length === 0) {return null;}
return (
<div className="variables-container">
{ordered.map((v) => (
<VariableSelector key={nameOf(v)} variable={v} />
))}
</div>
);
}
export default DashboardVariablesV2;

View File

@@ -1,29 +0,0 @@
/**
* Applies V2 `capturingRegexp` to each value: if the regex matches and has a
* capture group, replace the value with the first capture; otherwise keep
* the raw value. Invalid regex silently passes values through.
*
* Empty results (no match at all) are filtered out — they would be useless
* as selectable options.
*/
export function applyCapturingRegexp(
values: string[],
pattern: string | undefined | null,
): string[] {
if (!pattern) {return values;}
let re: RegExp;
try {
re = new RegExp(pattern);
} catch {
return values;
}
const out: string[] = [];
values.forEach((v) => {
const m = re.exec(v);
if (!m) {return;}
out.push(m[1] !== undefined ? m[1] : m[0]);
});
return out;
}

View File

@@ -1,37 +0,0 @@
/**
* Apply V2 sort modes to a resolved value list.
*
* Sort values come from the perses spec — `none`, `alphabetical-asc`,
* `alphabetical-desc`, `numerical-asc`, `numerical-desc`. Numerical sort
* falls back to string compare for values that aren't numbers so we never
* throw away non-numeric entries.
*/
export function applySort(
values: string[],
sort: string | null | undefined,
): string[] {
if (!sort || sort === 'none' || values.length <= 1) {return values;}
const copy = values.slice();
if (sort === 'alphabetical-asc') {
copy.sort((a, b) => a.localeCompare(b));
} else if (sort === 'alphabetical-desc') {
copy.sort((a, b) => b.localeCompare(a));
} else if (sort === 'numerical-asc' || sort === 'numerical-desc') {
copy.sort((a, b) => {
const na = Number(a);
const nb = Number(b);
const aFinite = Number.isFinite(na);
const bFinite = Number.isFinite(nb);
if (aFinite && bFinite) {
return sort === 'numerical-asc' ? na - nb : nb - na;
}
// Mixed numeric/non-numeric: keep non-numerics at the end, sorted alpha.
if (aFinite) {return -1;}
if (bFinite) {return 1;}
return sort === 'numerical-asc'
? a.localeCompare(b)
: b.localeCompare(a);
});
}
return copy;
}

View File

@@ -1,18 +0,0 @@
/**
* Output of resolving a single list variable. Text variables don't go
* through resolution — their value is the literal string.
*/
export interface ResolvedValues {
values: string[];
status: 'idle' | 'loading' | 'success' | 'error';
error?: string;
}
export const idle: ResolvedValues = { values: [], status: 'idle' };
export const loading: ResolvedValues = { values: [], status: 'loading' };
export function success(values: string[]): ResolvedValues {
return { values, status: 'success' };
}
export function failure(error: string): ResolvedValues {
return { values: [], status: 'error', error };
}

View File

@@ -1,15 +0,0 @@
import { useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { success, type ResolvedValues } from './types';
/**
* CUSTOM variables: the comma-separated user input is the value list.
* No network call, purely client-side.
*/
export function useCustomResolver(customValue: string): ResolvedValues {
return useMemo(
() => success(commaValuesParser(customValue).map((v) => String(v))),
[customValue],
);
}

View File

@@ -1,51 +0,0 @@
// eslint-disable-next-line no-restricted-imports -- TODO: migrate global time selector off redux
import { useSelector } from 'react-redux';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { failure, idle, loading, success, type ResolvedValues } from './types';
function signalToV1(
signal: TelemetrytypesSignalDTO | undefined,
): 'traces' | 'logs' | 'metrics' | undefined {
if (signal === TelemetrytypesSignalDTO.traces) {return 'traces';}
if (signal === TelemetrytypesSignalDTO.logs) {return 'logs';}
if (signal === TelemetrytypesSignalDTO.metrics) {return 'metrics';}
return undefined;
}
/**
* DYNAMIC variables: telemetry attribute lookup.
* - `signal === undefined` → search across all telemetry types.
* - Otherwise scoped to the specific signal.
*
* Uses the existing V1 hook directly; the API is V2-shape-agnostic.
*/
export function useDynamicResolver(
attributeName: string,
signal: TelemetrytypesSignalDTO | undefined,
): ResolvedValues {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const enabled = !!attributeName;
const { data, isLoading, isError, error } = useGetFieldValues({
signal: signalToV1(signal),
name: attributeName,
enabled,
startUnixMilli: minTime,
endUnixMilli: maxTime,
});
if (!enabled) {return idle;}
if (isLoading) {return loading;}
if (isError) {
return failure(
(error as Error)?.message ?? 'Failed to resolve dynamic variable',
);
}
return success(data?.data?.normalizedValues ?? []);
}

View File

@@ -1,78 +0,0 @@
import { useQuery } from 'react-query';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
import { substituteVariables } from '../substitution';
import type { SelectionsByName } from '../state/types';
import { failure, idle, loading, success, type ResolvedValues } from './types';
/**
* Reduce the user's V2 selections to the V1 `PayloadVariables` shape the
* variables/query endpoint expects (a plain name → selected-value map).
*/
function selectionsToPayload(
selections: SelectionsByName,
): PayloadVariables {
const out: PayloadVariables = {};
Object.entries(selections).forEach(([name, sel]) => {
if (!sel) {return;}
if (sel.kind === 'text') {
out[name] = sel.value;
} else if (sel.allSelected) {
// Endpoint understands `__ALL__`-style markers via the substitution
// done client-side; leave the value out so server doesn't double up.
// (Callers using IN ($var) expand via substituteVariables instead.)
} else if (sel.values.length === 1) {
out[name] = sel.values[0];
} else {
out[name] = sel.values;
}
});
return out;
}
interface UseQueryResolverArgs {
variableName: string;
queryValue: string;
selections: SelectionsByName;
enabled: boolean;
}
/**
* QUERY variables: substitute `$var` references using current selections,
* then POST to `/api/v2/variables/query`. React Query caches per
* (name, substitutedQuery) so re-render with the same inputs reuses results.
*/
export function useQueryResolver({
variableName,
queryValue,
selections,
enabled,
}: UseQueryResolverArgs): ResolvedValues {
const substituted = substituteVariables(queryValue, selections);
const { data, isLoading, isError, error } = useQuery({
queryKey: ['v2-variable-query', variableName, substituted],
queryFn: () =>
dashboardVariablesQuery({
query: substituted,
variables: selectionsToPayload(selections),
}),
enabled: enabled && !!substituted,
refetchOnWindowFocus: false,
});
if (!enabled || !substituted) {return idle;}
if (isLoading) {return loading;}
if (isError) {
return failure(
(error as { details?: { error?: string } })?.details?.error ??
(error as Error)?.message ??
'Variable query failed',
);
}
const payload = (data as { payload?: { variableValues?: unknown[] } } | undefined)
?.payload;
const values = (payload?.variableValues ?? []).map((v) => String(v));
return success(values);
}

View File

@@ -1,74 +0,0 @@
import { useMemo } from 'react';
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useVariableSelectionStore } from '../state/selectionStore';
import { applyCapturingRegexp } from './capturingRegexp';
import { applySort } from './sorting';
import { useCustomResolver } from './useCustomResolver';
import { useDynamicResolver } from './useDynamicResolver';
import { useQueryResolver } from './useQueryResolver';
import { idle, success, type ResolvedValues } from './types';
interface UseResolveVariableArgs {
variable: DashboardtypesVariableDTO;
}
/**
* Routes a variable to the correct resolver hook and applies the V2
* post-processing pipeline:
*
* raw values → capturingRegexp → sort → final list
*
* Text variables short-circuit since they don't have a value list.
*/
export function useResolveVariable({
variable,
}: UseResolveVariableArgs): ResolvedValues {
const selections = useVariableSelectionStore((s) => s.selections);
// Read all fields up front so the React Query / hook order is stable
// across renders (hooks must not be called conditionally).
const isText = variable.kind === 'TextVariable';
const listSpec = (variable.spec as DashboardtypesListVariableSpecDTO) ?? {};
const pluginKind = listSpec.plugin?.kind;
const pluginSpec = (listSpec.plugin?.spec as Record<string, unknown> | undefined) ?? {};
const name = listSpec?.name ?? '';
const customValue = (pluginSpec.customValue as string) ?? '';
const queryValue = (pluginSpec.queryValue as string) ?? '';
const dynName = (pluginSpec.name as string) ?? '';
const dynSignal = pluginSpec.signal as TelemetrytypesSignalDTO | undefined;
const customRes = useCustomResolver(
pluginKind === 'signoz/CustomVariable' ? customValue : '',
);
const dynRes = useDynamicResolver(
pluginKind === 'signoz/DynamicVariable' ? dynName : '',
dynSignal,
);
const queryRes = useQueryResolver({
variableName: name,
queryValue: pluginKind === 'signoz/QueryVariable' ? queryValue : '',
selections,
enabled: pluginKind === 'signoz/QueryVariable',
});
const raw: ResolvedValues = useMemo(() => {
if (isText) {return success([]);}
if (pluginKind === 'signoz/CustomVariable') {return customRes;}
if (pluginKind === 'signoz/DynamicVariable') {return dynRes;}
if (pluginKind === 'signoz/QueryVariable') {return queryRes;}
return idle;
}, [isText, pluginKind, customRes, dynRes, queryRes]);
return useMemo(() => {
if (raw.status !== 'success') {return raw;}
const afterRegex = applyCapturingRegexp(raw.values, listSpec.capturingRegexp);
const afterSort = applySort(afterRegex, listSpec.sort);
return success(afterSort);
}, [raw, listSpec.capturingRegexp, listSpec.sort]);
}

View File

@@ -1,89 +0,0 @@
import { useMemo } from 'react';
import SelectVariableInput from 'container/DashboardContainer/DashboardVariablesSelection/SelectVariableInput';
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import type { ResolvedValues } from '../resolution/types';
import type { VariableSelection } from '../state/types';
interface Props {
variableId: string;
resolved: ResolvedValues;
selection: VariableSelection | undefined;
allowMultiple: boolean;
allowAllValue: boolean;
defaultValue: string;
onChange: (selection: VariableSelection) => void;
onClear: () => void;
}
function selectionToValue(
selection: VariableSelection | undefined,
defaultValue: string,
allowMultiple: boolean,
): string | string[] | undefined {
if (selection && selection.kind === 'list') {
if (selection.allSelected) {return ALL_SELECT_VALUE;}
if (allowMultiple) {return selection.values;}
return selection.values[0];
}
if (defaultValue) {return allowMultiple ? [defaultValue] : defaultValue;}
return undefined;
}
/**
* QUERY / CUSTOM / DYNAMIC variables share the same dropdown UX: a list of
* options + an optional ALL entry + single / multi-select. Reuses V1's
* `SelectVariableInput` so visuals match exactly.
*/
function ListVariableSelector({
variableId,
resolved,
selection,
allowMultiple,
allowAllValue,
defaultValue,
onChange,
onClear,
}: Props): JSX.Element {
const options = useMemo(
() => resolved.values.map((v) => ({ label: v, value: v })),
[resolved.values],
);
const value = selectionToValue(selection, defaultValue, allowMultiple);
return (
<SelectVariableInput
variableId={variableId}
options={options}
value={value}
enableSelectAll={allowAllValue}
isMultiSelect={allowMultiple}
loading={resolved.status === 'loading'}
errorMessage={resolved.error ?? null}
onChange={(next): void => {
if (Array.isArray(next)) {
// Multi-select. Antd's CustomMultiSelect emits the ALL sentinel
// when the user toggles the "Select all" row.
const hasAll = next.includes(ALL_SELECT_VALUE);
onChange({
kind: 'list',
values: hasAll ? [] : next,
allSelected: hasAll,
});
} else if (next === ALL_SELECT_VALUE) {
onChange({ kind: 'list', values: [], allSelected: true });
} else {
onChange({
kind: 'list',
values: next ? [next] : [],
allSelected: false,
});
}
}}
onClear={onClear}
/>
);
}
export default ListVariableSelector;

View File

@@ -1,27 +0,0 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { SolidInfoCircle } from '@signozhq/icons';
interface Props {
name: string;
description?: string;
}
/**
* V1-style label: `$name` + an info tooltip if a description is set.
* Mirrors [DashboardVariablesSelection/VariableItem.tsx:34-42](V1).
*/
function SelectorLabel({ name, description }: Props): JSX.Element {
return (
<Typography.Text className="variable-name" truncate={1}>
${name}
{description ? (
<Tooltip title={description}>
<SolidInfoCircle className="info-icon" size="md" />
</Tooltip>
) : null}
</Typography.Text>
);
}
export default SelectorLabel;

View File

@@ -1,37 +0,0 @@
import { useEffect, useState } from 'react';
import { Input } from 'antd';
interface Props {
value: string;
onCommit: (v: string) => void;
}
/**
* Text variable input — commits on blur (and on Enter), matching V1's
* `TextboxVariableInput` UX which avoids re-fetching panels on every
* keystroke.
*/
function TextVariableSelector({ value, onCommit }: Props): JSX.Element {
const [draft, setDraft] = useState<string>(value);
useEffect(() => {
setDraft(value);
}, [value]);
const commit = (): void => {
if (draft !== value) {onCommit(draft);}
};
return (
<Input
className="variable-select"
value={draft}
onChange={(e): void => setDraft(e.target.value)}
onBlur={commit}
onPressEnter={commit}
data-testid="text-variable-input-v2"
/>
);
}
export default TextVariableSelector;

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