Compare commits

..

8 Commits

Author SHA1 Message Date
Tushar Vats
b5e030f513 fix: draft 2026-05-03 03:54:23 +05:30
Vinicius Lourenço
8b13f004ed Revert "feat(global-time-store): add support to context, url persistence, store persistence, drift handle (#11081)" (#11152)
This reverts commit cc3da72aa5.
2026-04-30 15:46:28 +00:00
Abhi kumar
8c1d13bb38 fix: added fix for groupby being undefined (#11151) 2026-04-30 15:46:05 +00:00
SagarRajput-7
ad8f3328e0 fix(mcp-page): added acitve host url instead of current url on mcp page (#11141)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(mcp-page): added acitve host url instead of current url on mcp page

* fix(mcp-page): configure access and role control

* chore: move get hosts api access to viewers (#11145)

* chore: move get hosts api access to viewers

* chore: update openapi spec

---------

Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>

* fix: allowed hosts api to run on all the cloud users

* fix: updated test cases

---------

Co-authored-by: Karan Balani <29383381+balanikaran@users.noreply.github.com>
2026-04-30 13:31:43 +00:00
Vinicius Lourenço
cc3da72aa5 feat(global-time-store): add support to context, url persistence, store persistence, drift handle (#11081)
* feat(global-time-store): add support to context, url persistence, store persistence, drift handle

* chore(fmt): fix issue with format

* refactor(hooks): mark internal and public ones

* refactor(store): adapt to don't need round down

* refactor(global-time): scope queries via name for auto refresh to be isolated

* chore(use-query-cache): add little doc

* chore(global-time): update docs
2026-04-30 11:11:58 +00:00
Nityananda Gohain
755390c4b5 feat: types and handler for llm pricing rules (#10908)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: 1.Types for ai-o11y ricing rules

* fix: changes

* fix: minor changes

* fix: more changes

* fix: new updates

* fix: address comments

* fix: remove nullable

* fix: types

* fix: address comments

* fix: use mustnewuuid

* fix: correct table name

* fix: address comments and move pricing to a single struct

* fix: linting issues
2026-04-30 05:44:12 +00:00
SagarRajput-7
adbd89aae9 fix(platform): fix semantic tokens and component upgrade issue in platform surfaces (#11142)
* fix(platform): fix semantic tokens and component upgrade issue in platform surfaces

* fix: updated signozhq/ui version
2026-04-30 00:31:33 +00:00
primus-bot[bot]
b71de5b561 chore(release): bump to v0.121.0 (#11139)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-04-29 16:08:15 +00:00
61 changed files with 2416 additions and 349 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.120.0
image: signoz/signoz:v0.121.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.120.0
image: signoz/signoz:v0.121.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.120.0}
image: signoz/signoz:${VERSION:-v0.121.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.120.0}
image: signoz/signoz:${VERSION:-v0.121.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -2632,6 +2632,158 @@ components:
- list
- grouped_list
type: string
LlmpricingruletypesGettablePricingRules:
properties:
items:
items:
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRule'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
total:
type: integer
required:
- items
- total
- offset
- limit
type: object
LlmpricingruletypesLLMPricingCacheCosts:
properties:
mode:
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRuleCacheMode'
read:
format: double
type: number
write:
format: double
type: number
required:
- mode
type: object
LlmpricingruletypesLLMPricingRule:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
enabled:
type: boolean
id:
type: string
isOverride:
type: boolean
modelName:
type: string
modelPattern:
$ref: '#/components/schemas/LlmpricingruletypesStringSlice'
orgId:
type: string
pricing:
$ref: '#/components/schemas/LlmpricingruletypesLLMRulePricing'
provider:
type: string
sourceId:
type: string
syncedAt:
format: date-time
nullable: true
type: string
unit:
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRuleUnit'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- orgId
- modelName
- provider
- modelPattern
- unit
- pricing
- isOverride
- enabled
type: object
LlmpricingruletypesLLMPricingRuleCacheMode:
enum:
- subtract
- additive
- unknown
type: string
LlmpricingruletypesLLMPricingRuleUnit:
enum:
- per_million_tokens
type: string
LlmpricingruletypesLLMRulePricing:
properties:
cache:
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingCacheCosts'
input:
format: double
type: number
output:
format: double
type: number
required:
- input
- output
type: object
LlmpricingruletypesStringSlice:
items:
type: string
nullable: true
type: array
LlmpricingruletypesUpdatableLLMPricingRule:
properties:
enabled:
type: boolean
id:
nullable: true
type: string
isOverride:
nullable: true
type: boolean
modelName:
type: string
modelPattern:
items:
type: string
nullable: true
type: array
pricing:
$ref: '#/components/schemas/LlmpricingruletypesLLMRulePricing'
provider:
type: string
sourceId:
nullable: true
type: string
unit:
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRuleUnit'
required:
- modelName
- provider
- modelPattern
- unit
- pricing
- enabled
type: object
LlmpricingruletypesUpdatableLLMPricingRules:
properties:
rules:
items:
$ref: '#/components/schemas/LlmpricingruletypesUpdatableLLMPricingRule'
nullable: true
type: array
required:
- rules
type: object
MetricsexplorertypesInspectMetricsRequest:
properties:
end:
@@ -7675,6 +7827,218 @@ paths:
summary: Create bulk invite
tags:
- users
/api/v1/llm_pricing_rules:
get:
deprecated: false
description: Returns all LLM pricing rules for the authenticated org, with pagination.
operationId: ListLLMPricingRules
parameters:
- in: query
name: offset
schema:
type: integer
- in: query
name: limit
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LlmpricingruletypesGettablePricingRules'
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 pricing rules
tags:
- llmpricingrules
put:
deprecated: false
description: Single write endpoint used by both the user and the Zeus sync job.
Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true)
are fully preserved when the request does not provide isOverride; only synced_at
is stamped.
operationId: CreateOrUpdateLLMPricingRules
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LlmpricingruletypesUpdatableLLMPricingRules'
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create or update pricing rules
tags:
- llmpricingrules
/api/v1/llm_pricing_rules/{id}:
delete:
deprecated: false
description: Hard-deletes a pricing rule. If auto-synced, it will be recreated
on the next sync cycle.
operationId: DeleteLLMPricingRule
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"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 a pricing rule
tags:
- llmpricingrules
get:
deprecated: false
description: Returns a single LLM pricing rule by ID.
operationId: GetLLMPricingRule
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LlmpricingruletypesLLMPricingRule'
status:
type: string
required:
- status
- data
type: object
description: OK
"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:
- VIEWER
- tokenizer:
- VIEWER
summary: Get a pricing rule
tags:
- llmpricingrules
/api/v1/logs/promote_paths:
get:
deprecated: false
@@ -16909,9 +17273,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- VIEWER
- tokenizer:
- ADMIN
- VIEWER
summary: Get host info from Zeus.
tags:
- zeus

View File

@@ -105,6 +105,10 @@ func (h *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRawStream(rw, req)
}
func (h *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
h.community.QueryRangePreview(rw, req)
}
func (h *handler) ReplaceVariables(rw http.ResponseWriter, req *http.Request) {
h.community.ReplaceVariables(rw, req)
}

View File

@@ -51,7 +51,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.10",
"@signozhq/ui": "0.0.12",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -0,0 +1,399 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
DeleteLLMPricingRulePathParameters,
GetLLMPricingRule200,
GetLLMPricingRulePathParameters,
ListLLMPricingRules200,
ListLLMPricingRulesParams,
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns all LLM pricing rules for the authenticated org, with pagination.
* @summary List pricing rules
*/
export const listLLMPricingRules = (
params?: ListLLMPricingRulesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListLLMPricingRules200>({
url: `/api/v1/llm_pricing_rules`,
method: 'GET',
params,
signal,
});
};
export const getListLLMPricingRulesQueryKey = (
params?: ListLLMPricingRulesParams,
) => {
return [`/api/v1/llm_pricing_rules`, ...(params ? [params] : [])] as const;
};
export const getListLLMPricingRulesQueryOptions = <
TData = Awaited<ReturnType<typeof listLLMPricingRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListLLMPricingRulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listLLMPricingRules>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListLLMPricingRulesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listLLMPricingRules>>
> = ({ signal }) => listLLMPricingRules(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listLLMPricingRules>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListLLMPricingRulesQueryResult = NonNullable<
Awaited<ReturnType<typeof listLLMPricingRules>>
>;
export type ListLLMPricingRulesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List pricing rules
*/
export function useListLLMPricingRules<
TData = Awaited<ReturnType<typeof listLLMPricingRules>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListLLMPricingRulesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listLLMPricingRules>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListLLMPricingRulesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List pricing rules
*/
export const invalidateListLLMPricingRules = async (
queryClient: QueryClient,
params?: ListLLMPricingRulesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListLLMPricingRulesQueryKey(params) },
options,
);
return queryClient;
};
/**
* Single write endpoint used by both the user and the Zeus sync job. Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true) are fully preserved when the request does not provide isOverride; only synced_at is stamped.
* @summary Create or update pricing rules
*/
export const createOrUpdateLLMPricingRules = (
llmpricingruletypesUpdatableLLMPricingRulesDTO: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/llm_pricing_rules`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: llmpricingruletypesUpdatableLLMPricingRulesDTO,
});
};
export const getCreateOrUpdateLLMPricingRulesMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>,
TError,
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>,
TError,
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> },
TContext
> => {
const mutationKey = ['createOrUpdateLLMPricingRules'];
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 createOrUpdateLLMPricingRules>>,
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> }
> = (props) => {
const { data } = props ?? {};
return createOrUpdateLLMPricingRules(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateOrUpdateLLMPricingRulesMutationResult = NonNullable<
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>
>;
export type CreateOrUpdateLLMPricingRulesMutationBody =
BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO>;
export type CreateOrUpdateLLMPricingRulesMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create or update pricing rules
*/
export const useCreateOrUpdateLLMPricingRules = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>,
TError,
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createOrUpdateLLMPricingRules>>,
TError,
{ data: BodyType<LlmpricingruletypesUpdatableLLMPricingRulesDTO> },
TContext
> => {
const mutationOptions =
getCreateOrUpdateLLMPricingRulesMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Hard-deletes a pricing rule. If auto-synced, it will be recreated on the next sync cycle.
* @summary Delete a pricing rule
*/
export const deleteLLMPricingRule = ({
id,
}: DeleteLLMPricingRulePathParameters) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/llm_pricing_rules/${id}`,
method: 'DELETE',
});
};
export const getDeleteLLMPricingRuleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteLLMPricingRule>>,
TError,
{ pathParams: DeleteLLMPricingRulePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteLLMPricingRule>>,
TError,
{ pathParams: DeleteLLMPricingRulePathParameters },
TContext
> => {
const mutationKey = ['deleteLLMPricingRule'];
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 deleteLLMPricingRule>>,
{ pathParams: DeleteLLMPricingRulePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteLLMPricingRule(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteLLMPricingRuleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteLLMPricingRule>>
>;
export type DeleteLLMPricingRuleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete a pricing rule
*/
export const useDeleteLLMPricingRule = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteLLMPricingRule>>,
TError,
{ pathParams: DeleteLLMPricingRulePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteLLMPricingRule>>,
TError,
{ pathParams: DeleteLLMPricingRulePathParameters },
TContext
> => {
const mutationOptions = getDeleteLLMPricingRuleMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a single LLM pricing rule by ID.
* @summary Get a pricing rule
*/
export const getLLMPricingRule = (
{ id }: GetLLMPricingRulePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetLLMPricingRule200>({
url: `/api/v1/llm_pricing_rules/${id}`,
method: 'GET',
signal,
});
};
export const getGetLLMPricingRuleQueryKey = ({
id,
}: GetLLMPricingRulePathParameters) => {
return [`/api/v1/llm_pricing_rules/${id}`] as const;
};
export const getGetLLMPricingRuleQueryOptions = <
TData = Awaited<ReturnType<typeof getLLMPricingRule>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetLLMPricingRulePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getLLMPricingRule>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetLLMPricingRuleQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getLLMPricingRule>>
> = ({ signal }) => getLLMPricingRule({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getLLMPricingRule>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetLLMPricingRuleQueryResult = NonNullable<
Awaited<ReturnType<typeof getLLMPricingRule>>
>;
export type GetLLMPricingRuleQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a pricing rule
*/
export function useGetLLMPricingRule<
TData = Awaited<ReturnType<typeof getLLMPricingRule>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetLLMPricingRulePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getLLMPricingRule>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetLLMPricingRuleQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get a pricing rule
*/
export const invalidateGetLLMPricingRule = async (
queryClient: QueryClient,
{ id }: GetLLMPricingRulePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetLLMPricingRuleQueryKey({ id }) },
options,
);
return queryClient;
};

View File

@@ -3413,6 +3413,170 @@ export enum InframonitoringtypesResponseTypeDTO {
list = 'list',
grouped_list = 'grouped_list',
}
export interface LlmpricingruletypesGettablePricingRulesDTO {
/**
* @type array
* @nullable true
*/
items: LlmpricingruletypesLLMPricingRuleDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset: number;
/**
* @type integer
*/
total: number;
}
export interface LlmpricingruletypesLLMPricingCacheCostsDTO {
mode: LlmpricingruletypesLLMPricingRuleCacheModeDTO;
/**
* @type number
* @format double
*/
read?: number;
/**
* @type number
* @format double
*/
write?: number;
}
export interface LlmpricingruletypesLLMPricingRuleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
createdBy?: string;
/**
* @type boolean
*/
enabled: boolean;
/**
* @type string
*/
id: string;
/**
* @type boolean
*/
isOverride: boolean;
/**
* @type string
*/
modelName: string;
modelPattern: LlmpricingruletypesStringSliceDTO;
/**
* @type string
*/
orgId: string;
pricing: LlmpricingruletypesLLMRulePricingDTO;
/**
* @type string
*/
provider: string;
/**
* @type string
*/
sourceId?: string;
/**
* @type string
* @format date-time
* @nullable true
*/
syncedAt?: Date | null;
unit: LlmpricingruletypesLLMPricingRuleUnitDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
/**
* @type string
*/
updatedBy?: string;
}
export enum LlmpricingruletypesLLMPricingRuleCacheModeDTO {
subtract = 'subtract',
additive = 'additive',
unknown = 'unknown',
}
export enum LlmpricingruletypesLLMPricingRuleUnitDTO {
per_million_tokens = 'per_million_tokens',
}
export interface LlmpricingruletypesLLMRulePricingDTO {
cache?: LlmpricingruletypesLLMPricingCacheCostsDTO;
/**
* @type number
* @format double
*/
input: number;
/**
* @type number
* @format double
*/
output: number;
}
/**
* @nullable
*/
export type LlmpricingruletypesStringSliceDTO = string[] | null;
export interface LlmpricingruletypesUpdatableLLMPricingRuleDTO {
/**
* @type boolean
*/
enabled: boolean;
/**
* @type string
* @nullable true
*/
id?: string | null;
/**
* @type boolean
* @nullable true
*/
isOverride?: boolean | null;
/**
* @type string
*/
modelName: string;
/**
* @type array
* @nullable true
*/
modelPattern: string[] | null;
pricing: LlmpricingruletypesLLMRulePricingDTO;
/**
* @type string
*/
provider: string;
/**
* @type string
* @nullable true
*/
sourceId?: string | null;
unit: LlmpricingruletypesLLMPricingRuleUnitDTO;
}
export interface LlmpricingruletypesUpdatableLLMPricingRulesDTO {
/**
* @type array
* @nullable true
*/
rules: LlmpricingruletypesUpdatableLLMPricingRuleDTO[] | null;
}
export interface MetricsexplorertypesInspectMetricsRequestDTO {
/**
* @type integer
@@ -7004,6 +7168,41 @@ export type CreateInvite201 = {
status: string;
};
export type ListLLMPricingRulesParams = {
/**
* @type integer
* @description undefined
*/
offset?: number;
/**
* @type integer
* @description undefined
*/
limit?: number;
};
export type ListLLMPricingRules200 = {
data: LlmpricingruletypesGettablePricingRulesDTO;
/**
* @type string
*/
status: string;
};
export type DeleteLLMPricingRulePathParameters = {
id: string;
};
export type GetLLMPricingRulePathParameters = {
id: string;
};
export type GetLLMPricingRule200 = {
data: LlmpricingruletypesLLMPricingRuleDTO;
/**
* @type string
*/
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array

View File

@@ -46,6 +46,7 @@ function DeleteMemberDialog({
color="destructive"
disabled={isDeleting}
onClick={onConfirm}
loading={isDeleting}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : title}
@@ -63,7 +64,6 @@ function DeleteMemberDialog({
}}
title={title}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
footer={footer}

View File

@@ -28,18 +28,6 @@
cursor: default;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--l1-border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__input-wrapper {
display: flex;
align-items: center;
@@ -48,7 +36,7 @@
padding: var(--padding-1) var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--l1-border);
border: 1px solid var(--border);
box-sizing: border-box;
&--disabled {
@@ -65,8 +53,8 @@
}
&__email-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
@@ -178,36 +166,6 @@
}
}
.delete-dialog {
background: var(--l2-background);
border: 1px solid var(--l1-border);
[data-slot='dialog-title'] {
color: var(--l1-foreground);
}
&__body {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l2-foreground);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.065px;
margin: 0;
strong {
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
margin-top: var(--margin-6);
}
}
.reset-link-dialog {
background: var(--l2-background);
border: 1px solid var(--l1-border);
@@ -264,13 +222,6 @@
}
&__copy-btn {
flex-shrink: 0;
height: 32px;
border-radius: 0 2px 2px 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--l1-border);
min-width: 64px;
}
}

View File

@@ -224,7 +224,7 @@ function EditMemberDrawer({
try {
await rawRetry();
setSaveErrors((prev) => prev.filter((e) => e.context !== context));
refetchUser();
void refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
@@ -250,7 +250,7 @@ function EditMemberDrawer({
});
}
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
refetchUser();
void refetchUser();
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
@@ -319,7 +319,7 @@ function EditMemberDrawer({
}),
];
});
refetchUser();
void refetchUser();
},
});
} else {
@@ -340,7 +340,7 @@ function EditMemberDrawer({
onComplete();
}
refetchUser();
void refetchUser();
} finally {
setIsSaving(false);
}
@@ -465,7 +465,6 @@ function EditMemberDrawer({
prev.filter((err) => err.context !== 'Name update'),
);
}}
className="edit-member-drawer__input"
placeholder="Enter name"
disabled={isRootUser || isDeleted}
/>
@@ -631,7 +630,7 @@ function EditMemberDrawer({
</div>
<div className="edit-member-drawer__footer-right">
<Button variant="solid" color="secondary" onClick={handleClose}>
<Button variant="outlined" color="secondary" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
@@ -641,6 +640,7 @@ function EditMemberDrawer({
color="primary"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>

View File

@@ -44,9 +44,8 @@ function ResetLinkDialog({
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
variant="link"
color="secondary"
size="sm"
onClick={onCopy}
prefix={hasCopied ? <Check size={12} /> : <Copy size={12} />}
className="reset-link-dialog__copy-btn"

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Style } from '@signozhq/design-tokens';
import { ChevronDown, CircleAlert, Plus, Trash2, X } from '@signozhq/icons';
import { ChevronDown, Plus, Trash2, X } from '@signozhq/icons';
import {
Button,
Callout,
@@ -294,10 +294,8 @@ function InviteMembersModal({
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
>
{getValidationErrorMessage()}
</Callout>
title={getValidationErrorMessage()}
/>
</div>
)}
</div>

View File

@@ -87,7 +87,7 @@
input {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
font-size: var(--font-size-xs);
}
.ant-picker-suffix {
@@ -126,12 +126,6 @@
}
&__copy-btn {
flex-shrink: 0;
height: 32px;
border-radius: 0 2px 2px 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--l1-border);
min-width: 40px;
}
@@ -152,6 +146,7 @@
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
margin-bottom: var(--spacing-4);
}
&__footer {

View File

@@ -22,9 +22,8 @@ function KeyCreatedPhase({
<div className="add-key-modal__key-display">
<span className="add-key-modal__key-text">{createdKey.key}</span>
<Button
variant="outlined"
variant="link"
color="secondary"
size="sm"
onClick={onCopy}
className="add-key-modal__copy-btn"
>

View File

@@ -106,7 +106,7 @@ function KeyFormPhase({
<div className="add-key-modal__footer">
<div className="add-key-modal__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
<Button variant="solid" color="secondary" onClick={onClose}>
Cancel
</Button>
<Button
@@ -115,7 +115,6 @@ function KeyFormPhase({
form={FORM_ID}
variant="solid"
color="primary"
size="sm"
loading={isSubmitting}
disabled={!isValid}
>

View File

@@ -136,7 +136,7 @@ function EditKeyForm({
</form>
<div className="edit-key-modal__footer">
<Button variant="ghost" color="destructive" onClick={onRevokeClick}>
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>

View File

@@ -119,7 +119,7 @@
input {
color: var(--l1-foreground);
font-size: 13px;
font-size: var(--font-size-xs);
}
.ant-picker-suffix {

View File

@@ -20,7 +20,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RevokeKeyContent } from '../RevokeKeyModal';
import { RevokeKeyFooter } from '../RevokeKeyModal';
import EditKeyForm from './EditKeyForm';
import type { FormValues } from './types';
import { DEFAULT_FORM_VALUES, ExpiryMode } from './types';
@@ -158,17 +158,25 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
}
width={isRevokeConfirmOpen ? 'narrow' : 'base'}
className={
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
isRevokeConfirmOpen ? 'alert-dialog sa-delete-dialog' : 'edit-key-modal'
}
showCloseButton={!isRevokeConfirmOpen}
disableOutsideClick={isErrorModalVisible}
footer={
isRevokeConfirmOpen ? (
<RevokeKeyFooter
isRevoking={isRevoking}
onCancel={(): void => setIsRevokeConfirmOpen(false)}
onConfirm={handleRevoke}
/>
) : undefined
}
>
{isRevokeConfirmOpen ? (
<RevokeKeyContent
isRevoking={isRevoking}
onCancel={(): void => setIsRevokeConfirmOpen(false)}
onConfirm={handleRevoke}
/>
<>
Revoking this key will permanently invalidate it. Any systems using this
key will lose access immediately.
</>
) : (
<EditKeyForm
register={register}

View File

@@ -72,7 +72,6 @@ function OverviewTab({
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
className="sa-drawer__input"
placeholder="Enter name"
/>
)}

View File

@@ -17,39 +17,32 @@ import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
export interface RevokeKeyContentProps {
export interface RevokeKeyFooterProps {
isRevoking: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function RevokeKeyContent({
export function RevokeKeyFooter({
isRevoking,
onCancel,
onConfirm,
}: RevokeKeyContentProps): JSX.Element {
}: RevokeKeyFooterProps): JSX.Element {
return (
<>
<p className="delete-dialog__body">
Revoking this key will permanently invalidate it. Any systems using this key
will lose access immediately.
</p>
<div className="delete-dialog__footer">
<Button variant="solid" color="secondary" size="sm" onClick={onCancel}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</div>
<Button variant="solid" color="secondary" onClick={onCancel}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</>
);
}
@@ -112,15 +105,19 @@ function RevokeKeyModal(): JSX.Element {
}}
title={`Revoke ${keyName ?? 'key'}?`}
width="narrow"
className="alert-dialog delete-dialog"
className="alert-dialog sa-delete-dialog"
showCloseButton={false}
disableOutsideClick={isErrorModalVisible}
footer={
<RevokeKeyFooter
isRevoking={isRevoking}
onCancel={handleCancel}
onConfirm={handleConfirm}
/>
}
>
<RevokeKeyContent
isRevoking={isRevoking}
onCancel={handleCancel}
onConfirm={handleConfirm}
/>
Revoking this key will permanently invalidate it. Any systems using this key
will lose access immediately.
</DialogWrapper>
);
}

View File

@@ -57,6 +57,8 @@
color: var(--l1-foreground);
}
}
min-width: 220px;
}
&__tab {
@@ -166,18 +168,6 @@
cursor: default;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--l1-border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__input-wrapper {
display: flex;
align-items: center;
@@ -186,7 +176,7 @@
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--l1-border);
border: 1px solid var(--border);
&--disabled {
cursor: not-allowed;
@@ -195,8 +185,8 @@
}
&__input-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;

View File

@@ -129,7 +129,7 @@ function ServiceAccountDrawer({
useEffect(() => {
if (account?.id) {
setLocalName(account?.name ?? '');
setKeysPage(1);
void setKeysPage(1);
}
}, [account?.id, account?.name, setKeysPage]);
@@ -176,7 +176,7 @@ function ServiceAccountDrawer({
}
const maxPage = Math.max(1, Math.ceil(keys.length / PAGE_SIZE));
if (keysPage > maxPage) {
setKeysPage(maxPage);
void setKeysPage(maxPage);
}
}, [keysLoading, keys.length, keysPage, setKeysPage]);
@@ -214,8 +214,8 @@ function ServiceAccountDrawer({
data: { name: localName },
});
setSaveErrors((prev) => prev.filter((e) => e.context !== 'Name update'));
refetchAccount();
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
void refetchAccount();
void queryClient.invalidateQueries(getListServiceAccountsQueryKey());
} catch (err) {
setSaveErrors((prev) =>
prev.map((e) =>
@@ -337,8 +337,8 @@ function ServiceAccountDrawer({
onSuccess({ closeDrawer: false });
}
refetchAccount();
queryClient.invalidateQueries(getListServiceAccountsQueryKey());
void refetchAccount();
void queryClient.invalidateQueries(getListServiceAccountsQueryKey());
} finally {
setIsSaving(false);
}
@@ -357,12 +357,12 @@ function ServiceAccountDrawer({
]);
const handleClose = useCallback((): void => {
setIsDeleteOpen(null);
setIsAddKeyOpen(null);
setSelectedAccountId(null);
setActiveTab(null);
setKeysPage(null);
setEditKeyId(null);
void setIsDeleteOpen(null);
void setIsAddKeyOpen(null);
void setSelectedAccountId(null);
void setActiveTab(null);
void setKeysPage(null);
void setEditKeyId(null);
setSaveErrors([]);
}, [
setSelectedAccountId,
@@ -379,12 +379,13 @@ function ServiceAccountDrawer({
<ToggleGroup
type="single"
value={activeTab}
size="sm"
onChange={(val): void => {
if (val) {
setActiveTab(val as ServiceAccountDrawerTab);
void setActiveTab(val as ServiceAccountDrawerTab);
if (val !== ServiceAccountDrawerTab.Keys) {
setKeysPage(null);
setEditKeyId(null);
void setKeysPage(null);
void setEditKeyId(null);
}
}
}}
@@ -415,7 +416,7 @@ function ServiceAccountDrawer({
color="secondary"
disabled={isDeleted}
onClick={(): void => {
setIsAddKeyOpen(true);
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
@@ -503,7 +504,7 @@ function ServiceAccountDrawer({
variant="link"
color="destructive"
onClick={(): void => {
setIsDeleteOpen(true);
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
@@ -512,7 +513,7 @@ function ServiceAccountDrawer({
)}
{!isDeleted && (
<div className="sa-drawer__footer-right">
<Button variant="solid" color="secondary" onClick={handleClose}>
<Button variant="outlined" color="secondary" onClick={handleClose}>
<X size={14} />
Cancel
</Button>

View File

@@ -78,6 +78,7 @@
display: flex;
align-items: center;
gap: var(--spacing-10);
padding-left: 18px;
}
.custom-domain-card-meta-row.workspace-name-hidden {
@@ -124,30 +125,6 @@
}
}
.workspace-url-trigger {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--l1-foreground);
font-size: var(--font-size-xs);
line-height: var(--line-height-18);
letter-spacing: -0.06px;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
flex-shrink: 0;
color: var(--l2-foreground);
}
}
.workspace-url-dropdown {
border-radius: 4px;
border: 1px solid var(--l1-border);

View File

@@ -204,6 +204,7 @@ export default function CustomDomainSettings(): JSX.Element {
>
<Dropdown
trigger={['click']}
disabled={isFetchingHosts}
dropdownRender={(): JSX.Element => (
<div className="workspace-url-dropdown">
<span className="workspace-url-dropdown-header">
@@ -239,12 +240,7 @@ export default function CustomDomainSettings(): JSX.Element {
</div>
)}
>
<Button
className="workspace-url-trigger"
disabled={isFetchingHosts}
variant="link"
color="none"
>
<Button variant="link" color="none">
<Link2 size={12} />
<span>{stripProtocol(activeHost?.url ?? '')}</span>
<ChevronDown size={12} />

View File

@@ -14,6 +14,7 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
import get from 'lodash/get';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -114,7 +115,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
}, []);
const groupBy = useMemo(() => {
return widget.query.builder.queryData[0].groupBy;
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
return (

View File

@@ -10,6 +10,7 @@ import { ContextMenu } from 'periscope/components/ContextMenu';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import get from 'lodash/get';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
@@ -105,7 +106,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
]);
const groupBy = useMemo(() => {
return widget.query.builder.queryData[0].groupBy;
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
return (

View File

@@ -67,4 +67,40 @@ describe('AuthCard', () => {
expect(mockOnCreateServiceAccount).toHaveBeenCalledTimes(1);
});
it('shows URL for non-admin (all roles can fetch instance URL)', () => {
render(<AuthCard {...defaultProps} isAdmin={false} />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
});
describe('isLoadingInstanceUrl', () => {
it('shows a skeleton and hides the URL while loading', () => {
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
});
it('does not render the copy button while loading', () => {
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl />);
expect(
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
).not.toBeInTheDocument();
});
it('shows the URL and copy button once loading is done', () => {
render(<AuthCard {...defaultProps} isAdmin isLoadingInstanceUrl={false} />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
expect(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
).toBeInTheDocument();
});
});
});

View File

@@ -1,3 +1,4 @@
import { Skeleton } from 'antd';
import { Badge, Button } from '@signozhq/ui';
import { Info, KeyRound } from '@signozhq/icons';
import CopyIconButton from '../CopyIconButton';
@@ -7,6 +8,7 @@ import './AuthCard.styles.scss';
interface AuthCardProps {
isAdmin: boolean;
instanceUrl: string;
isLoadingInstanceUrl?: boolean;
onCopyInstanceUrl: () => void;
onCreateServiceAccount: () => void;
}
@@ -14,6 +16,7 @@ interface AuthCardProps {
function AuthCard({
isAdmin,
instanceUrl,
isLoadingInstanceUrl = false,
onCopyInstanceUrl,
onCreateServiceAccount,
}: AuthCardProps): JSX.Element {
@@ -32,13 +35,18 @@ function AuthCard({
<div className="mcp-auth-card__field">
<span className="mcp-auth-card__field-label">SigNoz Instance URL</span>
<div className="mcp-auth-card__endpoint-value">
<span data-testid="mcp-instance-url">{instanceUrl}</span>
<CopyIconButton
ariaLabel="Copy SigNoz instance URL"
onCopy={onCopyInstanceUrl}
/>
</div>
{isLoadingInstanceUrl ? (
<Skeleton.Input active size="small" />
) : (
<div className="mcp-auth-card__endpoint-value">
<span data-testid="mcp-instance-url">{instanceUrl}</span>
<CopyIconButton
ariaLabel="Copy SigNoz instance URL"
onCopy={onCopyInstanceUrl}
disabled={isLoadingInstanceUrl}
/>
</div>
)}
</div>
<div className="mcp-auth-card__field">

View File

@@ -6,6 +6,8 @@ const mockLogEvent = jest.fn();
const mockCopyToClipboard = jest.fn();
const mockHistoryPush = jest.fn();
const mockUseGetGlobalConfig = jest.fn();
const mockUseGetHosts = jest.fn();
const mockUseGetTenantLicense = jest.fn();
const mockToastSuccess = jest.fn();
const mockToastWarning = jest.fn();
@@ -19,6 +21,14 @@ jest.mock('api/generated/services/global', () => ({
mockUseGetGlobalConfig(...args),
}));
jest.mock('api/generated/services/zeus', () => ({
useGetHosts: (...args: unknown[]): unknown => mockUseGetHosts(...args),
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: (): unknown => mockUseGetTenantLicense(),
}));
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
@@ -47,6 +57,23 @@ jest.mock('utils/basePath', () => ({
}));
const MCP_URL = 'https://mcp.us.signoz.cloud/mcp';
const CUSTOM_HOST_URL = 'https://myteam.signoz.cloud';
const DEFAULT_HOST_URL = 'https://default.signoz.cloud';
function setupLicense({
isCloudUser = true,
isEnterpriseSelfHostedUser = false,
}: {
isCloudUser?: boolean;
isEnterpriseSelfHostedUser?: boolean;
} = {}): void {
mockUseGetTenantLicense.mockReturnValue({
isCloudUser,
isEnterpriseSelfHostedUser,
isCommunityUser: !isCloudUser && !isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser: false,
});
}
function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
mockUseGetGlobalConfig.mockReturnValue({
@@ -55,7 +82,29 @@ function setupGlobalConfig({ mcpUrl }: { mcpUrl: string | null }): void {
});
}
function setupHosts({
hosts = [],
isLoading = false,
isError = false,
}: {
hosts?: { name?: string; url?: string; is_default?: boolean }[];
isLoading?: boolean;
isError?: boolean;
} = {}): void {
mockUseGetHosts.mockReturnValue({
data: isLoading || isError ? undefined : { data: { hosts } },
isLoading,
isError,
});
}
describe('MCPServerSettings', () => {
beforeEach(() => {
// Default: cloud user, hosts loaded but empty → instanceUrl falls back to getBaseUrl()
setupLicense();
setupHosts();
});
afterEach(() => {
jest.clearAllMocks();
});
@@ -158,4 +207,145 @@ describe('MCPServerSettings', () => {
'Instance URL copied to clipboard',
);
});
describe('instance URL resolution', () => {
it('uses the active custom host URL when available', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({
hosts: [
{ name: 'default', url: DEFAULT_HOST_URL, is_default: true },
{ name: 'myteam', url: CUSTOM_HOST_URL, is_default: false },
],
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
CUSTOM_HOST_URL,
);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith(CUSTOM_HOST_URL);
});
it('falls back to the default host URL when no custom host exists', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({
hosts: [{ name: 'default', url: DEFAULT_HOST_URL, is_default: true }],
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
DEFAULT_HOST_URL,
);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith(DEFAULT_HOST_URL);
});
it('falls back to browser URL when hosts request errors', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({ isError: true });
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
});
it('shows URL skeleton while hosts are loading', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({ isLoading: true });
render(<MCPServerSettings />);
expect(screen.queryByTestId('mcp-instance-url')).not.toBeInTheDocument();
expect(document.querySelector('.ant-skeleton-input')).toBeInTheDocument();
});
it('does not copy while hosts are still loading', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupHosts({ isLoading: true });
userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />);
expect(
screen.queryByRole('button', { name: 'Copy SigNoz instance URL' }),
).not.toBeInTheDocument();
expect(mockCopyToClipboard).not.toHaveBeenCalled();
});
it('disables the hosts query for non-cloud deployments', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
expect(callOptions?.query?.enabled).toBe(false);
});
it('uses browser URL immediately for enterprise self-hosted (no skeleton)', async () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: false, isEnterpriseSelfHostedUser: true });
setupHosts({ isLoading: false });
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MCPServerSettings />, undefined, { role: 'ADMIN' });
expect(
document.querySelector('.ant-skeleton-input'),
).not.toBeInTheDocument();
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
'http://localhost',
);
await user.click(
screen.getByRole('button', { name: 'Copy SigNoz instance URL' }),
);
expect(mockCopyToClipboard).toHaveBeenCalledWith('http://localhost');
});
it('enables the hosts query for all cloud users including viewers', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: true });
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
const callOptions = mockUseGetHosts.mock.calls[0]?.[0];
expect(callOptions?.query?.enabled).toBe(true);
});
it('shows instance URL for cloud viewer', () => {
setupGlobalConfig({ mcpUrl: MCP_URL });
setupLicense({ isCloudUser: true });
setupHosts({
hosts: [{ name: 'default', url: DEFAULT_HOST_URL, is_default: true }],
});
render(<MCPServerSettings />, undefined, { role: 'VIEWER' });
expect(
document.querySelector('.ant-skeleton-input'),
).not.toBeInTheDocument();
expect(screen.getByTestId('mcp-instance-url')).toHaveTextContent(
DEFAULT_HOST_URL,
);
});
});
});

View File

@@ -1,11 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import { useGetGlobalConfig } from 'api/generated/services/global';
import { useGetHosts } from 'api/generated/services/zeus';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { USER_ROLES } from 'types/roles';
import { getBaseUrl } from 'utils/basePath';
@@ -34,7 +36,23 @@ function MCPServerSettings(): JSX.Element {
const [, copyToClipboard] = useCopyToClipboard();
const isAdmin = user.role === USER_ROLES.ADMIN;
const instanceUrl = getBaseUrl();
const { isCloudUser } = useGetTenantLicense();
const {
data: hostsData,
isLoading: isLoadingHosts,
isError: isHostsError,
} = useGetHosts({ query: { enabled: isCloudUser } });
const instanceUrl = useMemo(() => {
if (isLoadingHosts || isHostsError || !hostsData) {
return getBaseUrl();
}
const hosts = hostsData.data?.hosts ?? [];
const activeHost =
hosts.find((h) => !h.is_default) ?? hosts.find((h) => h.is_default);
return activeHost?.url ?? getBaseUrl();
}, [hostsData, isLoadingHosts, isHostsError]);
const { data: globalConfig, isLoading: isConfigLoading } =
useGetGlobalConfig();
@@ -70,10 +88,13 @@ function MCPServerSettings(): JSX.Element {
}, []);
const handleCopyInstanceUrl = useCallback(() => {
if (isLoadingHosts) {
return;
}
copyToClipboard(instanceUrl);
toast.success('Instance URL copied to clipboard');
void logEvent(ANALYTICS.INSTANCE_URL_COPIED, {});
}, [copyToClipboard, instanceUrl]);
}, [copyToClipboard, instanceUrl, isLoadingHosts]);
const handleDocsLinkClick = useCallback((target: string) => {
void logEvent(ANALYTICS.DOCS_LINK_CLICKED, { target });
@@ -132,6 +153,7 @@ function MCPServerSettings(): JSX.Element {
<AuthCard
isAdmin={isAdmin}
instanceUrl={instanceUrl}
isLoadingInstanceUrl={isLoadingHosts}
onCopyInstanceUrl={handleCopyInstanceUrl}
onCreateServiceAccount={handleCreateServiceAccount}
/>

View File

@@ -89,25 +89,4 @@
) !important;
}
}
&__add-btn {
width: 100%;
// Ensure icon is visible
svg,
[class*='icon'] {
color: var(--l2-foreground) !important;
display: inline-block !important;
opacity: 1 !important;
}
&:hover {
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
}

View File

@@ -69,10 +69,10 @@ function DomainMappingList({
))}
<Button
variant="dashed"
variant="outlined"
color="secondary"
onClick={(): void => add({ domain: '', adminEmail: '' })}
prefix={<Plus size={14} />}
className="domain-mapping-list__add-btn"
>
Add Domain Mapping
</Button>

View File

@@ -51,35 +51,6 @@
border-radius: 2px;
}
}
// todo: https://github.com/SigNoz/components/issues/116
.roles-search-wrapper {
flex: 1;
input {
width: 100%;
background: var(--l3-background);
border: 1px solid var(--l1-border);
border-radius: 2px;
padding: 6px 6px 6px 8px;
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
outline: none;
height: 32px;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--input);
}
}
}
}
.roles-description-tooltip {

View File

@@ -22,14 +22,12 @@ function RolesSettings(): JSX.Element {
</div>
<div className="roles-settings-content">
<div className="roles-settings-toolbar">
<div className="roles-search-wrapper">
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
</div>
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
{IS_ROLE_DETAILS_AND_CRUD_ENABLED && (
<Button
variant="solid"

View File

@@ -36,6 +36,7 @@ function SettingsPage(): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isEditor = user.role === USER_ROLES.EDITOR;
const isViewer = user.role === USER_ROLES.VIEWER;
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
@@ -102,6 +103,13 @@ function SettingsPage(): JSX.Element {
: item.isEnabled,
}));
}
if (isViewer) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
}));
}
}
if (isEnterpriseSelfHostedUser) {
@@ -134,6 +142,13 @@ function SettingsPage(): JSX.Element {
: item.isEnabled,
}));
}
if (isViewer) {
updatedItems = updatedItems.map((item) => ({
...item,
isEnabled: item.key === ROUTES.MCP_SERVER ? true : item.isEnabled,
}));
}
}
if (!isCloudUser && !isEnterpriseSelfHostedUser) {
@@ -166,6 +181,7 @@ function SettingsPage(): JSX.Element {
}, [
isAdmin,
isEditor,
isViewer,
isCloudUser,
isEnterpriseSelfHostedUser,
isFetchingActiveLicense,

View File

@@ -82,12 +82,13 @@ describe('SettingsPage nav sections', () => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it.each(['billing', 'roles', 'mcp-server'])(
'does not render "%s" element',
(id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
},
);
it.each(['billing', 'roles'])('does not render "%s" element', (id) => {
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
});
it('renders "mcp-server" element', () => {
expect(screen.getByTestId('mcp-server')).toBeInTheDocument();
});
});
describe('Self-hosted Admin', () => {

View File

@@ -720,6 +720,10 @@ notifications - 2050
animation: spin 1s linear infinite;
}
.animate-fast-spin {
animation: spin 0.5s linear infinite;
}
// Custom legend tooltip for immediate display
.legend-tooltip {
position: fixed;

View File

@@ -5586,10 +5586,10 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/ui@0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff"
integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ==
"@signozhq/ui@0.0.12":
version "0.0.12"
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.12.tgz#b623c1729a0d85532d555fe7e756f3a4207e8e5d"
integrity sha512-69XS/j9R+uTNMdupyjki/WK1j0d5K5j0/pJrINGiteQRRrPg/AOMue7v/W6dkLICRhXcz/mgI6tLeT2FAuzKFw==
dependencies:
"@chenglou/pretext" "^0.0.5"
"@radix-ui/react-checkbox" "^1.2.3"
@@ -5611,7 +5611,7 @@
clsx "^2.1.1"
cmdk "^1.1.1"
dayjs "^1.11.10"
lodash-es "^4.17.21"
lodash-es "^4.18.1"
motion "^11.11.17"
next-themes "^0.4.6"
nuqs "^2.8.9"
@@ -13291,6 +13291,11 @@ lodash-es@4, lodash-es@^4.17.21:
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash-es@^4.18.1:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
lodash.camelcase@^4.3.0:
version "4.3.0"
resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz"

View File

@@ -0,0 +1,93 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/gorilla/mux"
)
func (provider *provider) addLLMPricingRuleRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/llm_pricing_rules", handler.New(
provider.authZ.ViewAccess(provider.llmPricingRuleHandler.List),
handler.OpenAPIDef{
ID: "ListLLMPricingRules",
Tags: []string{"llmpricingrules"},
Summary: "List pricing rules",
Description: "Returns all LLM pricing rules for the authenticated org, with pagination.",
Request: nil,
RequestContentType: "",
RequestQuery: new(llmpricingruletypes.ListPricingRulesQuery),
Response: new(llmpricingruletypes.GettablePricingRules),
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", handler.New(
provider.authZ.AdminAccess(provider.llmPricingRuleHandler.CreateOrUpdate),
handler.OpenAPIDef{
ID: "CreateOrUpdateLLMPricingRules",
Tags: []string{"llmpricingrules"},
Summary: "Create or update pricing rules",
Description: "Single write endpoint used by both the user and the Zeus sync job. Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true) are fully preserved when the request does not provide isOverride; only synced_at is stamped.",
Request: new(llmpricingruletypes.UpdatableLLMPricingRules),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/llm_pricing_rules/{id}", handler.New(
provider.authZ.ViewAccess(provider.llmPricingRuleHandler.Get),
handler.OpenAPIDef{
ID: "GetLLMPricingRule",
Tags: []string{"llmpricingrules"},
Summary: "Get a pricing rule",
Description: "Returns a single LLM pricing rule by ID.",
Request: nil,
RequestContentType: "",
Response: new(llmpricingruletypes.GettableLLMPricingRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
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.authZ.AdminAccess(provider.llmPricingRuleHandler.Delete),
handler.OpenAPIDef{
ID: "DeleteLLMPricingRule",
Tags: []string{"llmpricingrules"},
Summary: "Delete a pricing rule",
Description: "Hard-deletes a pricing rule. If auto-synced, it will be recreated on the next sync cycle.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
@@ -67,6 +68,7 @@ type provider struct {
alertmanagerHandler alertmanager.Handler
traceDetailHandler tracedetail.Handler
rulerHandler ruler.Handler
llmPricingRuleHandler llmpricingrule.Handler
}
func NewFactory(
@@ -96,6 +98,7 @@ func NewFactory(
ruleStateHistoryHandler rulestatehistory.Handler,
spanMapperHandler spanmapper.Handler,
alertmanagerHandler alertmanager.Handler,
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
@@ -130,6 +133,7 @@ func NewFactory(
ruleStateHistoryHandler,
spanMapperHandler,
alertmanagerHandler,
llmPricingRuleHandler,
traceDetailHandler,
rulerHandler,
)
@@ -166,6 +170,7 @@ func newProvider(
ruleStateHistoryHandler rulestatehistory.Handler,
spanMapperHandler spanmapper.Handler,
alertmanagerHandler alertmanager.Handler,
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
) (apiserver.APIServer, error) {
@@ -202,6 +207,7 @@ func newProvider(
alertmanagerHandler: alertmanagerHandler,
traceDetailHandler: traceDetailHandler,
rulerHandler: rulerHandler,
llmPricingRuleHandler: llmPricingRuleHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
@@ -314,6 +320,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addLLMPricingRuleRoutes(router); err != nil {
return err
}
if err := provider.addTraceDetailRoutes(router); err != nil {
return err
}

View File

@@ -451,6 +451,22 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v5/query_range/preview", handler.New(provider.authZ.ViewAccess(provider.querierHandler.QueryRangePreview), handler.OpenAPIDef{
ID: "QueryRangePreviewV5",
Tags: []string{"querier"},
Summary: "Query range (dry-run)",
Description: "Validates the request and renders the underlying SQL/PromQL for each query in the composite query without executing it. When the 'explain' query parameter is set to true, an EXPLAIN is run against ClickHouse for each rendered SQL statement.",
Request: new(qbtypes.QueryRangeRequest),
RequestContentType: "application/json",
Response: new(qbtypes.QueryRangePreviewResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v5/substitute_vars", handler.New(provider.authZ.ViewAccess(provider.querierHandler.ReplaceVariables), handler.OpenAPIDef{
ID: "ReplaceVariables",
Tags: []string{"querier"},

View File

@@ -27,7 +27,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authZ.AdminAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authZ.ViewAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
ID: "GetHosts",
Tags: []string{"zeus"},
Summary: "Get host info from Zeus.",
@@ -39,7 +39,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -0,0 +1,158 @@
package impllmpricingrule
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
const maxLimit = 100
type handler struct {
module llmpricingrule.Module
providerSettings factory.ProviderSettings
}
func NewHandler(module llmpricingrule.Module, providerSettings factory.ProviderSettings) llmpricingrule.Handler {
return &handler{module: module, providerSettings: providerSettings}
}
// List handles GET /api/v1/llm_pricing_rules.
func (h *handler) List(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)
var q llmpricingruletypes.ListPricingRulesQuery
if err := binding.Query.BindQuery(r.URL.Query(), &q); err != nil {
render.Error(rw, err)
return
}
if q.Limit <= 0 {
q.Limit = 20
} else if q.Limit > maxLimit {
q.Limit = maxLimit
}
if q.Offset < 0 {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, llmpricingruletypes.ErrCodePricingRuleInvalidInput, "offset must be a non-negative integer"))
return
}
rules, total, err := h.module.List(ctx, orgID, q.Offset, q.Limit)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, llmpricingruletypes.NewGettableLLMPricingRulesFromLLMPricingRules(rules, total, q.Offset, q.Limit))
}
// Get handles GET /api/v1/llm_pricing_rules/{id}.
func (h *handler) Get(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)
id, err := ruleIDFromPath(r)
if err != nil {
render.Error(rw, err)
return
}
rule, err := h.module.Get(ctx, orgID, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, rule)
}
func (h *handler) CreateOrUpdate(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
req := new(llmpricingruletypes.UpdatableLLMPricingRules)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
err = h.module.CreateOrUpdate(ctx, orgID, claims.Email, req.Rules)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
// 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)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id, err := ruleIDFromPath(r)
if err != nil {
render.Error(rw, err)
return
}
if err := h.module.Delete(ctx, orgID, id); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
// ruleIDFromPath extracts and validates the {id} path variable.
func ruleIDFromPath(r *http.Request) (valuer.UUID, error) {
raw := mux.Vars(r)["id"]
id, err := valuer.NewUUID(raw)
if err != nil {
return valuer.UUID{}, errors.Wrapf(err, errors.TypeInvalidInput, llmpricingruletypes.ErrCodePricingRuleInvalidInput, "id is not a valid uuid")
}
return id, nil
}

View File

@@ -0,0 +1,24 @@
package llmpricingrule
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error)
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
}
// Handler defines the HTTP handler interface for pricing rule endpoints.
type Handler interface {
List(rw http.ResponseWriter, r *http.Request)
Get(rw http.ResponseWriter, r *http.Request)
CreateOrUpdate(rw http.ResponseWriter, r *http.Request)
Delete(rw http.ResponseWriter, r *http.Request)
}

View File

@@ -72,6 +72,53 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, queryRangeResponse)
}
func (handler *handler) QueryRangePreview(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "querier",
instrumentationtypes.CodeFunctionName: "QueryRangePreview",
})
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
var queryRangeRequest qbtypes.QueryRangeRequest
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}
if err := queryRangeRequest.Validate(); err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
explain, err := ParseExplainVariant(req.URL.Query().Get("explain"))
if err != nil {
render.Error(rw, err)
return
}
opts := qbtypes.QueryRangePreviewOptions{Explain: explain}
previewResp, err := handler.querier.QueryRangePreview(ctx, orgID, &queryRangeRequest, opts)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, previewResp)
}
func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()

View File

@@ -192,6 +192,12 @@ func (q *builderQuery[T]) isWindowList() bool {
return true
}
// Statement renders the SQL statement for the builder query without executing
// it. It is used by the dry-run/preview path.
func (q *builderQuery[T]) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
}
func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error) {
// can we do window based pagination?

View File

@@ -99,6 +99,16 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
return newQuery.String(), nil
}
// Statement renders the SQL statement for the ClickHouse SQL query without
// executing it. It is used by the dry-run/preview path.
func (q *chSQLQuery) Statement(_ context.Context) (*qbtypes.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
if err != nil {
return nil, err
}
return &qbtypes.Statement{Query: rendered, Args: q.args}, nil
}
func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.QueryDuration: instrumentationtypes.DurationBucket(q.fromMS, q.toMS),

View File

@@ -12,6 +12,11 @@ import (
type Querier interface {
QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error)
QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream)
// QueryRangePreview validates and renders the queries in req without
// executing them. opts controls dry-run behavior such as which
// EXPLAIN variant to attach to the response; the zero value performs
// a validation-only preview with no EXPLAIN.
QueryRangePreview(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, opts qbtypes.QueryRangePreviewOptions) (*qbtypes.QueryRangePreviewResponse, error)
}
// BucketCache is the interface for bucket-based caching.
@@ -25,5 +30,6 @@ type BucketCache interface {
type Handler interface {
QueryRange(rw http.ResponseWriter, req *http.Request)
QueryRawStream(rw http.ResponseWriter, req *http.Request)
QueryRangePreview(rw http.ResponseWriter, req *http.Request)
ReplaceVariables(rw http.ResponseWriter, req *http.Request)
}

View File

@@ -627,27 +627,17 @@ func convertTimeSeriesDataToScalar(tsData *qbtypes.TimeSeriesData, queryName str
return &qbtypes.ScalarData{QueryName: queryName}
}
// Series can have ragged label sets; build the column schema from the
// union of all label keys (first-seen order) and fill rows by key lookup.
keyOrder := []telemetrytypes.TelemetryFieldKey{}
keyIndex := map[string]int{}
for _, series := range tsData.Aggregations[0].Series {
for _, label := range series.Labels {
if _, ok := keyIndex[label.Key.Name]; ok {
continue
}
keyIndex[label.Key.Name] = len(keyOrder)
keyOrder = append(keyOrder, label.Key)
}
}
columns := []*qbtypes.ColumnDescriptor{}
columns := make([]*qbtypes.ColumnDescriptor, 0, len(keyOrder)+len(tsData.Aggregations))
for _, key := range keyOrder {
columns = append(columns, &qbtypes.ColumnDescriptor{
TelemetryFieldKey: key,
QueryName: queryName,
Type: qbtypes.ColumnTypeGroup,
})
// Add group columns from first series
if len(tsData.Aggregations[0].Series) > 0 {
for _, label := range tsData.Aggregations[0].Series[0].Labels {
columns = append(columns, &qbtypes.ColumnDescriptor{
TelemetryFieldKey: label.Key,
QueryName: queryName,
Type: qbtypes.ColumnTypeGroup,
})
}
}
// Add aggregation columns
@@ -665,18 +655,18 @@ func convertTimeSeriesDataToScalar(tsData *qbtypes.TimeSeriesData, queryName str
})
}
// Build rows.
groupColCount := len(keyOrder)
// Build rows
data := [][]any{}
for seriesIdx, series := range tsData.Aggregations[0].Series {
row := make([]any, len(columns))
// Place each label under its key's column (by lookup, not index).
for _, label := range series.Labels {
row[keyIndex[label.Key.Name]] = label.Value
// Add group values
for i, label := range series.Labels {
row[i] = label.Value
}
// Add aggregation values (last value)
groupColCount := len(series.Labels)
for aggIdx, agg := range tsData.Aggregations {
if seriesIdx < len(agg.Series) && len(agg.Series[seriesIdx].Values) > 0 {
lastValue := agg.Series[seriesIdx].Values[len(agg.Series[seriesIdx].Values)-1].Value

View File

@@ -1,53 +0,0 @@
package querier
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// Multiple series with different number of labels, shouldn't panic and should align labels correctly.
func TestConvertTimeSeriesDataToScalar_RaggedLabels(t *testing.T) {
label := func(name string, value any) *qbtypes.Label {
return &qbtypes.Label{
Key: telemetrytypes.TelemetryFieldKey{Name: name},
Value: value,
}
}
series := func(labels []*qbtypes.Label, value float64) *qbtypes.TimeSeries {
return &qbtypes.TimeSeries{
Labels: labels,
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1, Value: value}},
}
}
tsData := &qbtypes.TimeSeriesData{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
Index: 0,
Series: []*qbtypes.TimeSeries{
series([]*qbtypes.Label{label("label_1", "orphan-0")}, 20),
series([]*qbtypes.Label{label("label_1", "box-0"), label("label_2", "rpc-0")}, 10),
},
}},
}
var sd *qbtypes.ScalarData
require.NotPanics(t, func() {
sd = convertTimeSeriesDataToScalar(tsData, "A")
})
require.NotNil(t, sd)
require.Len(t, sd.Columns, 3)
assert.Equal(t, "label_1", sd.Columns[0].Name)
assert.Equal(t, "label_2", sd.Columns[1].Name)
assert.Equal(t, "__result_0", sd.Columns[2].Name)
require.Len(t, sd.Data, 2)
assert.Equal(t, []any{"orphan-0", nil, 20.0}, sd.Data[0])
assert.Equal(t, []any{"box-0", "rpc-0", 10.0}, sd.Data[1])
}

360
pkg/querier/preview.go Normal file
View File

@@ -0,0 +1,360 @@
package querier
import (
"context"
"log/slog"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// statementProvider is implemented by query types that can render the
// underlying SQL/PromQL statement without executing it.
type statementProvider interface {
Statement(ctx context.Context) (*qbtypes.Statement, error)
}
// clickhouseExplainClause maps a variant to the EXPLAIN clause understood
// by ClickHouse (i.e. what comes between EXPLAIN and the SELECT).
func clickhouseExplainClause(v qbtypes.ExplainVariant) (string, bool) {
switch v {
case qbtypes.ExplainVariantPlan:
return "PLAN", true
case qbtypes.ExplainVariantAST:
return "AST", true
case qbtypes.ExplainVariantSyntax:
return "SYNTAX", true
case qbtypes.ExplainVariantPipeline:
return "PIPELINE", true
case qbtypes.ExplainVariantEstimate:
return "ESTIMATE", true
case qbtypes.ExplainVariantQueryTree:
return "QUERY TREE", true
default:
return "", false
}
}
// ParseExplainVariant parses the ?explain= query parameter. An empty value
// (or "false") returns ExplainVariantNone. The literal "true" maps to PLAN
// for back-compat with simple ?explain=true. Otherwise the value must
// match one of the named variants.
func ParseExplainVariant(value string) (qbtypes.ExplainVariant, error) {
token := strings.ToLower(strings.TrimSpace(value))
switch token {
case "", "false":
return qbtypes.ExplainVariantNone, nil
case "true":
return qbtypes.ExplainVariantPlan, nil
}
v := qbtypes.ExplainVariant(token)
if _, ok := clickhouseExplainClause(v); !ok {
return qbtypes.ExplainVariantNone, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported explain variant %q (allowed: plan, ast, syntax, pipeline, estimate, query_tree)", token)
}
return v, nil
}
// QueryRangePreview validates the request and renders the underlying SQL
// (or PromQL) for each query in the composite query without executing it.
// When opts.Explain is non-empty, EXPLAIN <variant> is run against the
// telemetry store for each rendered SQL statement and attached to the
// response.
func (q *querier) QueryRangePreview(
ctx context.Context,
_ valuer.UUID,
req *qbtypes.QueryRangeRequest,
opts qbtypes.QueryRangePreviewOptions,
) (*qbtypes.QueryRangePreviewResponse, error) {
tmplVars := req.Variables
if tmplVars == nil {
tmplVars = make(map[string]qbtypes.VariableItem)
}
dependencyQueries := make(map[string]bool)
for _, query := range req.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeTraceOperator {
if spec, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator); ok {
if err := spec.ParseExpression(); err != nil {
return nil, err
}
for _, dep := range spec.CollectReferencedQueries(spec.ParsedExpression) {
dependencyQueries[dep] = true
}
}
}
}
// First pass: normalize step intervals and collect metric names that
// need temporality/type lookup.
metricNames := make([]string, 0)
for idx, query := range req.CompositeQuery.Queries {
switch query.Type {
case qbtypes.QueryTypeBuilder:
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]); ok {
for _, agg := range spec.Aggregations {
if agg.MetricName != "" {
metricNames = append(metricNames, agg.MetricName)
}
}
}
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepInterval(req.Start, req.End))}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepInterval(req.Start, req.End)) {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.MinAllowedStepInterval(req.Start, req.End))}
}
req.CompositeQuery.Queries[idx].Spec = spec
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepInterval(req.Start, req.End))}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepInterval(req.Start, req.End)) {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.MinAllowedStepInterval(req.Start, req.End))}
}
req.CompositeQuery.Queries[idx].Spec = spec
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if spec.Source == telemetrytypes.SourceMeter {
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMeter(req.Start, req.End))}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepIntervalForMeter(req.Start, req.End)) {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.MinAllowedStepIntervalForMeter(req.Start, req.End))}
}
} else {
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMetric(req.Start, req.End))}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepIntervalForMetric(req.Start, req.End)) {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.MinAllowedStepIntervalForMetric(req.Start, req.End))}
}
}
req.CompositeQuery.Queries[idx].Spec = spec
}
case qbtypes.QueryTypePromQL:
if spec, ok := query.Spec.(qbtypes.PromQuery); ok {
if spec.Step.Seconds() == 0 {
spec.Step = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepIntervalForMetric(req.Start, req.End))}
}
req.CompositeQuery.Queries[idx].Spec = spec
}
case qbtypes.QueryTypeTraceOperator:
if spec, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator); ok {
if spec.StepInterval.Seconds() == 0 {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.RecommendedStepInterval(req.Start, req.End))}
}
if spec.StepInterval.Seconds() < float64(querybuilder.MinAllowedStepInterval(req.Start, req.End)) {
spec.StepInterval = qbtypes.Step{Duration: time.Second * time.Duration(querybuilder.MinAllowedStepInterval(req.Start, req.End))}
}
req.CompositeQuery.Queries[idx].Spec = spec
}
}
}
// Fetch metric temporality/type for any builder metric queries.
var metricTemporality map[string]metrictypes.Temporality
var metricTypes map[string]metrictypes.Type
if len(metricNames) > 0 {
var err error
metricTemporality, metricTypes, err = q.metadataStore.FetchTemporalityAndTypeMulti(ctx, req.Start, req.End, metricNames...)
if err != nil {
q.logger.WarnContext(ctx, "failed to fetch metric temporality during preview", errors.Attr(err), slog.Any("metrics", metricNames))
return nil, errors.NewInternalf(errors.CodeInternal, "failed to fetch metrics temporality")
}
}
type queryEntry struct {
name string
queryType qbtypes.QueryType
query qbtypes.Query
}
entries := make([]queryEntry, 0, len(req.CompositeQuery.Queries))
for _, query := range req.CompositeQuery.Queries {
var queryName string
isTraceOperator := query.Type == qbtypes.QueryTypeTraceOperator
switch query.Type {
case qbtypes.QueryTypeTraceOperator:
if spec, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator); ok {
queryName = spec.Name
}
case qbtypes.QueryTypePromQL:
if spec, ok := query.Spec.(qbtypes.PromQuery); ok {
queryName = spec.Name
}
case qbtypes.QueryTypeClickHouseSQL:
if spec, ok := query.Spec.(qbtypes.ClickHouseQuery); ok {
queryName = spec.Name
}
case qbtypes.QueryTypeBuilder:
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
queryName = spec.Name
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
queryName = spec.Name
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
queryName = spec.Name
}
}
if !isTraceOperator && dependencyQueries[queryName] {
continue
}
switch query.Type {
case qbtypes.QueryTypePromQL:
promQuery, ok := query.Spec.(qbtypes.PromQuery)
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
}
pq := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
entries = append(entries, queryEntry{name: promQuery.Name, queryType: query.Type, query: pq})
case qbtypes.QueryTypeClickHouseSQL:
chQuery, ok := query.Spec.(qbtypes.ClickHouseQuery)
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid clickhouse query spec %T", query.Spec)
}
cq := newchSQLQuery(q.logger, q.telemetryStore, chQuery, nil, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
entries = append(entries, queryEntry{name: chQuery.Name, queryType: query.Type, query: cq})
case qbtypes.QueryTypeTraceOperator:
traceOpQuery, ok := query.Spec.(qbtypes.QueryBuilderTraceOperator)
if !ok {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid trace operator query spec %T", query.Spec)
}
toq := &traceOperatorQuery{
telemetryStore: q.telemetryStore,
stmtBuilder: q.traceOperatorStmtBuilder,
spec: traceOpQuery,
compositeQuery: &req.CompositeQuery,
fromMS: uint64(req.Start),
toMS: uint64(req.End),
kind: req.RequestType,
}
entries = append(entries, queryEntry{name: traceOpQuery.Name, queryType: query.Type, query: toq})
case qbtypes.QueryTypeBuilder:
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
entries = append(entries, queryEntry{name: spec.Name, queryType: query.Type, query: bq})
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
stmtBuilder := q.logStmtBuilder
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars)
entries = append(entries, queryEntry{name: spec.Name, queryType: query.Type, query: bq})
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
presentAggregations := []qbtypes.MetricAggregation{}
for i := range spec.Aggregations {
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Temporality == metrictypes.Unknown {
if temp, ok := metricTemporality[spec.Aggregations[i].MetricName]; ok && temp != metrictypes.Unknown {
spec.Aggregations[i].Temporality = temp
}
}
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
if foundMetricType, ok := metricTypes[spec.Aggregations[i].MetricName]; ok && foundMetricType != metrictypes.UnspecifiedType {
spec.Aggregations[i].Type = foundMetricType
}
}
if spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
continue
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
if len(presentAggregations) == 0 {
// nothing renderable for this query — skip
continue
}
spec.Aggregations = presentAggregations
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
var bq *builderQuery[qbtypes.MetricAggregation]
if spec.Source == telemetrytypes.SourceMeter {
bq = newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
} else {
bq = newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
}
entries = append(entries, queryEntry{name: spec.Name, queryType: query.Type, query: bq})
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported builder spec type %T", query.Spec)
}
}
}
statements := make([]*qbtypes.PreviewStatement, 0, len(entries))
for _, entry := range entries {
provider, ok := entry.query.(statementProvider)
if !ok {
return nil, errors.NewInternalf(errors.CodeInternal, "query %s does not support preview", entry.name)
}
stmt, err := provider.Statement(ctx)
if err != nil {
return nil, err
}
ps := &qbtypes.PreviewStatement{
QueryName: entry.name,
QueryType: entry.queryType.StringValue(),
Query: stmt.Query,
Args: stmt.Args,
Warnings: stmt.Warnings,
}
if opts.Explain != qbtypes.ExplainVariantNone && entry.queryType != qbtypes.QueryTypePromQL {
out, err := q.runExplain(ctx, opts.Explain, stmt.Query, stmt.Args)
if err != nil {
return nil, err
}
ps.ExplainVariant = string(opts.Explain)
ps.Explain = out
}
statements = append(statements, ps)
}
return &qbtypes.QueryRangePreviewResponse{
Type: req.RequestType,
Statements: statements,
}, nil
}
// runExplain runs `EXPLAIN <variant> <stmt>` against the telemetry store
// and returns the formatted output as a single string with one row per
// line.
func (q *querier) runExplain(ctx context.Context, variant qbtypes.ExplainVariant, stmt string, args []any) (string, error) {
clause, ok := clickhouseExplainClause(variant)
if !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported explain variant %q", string(variant))
}
explainQuery := "EXPLAIN " + clause + " " + stmt
rows, err := q.telemetryStore.ClickhouseDB().Query(ctx, explainQuery, args...)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to run EXPLAIN %s", clause)
}
defer rows.Close()
var lines []string
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to scan EXPLAIN row")
}
lines = append(lines, line)
}
if err := rows.Err(); err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "EXPLAIN row iteration failed")
}
return strings.Join(lines, "\n"), nil
}

