Compare commits

..

3 Commits

Author SHA1 Message Date
Srikanth Chekuri
c6bb7569af Merge branch 'main' into issue-5535 2026-07-06 11:51:09 +05:30
srikanthccv
5d431f9f6f chore: add to ci 2026-07-06 11:50:46 +05:30
srikanthccv
1f0113645e chore: add integration tests for metrics under reduction - query part 2026-07-06 11:19:45 +05:30
136 changed files with 2209 additions and 7511 deletions

View File

@@ -56,6 +56,8 @@ jobs:
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
- clickhousecluster
- metricreduction
sqlstore-provider:
- postgres
- sqlite

View File

@@ -515,13 +515,6 @@ components:
url:
type: string
type: object
AuthtypesDeprecatedPostableUserRole:
properties:
id:
type: string
required:
- id
type: object
AuthtypesGettableAuthDomain:
properties:
authNProviderInfo:
@@ -667,20 +660,17 @@ components:
type: string
userRoles:
items:
$ref: '#/components/schemas/AuthtypesDeprecatedPostableUserRole'
$ref: '#/components/schemas/AuthtypesPostableUserRole'
type: array
required:
- email
type: object
AuthtypesPostableUserRole:
properties:
roleId:
type: string
userId:
id:
type: string
required:
- userId
- roleId
- id
type: object
AuthtypesRelation:
enum:
@@ -2927,13 +2917,6 @@ components:
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesGettablePublicDashboardDataV2:
properties:
dashboard:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -5241,16 +5224,6 @@ components:
- offset
- limit
type: object
LlmpricingruletypesGettableUnmappedModels:
properties:
items:
items:
$ref: '#/components/schemas/LlmpricingruletypesUnmappedModel'
nullable: true
type: array
required:
- items
type: object
LlmpricingruletypesLLMPricingCacheCosts:
properties:
mode:
@@ -5340,19 +5313,6 @@ components:
type: string
nullable: true
type: array
LlmpricingruletypesUnmappedModel:
properties:
modelName:
type: string
provider:
type: string
spanCount:
minimum: 0
type: integer
required:
- modelName
- spanCount
type: object
LlmpricingruletypesUpdatableLLMPricingRule:
properties:
enabled:
@@ -7440,13 +7400,6 @@ components:
enum:
- basic
type: string
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
type: string
required:
- id
type: object
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -7503,13 +7456,10 @@ components:
type: object
ServiceaccounttypesPostableServiceAccountRole:
properties:
roleId:
type: string
serviceAccountId:
id:
type: string
required:
- serviceAccountId
- roleId
- id
type: object
ServiceaccounttypesServiceAccount:
properties:
@@ -11240,60 +11190,6 @@ paths:
summary: Get a pricing rule
tags:
- llmpricingrules
/api/v1/llm_pricing_rules/unmapped_models:
get:
deprecated: false
description: Returns models seen in the last hour of trace data (gen_ai.request.model)
that no pricing rule pattern matches, so the user can add them to an existing
rule or create a new one.
operationId: ListUnmappedLLMModels
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LlmpricingruletypesGettableUnmappedModels'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List unmapped models
tags:
- llmpricingrules
/api/v1/logs/promote_paths:
get:
deprecated: false
@@ -12272,188 +12168,6 @@ paths:
summary: Update route policy
tags:
- routepolicies
/api/v1/service_account_roles:
post:
deprecated: false
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRole
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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
security:
- api_key:
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:attach
- role:attach
summary: Create service account role
tags:
- serviceaccount
/api/v1/service_account_roles/{id}:
delete:
deprecated: false
description: This endpoint revokes a role from a service account
operationId: DeleteServiceAccountRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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
security:
- api_key:
- serviceaccount:detach
- role:detach
- tokenizer:
- serviceaccount:detach
- role:detach
summary: Delete service account role
tags:
- serviceaccount
get:
deprecated: false
description: This endpoint gets an existing service account role
operationId: GetServiceAccountRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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
security:
- api_key:
- serviceaccount:read
- tokenizer:
- serviceaccount:read
summary: Get service account role
tags:
- serviceaccount
/api/v1/service_accounts:
get:
deprecated: false
@@ -13023,9 +12737,9 @@ paths:
tags:
- serviceaccount
post:
deprecated: true
deprecated: false
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRoleDeprecated
operationId: CreateServiceAccountRole
parameters:
- in: path
name: id
@@ -13036,7 +12750,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
responses:
"201":
content:
@@ -13088,9 +12802,9 @@ paths:
- serviceaccount
/api/v1/service_accounts/{id}/roles/{rid}:
delete:
deprecated: true
deprecated: false
description: This endpoint revokes a role from service account
operationId: DeleteServiceAccountRoleDeprecated
operationId: DeleteServiceAccountRole
parameters:
- in: path
name: id
@@ -17727,138 +17441,6 @@ paths:
summary: Update my organization
tags:
- orgs
/api/v2/public/dashboards/{id}:
get:
deprecated: false
description: This endpoint returns the sanitized v2-shape dashboard data for
public access. Each panel query is reduced to a safe field subset, so filters
and raw query strings are not exposed.
operationId: GetPublicDashboardDataV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardDataV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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
security:
- anonymous:
- public-dashboard:read
summary: Get public dashboard data (v2)
tags:
- dashboard
/api/v2/public/dashboards/{id}/panels/{key}/query_range:
get:
deprecated: false
description: This endpoint returns query range results for a panel of a v2-shape
public dashboard. The panel is addressed by its key in spec.panels.
operationId: GetPublicDashboardPanelQueryRangeV2
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: key
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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
security:
- anonymous:
- public-dashboard:read
summary: Get query range result (v2)
tags:
- dashboard
/api/v2/readyz:
get:
operationId: Readyz
@@ -22192,184 +21774,6 @@ paths:
summary: Rotate session
tags:
- sessions
/api/v2/user_roles:
post:
deprecated: false
description: This endpoint assigns a role to a user
operationId: CreateUserRole
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableUserRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create user role
tags:
- users
/api/v2/user_roles/{id}:
delete:
deprecated: false
description: This endpoint revokes a role from a user
operationId: DeleteUserRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete user role
tags:
- users
get:
deprecated: false
description: This endpoint gets an existing user role
operationId: GetUserRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesUserRole'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"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
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get user role
tags:
- users
/api/v2/users:
get:
deprecated: false
@@ -22823,7 +22227,7 @@ paths:
tags:
- users
post:
deprecated: true
deprecated: false
description: This endpoint assigns the role to the user roles by user id
operationId: SetRoleByUserID
parameters:
@@ -22874,7 +22278,7 @@ paths:
- users
/api/v2/users/{id}/roles/{roleId}:
delete:
deprecated: true
deprecated: false
description: This endpoint removes a role from the user by user id and role
id
operationId: RemoveUserRoleByUserIDAndRoleID

View File

@@ -519,7 +519,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
if err != nil {
return "", err
}
_, err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return "", err
}

View File

@@ -29,7 +29,6 @@ type module struct {
settings factory.ScopedProviderSettings
querier querier.Querier
licensing licensing.Licensing
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
@@ -42,7 +41,6 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
settings: scopedProviderSettings,
querier: querier,
licensing: licensing,
tagModule: tagModule,
}
}
@@ -134,55 +132,6 @@ func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.U
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForResource(ctx, storableDashboard.OrgID, coretypes.KindDashboard, storableDashboard.ID)
if err != nil {
return nil, err
}
return storableDashboard.ToDashboardV2(tags)
}
func (module *module) GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "dashboard",
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRangeV2",
})
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
if err != nil {
return nil, err
}
// tags are not needed for query range.
dashboard, err := storableDashboard.ToDashboardV2(nil)
if err != nil {
return nil, err
}
publicDashboard, err := module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
if err != nil {
return nil, err
}
startTime, endTime, err := publicDashboard.ResolveTimeRange(startTimeRaw, endTimeRaw)
if err != nil {
return nil, err
}
query, err := dashboard.GetPanelQuery(startTime, endTime, panelKey)
if err != nil {
return nil, err
}
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}
func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
_, err := module.licensing.GetActive(ctx, orgID)
if err != nil {

View File

@@ -141,7 +141,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Cont
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
}
_, err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
}

View File

@@ -293,8 +293,6 @@
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
"signoz/no-css-module-bracket-access": "warn",
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
"signoz/no-dashboard-fetch-outside-root": "error",
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
"no-restricted-globals": [
"error",
{
@@ -560,18 +558,6 @@
"rules": {
"signoz/no-zustand-getstate-in-hooks": "off"
}
},
{
// Root V2 pages own the dashboard fetch lifecycle; useDashboardFetchRequired wraps it.
// Everywhere else must use useDashboardFetchRequired().
"files": [
"src/pages/DashboardPageV2/DashboardPageV2.tsx",
"src/pages/DashboardPageV2/PanelEditorPage/PanelEditorPage.tsx",
"src/pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired.ts"
],
"rules": {
"signoz/no-dashboard-fetch-outside-root": "off"
}
}
]
}

View File

@@ -1,48 +0,0 @@
/**
* Rule: no-dashboard-fetch-outside-root
*
* `useDashboardFetch` owns the V2 dashboard fetch and exposes its loading/error/refetch
* lifecycle. That lifecycle is a root-page concern: DashboardPageV2 and PanelEditorPage
* gate their subtree on a resolved dashboard before mounting anything below them.
*
* Every other component therefore renders inside an already-loaded subtree and must use
* `useDashboardFetchRequired`, which reuses the same react-query cache entry but guarantees
* a non-undefined dashboard (throwing otherwise) — so consumers don't re-handle states the
* root page already owns.
*
* This rule flags `useDashboardFetch` imports. It is disabled via an override in
* .oxlintrc.json for the two root pages and for useDashboardFetchRequired.ts (which wraps it).
*/
const FETCH_HOOK = 'useDashboardFetch';
export default {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow useDashboardFetch outside the root dashboard pages; use useDashboardFetchRequired instead',
category: 'Dashboard V2',
},
schema: [],
messages: {
useRequired:
'Use useDashboardFetchRequired() instead of useDashboardFetch — the latter is reserved for the root pages (DashboardPageV2, PanelEditorPage) that own the fetch lifecycle. Components below them render inside a loaded subtree and should assume a guaranteed dashboard.',
},
},
create(context) {
return {
ImportDeclaration(node) {
node.specifiers.forEach((specifier) => {
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.name === FETCH_HOOK
) {
context.report({ node: specifier, messageId: 'useRequired' });
}
});
},
};
},
};

View File

@@ -12,7 +12,6 @@ import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
import noAntdComponents from './rules/no-antd-components.mjs';
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
export default {
meta: {
@@ -26,6 +25,5 @@ export default {
'no-antd-components': noAntdComponents,
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
'no-css-module-bracket-access': noCssModuleBracketAccess,
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
},
};

View File

@@ -38,10 +38,6 @@ import type {
GetPublicDashboard200,
GetPublicDashboardData200,
GetPublicDashboardDataPathParameters,
GetPublicDashboardDataV2200,
GetPublicDashboardDataV2PathParameters,
GetPublicDashboardPanelQueryRangeV2200,
GetPublicDashboardPanelQueryRangeV2PathParameters,
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
@@ -1803,217 +1799,6 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
* @summary Get public dashboard data (v2)
*/
export const getPublicDashboardDataV2 = (
{ id }: GetPublicDashboardDataV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardDataV2200>({
url: `/api/v2/public/dashboards/${id}`,
method: 'GET',
signal,
});
};
export const getGetPublicDashboardDataV2QueryKey = ({
id,
}: GetPublicDashboardDataV2PathParameters) => {
return [`/api/v2/public/dashboards/${id}`] as const;
};
export const getGetPublicDashboardDataV2QueryOptions = <
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetPublicDashboardDataV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetPublicDashboardDataV2QueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
> = ({ signal }) => getPublicDashboardDataV2({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPublicDashboardDataV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
>;
export type GetPublicDashboardDataV2QueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get public dashboard data (v2)
*/
export function useGetPublicDashboardDataV2<
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetPublicDashboardDataV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardDataV2QueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get public dashboard data (v2)
*/
export const invalidateGetPublicDashboardDataV2 = async (
queryClient: QueryClient,
{ id }: GetPublicDashboardDataV2PathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardDataV2QueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.
* @summary Get query range result (v2)
*/
export const getPublicDashboardPanelQueryRangeV2 = (
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
method: 'GET',
signal,
});
};
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
id,
key,
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
};
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
return {
queryKey,
queryFn,
enabled: !!(id && key),
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetPublicDashboardPanelQueryRangeV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
>;
export type GetPublicDashboardPanelQueryRangeV2QueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get query range result (v2)
*/
export function useGetPublicDashboardPanelQueryRangeV2<
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
{ id, key },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get query range result (v2)
*/
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
queryClient: QueryClient,
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
options,
);
return queryClient;
};
/**
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
* @summary List dashboards for the current user (v2)

View File

@@ -23,7 +23,6 @@ import type {
GetLLMPricingRulePathParameters,
ListLLMPricingRules200,
ListLLMPricingRulesParams,
ListUnmappedLLMModels200,
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -394,87 +393,3 @@ export const invalidateGetLLMPricingRule = async (
return queryClient;
};
/**
* Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.
* @summary List unmapped models
*/
export const listUnmappedLLMModels = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListUnmappedLLMModels200>({
url: `/api/v1/llm_pricing_rules/unmapped_models`,
method: 'GET',
signal,
});
};
export const getListUnmappedLLMModelsQueryKey = () => {
return [`/api/v1/llm_pricing_rules/unmapped_models`] as const;
};
export const getListUnmappedLLMModelsQueryOptions = <
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListUnmappedLLMModelsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listUnmappedLLMModels>>
> = ({ signal }) => listUnmappedLLMModels(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListUnmappedLLMModelsQueryResult = NonNullable<
Awaited<ReturnType<typeof listUnmappedLLMModels>>
>;
export type ListUnmappedLLMModelsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List unmapped models
*/
export function useListUnmappedLLMModels<
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListUnmappedLLMModelsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List unmapped models
*/
export const invalidateListUnmappedLLMModels = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListUnmappedLLMModelsQueryKey() },
options,
);
return queryClient;
};

View File