View File

@@ -211,6 +211,17 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
return newQuery.String(), nil
}
// Statement renders the PromQL query string after variable substitution. It
// is used by the dry-run/preview path; PromQL queries do not have a
// SQL-style argument list.
func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
return nil, err
}
return &qbv5.Statement{Query: rendered}, nil
}
func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{

View File

@@ -32,6 +32,12 @@ func (q *traceOperatorQuery) Window() (uint64, uint64) {
return q.fromMS, q.toMS
}
// Statement renders the SQL statement for the trace operator query without
// executing it. It is used by the dry-run/preview path.
func (q *traceOperatorQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.compositeQuery)
}
func (q *traceOperatorQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
stmt, err := q.stmtBuilder.Build(
ctx,

View File

@@ -22,6 +22,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/fields/implfields"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring/implinframonitoring"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer/implmetricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
@@ -77,6 +79,7 @@ type Handlers struct {
AlertmanagerHandler alertmanager.Handler
TraceDetail tracedetail.Handler
RulerHandler ruler.Handler
LLMPricingRuleHandler llmpricingrule.Handler
}
func NewHandlers(
@@ -121,5 +124,6 @@ func NewHandlers(
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
RulerHandler: signozruler.NewHandler(rulerService),
LLMPricingRuleHandler: impllmpricingrule.NewHandler(nil, providerSettings),
}
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
@@ -77,6 +78,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ rulestatehistory.Handler }{},
struct{ spanmapper.Handler }{},
struct{ alertmanager.Handler }{},
struct{ llmpricingrule.Handler }{},
struct{ tracedetail.Handler }{},
struct{ ruler.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})

View File

@@ -283,6 +283,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RuleStateHistory,
handlers.SpanMapperHandler,
handlers.AlertmanagerHandler,
handlers.LLMPricingRuleHandler,
handlers.TraceDetail,
handlers.RulerHandler,
),

View File

@@ -0,0 +1,185 @@
package llmpricingruletypes
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var (
ErrCodePricingRuleNotFound = errors.MustNewCode("pricing_rule_not_found")
ErrCodePricingRuleInvalidInput = errors.MustNewCode("pricing_rule_invalid_input")
)
type LLMPricingRuleUnit struct {
valuer.String
}
var (
UnitPerMillionTokens = LLMPricingRuleUnit{valuer.NewString("per_million_tokens")}
)
type LLMPricingRuleCacheMode struct {
valuer.String
}
var (
// LLMPricingRuleCacheModeSubtract: cached tokens are inside input_tokens (OpenAI-style).
LLMPricingRuleCacheModeSubtract = LLMPricingRuleCacheMode{valuer.NewString("subtract")}
// LLMPricingRuleCacheModeAdditive: cached tokens are reported separately (Anthropic-style).
LLMPricingRuleCacheModeAdditive = LLMPricingRuleCacheMode{valuer.NewString("additive")}
// LLMPricingRuleCacheModeUnknown: provider behaviour is unknown; falls back to subtract.
LLMPricingRuleCacheModeUnknown = LLMPricingRuleCacheMode{valuer.NewString("unknown")}
)
// StringSlice is a []string that is stored as a JSON text column.
// It is compatible with both SQLite and PostgreSQL.
type StringSlice []string
// LLMRulePricing is the per-rule pricing shape, persisted as a single JSON.
type LLMRulePricing struct {
Input float64 `json:"input" required:"true"`
Output float64 `json:"output" required:"true"`
Cache *LLMPricingCacheCosts `json:"cache,omitempty"`
}
type LLMPricingCacheCosts struct {
Mode LLMPricingRuleCacheMode `json:"mode" required:"true"`
Read float64 `json:"read"`
Write float64 `json:"write"`
}
type LLMPricingRule struct {
bun.BaseModel `bun:"table:llm_pricing_rule,alias:llm_pricing_rule" json:"-"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgId" required:"true"`
SourceID *valuer.UUID `bun:"source_id,type:text" json:"sourceId,omitempty"`
Model string `bun:"model,type:text,notnull" json:"modelName" required:"true"`
Provider string `bun:"provider,type:text,notnull" json:"provider" required:"true"`
ModelPattern StringSlice `bun:"model_pattern,type:text,notnull" json:"modelPattern" required:"true"`
Unit LLMPricingRuleUnit `bun:"unit,type:text,notnull" json:"unit" required:"true"`
Pricing LLMRulePricing `bun:"pricing,type:text,notnull,default:'{}'" json:"pricing" required:"true"`
// IsOverride marks the row as user-pinned. When true, Zeus skips it entirely.
IsOverride bool `bun:"is_override,notnull,default:false" json:"isOverride" required:"true"`
SyncedAt *time.Time `bun:"synced_at" json:"syncedAt,omitempty"`
Enabled bool `bun:"enabled,notnull,default:true" json:"enabled" required:"true"`
}
type GettableLLMPricingRule = LLMPricingRule
type StorableLLMPricingRule = LLMPricingRule
// UpdatableLLMPricingRule is one entry in the bulk upsert batch.
//
// Identification:
// - ID set → match by id (user editing a known row).
// - SourceID set → match by source_id (Zeus sync, or user editing a Zeus-synced row).
// - neither set → insert a new row with source_id = NULL (user-created custom rule).
//
// IsOverride is a pointer so the caller can distinguish "not sent" from "set to false".
// When IsOverride is nil AND the matched row has is_override = true, the row is fully
// preserved — only synced_at is stamped.
type UpdatableLLMPricingRule struct {
ID *valuer.UUID `json:"id,omitempty"`
SourceID *valuer.UUID `json:"sourceId,omitempty"`
Model string `json:"modelName" required:"true"`
Provider string `json:"provider" required:"true"`
ModelPattern []string `json:"modelPattern" required:"true"`
Unit LLMPricingRuleUnit `json:"unit" required:"true"`
Pricing LLMRulePricing `json:"pricing" required:"true"`
IsOverride *bool `json:"isOverride,omitempty"`
Enabled bool `json:"enabled" required:"true"`
}
type UpdatableLLMPricingRules struct {
Rules []UpdatableLLMPricingRule `json:"rules" required:"true"`
}
type ListPricingRulesQuery struct {
Offset int `query:"offset" json:"offset"`
Limit int `query:"limit" json:"limit"`
}
type GettablePricingRules struct {
Items []*GettableLLMPricingRule `json:"items" required:"true"`
Total int `json:"total" required:"true"`
Offset int `json:"offset" required:"true"`
Limit int `json:"limit" required:"true"`
}
func (LLMPricingRuleUnit) Enum() []any {
return []any{UnitPerMillionTokens}
}
func (LLMPricingRuleCacheMode) Enum() []any {
return []any{LLMPricingRuleCacheModeSubtract, LLMPricingRuleCacheModeAdditive, LLMPricingRuleCacheModeUnknown}
}
func (s StringSlice) Value() (driver.Value, error) {
if s == nil {
return "[]", nil
}
b, err := json.Marshal(s)
if err != nil {
return nil, err
}
return string(b), nil
}
func (s *StringSlice) Scan(src any) error {
var raw []byte
switch v := src.(type) {
case string:
raw = []byte(v)
case []byte:
raw = v
case nil:
*s = nil
return nil
default:
return errors.NewInternalf(errors.CodeInternal, "llmpricingruletypes: cannot scan %T into StringSlice", src)
}
return json.Unmarshal(raw, s)
}
func (p LLMRulePricing) Value() (driver.Value, error) {
b, err := json.Marshal(p)
if err != nil {
return nil, err
}
return string(b), nil
}
func (p *LLMRulePricing) Scan(src any) error {
var raw []byte
switch v := src.(type) {
case string:
raw = []byte(v)
case []byte:
raw = v
case nil:
*p = LLMRulePricing{}
return nil
default:
return errors.NewInternalf(errors.CodeInternal, "llmpricingruletypes: cannot scan %T into LLMRulePricing", src)
}
return json.Unmarshal(raw, p)
}
func NewGettableLLMPricingRulesFromLLMPricingRules(items []*LLMPricingRule, total, offset, limit int) *GettablePricingRules {
return &GettablePricingRules{
Items: items,
Total: total,
Offset: offset,
Limit: limit,
}
}