@@ -22,16 +22,12 @@ import type {
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRoleDeprecated201,
CreateServiceAccountRoleDeprecatedPathParameters,
CreateServiceAccountRolePathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
GetServiceAccountPathParameters,
GetServiceAccountRole200,
GetServiceAccountRolePathParameters,
GetServiceAccountRoles200,
GetServiceAccountRolesPathParameters,
ListServiceAccountKeys200,
@@ -39,7 +35,6 @@ import type {
ListServiceAccounts200,
RenderErrorResponseDTO,
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
@@ -51,272 +46,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint assigns a role to a service account
* @summary Create service account role
*/
export const createServiceAccountRole = (
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRole201>({
url: `/api/v1/service_account_roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
> => {
const mutationKey = ['createServiceAccountRole'];
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 createServiceAccountRole>>,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> }
> = (props) => {
const { data } = props ?? {};
return createServiceAccountRole(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRole>>
>;
export type CreateServiceAccountRoleMutationBody =
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create service account role
*/
export const useCreateServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
> => {
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from a service account
* @summary Delete service account role
*/
export const deleteServiceAccountRole = (
{ id }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/service_account_roles/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRole'];
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 deleteServiceAccountRole>>,
{ pathParams: DeleteServiceAccountRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRole>>
>;
export type DeleteServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete service account role
*/
export const useDeleteServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint gets an existing service account role
* @summary Get service account role
*/
export const getServiceAccountRole = (
{ id }: GetServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetServiceAccountRole200>({
url: `/api/v1/service_account_roles/${id}`,
method: 'GET',
signal,
});
};
export const getGetServiceAccountRoleQueryKey = ({
id,
}: GetServiceAccountRolePathParameters) => {
return [`/api/v1/service_account_roles/${id}`] as const;
};
export const getGetServiceAccountRoleQueryOptions = <
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetServiceAccountRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetServiceAccountRoleQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getServiceAccountRole>>
> = ({ signal }) => getServiceAccountRole({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetServiceAccountRoleQueryResult = NonNullable<
Awaited<ReturnType<typeof getServiceAccountRole>>
>;
export type GetServiceAccountRoleQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service account role
*/
export function useGetServiceAccountRole<
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetServiceAccountRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceAccountRoleQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get service account role
*/
export const invalidateGetServiceAccountRole = async (
queryClient: QueryClient,
{ id }: GetServiceAccountRolePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceAccountRoleQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint lists the service accounts for an organisation
* @summary List service accounts
@@ -1255,46 +984,45 @@ export const invalidateGetServiceAccountRoles = async (
/**
* This endpoint assigns a role to a service account
* @deprecated
* @summary Create service account role
*/
export const createServiceAccountRoleDeprecated = (
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
export const createServiceAccountRole = (
{ id }: CreateServiceAccountRolePathParameters,
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
return GeneratedAPIInstance<CreateServiceAccountRole201>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
data: serviceaccounttypesPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
export const getCreateServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['createServiceAccountRoleDeprecated'];
const mutationKey = ['createServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -1304,66 +1032,62 @@ export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createServiceAccountRoleDeprecated(pathParams, data);
return createServiceAccountRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
export type CreateServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRole>>
>;
export type CreateServiceAccountRoleDeprecatedMutationBody =
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
export type CreateServiceAccountRoleMutationBody =
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleDeprecatedMutationError =
export type CreateServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create service account role
*/
export const useCreateServiceAccountRoleDeprecated = <
export const useCreateServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
> => {
return useMutation(
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
);
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from service account
* @deprecated
* @summary Delete service account role
*/
export const deleteServiceAccountRoleDeprecated = (
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
export const deleteServiceAccountRole = (
{ id, rid }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
@@ -1373,23 +1097,23 @@ export const deleteServiceAccountRoleDeprecated = (
});
};
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
export const getDeleteServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
const mutationKey = ['deleteServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -1399,47 +1123,44 @@ export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
{ pathParams: DeleteServiceAccountRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRoleDeprecated(pathParams);
return deleteServiceAccountRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
export type DeleteServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRole>>
>;
export type DeleteServiceAccountRoleDeprecatedMutationError =
export type DeleteServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete service account role
*/
export const useDeleteServiceAccountRoleDeprecated = <
export const useDeleteServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
return useMutation(
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
);
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint gets my service account

View File

@@ -2048,13 +2048,6 @@ export interface AuthtypesAuthNSupportDTO {
password?: AuthtypesPasswordAuthNSupportDTO[] | null;
}
export interface AuthtypesDeprecatedPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
@@ -2294,6 +2287,13 @@ export interface AuthtypesPostableRotateTokenDTO {
refreshToken?: string;
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesPostableUserDTO {
/**
* @type string
@@ -2310,18 +2310,7 @@ export interface AuthtypesPostableUserDTO {
/**
* @type array
*/
userRoles?: AuthtypesDeprecatedPostableUserRoleDTO[];
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
roleId: string;
/**
* @type string
*/
userId: string;
userRoles?: AuthtypesPostableUserRoleDTO[];
}
export interface AuthtypesRoleDTO {
@@ -4952,11 +4941,6 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
dashboard?: DashboardtypesGettableDashboardV2DTO;
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export enum DashboardtypesPatchOpDTO {
add = 'add',
remove = 'remove',
@@ -6834,29 +6818,6 @@ export interface LlmpricingruletypesGettablePricingRulesDTO {
total: number;
}
export interface LlmpricingruletypesUnmappedModelDTO {
/**
* @type string
*/
modelName: string;
/**
* @type string
*/
provider?: string;
/**
* @type integer
* @minimum 0
*/
spanCount: number;
}
export interface LlmpricingruletypesGettableUnmappedModelsDTO {
/**
* @type array,null
*/
items: LlmpricingruletypesUnmappedModelDTO[] | null;
}
export interface LlmpricingruletypesUpdatableLLMPricingRuleDTO {
/**
* @type boolean
@@ -8519,13 +8480,6 @@ export interface RuletypesRuleDTO {
export enum RuletypesThresholdKindDTO {
basic = 'basic',
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -8595,11 +8549,7 @@ export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
/**
* @type string
*/
roleId: string;
/**
* @type string
*/
serviceAccountId: string;
id: string;
}
export interface ServiceaccounttypesServiceAccountDTO {
@@ -10218,14 +10168,6 @@ export type GetLLMPricingRule200 = {
status: string;
};
export type ListUnmappedLLMModels200 = {
data: LlmpricingruletypesGettableUnmappedModelsDTO;
/**
* @type string
*/
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array,null
@@ -10365,28 +10307,6 @@ export type UpdateRoutePolicy200 = {
status: string;
};
export type CreateServiceAccountRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteServiceAccountRolePathParameters = {
id: string;
};
export type GetServiceAccountRolePathParameters = {
id: string;
};
export type GetServiceAccountRole200 = {
data: ServiceaccounttypesServiceAccountRoleDTO;
/**
* @type string
*/
status: string;
};
export type ListServiceAccounts200 = {
/**
* @type array
@@ -10470,10 +10390,10 @@ export type GetServiceAccountRoles200 = {
status: string;
};
export type CreateServiceAccountRoleDeprecatedPathParameters = {
export type CreateServiceAccountRolePathParameters = {
id: string;
};
export type CreateServiceAccountRoleDeprecated201 = {
export type CreateServiceAccountRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
@@ -10481,7 +10401,7 @@ export type CreateServiceAccountRoleDeprecated201 = {
status: string;
};
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
export type DeleteServiceAccountRolePathParameters = {
id: string;
rid: string;
};
@@ -11241,29 +11161,6 @@ export type GetMyOrganization200 = {
status: string;
};
export type GetPublicDashboardDataV2PathParameters = {
id: string;
};
export type GetPublicDashboardDataV2200 = {
data: DashboardtypesGettablePublicDashboardDataV2DTO;
/**
* @type string
*/
status: string;
};
export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
id: string;
key: string;
};
export type GetPublicDashboardPanelQueryRangeV2200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**
* @type string
*/
status: string;
};
export type Readyz200 = {
data: FactoryResponseDTO;
/**
@@ -11641,28 +11538,6 @@ export type RotateSession200 = {
status: string;
};
export type CreateUserRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteUserRolePathParameters = {
id: string;
};
export type GetUserRolePathParameters = {
id: string;
};
export type GetUserRole200 = {
data: AuthtypesUserRoleDTO;
/**
* @type string
*/
status: string;
};
export type ListUsers200 = {
/**
* @type array

View File

@@ -19,15 +19,12 @@ import type {
import type {
AuthtypesPostableUserDTO,
AuthtypesPostableUserRoleDTO,
CreateInvite201,
CreateResetPasswordToken201,
CreateResetPasswordTokenPathParameters,
CreateUser201,
CreateUserRole201,
DeleteUserDeprecatedPathParameters,
DeleteUserPathParameters,
DeleteUserRolePathParameters,
GetMyUser200,
GetMyUserDeprecated200,
GetResetPasswordToken200,
@@ -40,8 +37,6 @@ import type {
GetUserDeprecated200,
GetUserDeprecatedPathParameters,
GetUserPathParameters,
GetUserRole200,
GetUserRolePathParameters,
GetUsersByRoleID200,
GetUsersByRoleIDPathParameters,
ListUsers200,
@@ -1159,267 +1154,6 @@ export const invalidateGetUsersByRoleID = async (
return queryClient;
};
/**
* This endpoint assigns a role to a user
* @summary Create user role
*/
export const createUserRole = (
authtypesPostableUserRoleDTO?: BodyType<AuthtypesPostableUserRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateUserRole201>({
url: `/api/v2/user_roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableUserRoleDTO,
signal,
});
};
export const getCreateUserRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
> => {
const mutationKey = ['createUserRole'];
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 createUserRole>>,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> }
> = (props) => {
const { data } = props ?? {};
return createUserRole(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createUserRole>>
>;
export type CreateUserRoleMutationBody =
| BodyType<AuthtypesPostableUserRoleDTO>
| undefined;
export type CreateUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create user role
*/
export const useCreateUserRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
> => {
return useMutation(getCreateUserRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from a user
* @summary Delete user role
*/
export const deleteUserRole = (
{ id }: DeleteUserRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/user_roles/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteUserRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
> => {
const mutationKey = ['deleteUserRole'];
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 deleteUserRole>>,
{ pathParams: DeleteUserRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteUserRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteUserRole>>
>;
export type DeleteUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete user role
*/
export const useDeleteUserRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
> => {
return useMutation(getDeleteUserRoleMutationOptions(options));
};
/**
* This endpoint gets an existing user role
* @summary Get user role
*/
export const getUserRole = (
{ id }: GetUserRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetUserRole200>({
url: `/api/v2/user_roles/${id}`,
method: 'GET',
signal,
});
};
export const getGetUserRoleQueryKey = ({ id }: GetUserRolePathParameters) => {
return [`/api/v2/user_roles/${id}`] as const;
};
export const getGetUserRoleQueryOptions = <
TData = Awaited<ReturnType<typeof getUserRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetUserRoleQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUserRole>>> = ({
signal,
}) => getUserRole({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetUserRoleQueryResult = NonNullable<
Awaited<ReturnType<typeof getUserRole>>
>;
export type GetUserRoleQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get user role
*/
export function useGetUserRole<
TData = Awaited<ReturnType<typeof getUserRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetUserRoleQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get user role
*/
export const invalidateGetUserRole = async (
queryClient: QueryClient,
{ id }: GetUserRolePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetUserRoleQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint lists all users for the organization
* @summary List users v2
@@ -2131,7 +1865,6 @@ export const invalidateGetRolesByUserID = async (
/**
* This endpoint assigns the role to the user roles by user id
* @deprecated
* @summary Set user roles
*/
export const setRoleByUserID = (
@@ -2203,7 +1936,6 @@ export type SetRoleByUserIDMutationBody =
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Set user roles
*/
export const useSetRoleByUserID = <
@@ -2232,7 +1964,6 @@ export const useSetRoleByUserID = <
};
/**
* This endpoint removes a role from the user by user id and role id
* @deprecated
* @summary Remove a role from user
*/
export const removeUserRoleByUserIDAndRoleID = (
@@ -2291,7 +2022,6 @@ export type RemoveUserRoleByUserIDAndRoleIDMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Remove a role from user
*/
export const useRemoveUserRoleByUserIDAndRoleID = <

View File

@@ -3,6 +3,7 @@ import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { ResizeTable } from 'components/ResizeTable';
import { useNotifications } from 'hooks/useNotifications';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
@@ -10,7 +11,6 @@ import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { toast } from '@signozhq/ui/sonner';
import { getChartManagerColumns } from './getChartMangerColumns';
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
@@ -44,6 +44,7 @@ export default function ChartManager({
decimalPrecision = PrecisionOptionsEnum.TWO,
onCancel,
}: ChartManagerProps): JSX.Element {
const { notifications } = useNotifications();
const { legendItemsMap } = useLegendsSync({
config,
subscribeToFocusChange: false,
@@ -135,9 +136,11 @@ export default function ChartManager({
const handleSave = useCallback((): void => {
syncSeriesVisibilityToLocalStorage();
toast.success('The updated graphs & legends are saved');
notifications.success({
message: 'The updated graphs & legends are saved',
});
onCancel?.();
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
return (
<div className="chart-manager-container">

View File

@@ -5,7 +5,7 @@ import { render, screen } from 'tests/test-utils';
import ChartManager from '../ChartManager';
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
const mockToastSuccess = jest.fn();
const mockNotificationsSuccess = jest.fn();
jest.mock('lib/uPlotV2/context/PlotContext', () => ({
usePlotContext: (): {
@@ -46,11 +46,12 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
}): boolean => s.dashboardData?.locked ?? false,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
},
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): { notifications: { success: jest.Mock } } => ({
notifications: {
success: mockNotificationsSuccess,
},
}),
}));
jest.mock('components/ResizeTable', () => {
@@ -159,7 +160,7 @@ describe('ChartManager', () => {
expect(screen.queryByTestId('row-2')).not.toBeInTheDocument();
});
it('calls syncSeriesVisibilityToLocalStorage, toast.success, and onCancel when Save is clicked', async () => {
it('calls syncSeriesVisibilityToLocalStorage, notifications.success, and onCancel when Save is clicked', async () => {
render(
<ChartManager
config={createMockConfig() as UPlotConfigBuilder}
@@ -171,9 +172,9 @@ describe('ChartManager', () => {
await userEvent.click(screen.getByRole('button', { name: /Save/ }));
expect(mockSyncSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
expect(mockToastSuccess).toHaveBeenCalledWith(
'The updated graphs & legends are saved',
);
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
message: 'The updated graphs & legends are saved',
});
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,14 +5,6 @@
height: 100%;
flex-direction: column;
// Stacked children (the FullView / standalone graph-manager) sit below the chart
// in the same container; size the chart region to its content so they aren't
// pushed out. Only this case opts out of filling the height — the dashboard grid,
// alert preview, and other charts keep 100% so they fill their container.
&--with-layout-children {
height: auto;
}
&--legend-right {
flex-direction: row;
}

View File

@@ -63,7 +63,6 @@ export default function ChartLayout({
className={cx('chart-layout', {
'chart-layout--legend-right':
legendConfig.position === LegendPosition.RIGHT,
'chart-layout--with-layout-children': !!layoutChildren,
})}
>
<div className="chart-layout__content">

View File

@@ -66,6 +66,20 @@
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
border-bottom: 1px solid var(--l1-border);
padding: 12px 8px;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
padding-block: 0 !important;
:global(.quick-filters .checkbox-filter) {
padding-left: 18px;
}
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
@@ -97,6 +111,22 @@
justify-content: space-between;
}
.quickFiltersCategoryLabel {
display: flex;
align-items: center;
gap: 4px;
}
.quickFiltersCategoryLabelIcon {
margin-right: 8px;
}
.quickFiltersCategoryLabelContainer {
display: flex;
align-items: center;
gap: 4px;
}
.listContainer {
flex: 1;
min-width: 0;
@@ -130,106 +160,3 @@
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
}
.categorySelectorSection {
padding: var(--spacing-4);
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-4);
&[data-type='filter'] {
padding: 0 var(--spacing-4);
}
}
.sectionLabel {
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l3-foreground);
white-space: nowrap;
}
.sectionLine {
flex: 1;
height: 1px;
background: var(--l1-border);
}
.categoryCard {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--spacing-3);
padding: var(--spacing-2);
}
.categoryList {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.categoryItem {
display: flex;
align-items: center;
gap: var(--spacing-5);
padding: var(--spacing-3) var(--spacing-4);
border: none;
border-radius: var(--spacing-2);
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
transition:
background-color 0.2s ease,
color 0.2s ease;
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
--typography-color: var(--l1-foreground);
&:hover:not(.categoryItemSelected) {
background: var(--l2-background-hover);
}
svg {
color: var(--l3-foreground);
flex-shrink: 0;
transition: color 0.2s ease;
}
&.categoryItemSelected {
background: var(--accent-primary);
color: var(--l1-background);
font-weight: 600;
&:hover {
background: var(--accent-primary-hover, var(--accent-primary));
}
svg {
color: var(--l1-foreground);
}
}
}
.quickFiltersSection {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import { Button, CollapseProps } from 'antd';
import { Collapse, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
@@ -112,74 +113,192 @@ export default function InfraMonitoringK8s(): JSX.Element {
});
};
const categories = useMemo(
() => [
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
},
],
[dotMetricsEnabled],
const renderCategoryLabel = (
icon: JSX.Element,
label: string,
): JSX.Element => (
<div className={styles.quickFiltersCategoryLabel}>
<div className={styles.quickFiltersCategoryLabelContainer}>
{icon}
<Typography.Text>{label}</Typography.Text>
</div>
</div>
);
const selectedCategoryConfig = useMemo(
() => categories.find((cat) => cat.key === selectedCategory)?.config,
[categories, selectedCategory],
);
const items: CollapseProps['items'] = [
{
label: renderCategoryLabel(
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Pods',
),
key: K8sCategories.PODS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Nodes',
),
key: K8sCategories.NODES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Namespaces',
),
key: K8sCategories.NAMESPACES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Clusters',
),
key: K8sCategories.CLUSTERS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Deployments',
),
key: K8sCategories.DEPLOYMENTS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Jobs',
),
key: K8sCategories.JOBS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'DaemonSets',
),
key: K8sCategories.DAEMONSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'StatefulSets',
),
key: K8sCategories.STATEFULSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Volumes',
),
key: K8sCategories.VOLUMES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
// TODO: Enable once we have implemented containers.
// {
// label: (
// <div className="k8s-quick-filters-category-label">
// <div className="k8s-quick-filters-category-label-container">
// <PackageOpen
// size={14}
// className="k8s-quick-filters-category-label-icon"
// />
// <Typography.Text>Containers</Typography.Text>
// </div>
// </div>
// ),
// key: K8sCategories.CONTAINERS,
// showArrow: false,
// children: (
// <QuickFilters
// source={QuickFiltersSource.INFRA_MONITORING}
// config={ContainersQuickFiltersConfig}
// handleFilterVisibilityChange={handleFilterVisibilityChange}
// onFilterChange={handleFilterChange}
// />
// ),
// },
];
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
setSelectedCategory(key);
const handleCategoryChange = (key: string | string[]): void => {
if (Array.isArray(key) && key.length > 0) {
setSelectedCategory(key[0] as string);
// Reset filters
setUrlFilters(null);
setOrderBy(null);
@@ -213,58 +332,26 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
{categories.map((category) => (
<button
key={category.key}
type="button"
className={`${styles.categoryItem} ${
selectedCategory === category.key
? styles.categoryItemSelected
: ''
}`}
onClick={(): void => handleCategorySelect(category.key)}
data-testid={`category-${category.key}`}
>
{category.icon}
<Typography.Text>{category.label}</Typography.Text>
</button>
))}
</div>
</div>
</div>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<div className={styles.quickFiltersSection}>
<div className={styles.sectionHeader} data-type="filter">
<Typography.Text className={styles.sectionLabel}>
Filter by
</Typography.Text>
<div className={styles.sectionLine} />
</div>
{selectedCategoryConfig && (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
)}
</Tooltip>
</div>
<Collapse
onChange={handleCategoryChange}
items={items}
defaultActiveKey={[selectedCategory]}
activeKey={[selectedCategory]}
accordion
bordered={false}
ghost
/>
</div>
)}

View File

@@ -2,8 +2,8 @@ import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetServiceAccountRolesQueryKey,
useCreateServiceAccountRoleDeprecated,
useDeleteServiceAccountRoleDeprecated,
useCreateServiceAccountRole,
useDeleteServiceAccountRole,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
@@ -46,10 +46,10 @@ export function useServiceAccountRoleManager(
);
// the retry for these mutations is safe due to being idempotent on backend
const { mutateAsync: createRole } = useCreateServiceAccountRoleDeprecated({
const { mutateAsync: createRole } = useCreateServiceAccountRole({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteRole } = useDeleteServiceAccountRoleDeprecated({
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
mutation: { retry: retryOn429 },
});

View File

@@ -1,18 +1,19 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
import { getPanelTypeDisabledReason } from './utils';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
panelKind: PanelKind;
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). */
queryType: EQueryType;
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). Defaults to Query Builder. */
queryType?: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
onChange: (kind: PanelKind) => void;
@@ -30,7 +31,22 @@ function PanelTypeSwitcher({
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind: panelKind,
queryType: queryType ?? EQueryType.QUERY_BUILDER,
signal,
label,
});
return {
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,
};
});
return (
<div className={styles.field}>

View File

@@ -49,11 +49,7 @@ describe('PanelTypeSwitcher', () => {
it('fires onChange with the chosen plugin kind', () => {
const onChange = jest.fn();
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
);
openDropdown();
@@ -66,7 +62,6 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
@@ -82,7 +77,6 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);

View File

@@ -1,48 +0,0 @@
import { useMemo } from 'react';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
import { getPanelTypeDisabledReason } from './utils';
interface UsePanelTypeSelectItemsArgs {
/** Active query type — a kind that can't be authored in it is disabled. */
queryType: EQueryType;
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
}
/**
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
* tooltip) when the active query type or signal is incompatible — resolved through
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
* modal's header so the two selectors apply the same rule and can't drift.
*/
export function usePanelTypeSelectItems({
queryType,
signal,
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
return useMemo(
() =>
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind: panelKind,
queryType,
signal,
label,
});
return {
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,
};
}),
[queryType, signal],
);
}

View File

@@ -1,259 +0,0 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import type { UrlParam, VariableItem } from './types';
import {
getUrlParams,
insertVariableAtCursor,
isValidContextLinkUrl,
updateUrlWithParams,
} from './utils';
import VariablesPopover from './VariablesPopover';
import styles from './ContextLinksSection.module.scss';
interface ContextLinkDialogProps {
open: boolean;
/** The link being edited, or null when adding a new one. */
initialLink: DashboardLinkDTO | null;
variables: VariableItem[];
onOpenChange: (open: boolean) => void;
onSave: (link: DashboardLinkDTO) => void;
}
const URL_ERROR = 'URL must start with http(s)://, /, or {{variable}}/';
const cursorOf = (e: { target: EventTarget }): number =>
(e.target as HTMLInputElement).selectionStart ?? 0;
/**
* Modal editor for a single context link (V1 parity): label + URL with `{{variable}}`
* autocomplete + validation, a key/value URL-parameters editor, and an "open in new tab"
* toggle. The URL is the source of truth; parameter rows are its query string projected
* out (blank-key rows live only in local state until they get a key). Save is gated on a
* non-empty, well-formed URL. `renderVariables` is set so consumers interpolate the URL.
*/
function ContextLinkDialog({
open,
initialLink,
variables,
onOpenChange,
onSave,
}: ContextLinkDialogProps): JSX.Element {
const [name, setName] = useState('');
const [url, setUrlState] = useState('');
const [targetBlank, setTargetBlank] = useState(true);
const [params, setParams] = useState<UrlParam[]>([]);
// Seed the draft each time the dialog opens for a (possibly different) link.
useEffect(() => {
if (open) {
setName(initialLink?.name ?? '');
setUrlState(initialLink?.url ?? '');
setTargetBlank(initialLink?.targetBlank ?? true);
setParams(getUrlParams(initialLink?.url ?? ''));
}
}, [open, initialLink]);
const setUrl = (next: string): void => {
setUrlState(next);
setParams(getUrlParams(next));
};
const applyParams = (next: UrlParam[]): void => {
setParams(next);
setUrlState(updateUrlWithParams(url, next));
};
const patchParam = (index: number, patch: Partial<UrlParam>): void =>
applyParams(params.map((p, i) => (i === index ? { ...p, ...patch } : p)));
const addParam = (): void => {
const last = params[params.length - 1];
if (!last || last.key.trim() || last.value.trim()) {
setParams([...params, { key: '', value: '' }]);
}
};
const urlInvalid = !isValidContextLinkUrl(url);
const canSave = !!url.trim() && !urlInvalid;
const handleSave = (): void => {
if (canSave) {
onSave({ name, url, targetBlank, renderVariables: true });
}
};
return (
<DialogWrapper
open={open}
onOpenChange={onOpenChange}
title={initialLink ? 'Edit context link' : 'Add a context link'}
width="wide"
testId="context-link-dialog"
footer={
<>
<Button
type="button"
variant="outlined"
color="secondary"
data-testid="context-link-cancel"
onClick={(): void => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
variant="solid"
color="primary"
disabled={!canSave}
data-testid="context-link-save"
onClick={handleSave}
>
Save
</Button>
</>
}
>
<div className={styles.form}>
<div className={styles.formField}>
<Typography.Text className={styles.formLabel}>Label</Typography.Text>
<Input
data-testid="context-link-label"
placeholder="View trace details: {{_traceId}}"
value={name}
onChange={(e): void => setName(e.target.value)}
/>
</div>
<div className={styles.formField}>
<Typography.Text className={styles.formLabel}>
URL <span className={styles.required}>*</span>
</Typography.Text>
<VariablesPopover
variables={variables}
onVariableSelect={(token, cursor): void =>
setUrl(insertVariableAtCursor(url, token, cursor))
}
>
{({ setIsOpen, setCursorPosition }): JSX.Element => (
<Input
data-testid="context-link-url"
placeholder="https://… or /path?var={{variable}}"
value={url}
status={urlInvalid ? 'error' : undefined}
autoComplete="off"
onChange={(e): void => {
setCursorPosition(e.target.selectionStart ?? 0);
setUrl(e.target.value);
}}
onFocus={(): void => setIsOpen(true)}
onClick={(e): void => setCursorPosition(cursorOf(e))}
onKeyUp={(e): void => setCursorPosition(cursorOf(e))}
/>
)}
</VariablesPopover>
{urlInvalid && (
<Typography.Text
className={styles.urlError}
data-testid="context-link-url-error"
>
{URL_ERROR}
</Typography.Text>
)}
</div>
{params.length > 0 && (
<div className={styles.formField}>
<Typography.Text className={styles.formLabel}>
URL parameters
</Typography.Text>
<div className={styles.params}>
{params.map((param, index) => (
<div
className={styles.paramRow}
// Parameter rows have no stable id; index is the row identity here.
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<Input
data-testid={`context-link-param-key-${index}`}
placeholder="Key"
value={param.key}
onChange={(e): void => patchParam(index, { key: e.target.value })}
/>
<VariablesPopover
variables={variables}
onVariableSelect={(token, cursor): void =>
patchParam(index, {
value: insertVariableAtCursor(param.value, token, cursor),
})
}
>
{({ setIsOpen, setCursorPosition }): JSX.Element => (
<Input
data-testid={`context-link-param-value-${index}`}
placeholder="Value"
value={param.value}
onChange={(e): void => {
setCursorPosition(e.target.selectionStart ?? 0);
patchParam(index, { value: e.target.value });
}}
onFocus={(): void => setIsOpen(true)}
onClick={(e): void => setCursorPosition(cursorOf(e))}
onKeyUp={(e): void => setCursorPosition(cursorOf(e))}
/>
)}
</VariablesPopover>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove parameter ${index + 1}`}
data-testid={`context-link-param-remove-${index}`}
onClick={(): void =>
applyParams(params.filter((_, i) => i !== index))
}
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
</div>
)}
<Button
type="button"
variant="dashed"
color="secondary"
prefix={<Plus size={12} />}
data-testid="context-link-add-param"
onClick={addParam}
>
Add URL parameter
</Button>
<div className={styles.newTab}>
<Switch
testId="context-link-newtab"
value={targetBlank}
onChange={setTargetBlank}
/>
<Typography.Text className={styles.newTabLabel}>
Open in new tab
</Typography.Text>
</div>
</div>
</DialogWrapper>
);
}
export default ContextLinkDialog;

View File

@@ -1,62 +0,0 @@
import { Pencil, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './ContextLinksSection.module.scss';
interface ContextLinkListItemProps {
link: DashboardLinkDTO;
index: number;
onEdit: () => void;
onRemove: () => void;
}
/** A saved context link in the section list: its label + URL, with edit / delete actions. */
function ContextLinkListItem({
link,
index,
onEdit,
onRemove,
}: ContextLinkListItemProps): JSX.Element {
const label = link.name?.trim() || link.url || 'Untitled link';
return (
<div className={styles.listItem} data-testid={`context-link-item-${index}`}>
<div className={styles.listItemText}>
<Typography.Text className={styles.listItemLabel}>{label}</Typography.Text>
{!!link.url && (
<Typography.Text className={styles.listItemUrl}>
{link.url}
</Typography.Text>
)}
</div>
<div className={styles.listItemActions}>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label={`Edit link ${index + 1}`}
data-testid={`context-link-edit-${index}`}
onClick={onEdit}
>
<Pencil size={14} />
</Button>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove link ${index + 1}`}
data-testid={`context-link-remove-${index}`}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
</div>
);
}
export default ContextLinkListItem;

View File

@@ -1,99 +1,32 @@
.list {
display: flex;
flex-direction: column;
gap: 8px;
gap: 12px;
}
/* --- Saved-link list item --- */
.listItem {
.row {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
gap: 8px;
padding: 8px 10px;
padding: 10px;
border: 1px solid var(--l2-border);
border-radius: 6px;
}
.listItemText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.listItemLabel,
.listItemUrl {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.listItemLabel {
font-size: 13px;
color: var(--text-vanilla-100);
}
.listItemUrl {
font-size: 11px;
color: var(--text-vanilla-400);
}
.listItemActions {
.rowFooter {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
/* --- Dialog form --- */
.form {
display: flex;
flex-direction: column;
gap: 14px;
}
.formField {
display: flex;
flex-direction: column;
gap: 6px;
}
.formLabel {
font-size: 12px;
color: var(--l2-foreground);
}
.required {
color: var(--bg-cherry-400);
}
.urlError {
font-size: 11px;
color: var(--bg-cherry-400);
}
.params {
display: flex;
flex-direction: column;
gap: 8px;
}
.paramRow {
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: center;
gap: 6px;
justify-content: space-between;
}
.newTab {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.newTabLabel {
font-size: 12px;
color: var(--l2-foreground);
color: var(--text-vanilla-400);
}

View File

@@ -1,63 +1,83 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Plus, Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import ContextLinkDialog from './ContextLinkDialog';
import ContextLinkListItem from './ContextLinkListItem';
import { useContextLinkVariables } from './useContextLinkVariables';
import styles from './ContextLinksSection.module.scss';
/**
* Edits the panel's context links (`spec.links`) as a list of saved links, each added or
* edited through a modal ({@link ContextLinkDialog}) that carries V1's full authoring UX
* (variable autocomplete, URL-parameters editor, validation).
* Edits the panel's context links (`spec.links`): a list of label + URL rows with an
* "open in new tab" toggle, plus add/remove. Atomic section — no per-kind sub-controls.
* URLs may reference dashboard/query variables; that interpolation is resolved at render
* time, so this editor just captures the raw strings.
*/
function ContextLinksSection({
value,
onChange,
}: SectionEditorProps<SectionKind.ContextLinks>): JSX.Element {
const links = value ?? [];
const variables = useContextLinkVariables();
// `index === null` while adding a new link; a number while editing an existing one.
const [dialog, setDialog] = useState<{ open: boolean; index: number | null }>({
open: false,
index: null,
});
const updateAt = (index: number, patch: Partial<DashboardLinkDTO>): void =>
onChange(
links.map((link, i) => (i === index ? { ...link, ...patch } : link)),
);
const addLink = (): void =>
onChange([...links, { name: '', url: '', targetBlank: true }]);
const removeAt = (index: number): void =>
onChange(links.filter((_, i) => i !== index));
const handleSave = (link: DashboardLinkDTO): void => {
onChange(
dialog.index === null
? [...links, link]
: links.map((existing, i) => (i === dialog.index ? link : existing)),
);
setDialog((d) => ({ ...d, open: false }));
};
const editingLink =
dialog.index !== null ? (links[dialog.index] ?? null) : null;
return (
<div className={styles.list}>
{links.map((link, index) => (
<ContextLinkListItem
// Links have no stable id on the wire; index is the row identity here.
// eslint-disable-next-line react/no-array-index-key
key={index}
link={link}
index={index}
onEdit={(): void => setDialog({ open: true, index })}
onRemove={(): void => removeAt(index)}
/>
// Links have no stable id on the wire; index is the row identity here.
// eslint-disable-next-line react/no-array-index-key
<div className={styles.row} key={index}>
<Input
data-testid={`context-link-label-${index}`}
placeholder="Label"
value={link.name ?? ''}
onChange={(e): void => updateAt(index, { name: e.target.value })}
/>
<Input
data-testid={`context-link-url-${index}`}
placeholder="https://… or /path?var=$variable"
value={link.url ?? ''}
onChange={(e): void => updateAt(index, { url: e.target.value })}
/>
<div className={styles.rowFooter}>
<div className={styles.newTab}>
<Switch
testId={`context-link-newtab-${index}`}
value={link.targetBlank ?? false}
onChange={(checked: boolean): void =>
updateAt(index, { targetBlank: checked })
}
/>
<Typography.Text className={styles.newTabLabel}>
Open in new tab
</Typography.Text>
</div>
<Button
type="button"
variant="ghost"
color="destructive"
size="icon"
aria-label={`Remove link ${index + 1}`}
data-testid={`context-link-remove-${index}`}
onClick={(): void => removeAt(index)}
>
<Trash2 size={14} />
</Button>
</div>
</div>
))}
<Button
@@ -66,18 +86,10 @@ function ContextLinksSection({
color="secondary"
prefix={<Plus size={14} />}
data-testid="panel-editor-v2-add-link"
onClick={(): void => setDialog({ open: true, index: null })}
onClick={addLink}
>
Add Context Link
Add link
</Button>
<ContextLinkDialog
open={dialog.open}
initialLink={editingLink}
variables={variables}
onOpenChange={(open): void => setDialog((d) => ({ ...d, open }))}
onSave={handleSave}
/>
</div>
);
}

View File

@@ -1,64 +0,0 @@
.container {
width: 100%;
}
.anchor {
width: 100%;
}
.content {
z-index: 1100;
padding: 4px 0;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
min-width: var(--radix-popover-trigger-width);
}
.empty {
padding: 8px 12px;
color: var(--text-vanilla-400);
font-size: 12px;
font-style: italic;
}
.item {
--button-display: flex;
--button-justify-content: flex-start;
--button-height: auto;
--button-padding: 8px 12px;
--button-border-radius: 0;
--button-font-size: 13px;
--button-line-height: 1.4;
width: 100%;
overflow: hidden;
}
.row {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.name,
.source {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name {
flex: 1 1 auto;
text-align: start;
}
.source {
color: var(--l3-foreground);
font-size: 12px;
flex: 0 1 auto;
}

View File

@@ -1,117 +0,0 @@
// Uses Popover (not DropdownMenu): DropdownMenuTrigger preventDefaults pointerdown,
// which steals input focus and dismisses on every keystroke. PopoverAnchor is a passive
// positioning element that leaves the wrapped input fully interactive.
import { ReactNode, useRef, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
import { Typography } from '@signozhq/ui/typography';
import type { VariableItem } from './types';
import styles from './VariablesPopover.module.scss';
interface VariablesPopoverRenderProps {
setIsOpen: (open: boolean) => void;
setCursorPosition: (position: number | null) => void;
}
interface VariablesPopoverProps {
variables: VariableItem[];
/** Called with the braces-wrapped token (e.g. `{{env}}`) and the tracked cursor offset. */
onVariableSelect: (token: string, cursorPosition?: number) => void;
children: (props: VariablesPopoverRenderProps) => ReactNode;
}
/**
* Autocomplete for context-link variables. Wraps an input (via the render-prop) and
* shows the available `{{variables}}` grouped by source; picking one inserts it at the
* tracked cursor. The consumer drives `setIsOpen` (on focus) and `setCursorPosition`.
*/
function VariablesPopover({
variables,
onVariableSelect,
children,
}: VariablesPopoverProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
const anchorRef = useRef<HTMLDivElement>(null);
const handleOpenChange = (open: boolean): void => {
// Accept "close" events (outside-click, Esc) but ignore opens — opening is driven
// by the input's onFocus in the consumer.
if (!open) {
setIsOpen(false);
}
};
// Keep the popover open while the pointer/focus is inside the anchored input.
const keepOpenIfInsideAnchor = (event: {
target: EventTarget | null;
}): void => {
const target = event.target as Node | null;
if (
target &&
anchorRef.current?.contains(target) &&
'preventDefault' in event
) {
(event as Event).preventDefault();
}
};
return (
<div className={styles.container}>
<Popover open={isOpen} onOpenChange={handleOpenChange} modal={false}>
<PopoverAnchor asChild>
<div className={styles.anchor} ref={anchorRef}>
{children({ setIsOpen, setCursorPosition })}
</div>
</PopoverAnchor>
<PopoverContent
align="start"
sideOffset={4}
// Render inside the anchor (not a body portal): the editor lives in a modal
// DialogWrapper, and radix's modal sets pointer-events:none on everything
// portalled outside it, which would make the suggestions unclickable.
withPortal={false}
className={styles.content}
onOpenAutoFocus={(e): void => e.preventDefault()}
onCloseAutoFocus={(e): void => e.preventDefault()}
onInteractOutside={keepOpenIfInsideAnchor}
onFocusOutside={keepOpenIfInsideAnchor}
>
{variables.length === 0 ? (
<div className={styles.empty}>No variables available</div>
) : (
variables.map((v) => (
<Button
key={`${v.source}-${v.name}`}
type="button"
variant="ghost"
color="secondary"
size="md"
className={styles.item}
aria-label={`Insert {{${v.name}}}`}
testId={`context-link-variable-${v.name}`}
// Prevent the input from losing focus when clicking an item.
onMouseDown={(e): void => e.preventDefault()}
onClick={(): void => {
onVariableSelect(`{{${v.name}}}`, cursorPosition ?? undefined);
setIsOpen(false);
}}
>
<div className={styles.row}>
<Typography.Text
className={styles.name}
>{`{{${v.name}}}`}</Typography.Text>
<Typography.Text className={styles.source}>{v.source}</Typography.Text>
</div>
</Button>
))
)}
</PopoverContent>
</Popover>
</div>
);
}
export default VariablesPopover;

View File

@@ -3,92 +3,47 @@ import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import ContextLinksSection from '../ContextLinksSection';
// The variable-source hook reads the query-builder provider + store; stub it so the
// editor can be exercised in isolation (its own suite covers the sourcing logic).
jest.mock('../useContextLinkVariables', () => ({
useContextLinkVariables: (): unknown => [
{ name: 'timestamp_start', source: 'Global timestamp' },
{ name: 'env', source: 'Dashboard variable' },
],
}));
const LINKS: DashboardLinkDTO[] = [
{ name: 'Docs', url: 'https://signoz.io', targetBlank: true },
];
const lastCall = (fn: jest.Mock): DashboardLinkDTO[] =>
fn.mock.calls[fn.mock.calls.length - 1][0];
describe('ContextLinksSection', () => {
it('renders only the add button when there are no links', () => {
render(<ContextLinksSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByTestId('panel-editor-v2-add-link')).toBeInTheDocument();
expect(screen.queryByTestId('context-link-item-0')).not.toBeInTheDocument();
expect(screen.queryByTestId('context-link-label-0')).not.toBeInTheDocument();
});
it('renders existing links as list items showing the label', () => {
render(<ContextLinksSection value={LINKS} onChange={jest.fn()} />);
expect(screen.getByTestId('context-link-item-0')).toHaveTextContent('Docs');
// The editor is a modal — no inline fields until it's opened.
expect(screen.queryByTestId('context-link-url')).not.toBeInTheDocument();
});
it('adds a link through the dialog (Save gated on a valid URL)', async () => {
it('appends a blank link (open-in-new-tab on) when Add link is clicked', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={[]} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
const save = await screen.findByTestId('context-link-save');
expect(save).toBeDisabled();
fireEvent.change(screen.getByTestId('context-link-url'), {
target: { value: 'https://signoz.io' },
});
fireEvent.change(screen.getByTestId('context-link-label'), {
target: { value: 'Docs' },
});
expect(save).not.toBeDisabled();
fireEvent.click(save);
expect(onChange).toHaveBeenCalledWith([
{
name: 'Docs',
url: 'https://signoz.io',
targetBlank: true,
renderVariables: true,
},
{ name: '', url: '', targetBlank: true },
]);
});
it('edits an existing link through the dialog', async () => {
it('renders existing links and edits a label through onChange', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
fireEvent.click(screen.getByTestId('context-link-edit-0'));
const label = await screen.findByTestId('context-link-label');
expect(label).toHaveValue('Docs');
expect(screen.getByTestId('context-link-url')).toHaveValue(
expect(screen.getByTestId('context-link-label-0')).toHaveValue('Docs');
expect(screen.getByTestId('context-link-url-0')).toHaveValue(
'https://signoz.io',
);
fireEvent.change(label, { target: { value: 'Runbook' } });
fireEvent.click(screen.getByTestId('context-link-save'));
fireEvent.change(screen.getByTestId('context-link-label-0'), {
target: { value: 'Runbook' },
});
expect(onChange).toHaveBeenCalledWith([
{
name: 'Runbook',
url: 'https://signoz.io',
targetBlank: true,
renderVariables: true,
},
{ name: 'Runbook', url: 'https://signoz.io', targetBlank: true },
]);
});
it('removes a link from the list', () => {
it('removes a link through onChange', () => {
const onChange = jest.fn();
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
@@ -96,52 +51,4 @@ describe('ContextLinksSection', () => {
expect(onChange).toHaveBeenCalledWith([]);
});
it('shows a validation error only for a malformed URL', async () => {
render(<ContextLinksSection value={[]} onChange={jest.fn()} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
const urlInput = await screen.findByTestId('context-link-url');
fireEvent.change(urlInput, { target: { value: 'not-a-url' } });
expect(screen.getByTestId('context-link-url-error')).toBeInTheDocument();
expect(screen.getByTestId('context-link-save')).toBeDisabled();
fireEvent.change(urlInput, { target: { value: '/valid/path' } });
expect(
screen.queryByTestId('context-link-url-error'),
).not.toBeInTheDocument();
});
it('adds a URL parameter and writes it into the URL query string', async () => {
const onChange = jest.fn();
render(<ContextLinksSection value={[]} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
fireEvent.change(await screen.findByTestId('context-link-url'), {
target: { value: '/logs' },
});
fireEvent.click(screen.getByTestId('context-link-add-param'));
fireEvent.change(screen.getByTestId('context-link-param-key-0'), {
target: { value: 'env' },
});
fireEvent.click(screen.getByTestId('context-link-save'));
expect(lastCall(onChange)).toStrictEqual([
{ name: '', url: '/logs?env=', targetBlank: true, renderVariables: true },
]);
});
it('inserts a {{variable}} into the URL from the autocomplete popover', async () => {
render(<ContextLinksSection value={[]} onChange={jest.fn()} />);
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
const urlInput = await screen.findByTestId('context-link-url');
fireEvent.focus(urlInput);
fireEvent.click(
await screen.findByTestId('context-link-variable-timestamp_start'),
);
expect(urlInput).toHaveValue('{{timestamp_start}}');
});
});

View File

@@ -1,83 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useContextLinkVariables } from '../useContextLinkVariables';
const mockUseGetDashboardV2 = jest.fn();
const mockUseQueryBuilder = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (
selector: (s: { dashboardId: string }) => unknown,
): unknown => selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
}));
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
}));
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters',
() => ({
dtoToFormModel: (dto: { name: string }): { name: string } => ({
name: dto.name,
}),
}),
);
describe('useContextLinkVariables', () => {
beforeEach(() => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: {
builder: {
queryData: [
{ groupBy: [{ key: 'service.name' }, { key: 'env' }] },
// Duplicate across queries — must dedupe.
{ groupBy: [{ key: 'service.name' }] },
],
},
},
});
mockUseGetDashboardV2.mockReturnValue({
data: {
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
},
});
});
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
const { result } = renderHook(() => useContextLinkVariables());
expect(result.current).toStrictEqual([
{ name: 'timestamp_start', source: 'Global timestamp' },
{ name: 'timestamp_end', source: 'Global timestamp' },
{ name: '_service.name', source: 'Query variable' },
{ name: '_env', source: 'Query variable' },
{ name: 'region', source: 'Dashboard variable' },
{ name: 'tier', source: 'Dashboard variable' },
]);
});
it('still returns the global timestamps when there are no queries or variables', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: { builder: { queryData: [] } },
});
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
// dashboard is present, so the empty case is an empty spec, not `undefined`.
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
const { result } = renderHook(() => useContextLinkVariables());
expect(result.current).toStrictEqual([
{ name: 'timestamp_start', source: 'Global timestamp' },
{ name: 'timestamp_end', source: 'Global timestamp' },
]);
});
});

View File

@@ -1,68 +0,0 @@
import {
getUrlParams,
insertVariableAtCursor,
isValidContextLinkUrl,
updateUrlWithParams,
} from '../utils';
describe('ContextLinksSection utils', () => {
describe('isValidContextLinkUrl', () => {
it.each([
['', true],
['https://signoz.io', true],
['http://localhost:3301/trace', true],
['/trace/{{_traceId}}', true],
['{{host}}/trace', true],
['trace/{{_traceId}}', false],
['ftp://signoz.io', false],
])('validates %p as %p', (url, expected) => {
expect(isValidContextLinkUrl(url)).toBe(expected);
});
});
describe('insertVariableAtCursor', () => {
it('inserts at the cursor position', () => {
expect(insertVariableAtCursor('/trace/', '{{id}}', 7)).toBe('/trace/{{id}}');
expect(insertVariableAtCursor('ab', '{{x}}', 1)).toBe('a{{x}}b');
});
it('appends when no cursor position is given', () => {
expect(insertVariableAtCursor('/trace/', '{{id}}')).toBe('/trace/{{id}}');
});
});
describe('getUrlParams', () => {
it('returns [] when there is no query string', () => {
expect(getUrlParams('/trace/{{id}}')).toStrictEqual([]);
});
it('parses and decodes key/value pairs', () => {
expect(getUrlParams('/logs?service={{svc}}&env=prod')).toStrictEqual([
{ key: 'service', value: '{{svc}}' },
{ key: 'env', value: 'prod' },
]);
});
it('double-decodes over-encoded values', () => {
// %257B%257B == encodeURIComponent('%7B%7B') == encodeURIComponent('{{')
expect(getUrlParams('/logs?q=%257B%257Bx%257D%257D')).toStrictEqual([
{ key: 'q', value: '{{x}}' },
]);
});
});
describe('updateUrlWithParams', () => {
it('rebuilds the query string and drops empty keys', () => {
expect(
updateUrlWithParams('/logs?old=1', [
{ key: 'service', value: '{{svc}}' },
{ key: '', value: 'ignored' },
]),
).toBe('/logs?service=%7B%7Bsvc%7D%7D');
});
it('drops the query string entirely when no valid params remain', () => {
expect(updateUrlWithParams('/logs?a=b', [])).toBe('/logs');
});
});
});

View File

@@ -1,29 +0,0 @@
/**
* Where a context-link variable comes from — shown as the right-hand label in the popover.
*
* - `Global timestamp` — the dashboard's selected time range: `timestamp_start` /
* `timestamp_end`. Always available, independent of the panel or its queries.
* - `Query variable` — a `groupBy` field from the panel's own queries, `_`-prefixed
* (e.g. `_service_name`). Resolved at click time from the data point the user clicked,
* so it reflects the current query definition and changes as the queries are edited.
* - `Dashboard variable` — a user-defined dashboard variable from Dashboard Settings
* (e.g. `env`, `region`). Shared across every panel and driven by the dashboard's
* variable selectors rather than by an individual query.
*/
export type VariableSource =
| 'Global timestamp'
| 'Query variable'
| 'Dashboard variable';
/** One entry in the context-link variable autocomplete. */
export interface VariableItem {
/** Bare variable name without braces, e.g. `timestamp_start`, `_service_name`, `env`. */
name: string;
source: VariableSource;
}
/** A single decoded key/value pair parsed from (or written back to) a URL query string. */
export interface UrlParam {
key: string;
value: string;
}

View File

@@ -1,60 +0,0 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { dtoToFormModel } from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from 'pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired';
import type { VariableItem } from './types';
// Global time-range variables, always available (V1 parity: `timestamp_start` / `_end`).
const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
{ name: 'timestamp_start', source: 'Global timestamp' },
{ name: 'timestamp_end', source: 'Global timestamp' },
];
/**
* Variables offered by the context-link autocomplete, ordered as in V1: global
* timestamps, then per-query `groupBy` fields (prefixed `_`), then dashboard variables.
*
* Self-contained — the editor renders inside the query-builder provider and the
* DashboardContainer, so every source is read here rather than threaded through the
* section registry. The dashboard fetch dedupes against the editor page's own query.
*/
export function useContextLinkVariables(): VariableItem[] {
const { currentQuery } = useQueryBuilder();
const { variables: variableDtos } = useDashboardFetchRequired();
const dashboardVariableNames = useMemo(
() =>
variableDtos
.map((dto) => dtoToFormModel(dto).name)
.filter((name): name is string => !!name),
[variableDtos],
);
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
const fieldVariableNames = useMemo(() => {
const names = new Set<string>();
(currentQuery?.builder?.queryData ?? []).forEach((query) => {
(query.groupBy ?? []).forEach((field) => {
if (field.key) {
names.add(`_${field.key}`);
}
});
});
return Array.from(names);
}, [currentQuery?.builder?.queryData]);
return useMemo(
() => [
...GLOBAL_TIMESTAMP_VARIABLES,
...fieldVariableNames.map(
(name): VariableItem => ({ name, source: 'Query variable' }),
),
...dashboardVariableNames.map(
(name): VariableItem => ({ name, source: 'Dashboard variable' }),
),
],
[fieldVariableNames, dashboardVariableNames],
);
}

View File

@@ -1,78 +0,0 @@
import type { UrlParam } from './types';
// A link URL must be absolute (http/https), root-relative (`/path`), or start with a
// leading `{{var}}/` segment (a variable that resolves to a host/path at click-time).
const CONTEXT_LINK_URL_PATTERN = /^(https?:\/\/|\/|{{.*}}\/)/;
/**
* Whether the URL is well-formed for a context link. Empty is treated as valid so the
* editor doesn't nag while a row is still being filled in — emptiness is a separate concern.
*/
export function isValidContextLinkUrl(url: string): boolean {
if (!url) {
return true;
}
return CONTEXT_LINK_URL_PATTERN.test(url);
}
/** Inserts `variable` into `current` at `cursorPosition`, or appends when it's unknown. */
export function insertVariableAtCursor(
current: string,
variable: string,
cursorPosition?: number,
): string {
if (cursorPosition === undefined) {
return current + variable;
}
return (
current.slice(0, cursorPosition) + variable + current.slice(cursorPosition)
);
}
// Values may be double-encoded on the wire; decode a second time only when it changes
// the string, so already-single-encoded values are left intact.
function decodeForDisplay(value: string): string {
const decoded = decodeURIComponent(value);
try {
const doubleDecoded = decodeURIComponent(decoded);
return doubleDecoded !== decoded ? doubleDecoded : decoded;
} catch {
return decoded;
}
}
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
export function getUrlParams(url: string): UrlParam[] {
const [, queryString] = url.split('?');
if (!queryString) {
return [];
}
const params: UrlParam[] = [];
queryString.split('&').forEach((pair) => {
const [key, value] = pair.split('=');
if (key) {
params.push({
key: decodeURIComponent(key),
value: decodeForDisplay(value || ''),
});
}
});
return params;
}
/** Rewrites `url`'s query string from `params`, dropping rows with an empty key. */
export function updateUrlWithParams(url: string, params: UrlParam[]): string {
const [baseUrl] = url.split('?');
const queryString = params
.filter((param) => param.key.trim() !== '')
.map(
(param) =>
`${encodeURIComponent(param.key.trim())}=${encodeURIComponent(
param.value,
)}`,
)
.join('&');
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
}

View File

@@ -3,7 +3,6 @@ import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { EQueryType } from 'types/common/dashboard';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
@@ -39,9 +38,7 @@ function VisualizationSection({
{controls.switchPanelKind && panelKind && onChangePanelKind && (
<PanelTypeSwitcher
panelKind={panelKind}
// queryType is optional on the kind-erased section context, but always
// supplied in practice; default to Query Builder at this boundary.
queryType={queryType ?? EQueryType.QUERY_BUILDER}
queryType={queryType}
signal={signal}
onChange={onChangePanelKind}
/>

View File

@@ -164,7 +164,7 @@ function PanelEditorQueryBuilder({
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
<RunQueryBtn
className="run-query-dashboard-btn"
label="Run Query"
label="Stage & Run Query"
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={onCancelQuery}

View File

@@ -49,13 +49,6 @@
background: var(--l2-background);
}
// Standalone View stacks the graph-manager below the chart inside the surface (it
// must stay within the chart's PlotContext). Let it flow out of the surface so the
// modal body scrolls as a whole, instead of clipping it or scrolling the panel.
.surfaceStacked {
overflow: visible;
}
.state {
flex: 1;
display: flex;

View File

@@ -1,13 +1,11 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
PanelPagination,
@@ -34,14 +32,6 @@ interface PreviewPaneProps {
onDragSelect: (start: number, end: number) => void;
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
pagination?: PanelPagination;
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
panelMode?: PanelMode;
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
hideHeader?: boolean;
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
dashboardPreference?: DashboardPreference;
/** Close the standalone View modal — forwarded to the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
}
/**
@@ -60,10 +50,6 @@ function PreviewPane({
refetch,
onDragSelect,
pagination,
panelMode = PanelMode.DASHBOARD_EDIT,
hideHeader = false,
dashboardPreference,
onCloseStandaloneView,
}: PreviewPaneProps): JSX.Element {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const queryType = getPanelQueryType(panel);
@@ -75,24 +61,18 @@ function PreviewPane({
return (
<div className={styles.preview}>
{!hideHeader && (
<div className={styles.header}>
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
<div className={styles.header}>
<PlotTag
queryType={queryType}
panelType={panelType}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
)}
</div>
<div className={styles.container}>
<div
className={cx(styles.surface, {
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
})}
>
<div className={styles.surface}>
<PanelHeader
panelId={panelId}
panel={panel}
@@ -114,11 +94,9 @@ function PreviewPane({
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={panelMode}
dashboardPreference={dashboardPreference}
panelMode={PanelMode.DASHBOARD_EDIT}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onCloseStandaloneView={onCloseStandaloneView}
/>
</div>
</div>

View File

@@ -1,274 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
/**
* Characterization test for the editor's composition: which derived values and
* options it forwards to the draft/query/query-sync/type-switch hooks and to its
* children. The leaf hooks are mocked as arg-capturing spies so this pins the
* wiring; it stays valid (and guards behavior) after that wiring is pulled into a
* shared edit-session hook, since the mocks intercept the leaf hooks either way.
*/
const mockSetSpec = jest.fn();
const mockRefetch = jest.fn();
const mockCancelQuery = jest.fn();
const mockBuildSaveSpec = jest.fn((spec: unknown) => spec);
const mockOnChangePanelKind = jest.fn();
const mockSave = jest.fn().mockResolvedValue(undefined);
const mockUseDraft = jest.fn();
jest.mock('../hooks/usePanelEditorDraft', () => ({
usePanelEditorDraft: (panel: unknown): unknown => mockUseDraft(panel),
}));
const mockUseQuery = jest.fn();
jest.mock('../../hooks/usePanelQuery', () => ({
usePanelQuery: (args: unknown): unknown => mockUseQuery(args),
}));
const mockUseQuerySync = jest.fn();
jest.mock('../hooks/usePanelEditorQuerySync', () => ({
usePanelEditorQuerySync: (args: unknown): unknown => mockUseQuerySync(args),
}));
const mockUseTypeSwitch = jest.fn();
jest.mock('../hooks/usePanelTypeSwitch', () => ({
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
}));
jest.mock('../hooks/usePanelEditorSave', () => ({
usePanelEditorSave: (): unknown => ({ save: mockSave, isSaving: false }),
}));
jest.mock('../hooks/useSwitchColumnsOnSignalChange', () => ({
useSwitchColumnsOnSignalChange: jest.fn(),
}));
jest.mock('../hooks/useSeedNewListColumns', () => ({
useSeedNewListColumns: jest.fn(),
}));
jest.mock('../hooks/useLegendSeries', () => ({
useLegendSeries: (): [] => [],
}));
jest.mock('../hooks/useTableColumns', () => ({
useTableColumns: (): [] => [],
}));
jest.mock('../hooks/useMetricYAxisUnit', () => ({
useMetricYAxisUnit: (): unknown => ({
metricUnit: undefined,
isLoading: false,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: { queryType: 'builder' } }),
}));
jest.mock(
'../../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions',
() => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: {},
}),
}),
);
jest.mock('@signozhq/ui/resizable', () => ({
__esModule: true,
ResizablePanelGroup: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
ResizablePanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
ResizableHandle: (): null => null,
useDefaultLayout: (): unknown => ({
defaultLayout: undefined,
onLayoutChanged: jest.fn(),
}),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
// Children mocked to capture props (and expose a Save trigger / footer slot).
const mockHeaderProps = jest.fn();
jest.mock('../Header/Header', () => ({
__esModule: true,
default: (props: { onSave: () => void }): JSX.Element => {
mockHeaderProps(props);
return (
<button type="button" data-testid="editor-save" onClick={props.onSave}>
save
</button>
);
},
}));
const mockPreviewProps = jest.fn();
jest.mock('../PreviewPane/PreviewPane', () => ({
__esModule: true,
default: (props: unknown): JSX.Element => {
mockPreviewProps(props);
return <div data-testid="preview" />;
},
}));
const mockQbProps = jest.fn();
jest.mock('../PanelEditorQueryBuilder/PanelEditorQueryBuilder', () => ({
__esModule: true,
default: (props: { footer?: React.ReactNode }): JSX.Element => {
mockQbProps(props);
return <div data-testid="qb">{props.footer}</div>;
},
}));
const mockConfigProps = jest.fn();
jest.mock('../ConfigPane/ConfigPane', () => ({
__esModule: true,
default: (props: unknown): JSX.Element => {
mockConfigProps(props);
return <div data-testid="config" />;
},
}));
jest.mock('../ListColumnsEditor/ListColumnsEditor', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="list-columns" />,
}));
function makePanel(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name: 'CPU' },
plugin: { kind, spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
const baseProps = {
dashboardId: 'dash-1',
panelId: 'panel-1',
onClose: jest.fn(),
onSaved: jest.fn(),
};
function setup(
panel: DashboardtypesPanelDTO,
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
): void {
mockUseDraft.mockReturnValue({
draft: panel,
spec: panel.spec,
setSpec: mockSetSpec,
isSpecDirty: false,
});
mockUseQuery.mockReturnValue({
data: { response: undefined },
isFetching: false,
error: null,
cancelQuery: mockCancelQuery,
refetch: mockRefetch,
pagination: undefined,
});
mockUseQuerySync.mockReturnValue({
runQuery: jest.fn(),
isQueryDirty: false,
buildSaveSpec: mockBuildSaveSpec,
});
mockUseTypeSwitch.mockReturnValue({
onChangePanelKind: mockOnChangePanelKind,
});
render(<PanelEditorContainer {...baseProps} panel={panel} {...overrides} />);
}
describe('PanelEditorContainer composition', () => {
beforeEach(() => jest.clearAllMocks());
it('renders the editor shell with preview, query builder, and config pane', () => {
const panel = makePanel('signoz/TimeSeriesPanel');
setup(panel);
expect(screen.getByTestId('panel-editor-v2')).toBeInTheDocument();
expect(screen.getByTestId('preview')).toBeInTheDocument();
expect(screen.getByTestId('qb')).toBeInTheDocument();
expect(screen.getByTestId('config')).toBeInTheDocument();
expect(mockPreviewProps).toHaveBeenCalledWith(
expect.objectContaining({
panel,
panelDefinition: getPanelDefinition('signoz/TimeSeriesPanel'),
}),
);
expect(mockQbProps).toHaveBeenCalledWith(
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
);
expect(mockConfigProps).toHaveBeenCalledWith(
expect.objectContaining({
panel,
spec: panel.spec,
onChangePanelKind: mockOnChangePanelKind,
}),
);
});
it('forwards the derived panel type + query-sync options to the leaf hooks', () => {
const panel = makePanel('signoz/TimeSeriesPanel');
setup(panel);
expect(mockUseQuery).toHaveBeenCalledWith(
expect.objectContaining({ panel, panelId: 'panel-1', enabled: true }),
);
expect(mockUseQuerySync).toHaveBeenCalledWith(
expect.objectContaining({
panelType: PANEL_TYPES.TIME_SERIES,
setSpec: mockSetSpec,
refetch: mockRefetch,
alwaysSerializeQuery: false,
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
}),
);
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
expect.objectContaining({
panelType: PANEL_TYPES.TIME_SERIES,
spec: panel.spec,
setSpec: mockSetSpec,
}),
);
});
it('marks a new panel dirty and always serializes its query', () => {
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
expect(mockUseQuerySync).toHaveBeenCalledWith(
expect.objectContaining({ alwaysSerializeQuery: true }),
);
expect(mockHeaderProps).toHaveBeenCalledWith(
expect.objectContaining({ isDirty: true }),
);
});
it('bakes the live query into the spec on save, then notifies', async () => {
const panel = makePanel('signoz/TimeSeriesPanel');
setup(panel, { onSaved: baseProps.onSaved });
await userEvent.click(screen.getByTestId('editor-save'));
await waitFor(() => expect(baseProps.onSaved).toHaveBeenCalled());
expect(mockBuildSaveSpec).toHaveBeenCalledWith(panel.spec);
expect(mockSave).toHaveBeenCalledWith(panel.spec);
});
it('renders the list-columns editor only for list panels', () => {
setup(makePanel('signoz/ListPanel'));
expect(screen.getByTestId('list-columns')).toBeInTheDocument();
});
it('omits the list-columns editor for non-list panels', () => {
setup(makePanel('signoz/TimeSeriesPanel'));
expect(screen.queryByTestId('list-columns')).not.toBeInTheDocument();
});
});

View File

@@ -1,119 +0,0 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
time?: PanelQueryTimeOverride;
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
alwaysSerializeQuery?: boolean;
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
seedQuerySignal?: boolean;
}
export interface UsePanelEditSessionReturn {
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
draft: DashboardtypesPanelDTO;
spec: DashboardtypesPanelSpecDTO;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
isSpecDirty: boolean;
/** Restore the draft to the originally-loaded panel. */
reset: () => void;
/** Draft kind → V1 panel type (drives the query builder + preview). */
panelType: PANEL_TYPES;
panelDefinition: RenderablePanelDefinition;
/** The kind's first supported signal — seeds new queries/columns. */
defaultSignal: TelemetrytypesSignalDTO;
/** Shared query result for the draft over the resolved time window. */
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft. */
runQuery: () => void;
isQueryDirty: boolean;
/** Bake the live (possibly un-run) query into a spec — for save / editor handoff. */
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
/** Switch the draft's visualization kind in place (reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The panel-editing pipeline shared by the full-page editor and the View modal's
* drilldown editor: a local draft, its query result over the resolved time window,
* the staged-query sync, and the visualization-kind switch. Each consumer layers its
* own concerns on top (the editor adds save + list seeding; the modal adds per-view
* time isolation + reset). Keeping the wiring here stops the two from drifting.
*/
export function usePanelEditSession({
panel,
panelId,
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
const { draft, spec, setSpec, isSpecDirty, reset } =
usePanelEditorDraft(panel);
const panelKind = draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const defaultSignal = panelDefinition.supportedSignals[0];
const query = usePanelQuery({
panel: draft,
panelId,
time,
enabled: !!panelDefinition,
});
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch: query.refetch,
alwaysSerializeQuery,
signal: seedQuerySignal ? defaultSignal : undefined,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draft.spec,
panelType,
setSpec,
});
return {
draft,
spec,
setSpec,
isSpecDirty,
reset,
panelType,
panelDefinition,
defaultSignal,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
};
}

View File

@@ -12,7 +12,13 @@ import {
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { getExecStats } from '../queryV5/v5ResponseData';
@@ -23,9 +29,12 @@ import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelQuery } from '../hooks/usePanelQuery';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelEditorQuerySync } from './hooks/usePanelEditorQuerySync';
import { useMetricYAxisUnit } from './hooks/useMetricYAxisUnit';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useTableColumns } from './hooks/useTableColumns';
@@ -61,36 +70,7 @@ function PanelEditorContainer({
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
panelDefinition,
defaultSignal,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
} = usePanelEditSession({
panel,
panelId,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
const { draft, spec, setSpec, isSpecDirty } = usePanelEditorDraft(panel);
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
@@ -114,7 +94,44 @@ function PanelEditorContainer({
storage: layoutStorage,
});
const panelKind = draft.spec.plugin.kind;
// Panel kind → V1 panel type, which drives the query builder and preview.
const fullKind = draft.spec.plugin.kind;
const panelType =
(fullKind && PANEL_KIND_TO_PANEL_TYPE[fullKind as PanelKind]) ??
PANEL_TYPES.TIME_SERIES;
// One shared query result for the whole editor; the preview renders it.
const panelDefinition = getPanelDefinition(draft.spec.plugin.kind);
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = usePanelQuery({
panel: draft,
panelId,
enabled: !!panelDefinition,
});
// A new panel's default signal (its kind's first supported) — seeds the query and columns.
const defaultSignal = panelDefinition.supportedSignals[0];
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
draft,
panelType,
setSpec,
refetch,
// New panel's seed query is the builder default, not a real saved query —
// always serialize it on save.
alwaysSerializeQuery: isNew,
signal: defaultSignal,
});
// Switch the panel's visualization kind in place (reversible per session).
const { onChangePanelKind } = usePanelTypeSwitch({ spec, panelType, setSpec });
// At editor level, not the collapsible FormattingSection, so seeding runs while closed.
const formattingUnit = (
@@ -146,7 +163,7 @@ function PanelEditorContainer({
// Spec and query dirtiness are tracked independently so query re-serialization
// never false-dirties. A new panel is always savable (you're creating it).
const isDirty = isNew || isSpecDirty || isQueryDirty;
const isListPanel = panelKind === 'signoz/ListPanel';
const isListPanel = fullKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
@@ -236,7 +253,7 @@ function PanelEditorContainer({
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<PanelEditorQueryBuilder
panelKind={panelKind}
panelKind={fullKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}

View File

@@ -1,10 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Router location state for opening the panel editor pre-loaded with edits instead of
* the saved panel. The View modal sets this so "Switch to Edit Mode" carries its
* drilldown-edited spec (queries/plugin) into the editor.
*/
export interface PanelEditorHandoffState {
editSpec?: DashboardtypesPanelSpecDTO;
}

View File

@@ -1,9 +1,7 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
@@ -39,7 +37,6 @@ function BarPanelRenderer({
onDragSelect,
dashboardPreference,
panelMode,
onCloseStandaloneView,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -117,32 +114,6 @@ function BarPanelRenderer({
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
// The standalone View modal shows V1's graph-manager legend below the chart:
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
const layoutChildren = useMemo(
() =>
panelMode === PanelMode.STANDALONE_VIEW ? (
<div className={PanelStyles.chartManagerContainer}>
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
onCancel={onCloseStandaloneView}
/>
</div>
) : null,
[
panelMode,
config,
chartData,
spec.formatting?.unit,
decimalPrecision,
onCloseStandaloneView,
],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
@@ -176,7 +147,6 @@ function BarPanelRenderer({
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}

View File

@@ -1,9 +1,7 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
@@ -39,7 +37,6 @@ function TimeSeriesPanelRenderer({
onDragSelect,
dashboardPreference,
panelMode,
onCloseStandaloneView,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -118,32 +115,6 @@ function TimeSeriesPanelRenderer({
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
// The standalone View modal shows V1's graph-manager legend below the chart:
// Filter Series + per-series show/hide + Save. Series visibility auto-persists to
// localStorage (STANDALONE_VIEW selection prefs), keyed by panelId.
const layoutChildren = useMemo(
() =>
panelMode === PanelMode.STANDALONE_VIEW ? (
<div className={PanelStyles.chartManagerContainer}>
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
onCancel={onCloseStandaloneView}
/>
</div>
) : null,
[
panelMode,
config,
chartData,
spec.formatting?.unit,
decimalPrecision,
onCloseStandaloneView,
],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
@@ -177,7 +148,6 @@ function TimeSeriesPanelRenderer({
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
layoutChildren={layoutChildren}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}

View File

@@ -7,7 +7,3 @@
height: 100%;
position: relative;
}
.chartManagerContainer {
padding: 36px 0;
}

View File

@@ -22,9 +22,6 @@ export type PanelClickEvent =
type DragSelect = (start: number, end: number) => void;
/** Close the standalone View modal — fired by the chart's graph-manager Save/Cancel. */
type CloseStandaloneView = () => void;
/**
* Per-kind interaction props — each kind exposes only the gestures it supports.
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
@@ -34,12 +31,10 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
'signoz/TimeSeriesPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/BarChartPanel': {
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
@@ -55,5 +50,4 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
export interface AnyPanelInteractionProps {
onClick?: (event: PanelClickEvent) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
}

View File

@@ -17,15 +17,3 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
};
/**
* Reverse of {@link PANEL_KIND_TO_PANEL_TYPE} — the mapping is a bijection, so every
* panel kind round-trips. Partial because `PANEL_TYPES` also has types with no V2 kind
* (e.g. trace/empty); a lookup on those returns `undefined`.
*/
export const PANEL_TYPE_TO_PANEL_KIND: Partial<Record<PANEL_TYPES, PanelKind>> =
Object.fromEntries(
(Object.entries(PANEL_KIND_TO_PANEL_TYPE) as [PanelKind, PANEL_TYPES][]).map(
([kind, type]) => [type, kind],
),
);

View File

@@ -1,73 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getSwitchedPluginSpec } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/getSwitchedPluginSpec';
import { PANEL_TYPE_TO_PANEL_KIND } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { toPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { buildViewPanelSpec } from '../buildViewPanelSpec';
// The query conversion + kind-switch spec builder are tested in their own suites; here we
// isolate buildViewPanelSpec's branching (same kind vs. kind switch).
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
() => ({ toPerses: jest.fn(() => [{ kind: 'mock-query' }]) }),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelEditor/getSwitchedPluginSpec',
() => ({ getSwitchedPluginSpec: jest.fn(() => ({ switched: true })) }),
);
const query = {} as Query;
function specOfKind(kind: string): DashboardtypesPanelSpecDTO {
return {
plugin: { kind, spec: { formatting: {} } },
queries: [],
display: { name: 'panel' },
} as unknown as DashboardtypesPanelSpecDTO;
}
describe('PANEL_TYPE_TO_PANEL_KIND', () => {
it('is the inverse of the kind→type map', () => {
expect(PANEL_TYPE_TO_PANEL_KIND[PANEL_TYPES.VALUE]).toBe(
'signoz/NumberPanel',
);
expect(PANEL_TYPE_TO_PANEL_KIND[PANEL_TYPES.TABLE]).toBe('signoz/TablePanel');
expect(PANEL_TYPE_TO_PANEL_KIND[PANEL_TYPES.TIME_SERIES]).toBe(
'signoz/TimeSeriesPanel',
);
});
});
describe('buildViewPanelSpec', () => {
beforeEach(() => jest.clearAllMocks());
it('keeps the kind and only swaps the queries when the target type matches', () => {
const spec = specOfKind('signoz/TimeSeriesPanel');
const result = buildViewPanelSpec({
spec,
query,
panelType: PANEL_TYPES.TIME_SERIES,
});
expect(result.plugin.kind).toBe('signoz/TimeSeriesPanel');
expect(result.plugin.spec).toBe(spec.plugin.spec);
expect(result.queries).toStrictEqual([{ kind: 'mock-query' }]);
expect(toPerses).toHaveBeenCalledWith(query, PANEL_TYPES.TIME_SERIES);
expect(getSwitchedPluginSpec).not.toHaveBeenCalled();
});
it('switches the kind (Value → Table) and rebuilds the plugin spec', () => {
const result = buildViewPanelSpec({
spec: specOfKind('signoz/NumberPanel'),
query,
panelType: PANEL_TYPES.TABLE,
});
expect(result.plugin.kind).toBe('signoz/TablePanel');
expect(result.plugin.spec).toStrictEqual({ switched: true });
expect(getSwitchedPluginSpec).toHaveBeenCalled();
expect(toPerses).toHaveBeenCalledWith(query, PANEL_TYPES.TABLE);
});
});

View File

@@ -1,52 +0,0 @@
import type {
DashboardtypesPanelPluginDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getSwitchedPluginSpec } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/getSwitchedPluginSpec';
import {
PANEL_TYPE_TO_PANEL_KIND,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { toPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderQueries } from '../getBuilderQueries';
/**
* Bakes a V1 query + target panel type into a View-modal spec (drilldown seed + URL re-hydration).
* When `panelType` maps to a different kind than the panel's (e.g. a breakout turns Value → Table),
* the kind is switched via the editor's `getSwitchedPluginSpec` so it opens with populated config.
*/
export function buildViewPanelSpec({
spec,
query,
panelType,
}: {
spec: DashboardtypesPanelSpecDTO;
query: Query;
panelType: PANEL_TYPES;
}): DashboardtypesPanelSpecDTO {
const queries = toPerses(query, panelType);
const currentKind = spec.plugin.kind as PanelKind;
const newKind = PANEL_TYPE_TO_PANEL_KIND[panelType] ?? currentKind;
if (newKind === currentKind) {
return { ...spec, queries };
}
// The plugin cast mirrors the editor's type-switch — a dynamically chosen kind can't be
// correlated with its spec statically.
const signal = getBuilderQueries(spec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
return {
...spec,
plugin: {
...spec.plugin,
kind: newKind,
spec: getSwitchedPluginSpec(spec, newKind, signal),
} as DashboardtypesPanelPluginDTO,
queries,
};
}

View File

@@ -14,19 +14,6 @@ jest.mock(
}),
);
const mockOpenView = jest.fn();
jest.mock('../../hooks/useViewPanel', () => ({
useViewPanel: (): {
openView: jest.Mock;
closeView: jest.Mock;
expandedPanelId: string | null;
} => ({
openView: mockOpenView,
closeView: jest.fn(),
expandedPanelId: null,
}),
}));
const mockMovePanel = jest.fn();
jest.mock('../../hooks/useMovePanelToSection', () => ({
useMovePanelToSection: (): jest.Mock => mockMovePanel,
@@ -277,13 +264,18 @@ describe('usePanelActionItems', () => {
});
});
it('view opens the View modal for the panel', () => {
it('not-yet-implemented actions (view) fire the placeholder alert with the feature name', () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const view = result.current.items.find(
(i) => 'key' in i && i.key === 'view-panel',
);
(view as { onClick: () => void }).onClick();
expect(mockOpenView).toHaveBeenCalledWith('panel-1');
expect(alertSpy).toHaveBeenCalledTimes(1);
expect(alertSpy).toHaveBeenCalledWith('View option clicked');
alertSpy.mockRestore();
});
it('create-alert seeds an alert from this panel', () => {

View File

@@ -30,7 +30,6 @@ import {
type MovePanelArgs,
useMovePanelToSection,
} from '../hooks/useMovePanelToSection';
import { useViewPanel } from '../hooks/useViewPanel';
import { PANEL_ACTION_META } from './panelActionMeta';
// Stable fallback so renders without layout context don't churn the mutation
@@ -147,7 +146,6 @@ export function usePanelActionItems({
const isEditable = useDashboardStore((s) => s.isEditable);
const openPanelEditor = useOpenPanelEditor();
const createAlert = useCreateAlertFromPanel();
const { openView } = useViewPanel();
// Mutations are store-backed (dashboardId/refetch) — the layout tree only
// supplies data (`sections`), so no callbacks are threaded through it.
@@ -180,7 +178,7 @@ export function usePanelActionItems({
key: 'view-panel',
label: 'View',
icon: <Fullscreen size={14} />,
onClick: (): void => openView(panelId),
onClick: (): void => notImplementedYet('View'),
});
}
if (isEditable && canEditWidget && panelCapabilities.edit) {
@@ -265,7 +263,6 @@ export function usePanelActionItems({
panelActions,
sections,
panelId,
openView,
openPanelEditor,
createAlert,
movePanel,

View File

@@ -35,8 +35,6 @@ interface PanelBodyProps {
searchTerm?: string;
/** Server-side paging handles — only consumed by raw/list renderers. */
pagination?: PanelPagination;
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
}
/**
@@ -57,7 +55,6 @@ function PanelBody({
panelMode = PanelMode.DASHBOARD_VIEW,
searchTerm,
pagination,
onCloseStandaloneView,
}: PanelBodyProps): JSX.Element {
// A retained response (keepPreviousData) counts as data only if its type matches the current
// request — else a prior panel kind's response (time_series → raw) flashes NoData on switch.
@@ -124,7 +121,6 @@ function PanelBody({
dashboardPreference={dashboardPreference}
searchTerm={searchTerm}
pagination={pagination}
onCloseStandaloneView={onCloseStandaloneView}
/>
</div>
);

View File

@@ -1,17 +1,9 @@
// Expanded state: a compact input that fits the header row.
.input {
width: min(100%, 320px);
height: 24px;
width: 180px;
}
.clear {
--button-height: 18px;
--button-width: 18px;
--button-padding: 0;
}
.searchTrigger {
--button-width: 24px;
--button-height: 24px;
--button-padding: 4px;
}

View File

@@ -43,7 +43,6 @@ function PanelHeaderSearch({
color="secondary"
size="icon"
onClick={(): void => setExpanded(true)}
className={styles.searchTrigger}
data-testid="panel-header-search-trigger"
aria-label="Search"
>

View File

@@ -1,63 +0,0 @@
@use '../../../../../../styles/scrollbar' as *;
.modal {
:global(.ant-modal-body) {
padding: 0px;
}
}
// Truncate a long panel name so the header stays on one line; the wrapping tooltip
// then surfaces the full name on hover.
.title {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
// Tall, fixed-height column so the renderer's resize observer measures real
// dimensions — the chart self-sizes to fill whatever space it's given.
.content {
display: flex;
flex-direction: column;
gap: 8px;
height: 78vh;
overflow: auto;
padding: 12px;
@include custom-scrollbar;
}
.queryBuilder {
flex: 0 0 auto;
overflow: auto;
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex: 0 0 auto;
}
.toolbarTime {
display: flex;
align-items: center;
gap: 4px;
}
.body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 480px;
}
.panelTypeSelector {
width: 240px;
}

View File

@@ -1,52 +0,0 @@
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { Modal } from 'antd';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ViewPanelModalContent from './ViewPanelModalContent';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelModalProps {
/**
* The expanded panel and its id. Absent while the modal is closed — a single
* host instance lives at the layout level and only carries a panel when open.
*/
panel?: DashboardtypesPanelDTO;
panelId?: string;
open: boolean;
onClose: () => void;
}
function ViewPanelModal({
panel,
panelId,
open,
onClose,
}: ViewPanelModalProps): JSX.Element {
const name = panel?.spec.display.name ?? '';
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
centered
width="85%"
destroyOnClose
className={styles.modal}
title={
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name} - (View mode)
</Typography.Text>
</TooltipSimple>
}
>
{open && panel && panelId && (
<ViewPanelModalContent panel={panel} panelId={panelId} onClose={onClose} />
)}
</Modal>
);
}
export default ViewPanelModal;

View File

@@ -1,125 +0,0 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import PanelEditorQueryBuilder from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { useOpenPanelEditor } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor';
import { usePanelInteractions } from '../hooks/usePanelInteractions';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelMode } from './useViewPanelMode';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelModalContentProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Close the modal — wired to the graph manager's Save/Cancel. */
onClose: () => void;
}
/**
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
* the panel (preview) over a per-view time window plus the shared query builder, so the
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
*/
function ViewPanelModalContent({
panel,
panelId,
onClose,
}: ViewPanelModalContentProps): JSX.Element | null {
const {
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
} = useViewPanelTimeWindow();
const {
draft,
panelDefinition,
signal,
queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
} = useViewPanelMode({ panel, panelId, time: timeOverride });
const { data, isFetching, error, refetch, cancelQuery, pagination } = query;
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
// cursor-sync group so a drag here can't replay onto the grid panels.
const { dashboardPreference } = usePanelInteractions();
const isolatedPreference = useMemo<DashboardPreference>(
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
[dashboardPreference],
);
const openPanelEditor = useOpenPanelEditor();
// The View action only appears for registered kinds, so this is defensive.
if (!panelDefinition) {
return null;
}
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}
onTimeChange={onTimeChange}
isFetching={isFetching}
onRefresh={(): void => {
// Relative windows re-anchor to now (new key → refetch); a fixed
// custom window just re-runs the same query.
if (selectedInterval === 'custom') {
refetch();
} else {
refreshWindow();
}
}}
onSwitchToEdit={(): void =>
// Carry the drilldown edits so the editor opens on them, not the saved panel.
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) })
}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}
/>
<div className={styles.queryBuilder}>
<PanelEditorQueryBuilder
panelKind={draft.spec.plugin.kind}
signal={signal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
/>
</div>
<div className={styles.body}>
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
panelMode={PanelMode.STANDALONE_VIEW}
dashboardPreference={isolatedPreference}
onCloseStandaloneView={onClose}
hideHeader
/>
</div>
</div>
);
}
export default ViewPanelModalContent;

View File

@@ -1,123 +0,0 @@
import { PenLine, RotateCw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import type {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { usePanelTypeSelectItems } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
import ConfigSelect from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
import type { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import type { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelModalHeaderProps {
selectedInterval: Time | CustomTimeType;
/** Current window bounds (epoch ms) — seed the picker's modal display. */
startMs: number;
endMs: number;
onTimeChange: (
interval: Time | CustomTimeType,
range?: [number, number],
) => void;
/** Any query in flight — spins the refresh icon and disables it. */
isFetching: boolean;
onRefresh: () => void;
onSwitchToEdit: () => void;
/** Draft's current kind (selected value of the panel-type selector). */
panelKind: PanelKind;
/**
* The active query-builder tab (Query Builder / PromQL / ClickHouse). The type
* selector greys out kinds that can't be authored in it — e.g. List is
* Query-Builder-only, so PromQL/ClickHouse disable it.
*/
queryType: EQueryType;
/** Current builder datasource — greys out kinds that don't support it (e.g. List needs logs/traces, not metrics). */
signal: TelemetrytypesSignalDTO;
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the saved query + kind (drilldown reset). */
onResetQuery: () => void;
}
/**
* Toolbar for the View modal: reset the drilldown, open the full editor, switch the
* visualization kind, pick a per-view time window (isolated from the dashboard), and
* refresh. Mirrors V1's FullView header controls.
*/
function ViewPanelModalHeader({
selectedInterval,
startMs,
endMs,
onTimeChange,
isFetching,
onRefresh,
onSwitchToEdit,
panelKind,
queryType,
signal,
onChangePanelKind,
onResetQuery,
}: ViewPanelModalHeaderProps): JSX.Element {
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
return (
<div className={styles.toolbar}>
<div className={styles.panelTypeSelector}>
<ConfigSelect<PanelKind>
testId="view-panel-type-selector"
value={panelKind}
items={panelTypeItems}
onChange={onChangePanelKind}
/>
</div>
<Button
variant="outlined"
color="secondary"
prefix={<PenLine />}
onClick={onSwitchToEdit}
data-testid="view-panel-switch-to-edit"
>
Switch to Edit Mode
</Button>
<Button
variant="link"
color="primary"
onClick={onResetQuery}
data-testid="view-panel-reset-query"
>
Reset Query
</Button>
<div className={styles.toolbarTime}>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
isModalTimeSelection
disableUrlSync
onTimeChange={onTimeChange}
modalSelectedInterval={selectedInterval as Time}
modalInitialStartTime={startMs}
modalInitialEndTime={endMs}
/>
<Button
size="icon"
variant="solid"
color="primary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"
data-testid="view-panel-refresh"
>
<RotateCw className={cx({ 'animate-spin': isFetching })} />
</Button>
</div>
</div>
);
}
export default ViewPanelModalHeader;

View File

@@ -1,139 +0,0 @@
import { useCallback, useMemo } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { usePanelEditSession } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { resolveSignal } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { buildViewPanelSpec } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import {
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { EQueryType } from 'types/common/dashboard';
interface UseViewPanelModeArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
time: PanelQueryTimeOverride;
}
export interface UseViewPanelModeReturn {
/** Local editable copy of the panel — the preview renders this, not the saved panel. */
draft: DashboardtypesPanelDTO;
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
panelDefinition: RenderablePanelDefinition;
/**
* Builder datasource driving the query builder and the panel-type selector's
* disabled rule. Resolved from the query, falling back to the kind's default
* signal, so it's always defined.
*/
signal: TelemetrytypesSignalDTO;
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
queryType: EQueryType;
/** Query result for the draft over the per-view window. */
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
runQuery: () => void;
/** Switch the draft's visualization kind (temporary; reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the query the view opened with, discarding in-modal edits. */
resetQuery: () => void;
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
}
/**
* The View modal's compact drilldown editor on the shared `usePanelEditSession`. Edits are
* temporary — they live in the builder/URL + draft, never the dashboard (V1 parity).
*/
export function useViewPanelMode({
panel,
panelId,
time,
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
// Seed the draft from the URL (`compositeQuery` + `graphType`) when present, else the saved
// panel — mount-only, so a refresh re-seeds from the URL and in-modal edits survive (V1 parity).
const urlQuery = useGetCompositeQueryParam();
const urlGraphType = useUrlQuery().get(
QueryParams.graphType,
) as PANEL_TYPES | null;
const initialPanel = useMemo<DashboardtypesPanelDTO>(
() =>
urlQuery
? {
...panel,
spec: buildViewPanelSpec({
spec: panel.spec,
query: urlQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
}),
}
: panel,
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
[],
);
const {
draft,
panelDefinition,
defaultSignal,
query,
runQuery,
onChangePanelKind,
buildSaveSpec,
reset,
} = usePanelEditSession({ panel: initialPanel, panelId, time });
// The query the view opened with, captured once — the Reset target.
const savedQuery = useMemo(
() =>
fromPerses(
panel.spec.queries,
PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
),
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
[],
);
const resetQuery = useCallback((): void => {
reset();
redirectWithQueryBuilderData(savedQuery);
}, [reset, redirectWithQueryBuilderData, savedQuery]);
// Current builder datasource — resolved the same way as the full editor's
// ConfigPane so the two selectors stay in sync, then defaulted to the kind's first
// signal (PromQL/ClickHouse carry none) so the query builder always has one.
const signal =
resolveSignal(draft.spec.queries, defaultSignal) ?? defaultSignal;
return {
draft,
panelDefinition,
signal,
queryType: currentQuery.queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
};
}

View File

@@ -1,108 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import type {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import GetMinMax from 'lib/getMinMax';
import type { PanelQueryTimeOverride } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
const NS_PER_MS = 1e6;
export interface ViewPanelTimeWindow {
/** Absolute window (epoch ms) to pass to usePanelQuery as a time override. */
timeOverride: PanelQueryTimeOverride;
/** Interval shown in the picker — a relative `Time` or `'custom'`. */
selectedInterval: Time | CustomTimeType;
/** Apply a selection from DateTimeSelectionV2 (modal mode). */
onTimeChange: (
interval: Time | CustomTimeType,
range?: [number, number],
) => void;
/** Re-anchor a relative window to "now" (manual refresh); no-op for custom. */
refreshWindow: () => void;
/** Drag-to-zoom on a time chart → set a custom window locally (not the dashboard's). */
onDragSelect: (start: number, end: number) => void;
}
/**
* Per-view time window for the panel View modal, isolated from the dashboard's
* global time (V1 parity: the modal's time selector doesn't move the grid). Seeded
* once from the current global window, then owned locally. Relative intervals
* resolve to an absolute ms window via the same `GetMinMax` the app-wide picker uses.
*/
export function useViewPanelTimeWindow(): ViewPanelTimeWindow {
const { selectedTime, minTime, maxTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const [selectedInterval, setSelectedInterval] = useState<
Time | CustomTimeType
>(selectedTime as Time);
const [timeOverride, setTimeOverride] = useState<PanelQueryTimeOverride>(
() => ({
startMs: Math.floor(minTime / NS_PER_MS),
endMs: Math.floor(maxTime / NS_PER_MS),
}),
);
const onTimeChange = useCallback(
(interval: Time | CustomTimeType, range?: [number, number]): void => {
setSelectedInterval(interval);
// Absolute range comes through directly (already epoch ms).
if (interval === 'custom' && range) {
setTimeOverride({
startMs: Math.floor(range[0]),
endMs: Math.floor(range[1]),
});
return;
}
// GetMinMax returns nanoseconds — convert to the ms window we work in.
const { minTime: startNs, maxTime: endNs } = GetMinMax(interval);
setTimeOverride({
startMs: Math.floor(startNs / NS_PER_MS),
endMs: Math.floor(endNs / NS_PER_MS),
});
},
[],
);
const refreshWindow = useCallback((): void => {
// A custom window is fixed; only relative intervals re-anchor to now.
if (selectedInterval === 'custom') {
return;
}
const { minTime: startNs, maxTime: endNs } = GetMinMax(selectedInterval);
setTimeOverride({
startMs: Math.floor(startNs / NS_PER_MS),
endMs: Math.floor(endNs / NS_PER_MS),
});
}, [selectedInterval]);
const onDragSelect = useCallback((start: number, end: number): void => {
// Drag values are already epoch ms (same as the global custom range).
const startMs = Math.floor(start);
const endMs = Math.floor(end);
// Ignore a click / zero-width or inverted selection.
if (startMs >= endMs) {
return;
}
setSelectedInterval('custom');
setTimeOverride({ startMs, endMs });
}, []);
return useMemo(
() => ({
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
}),
[timeOverride, selectedInterval, onTimeChange, refreshWindow, onDragSelect],
);
}

View File

@@ -1,177 +0,0 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
// The preview reuses the edit page's PreviewPane (chart + header + heavy render
// path); stub it (capturing props) so this suite asserts the modal shell + what it
// threads down, not the preview internals (PreviewPane/PanelHeader own those).
const mockPreviewPaneRender = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PreviewPane/PreviewPane',
() =>
function MockPreviewPane(props: Record<string, unknown>): ReactElement {
mockPreviewPaneRender(props);
return <div data-testid="preview-pane" />;
},
);
// Isolate from the draft/query-builder plumbing (its own suite covers it).
jest.mock('../ViewPanelModal/useViewPanelMode', () => ({
useViewPanelMode: (args: {
panel: { spec: { plugin: { kind: string } } };
}): unknown => {
const { kind } = args.panel.spec.plugin;
return {
draft: args.panel,
panelDefinition: {
kind,
actions: { search: kind === 'signoz/ListPanel' },
Renderer: (): null => null,
},
query: {
data: { response: undefined, requestPayload: undefined, legendMap: {} },
isLoading: false,
isFetching: false,
error: null,
refetch: jest.fn(),
cancelQuery: jest.fn(),
pagination: undefined,
},
runQuery: jest.fn(),
onChangePanelKind: jest.fn(),
resetQuery: jest.fn(),
signal: 'logs',
buildSaveSpec: (spec: unknown): unknown => spec,
};
},
}));
// The View modal reuses the edit page's query builder, which reads the global
// QueryBuilder context and pulls in the ClickHouse/PromQL editors; stub it here.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder',
() =>
function MockPanelEditorQueryBuilder(): ReactElement {
return <div data-testid="panel-editor-v2-query-builder" />;
},
);
jest.mock('../hooks/usePanelInteractions', () => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: { syncMode: 0 },
}),
}));
// The header mounts DateTimeSelectionV2 (redux + router + heavy deps); stub it so
// this suite asserts the modal body, not the toolbar internals.
jest.mock(
'../ViewPanelModal/ViewPanelModalHeader',
() =>
function MockViewPanelModalHeader(): ReactElement {
return <div data-testid="view-panel-header" />;
},
);
jest.mock('../ViewPanelModal/useViewPanelTimeWindow', () => ({
useViewPanelTimeWindow: (): unknown => ({
timeOverride: { startMs: 0, endMs: 0 },
selectedInterval: '5m',
onTimeChange: jest.fn(),
refreshWindow: jest.fn(),
onDragSelect: jest.fn(),
}),
}));
const mockOpenEditor = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOpenPanelEditor',
() => ({
useOpenPanelEditor: (): jest.Mock => mockOpenEditor,
}),
);
const renderWithProvider = (ui: ReactElement): ReturnType<typeof render> =>
render(<TooltipProvider>{ui}</TooltipProvider>);
function makePanel(kind: string, name = 'My panel'): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: {
display: { name },
plugin: { kind, spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
}
describe('ViewPanelModal', () => {
it('renders nothing until opened', () => {
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel')}
panelId="p1"
open={false}
onClose={jest.fn()}
/>,
);
expect(
screen.queryByTestId('view-panel-modal-content'),
).not.toBeInTheDocument();
});
it('renders the header, query builder, and preview when open', () => {
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel', 'CPU usage')}
panelId="p1"
open
onClose={jest.fn()}
/>,
);
expect(screen.getByTestId('view-panel-modal-content')).toBeInTheDocument();
expect(screen.getByTestId('view-panel-header')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-query-builder'),
).toBeInTheDocument();
expect(screen.getByTestId('preview-pane')).toBeInTheDocument();
});
it('invokes onClose when the modal is dismissed', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel')}
panelId="p1"
open
onClose={onClose}
/>,
);
await user.click(screen.getByLabelText('Close'));
expect(onClose).toHaveBeenCalled();
});
// Charts share one global cursor-sync key and uPlot replays drag across the
// group; the modal must opt out so a drag here can't move the dashboard's time.
it('opts the chart out of the dashboard cursor-sync group', () => {
mockPreviewPaneRender.mockClear();
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel')}
panelId="p1"
open
onClose={jest.fn()}
/>,
);
const props = mockPreviewPaneRender.mock.calls.at(-1)?.[0] as {
dashboardPreference?: { syncMode?: unknown };
};
expect(props.dashboardPreference?.syncMode).toBe(DashboardCursorSync.None);
});
});

View File

@@ -1,93 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import GetMinMax from 'lib/getMinMax';
import { useViewPanelTimeWindow } from '../ViewPanelModal/useViewPanelTimeWindow';
const NS_PER_MS = 1e6;
// Global time is stored in nanoseconds; the hook must surface milliseconds.
const mockState = {
globalTime: {
selectedTime: '6h',
minTime: 6_000_000 * NS_PER_MS,
maxTime: 7_000_000 * NS_PER_MS,
},
};
jest.mock('react-redux', () => ({
useSelector: (selector: (s: unknown) => unknown): unknown =>
selector(mockState),
}));
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockGetMinMax = GetMinMax as unknown as jest.Mock;
describe('useViewPanelTimeWindow', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('seeds the window from global time, converting ns → ms', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
expect(result.current.timeOverride).toStrictEqual({
startMs: mockState.globalTime.minTime / NS_PER_MS,
endMs: mockState.globalTime.maxTime / NS_PER_MS,
});
expect(result.current.selectedInterval).toBe('6h');
});
it('converts GetMinMax (ns) to ms on a relative selection', () => {
mockGetMinMax.mockReturnValue({
minTime: 1_700_000_000_000 * NS_PER_MS,
maxTime: 1_700_000_300_000 * NS_PER_MS,
});
const { result } = renderHook(() => useViewPanelTimeWindow());
act(() => result.current.onTimeChange('5m'));
expect(result.current.selectedInterval).toBe('5m');
expect(result.current.timeOverride).toStrictEqual({
startMs: 1_700_000_000_000,
endMs: 1_700_000_300_000,
});
});
it('uses an absolute custom range as-is (already ms)', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
act(() => result.current.onTimeChange('custom', [111, 222]));
expect(mockGetMinMax).not.toHaveBeenCalled();
expect(result.current.timeOverride).toStrictEqual({
startMs: 111,
endMs: 222,
});
});
it('sets a custom window from a drag selection (modal-local, ms)', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
act(() => result.current.onDragSelect(1000, 5000));
expect(result.current.selectedInterval).toBe('custom');
expect(result.current.timeOverride).toStrictEqual({
startMs: 1000,
endMs: 5000,
});
});
it('ignores a zero-width or inverted drag selection', () => {
const { result } = renderHook(() => useViewPanelTimeWindow());
const initial = result.current.timeOverride;
act(() => result.current.onDragSelect(5000, 5000));
act(() => result.current.onDragSelect(9000, 1000));
expect(result.current.timeOverride).toStrictEqual(initial);
});
});

View File

@@ -1,54 +0,0 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
export interface UseViewPanelApi {
/** Panel id currently expanded in the View modal; null when none is open. */
expandedPanelId: string | null;
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
openView: (panelId: string) => void;
/** Close the View modal by clearing the URL param. */
closeView: () => void;
}
/**
* Drives the panel View modal off the `expandedWidgetId` URL param (V1 parity):
* the open state is shareable, survives refresh, and the browser back-button
* closes it. Reuses V1's param key so a deep-linked V1 URL maps cleanly.
*/
export function useViewPanel(): UseViewPanelApi {
const { safeNavigate } = useSafeNavigate();
const { pathname } = useLocation();
const urlQuery = useUrlQuery();
const expandedPanelId = urlQuery.get(QueryParams.expandedWidgetId);
const openView = useCallback(
(panelId: string): void => {
// Copy before mutating: useUrlQuery returns a memoized instance.
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
// Drop any leftover in-modal query/kind so a plain View opens on the saved
// panel, not a stale URL query the modal would otherwise hydrate from.
next.delete(QueryParams.compositeQuery);
next.delete(QueryParams.graphType);
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
);
const closeView = useCallback((): void => {
const next = new URLSearchParams(urlQuery);
next.delete(QueryParams.expandedWidgetId);
// Drop the drilldown editor's URL state so it doesn't leak to the dashboard
// (the in-modal query builder writes compositeQuery, V1 parity).
next.delete(QueryParams.compositeQuery);
next.delete(QueryParams.graphType);
const search = next.toString();
safeNavigate(search ? `${pathname}?${search}` : pathname);
}, [pathname, safeNavigate, urlQuery]);
return { expandedPanelId, openView, closeView };
}

View File

@@ -8,8 +8,6 @@ import type {
import { useDashboardStore } from '../store/useDashboardStore';
import { layoutsToSections } from '../utils';
import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import { useViewPanel } from './Panel/hooks/useViewPanel';
import ViewPanelModal from './Panel/ViewPanelModal/ViewPanelModal';
import Section from './Section/Section/Section';
import SectionList from './Section/SectionList';
import styles from './PanelsAndSectionsLayout.module.scss';
@@ -28,12 +26,6 @@ function PanelsAndSectionsLayout({
}: PanelsAndSectionsLayoutProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
// Single View-modal host for the whole dashboard, driven by the URL
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
// subscriptions, and the expanded panel is looked up by id from the map.
const { expandedPanelId, closeView } = useViewPanel();
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;
const sections = useMemo(
() => layoutsToSections(layouts, panels),
[layouts, panels],
@@ -64,17 +56,7 @@ function PanelsAndSectionsLayout({
));
};
return (
<div className={styles.body}>
{renderContent()}
<ViewPanelModal
open={!!expandedPanel}
panel={expandedPanel}
panelId={expandedPanelId ?? undefined}
onClose={closeView}
/>
</div>
);
return <div className={styles.body}>{renderContent()}</div>;
}
export default PanelsAndSectionsLayout;

View File

@@ -1,58 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useDashboardFetchRequired } from '../useDashboardFetchRequired';
const mockUseGetDashboardV2 = jest.fn();
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (
selector: (s: { dashboardId: string }) => unknown,
): unknown => selector({ dashboardId: 'dash-1' }),
}),
);
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
}));
describe('useDashboardFetchRequired', () => {
it('returns the loaded dashboard and its variables', () => {
const dashboard = {
spec: { variables: [{ name: 'region' }, { name: 'tier' }] },
};
mockUseGetDashboardV2.mockReturnValue({ data: { data: dashboard } });
const { result } = renderHook(() => useDashboardFetchRequired());
expect(result.current.dashboard).toBe(dashboard);
expect(result.current.variables).toStrictEqual([
{ name: 'region' },
{ name: 'tier' },
]);
});
it('normalizes variables to an empty array when the spec has none', () => {
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
const { result } = renderHook(() => useDashboardFetchRequired());
expect(result.current.variables).toStrictEqual([]);
});
it('throws when used outside a loaded dashboard subtree', () => {
mockUseGetDashboardV2.mockReturnValue({ data: undefined });
// Suppress React's expected render-error logging for this case.
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(() => renderHook(() => useDashboardFetchRequired())).toThrow(
/outside a loaded dashboard subtree/,
);
consoleError.mockRestore();
});
});

View File

@@ -1,40 +0,0 @@
import {
getDashboardV2,
useGetDashboardV2,
} from 'api/generated/services/dashboard';
import type { GetDashboardV2QueryError } from 'api/generated/services/dashboard';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
// Instantiation expression pins `TData`/`TError` to the hook's defaults; a plain
// `ReturnType<typeof useGetDashboardV2>` collapses the generics to `unknown`.
type GetDashboardV2Query = ReturnType<
typeof useGetDashboardV2<
Awaited<ReturnType<typeof getDashboardV2>>,
GetDashboardV2QueryError
>
>;
export interface UseDashboardFetchResult {
dashboard: DashboardtypesGettableDashboardV2DTO | undefined;
isLoading: GetDashboardV2Query['isLoading'];
isError: GetDashboardV2Query['isError'];
error: GetDashboardV2Query['error'];
refetch: GetDashboardV2Query['refetch'];
}
/**
* Canonical fetch-only loader for a V2 dashboard by id — shared so the page, panel
* editor, and config sections resolve to one react-query cache entry. Exposes just the
* raw `dashboard` plus lifecycle; spec derivation (e.g. `variables`) lives in
* `useDashboardFetchRequired`, where the dashboard is guaranteed present.
*/
export function useDashboardFetch(
dashboardId: string,
): UseDashboardFetchResult {
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
id: dashboardId,
});
const dashboard = data?.data;
return { dashboard, isLoading, isError, error, refetch };
}

View File

@@ -1,47 +0,0 @@
import { useMemo } from 'react';
import type {
DashboardtypesGettableDashboardV2DTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { useDashboardFetch } from './useDashboardFetch';
import type { UseDashboardFetchResult } from './useDashboardFetch';
export interface UseDashboardFetchRequiredResult {
/** Guaranteed present — the hook throws otherwise. */
dashboard: DashboardtypesGettableDashboardV2DTO;
/** Always an array, so callers skip `?? []` guards. */
variables: DashboardtypesVariableDTO[];
refetch: UseDashboardFetchResult['refetch'];
}
/**
* Dashboard accessor for components rendered inside a loaded dashboard subtree. The
* root pages own the fetch and gate on a resolved dashboard before mounting their
* subtree, so this reuses the shared react-query cache entry and throws if the
* dashboard is missing — turning a silent `undefined` into a loud programmer error. It
* also derives spec collections (`variables`) since the dashboard is guaranteed here.
*
* Prefer this over `useDashboardFetch` outside the two root pages; the
* `signoz/no-dashboard-fetch-outside-root` lint rule enforces the split.
*/
export function useDashboardFetchRequired(): UseDashboardFetchRequiredResult {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { dashboard, refetch } = useDashboardFetch(dashboardId);
const variables = useMemo(
() => dashboard?.spec?.variables ?? [],
[dashboard?.spec?.variables],
);
if (!dashboard) {
throw new Error(
'useDashboardFetchRequired was used outside a loaded dashboard subtree — the ' +
'dashboard is undefined. Only call it below a root page that gates rendering ' +
'on useDashboardFetch (DashboardPageV2 / PanelEditorPage).',
);
}
return { dashboard, variables, refetch };
}

View File

@@ -3,28 +3,21 @@ import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
import { useDashboardStore } from '../store/useDashboardStore';
/**
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
* caller can open the editor with just the panel id. The optional `handoffState` is passed as
* router location state — the View modal uses it to hand its drilldown-edited spec off to the
* editor (view → edit) so the editor opens on those edits rather than the saved panel.
* caller can open the editor with just the panel id.
*/
export function useOpenPanelEditor(): (
panelId: string,
handoffState?: PanelEditorHandoffState,
) => void {
export function useOpenPanelEditor(): (panelId: string) => void {
const { safeNavigate } = useSafeNavigate();
const dashboardId = useDashboardStore((s) => s.dashboardId);
return useCallback(
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
(panelId: string): void => {
safeNavigate(
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
handoffState ? { state: handoffState } : undefined,
);
},
[safeNavigate, dashboardId],

View File

@@ -22,13 +22,9 @@ function DashboardContainer({
dashboard,
refetch,
}: DashboardContainerProps): JSX.Element {
const spec = dashboard.spec;
const image = dashboard.image || Base64Icons[0];
const name = spec.display.name;
useEffect(() => {
document.title = name;
}, [name]);
document.title = dashboard.name;
}, [dashboard.name]);
const fullScreenHandle = useFullScreenHandle();
@@ -59,6 +55,10 @@ function DashboardContainer({
// the store, so each panel's query substitutes the bar's selected values.
useResolvedVariables(dashboard);
const spec = dashboard.spec;
const image = dashboard.image || Base64Icons[0];
const name = spec.display.name;
return (
<FullScreen handle={fullScreenHandle}>
<div className={styles.container}>

View File

@@ -1,17 +1,20 @@
import { useParams } from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import Spinner from 'components/Spinner';
import DashboardContainer from './DashboardContainer';
import { useDashboardFetch } from './DashboardContainer/hooks/useDashboardFetch';
import styles from './DashboardPageV2.module.scss';
function DashboardPageV2(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const { dashboard, isLoading, isError, error, refetch } =
useDashboardFetch(dashboardId);
const { data, isLoading, isError, error, refetch } = useGetDashboardV2({
id: dashboardId,
});
const dashboard = data?.data;
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;

View File

@@ -6,17 +6,16 @@ import {
useParams,
} from 'react-router-dom';
import { Typography } from '@signozhq/ui/typography';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useDashboardFetch } from '../DashboardContainer/hooks/useDashboardFetch';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { buildPluginSpec } from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import PanelEditorContainer from '../DashboardContainer/PanelEditor';
import type { PanelEditorHandoffState } from '../DashboardContainer/PanelEditor/panelEditorHandoff';
import {
parseNewPanelKind,
parseNewPanelLayoutIndex,
@@ -33,34 +32,29 @@ function PanelEditorPage(): JSX.Element {
dashboardId: string;
panelId: string;
}>();
const { search, state } = useLocation();
const { search } = useLocation();
const { safeNavigate } = useSafeNavigate();
// Edits handed off from the View modal's drilldown — open the editor on these
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
const handoffSpec = (state as PanelEditorHandoffState | null)?.editSpec;
const { dashboard, isLoading, isError, error } =
useDashboardFetch(dashboardId);
const { data, isLoading, isError, error } = useGetDashboardV2({
id: dashboardId,
});
const dashboard = data?.data;
// A `panel/new?panelKind=…` route means "create": seed a default panel of that
// kind rather than looking one up. Persisted (with a real id) only on save.
const newKind = parseNewPanelKind(panelId, search);
const existingPanel = dashboard?.spec.panels[panelId];
const panel = useMemo(() => {
if (newKind) {
return createDefaultPanel(
newKind,
buildPluginSpec(getPanelDefinition(newKind).sections),
buildDefaultQueries(newKind),
);
}
if (!existingPanel) {
return undefined;
}
// Open on the modal's drilldown edits when handed off; else the saved panel.
return handoffSpec ? { ...existingPanel, spec: handoffSpec } : existingPanel;
}, [newKind, existingPanel, handoffSpec]);
const panel = useMemo(
() =>
newKind
? createDefaultPanel(
newKind,
buildPluginSpec(getPanelDefinition(newKind).sections),
buildDefaultQueries(newKind),
)
: existingPanel,
[newKind, existingPanel],
);
// Target section for a newly-created panel (set by the "Add panel" trigger).
const layoutIndex = parseNewPanelLayoutIndex(search);

View File

@@ -421,61 +421,5 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/public/dashboards/{id}", handler.New(provider.authzMiddleware.CheckWithoutClaims(
provider.dashboardHandler.GetPublicDataV2,
authtypes.Relation{Verb: coretypes.VerbRead},
coretypes.ResourceMetaResourcePublicDashboard,
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, valuer.UUID{}, err
}
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
}, []string{}), handler.OpenAPIDef{
ID: "GetPublicDashboardDataV2",
Tags: []string{"dashboard"},
Summary: "Get public dashboard data (v2)",
Description: "This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettablePublicDashboardDataV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/public/dashboards/{id}/panels/{key}/query_range", handler.New(provider.authzMiddleware.CheckWithoutClaims(
provider.dashboardHandler.GetPublicWidgetQueryRangeV2,
authtypes.Relation{Verb: coretypes.VerbRead},
coretypes.ResourceMetaResourcePublicDashboard,
func(req *http.Request, orgs []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, valuer.UUID{}, err
}
return provider.dashboardModule.GetPublicDashboardSelectorsAndOrg(req.Context(), id, orgs)
}, []string{}), handler.OpenAPIDef{
ID: "GetPublicDashboardPanelQueryRangeV2",
Tags: []string{"dashboard"},
Summary: "Get query range result (v2)",
Description: "This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.",
Request: nil,
RequestContentType: "",
Response: new(querybuildertypesv5.QueryRangeResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newAnonymousSecuritySchemes([]string{coretypes.ResourceMetaResourcePublicDashboard.Scope(coretypes.VerbRead)}),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -49,26 +49,6 @@ func (provider *provider) addLLMPricingRuleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/llm_pricing_rules/unmapped_models", handler.New(
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.ListUnmappedModels),
handler.OpenAPIDef{
ID: "ListUnmappedLLMModels",
Tags: []string{"llmpricingrules"},
Summary: "List unmapped models",
Description: "Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.",
Request: nil,
RequestContentType: "",
Response: new(llmpricingruletypes.GettableUnmappedModels),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/llm_pricing_rules/{id}", handler.New(
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.Get),
handler.OpenAPIDef{

View File

@@ -65,7 +65,6 @@ type provider struct {
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
serviceAccountGetter serviceaccount.Getter
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
@@ -100,7 +99,6 @@ func NewFactory(
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -138,7 +136,6 @@ func NewFactory(
zeusHandler,
querierHandler,
serviceAccountHandler,
serviceAccountGetter,
factoryHandler,
cloudIntegrationHandler,
ruleStateHistoryHandler,
@@ -178,7 +175,6 @@ func newProvider(
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -217,7 +213,6 @@ func newProvider(
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
serviceAccountGetter: serviceAccountGetter,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,

View File

@@ -141,17 +141,17 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRoleDeprecated",
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.DeprecatedPostableServiceAccountRole),
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
@@ -171,7 +171,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRoleDeprecated",
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
@@ -181,7 +181,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
@@ -398,96 +398,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_account_roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.CreateServiceAccountRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("serviceAccountId")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_account_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.GetServiceAccountRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Get service account role",
Description: "This endpoint gets an existing service account role",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: provider.serviceAccountIDExtractor(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_account_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteServiceAccountRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from a service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceServiceAccount,
SourceIDs: coretypes.OneID(provider.serviceAccountIDExtractor()),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(provider.roleIDExtractor()),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
// roleSelector resolves the FGA selectors for a role from its UUID. The id is
// already extracted by the ResourceDef (path or body); this only does the
// UUID -> name lookup the FGA object string requires. Shared by service account
// and role routes.
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(id)
if err != nil {
@@ -504,53 +421,3 @@ func (provider *provider) roleSelector(ctx context.Context, resource coretypes.R
resource.Type().MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return "", err
}
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
if err != nil {
return "", err
}
return serviceAccountRole.RoleID.String(), nil
})
}
func (provider *provider) serviceAccountIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return "", err
}
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
if err != nil {
return "", err
}
return serviceAccountRole.ServiceAccountID.String(), nil
})
}

View File

@@ -394,7 +394,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
@@ -411,7 +411,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
@@ -434,56 +434,5 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -216,11 +216,6 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
}
for _, resource := range resolved {
if err := resource.Err(); err != nil {
render.Error(rw, err)
return
}
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
render.Error(rw, err)
return

View File

@@ -81,12 +81,6 @@ type Module interface {
DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error
// get the v2 dashboard data by public dashboard id
GetDashboardByPublicIDV2(context.Context, valuer.UUID) (*dashboardtypes.DashboardV2, error)
// gets the query results by panel key and public shared id for a v2 dashboard
GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error)
CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error)
ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error)
@@ -107,10 +101,6 @@ type Handler interface {
GetPublicWidgetQueryRange(http.ResponseWriter, *http.Request)
GetPublicDataV2(http.ResponseWriter, *http.Request)
GetPublicWidgetQueryRangeV2(http.ResponseWriter, *http.Request)
UpdatePublic(http.ResponseWriter, *http.Request)
DeletePublic(http.ResponseWriter, *http.Request)

View File

@@ -254,14 +254,6 @@ func (module *module) GetPublicWidgetQueryRange(context.Context, valuer.UUID, ui
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
func (module *module) GetDashboardByPublicIDV2(_ context.Context, _ valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
func (module *module) GetPublicWidgetQueryRangeV2(context.Context, valuer.UUID, string, string, string) (*qbtypes.QueryRangeResponse, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
func (module *module) GetPublicDashboardSelectorsAndOrg(_ context.Context, _ valuer.UUID, _ []*types.Organization) ([]coretypes.Selector, valuer.UUID, error) {
return nil, valuer.UUID{}, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}

View File

@@ -376,53 +376,3 @@ func (handler *handler) DeleteV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) GetPublicDataV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.GetDashboardByPublicIDV2(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
publicDashboard, err := handler.module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewPublicDashboardDataFromDashboardV2(dashboard, publicDashboard))
}
func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
panelKey, ok := mux.Vars(r)["key"]
if !ok || panelKey == "" {
render.Error(rw, errors.New(errors.TypeInvalidInput, dashboardtypes.ErrCodePublicDashboardInvalidInput, "panel key is missing from the path"))
return
}
queryRangeResults, err := handler.module.GetPublicWidgetQueryRangeV2(ctx, id, panelKey, r.URL.Query().Get("startTime"), r.URL.Query().Get("endTime"))
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, queryRangeResults)
}

View File

@@ -92,7 +92,7 @@ func (h *handler) Get(rw http.ResponseWriter, r *http.Request) {
}
func (h *handler) CreateOrUpdate(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
@@ -118,28 +118,6 @@ func (h *handler) CreateOrUpdate(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
// ListUnmappedModels handles GET /api/v1/llm_pricing_rules/unmapped_models.
func (h *handler) ListUnmappedModels(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
models, err := h.module.ListUnmappedModels(ctx, orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, llmpricingruletypes.NewGettableUnmappedModels(models))
}
// Delete handles DELETE /api/v1/llm_pricing_rules/{id}.
func (h *handler) Delete(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)

View File

@@ -3,32 +3,24 @@ package impllmpricingrule
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// unmappedModelsLookback is the trace data window scanned to discover models in use.
const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
querier querier.Querier
flagger flagger.Flagger
}
func NewModule(store llmpricingruletypes.Store, flagger flagger.Flagger, querier querier.Querier) llmpricingrule.Module {
func NewModule(store llmpricingruletypes.Store, flagger flagger.Flagger) llmpricingrule.Module {
return &module{store: store, flagger: flagger}
}
@@ -40,28 +32,6 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return module.store.Get(ctx, orgID, id)
}
// ListUnmappedModels discovers the models present in the last hour of trace data
// (gen_ai.request.model) and returns the ones that no pricing rule pattern matches.
func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
models, err := module.discoverModels(ctx, orgID)
if err != nil {
return nil, err
}
rules, err := module.listAllRules(ctx, orgID)
if err != nil {
return nil, err
}
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
for _, m := range models {
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
unmapped = append(unmapped, m)
}
}
return unmapped, nil
}
// CreateOrUpdate applies a batch of pricing rule changes:
// - ID set → match by id, overwrite fields.
// - SourceID set → match by source_id; if found overwrite, else insert.
@@ -151,7 +121,7 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
}
func (module *module) getEnabledRules(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
rules, err := module.listAllRules(ctx, orgID)
rules, _, err := module.List(ctx, orgID, 0, 10000, "", nil)
if err != nil {
return nil, err
}
@@ -165,25 +135,6 @@ func (module *module) getEnabledRules(ctx context.Context, orgID valuer.UUID) ([
return enabled, nil
}
// listAllRules pages through every pricing rule for the org, since rule matching
// needs the full set and the count is otherwise unbounded.
func (module *module) listAllRules(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
const pageSize = 1000
all := make([]*llmpricingruletypes.LLMPricingRule, 0)
for offset := 0; ; offset += pageSize {
page, total, err := module.store.List(ctx, orgID, offset, pageSize, "", nil)
if err != nil {
return nil, err
}
all = append(all, page...)
if len(page) == 0 || len(all) >= total {
break
}
}
return all, nil
}
// findExisting returns the row matching the updatable's ID or SourceID.
// Returns a TypeNotFound error when neither matches; the caller treats that
// as "insert new".
@@ -197,91 +148,3 @@ func (module *module) findExisting(ctx context.Context, orgID valuer.UUID, u *ll
return nil, errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "rule has neither id nor sourceId")
}
}
// discoverModels runs a QBv5 traces aggregation grouped by gen_ai.request.model
// over the lookback window and returns each distinct model with its span count.
func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
now := time.Now()
req := &qbtypes.QueryRangeRequest{
Start: uint64(now.Add(-unmappedModelsLookback).UnixMilli()),
End: uint64(now.UnixMilli()),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()", Alias: "spanCount"},
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIRequestModel,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIProviderName,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
},
Limit: 1000,
},
},
},
},
}
resp, err := module.querier.QueryRange(ctx, orgID, req)
if err != nil {
return nil, err
}
if resp == nil || len(resp.Data.Results) == 0 {
return nil, nil
}
sd, ok := resp.Data.Results[0].(*qbtypes.ScalarData)
if !ok || sd == nil {
return nil, nil
}
modelIdx, providerIdx, countIdx := -1, -1, -1
for i, c := range sd.Columns {
switch c.Type {
case qbtypes.ColumnTypeGroup:
switch c.Name {
case llmpricingruletypes.GenAIRequestModel:
modelIdx = i
case llmpricingruletypes.GenAIProviderName:
providerIdx = i
}
case qbtypes.ColumnTypeAggregation:
countIdx = i
}
}
if modelIdx == -1 {
return nil, nil
}
models := make([]*llmpricingruletypes.UnmappedModel, 0, len(sd.Data))
for _, row := range sd.Data {
name, _ := row[modelIdx].(string)
if name == "" {
continue
}
provider := ""
if providerIdx != -1 {
provider, _ = row[providerIdx].(string)
}
var spanCount uint64
if countIdx >= 0 && countIdx < len(row) {
spanCount, _ = row[countIdx].(uint64)
}
models = append(models, &llmpricingruletypes.UnmappedModel{ModelName: name, Provider: provider, SpanCount: spanCount})
}
return models, nil
}

View File

@@ -17,7 +17,6 @@ type Module interface {
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
Delete(ctx context.Context, orgID, id valuer.UUID) error
ListUnmappedModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error)
}
// Handler defines the HTTP handler interface for pricing rule endpoints.
@@ -26,5 +25,4 @@ type Handler interface {
Get(rw http.ResponseWriter, r *http.Request)
CreateOrUpdate(rw http.ResponseWriter, r *http.Request)
Delete(rw http.ResponseWriter, r *http.Request)
ListUnmappedModels(rw http.ResponseWriter, r *http.Request)
}

View File

@@ -18,10 +18,6 @@ func NewGetter(store serviceaccounttypes.Store) serviceaccount.Getter {
return &getter{store: store}
}
func (getter *getter) GetServiceAccountRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error) {
return getter.store.GetServiceAccountRoleByOrgIDAndID(ctx, orgID, id)
}
func (getter *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, _ string) error {
serviceAccounts, err := getter.store.GetServiceAccountsByOrgIDAndRoleID(ctx, orgID, roleID)
if err != nil {

View File

@@ -15,11 +15,10 @@ import (
type handler struct {
module serviceaccount.Module
getter serviceaccount.Getter
}
func NewHandler(module serviceaccount.Module, getter serviceaccount.Getter) serviceaccount.Handler {
return &handler{module: module, getter: getter}
func NewHandler(module serviceaccount.Module) serviceaccount.Handler {
return &handler{module: module}
}
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
@@ -228,13 +227,13 @@ func (handler *handler) SetRole(rw http.ResponseWriter, r *http.Request) {
return
}
req := new(serviceaccounttypes.DeprecatedPostableServiceAccountRole)
req := new(serviceaccounttypes.PostableServiceAccountRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
_, err = handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), id, req.ID)
err = handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), id, req.ID)
if err != nil {
render.Error(rw, err)
return
@@ -272,81 +271,6 @@ func (handler *handler) DeleteRole(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) CreateServiceAccountRole(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
req := new(serviceaccounttypes.PostableServiceAccountRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
serviceAccountRole, err := handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), req.ServiceAccountID, req.RoleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, types.Identifiable{ID: serviceAccountRole.ID})
}
func (handler *handler) GetServiceAccountRole(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
serviceAccountRole, err := handler.getter.GetServiceAccountRole(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, serviceAccountRole)
}
func (handler *handler) DeleteServiceAccountRole(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(rw, err)
return
}
serviceAccountRole, err := handler.getter.GetServiceAccountRole(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.DeleteRole(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccountRole.ServiceAccountID, serviceAccountRole.RoleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) Delete(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)

View File

@@ -111,19 +111,19 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
return nil
}
func (module *module) SetRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, roleID valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error) {
func (module *module) SetRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, roleID valuer.UUID) error {
role, err := module.authz.Get(ctx, orgID, roleID)
if err != nil {
return nil, err
return err
}
return module.setRole(ctx, orgID, id, role)
}
func (module *module) SetRoleByName(ctx context.Context, orgID valuer.UUID, id valuer.UUID, name string) (*serviceaccounttypes.ServiceAccountRole, error) {
func (module *module) SetRoleByName(ctx context.Context, orgID valuer.UUID, id valuer.UUID, name string) error {
role, err := module.authz.GetByOrgIDAndName(ctx, orgID, name)
if err != nil {
return nil, err
return err
}
return module.setRole(ctx, orgID, id, role)
@@ -376,42 +376,28 @@ func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID
return identity, nil
}
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) (*serviceaccounttypes.ServiceAccountRole, error) {
func (module *module) setRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID, role *authtypes.Role) error {
serviceAccount, err := module.Get(ctx, orgID, id)
if err != nil {
return nil, err
return err
}
serviceAccountRole, err := serviceAccount.AddRole(role)
if err != nil {
return nil, err
return err
}
err = module.authz.Grant(ctx, orgID, []string{role.Name}, authtypes.MustNewSubject(coretypes.NewResourceServiceAccount(), id.String(), orgID, nil))
if err != nil {
return nil, err
return err
}
err = module.store.CreateServiceAccountRole(ctx, serviceAccountRole)
if err != nil {
if !errors.Ast(err, errors.TypeAlreadyExists) {
return nil, err
}
serviceAccountWithRoles, err := module.GetWithRoles(ctx, orgID, id)
if err != nil {
return nil, err
}
for _, existingServiceAccountRole := range serviceAccountWithRoles.ServiceAccountRoles {
if existingServiceAccountRole.RoleID == role.ID {
serviceAccountRole = existingServiceAccountRole
break
}
}
return err
}
return serviceAccountRole, nil
return nil
}
func (module *module) trackUser(ctx context.Context, orgID string, userID string, event string, attrs map[string]any) {

View File

@@ -198,35 +198,15 @@ func (store *store) CreateServiceAccountRole(ctx context.Context, serviceAccount
BunDBCtx(ctx).
NewInsert().
Model(serviceAccountRole).
On("CONFLICT (service_account_id, role_id) DO NOTHING").
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, serviceaccounttypes.ErrCodeServiceAccountRoleAlreadyExists, "role: %s is already assigned to service account: %s", serviceAccountRole.RoleID, serviceAccountRole.ServiceAccountID)
return err
}
return nil
}
func (store *store) GetServiceAccountRoleByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error) {
serviceAccountRole := new(serviceaccounttypes.ServiceAccountRole)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(serviceAccountRole).
Join("JOIN service_account").
JoinOn("service_account.id = service_account_role.service_account_id").
Where("service_account.org_id = ?", orgID).
Where("service_account_role.id = ?", id).
Relation("Role").
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeServiceAccountRoleNotFound, "service account role with id: %s doesn't exist in org: %s", id, orgID)
}
return serviceAccountRole, nil
}
func (store *store) DeleteServiceAccountRole(ctx context.Context, serviceAccountID valuer.UUID, roleID valuer.UUID) error {
_, err := store.
sqlstore.

View File

@@ -14,9 +14,6 @@ import (
type Getter interface {
// OnBeforeRoleDelete checks if any service accounts are assigned to the role and rejects deletion if so.
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID, roleName string) error
// Gets a service account role by org id and id.
GetServiceAccountRole(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error)
}
type Module interface {
@@ -38,11 +35,11 @@ type Module interface {
// Updates an existing service account
Update(context.Context, valuer.UUID, *serviceaccounttypes.ServiceAccount) error
// Assign a role to the service account and returns the service account role. this is safe to retry
SetRole(context.Context, valuer.UUID, valuer.UUID, valuer.UUID) (*serviceaccounttypes.ServiceAccountRole, error)
// Assign a role to the service account. this is safe to retry
SetRole(context.Context, valuer.UUID, valuer.UUID, valuer.UUID) error
// Assigns a role by name to service account and returns the service account role, this is safe to retry
SetRoleByName(context.Context, valuer.UUID, valuer.UUID, string) (*serviceaccounttypes.ServiceAccountRole, error)
// Assigns a role by name to service account, this is safe to retry
SetRoleByName(context.Context, valuer.UUID, valuer.UUID, string) error
// Revokes a role from service account, this is safe to retry
DeleteRole(context.Context, valuer.UUID, valuer.UUID, valuer.UUID) error
@@ -98,12 +95,6 @@ type Handler interface {
DeleteRole(http.ResponseWriter, *http.Request)
CreateServiceAccountRole(http.ResponseWriter, *http.Request)
GetServiceAccountRole(http.ResponseWriter, *http.Request)
DeleteServiceAccountRole(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
CreateFactorAPIKey(http.ResponseWriter, *http.Request)

View File

@@ -218,10 +218,6 @@ func (module *getter) GetRolesByUserID(ctx context.Context, userID valuer.UUID)
return userRoles, nil
}
func (module *getter) GetUserRoleByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.UserRole, error) {
return module.userRoleStore.GetUserRoleByOrgIDAndID(ctx, orgID, id)
}
func (module *getter) GetResetPasswordTokenByOrgIDAndUserID(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) (*types.ResetPasswordToken, error) {
return module.store.GetResetPasswordTokenByOrgIDAndUserID(ctx, orgID, userID)
}

View File

@@ -588,7 +588,7 @@ func (handler *handler) SetRoleByUserID(w http.ResponseWriter, r *http.Request)
return
}
if _, err := handler.setter.AddUserRole(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID), postableRole.Name); err != nil {
if err := handler.setter.AddUserRole(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID), postableRole.Name); err != nil {
render.Error(w, err)
return
}
@@ -642,93 +642,3 @@ func (handler *handler) GetUsersByRoleID(w http.ResponseWriter, r *http.Request)
render.Success(w, http.StatusOK, users)
}
func (handler *handler) CreateUserRole(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
req := new(authtypes.PostableUserRole)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(w, err)
return
}
if req.UserID.String() == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
userRole, err := handler.setter.AddUserRoleByRoleID(ctx, valuer.MustNewUUID(claims.OrgID), req.UserID, req.RoleID)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusCreated, types.Identifiable{ID: userRole.ID})
}
func (handler *handler) GetUserRole(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(w, err)
return
}
userRole, err := handler.getter.GetUserRoleByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, userRole)
}
func (handler *handler) DeleteUserRole(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
id, err := valuer.NewUUID(mux.Vars(r)["id"])
if err != nil {
render.Error(w, err)
return
}
userRole, err := handler.getter.GetUserRoleByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(w, err)
return
}
if userRole.UserID.String() == claims.UserID {
render.Error(w, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "users cannot call this api on self"))
return
}
if err := handler.setter.RemoveUserRole(ctx, valuer.MustNewUUID(claims.OrgID), userRole.UserID, userRole.RoleID); err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}

View File

@@ -914,27 +914,27 @@ func (module *setter) UpdateUserRoles(ctx context.Context, orgID, userID valuer.
})
}
func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) (*authtypes.UserRole, error) {
func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) error {
existingUser, err := module.getter.GetUserByOrgIDAndID(ctx, orgID, userID)
if err != nil {
return nil, err
return err
}
if err := existingUser.ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot add role for root user")
return errors.WithAdditionalf(err, "cannot add role for root user")
}
if err := existingUser.ErrIfDeleted(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot add role for deleted user")
return errors.WithAdditionalf(err, "cannot add role for deleted user")
}
// validate that the role name exists
foundRoles, err := module.authz.ListByOrgIDAndNames(ctx, orgID, []string{roleName})
if err != nil {
return nil, err
return err
}
if len(foundRoles) != 1 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "role name not found: %s", roleName)
return errors.NewInvalidInputf(errors.CodeInvalidInput, "role name not found: %s", roleName)
}
// grant via authz (additive, idempotent — OpenFGA uses OnDuplicate: "ignore")
@@ -944,43 +944,18 @@ func (module *setter) AddUserRole(ctx context.Context, orgID, userID valuer.UUID
[]string{roleName},
authtypes.MustNewSubject(coretypes.NewResourceUser(), existingUser.ID.StringValue(), existingUser.OrgID, nil),
); err != nil {
return nil, err
return err
}
// create user_role entry (swallow AlreadyExists for idempotency — DB has unique constraint on user_id+role_id)
userRole := authtypes.NewUserRoles(userID, foundRoles)[0]
if err := module.userRoleStore.CreateUserRoles(ctx, []*authtypes.UserRole{userRole}); err != nil {
userRoles := authtypes.NewUserRoles(userID, foundRoles)
if err := module.userRoleStore.CreateUserRoles(ctx, userRoles); err != nil {
if !errors.Ast(err, errors.TypeAlreadyExists) {
return nil, err
}
existingUserRoles, err := module.getter.GetRolesByUserID(ctx, userID)
if err != nil {
return nil, err
}
for _, existingUserRole := range existingUserRoles {
if existingUserRole.RoleID == foundRoles[0].ID {
userRole = existingUserRole
break
}
return err
}
}
if err := module.tokenizer.DeleteIdentity(ctx, userID); err != nil {
return nil, err
}
return userRole, nil
}
func (module *setter) AddUserRoleByRoleID(ctx context.Context, orgID, userID valuer.UUID, roleID valuer.UUID) (*authtypes.UserRole, error) {
role, err := module.authz.Get(ctx, orgID, roleID)
if err != nil {
return nil, err
}
return module.AddUserRole(ctx, orgID, userID, role.Name)
return module.tokenizer.DeleteIdentity(ctx, userID)
}
func (module *setter) RemoveUserRole(ctx context.Context, orgID, userID valuer.UUID, roleID valuer.UUID) error {

View File

@@ -39,26 +39,6 @@ func (store *userRoleStore) ListUserRolesByOrgIDAndUserIDs(ctx context.Context,
return userRoles, nil
}
func (store *userRoleStore) GetUserRoleByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.UserRole, error) {
userRole := new(authtypes.UserRole)
err := store.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(userRole).
Join("JOIN users").
JoinOn("users.id = user_role.user_id").
Where("users.org_id = ?", orgID).
Where("user_role.id = ?", id).
Relation("Role").
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, authtypes.ErrCodeUserRolesNotFound, "user role with id: %s doesn't exist in org: %s", id, orgID)
}
return userRole, nil
}
func (store *userRoleStore) CreateUserRoles(ctx context.Context, userRoles []*authtypes.UserRole) error {
_, err := store.sqlstore.
BunDBCtx(ctx).

View File

@@ -50,8 +50,7 @@ type Setter interface {
// Roles
UpdateUserRoles(ctx context.Context, orgID, userID valuer.UUID, finalRoleNames []string) error
AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) (*authtypes.UserRole, error)
AddUserRoleByRoleID(ctx context.Context, orgID, userID valuer.UUID, roleID valuer.UUID) (*authtypes.UserRole, error)
AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) error
RemoveUserRole(ctx context.Context, orgID, userID valuer.UUID, roleID valuer.UUID) error
statsreporter.StatsCollector
@@ -93,9 +92,6 @@ type Getter interface {
// Gets user_role with roles entries from db
GetRolesByUserID(ctx context.Context, userID valuer.UUID) ([]*authtypes.UserRole, error)
// Gets a single user role by org id and id.
GetUserRoleByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.UserRole, error)
// Gets all the user with role using role id in an org id
GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) ([]*types.User, error)
@@ -128,11 +124,6 @@ type Handler interface {
RemoveUserRoleByRoleID(http.ResponseWriter, *http.Request)
GetUsersByRoleID(http.ResponseWriter, *http.Request)
// user roles
CreateUserRole(http.ResponseWriter, *http.Request)
GetUserRole(http.ResponseWriter, *http.Request)
DeleteUserRole(http.ResponseWriter, *http.Request)
// Reset Password
GetResetPasswordTokenDeprecated(http.ResponseWriter, *http.Request)
GetResetPasswordToken(http.ResponseWriter, *http.Request)

View File

@@ -123,7 +123,7 @@ func NewHandlers(
AuthzHandler: signozauthzapi.NewHandler(authz),
ZeusHandler: zeus.NewHandler(zeusService, licensing),
QuerierHandler: querierHandler,
ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount, modules.ServiceAccountGetter),
ServiceAccountHandler: implserviceaccount.NewHandler(modules.ServiceAccount),
RegistryHandler: registryHandler,
RuleStateHistory: implrulestatehistory.NewHandler(modules.RuleStateHistory),
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),

View File

@@ -59,7 +59,7 @@ func TestNewHandlers(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger, tagModule, nil)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -86,10 +86,9 @@ type Modules struct {
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
ServiceAccountGetter serviceaccount.Getter
CloudIntegration cloudintegration.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
CloudIntegration cloudintegration.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
@@ -118,7 +117,6 @@ func NewModules(
userGetter user.Getter,
userRoleStore authtypes.UserRoleStore,
serviceAccount serviceaccount.Module,
serviceAccountGetter serviceaccount.Getter,
cloudIntegrationModule cloudintegration.Module,
retentionGetter retention.Getter,
fl flagger.Flagger,
@@ -156,14 +154,13 @@ func NewModules(
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: serviceAccount,
ServiceAccountGetter: serviceAccountGetter,
ServiceAccount: serviceAccount,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger)),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl),
Tag: tagModule,
}
}

View File

@@ -62,11 +62,9 @@ func TestNewModules(t *testing.T) {
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), nil, nil, nil, providerSettings, serviceaccount.Config{})
serviceAccountGetter := implserviceaccount.NewGetter(implserviceaccount.NewStore(sqlstore))
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -78,7 +78,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ zeus.Handler }{},
struct{ querier.Handler }{},
struct{ serviceaccount.Handler }{},
struct{ serviceaccount.Getter }{},
struct{ factory.Handler }{},
struct{ cloudintegration.Handler }{},
struct{ rulestatehistory.Handler }{},

View File

@@ -307,7 +307,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.ZeusHandler,
handlers.QuerierHandler,
handlers.ServiceAccountHandler,
modules.ServiceAccountGetter,
handlers.RegistryHandler,
handlers.CloudIntegrationHandler,
handlers.RuleStateHistory,

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