View File

@@ -0,0 +1,16 @@
package llmpricingruletypes
import (
"context"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Store interface {
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*StorableLLMPricingRule, int, error)
Get(ctx context.Context, orgID, id valuer.UUID) (*StorableLLMPricingRule, error)
GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*StorableLLMPricingRule, error)
Create(ctx context.Context, rule *StorableLLMPricingRule) error
Update(ctx context.Context, rule *StorableLLMPricingRule) error
Delete(ctx context.Context, orgID, id valuer.UUID) error
}

View File

@@ -64,6 +64,74 @@ type QueryRangeResponse struct {
QBEvent *QBEvent `json:"-"`
}
// QueryRangePreviewResponse describes the dry-run output of a query range
// request. Each entry corresponds to a single query in the composite query.
type QueryRangePreviewResponse struct {
Type RequestType `json:"type"`
Statements []*PreviewStatement `json:"statements"`
}
// ExplainVariant identifies one of the ClickHouse EXPLAIN modes that the
// preview endpoint can run against a rendered SQL statement.
type ExplainVariant string
const (
ExplainVariantNone ExplainVariant = ""
ExplainVariantPlan ExplainVariant = "plan"
ExplainVariantAST ExplainVariant = "ast"
ExplainVariantSyntax ExplainVariant = "syntax"
ExplainVariantPipeline ExplainVariant = "pipeline"
ExplainVariantEstimate ExplainVariant = "estimate"
ExplainVariantQueryTree ExplainVariant = "query_tree"
)
// IsValid reports whether the variant is one of the supported values
// (including the empty/no-explain sentinel).
func (v ExplainVariant) IsValid() bool {
switch v {
case ExplainVariantNone,
ExplainVariantPlan,
ExplainVariantAST,
ExplainVariantSyntax,
ExplainVariantPipeline,
ExplainVariantEstimate,
ExplainVariantQueryTree:
return true
}
return false
}
// QueryRangePreviewOptions carries per-call options for the query range
// preview (dry-run) endpoint. The zero value is meaningful and produces a
// validation-only preview with no EXPLAIN.
type QueryRangePreviewOptions struct {
// Explain selects which ClickHouse EXPLAIN variant to run for each
// rendered SQL statement. Leave empty to skip EXPLAIN.
Explain ExplainVariant
}
// PrepareJSONSchema adds description to the QueryRangePreviewResponse schema.
func (q *QueryRangePreviewResponse) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Response from the v5 query range preview (dry-run) endpoint. Returns the rendered SQL/PromQL for each query in the composite query along with optional EXPLAIN output when requested.")
return nil
}
// PreviewStatement is the rendered form of a single query.
type PreviewStatement struct {
QueryName string `json:"queryName"`
QueryType string `json:"queryType"`
Query string `json:"query"`
Args []any `json:"args,omitempty"`
Error error `json:"error"`
Warnings []string `json:"warnings,omitempty"`
// ExplainVariant is the EXPLAIN mode that produced Explain. Empty when
// no EXPLAIN was requested.
ExplainVariant string `json:"explainVariant,omitempty"`
// Explain is the formatted output returned by ClickHouse for the
// requested EXPLAIN variant. Empty when no EXPLAIN was requested.
Explain string `json:"explain,omitempty"`
}
var _ jsonschema.Preparer = &QueryRangeResponse{}
// PrepareJSONSchema adds description to the QueryRangeResponse schema.