mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-30 23:50:27 +01:00
Compare commits
10 Commits
traceop
...
feat/globa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eff8d4d09 | ||
|
|
0c9f237369 | ||
|
|
8b13f004ed | ||
|
|
8c1d13bb38 | ||
|
|
ad8f3328e0 | ||
|
|
cc3da72aa5 | ||
|
|
755390c4b5 | ||
|
|
adbd89aae9 | ||
|
|
b71de5b561 | ||
|
|
a672335a33 |
@@ -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.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -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.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -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.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -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.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
@@ -231,6 +231,7 @@
|
||||
"ts-jest": "29.4.6",
|
||||
"ts-node": "^10.2.1",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
"vite-plugin-checker": "0.12.0",
|
||||
"vite-plugin-compression": "0.5.1",
|
||||
"vite-plugin-image-optimizer": "2.0.3",
|
||||
@@ -266,4 +267,4 @@
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
399
frontend/src/api/generated/services/llmpricingrules/index.ts
Normal file
399
frontend/src/api/generated/services/llmpricingrules/index.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -37,10 +37,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
@@ -190,16 +187,19 @@ function K8sBaseDetails<T>({
|
||||
);
|
||||
|
||||
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
|
||||
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const { startMs, endMs } = useMemo(() => {
|
||||
const { minTime: startNs, maxTime: endNs } = getMinMaxTime(selectedTime);
|
||||
|
||||
return {
|
||||
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
|
||||
};
|
||||
}, [getMinMaxTime, selectedTime]);
|
||||
const { startMs, endMs } = useMemo(
|
||||
() => ({
|
||||
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
|
||||
const [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
@@ -246,7 +246,7 @@ function K8sBaseDetails<T>({
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
selectedItem,
|
||||
),
|
||||
[queryKeyPrefix, selectedItem, selectedTime],
|
||||
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -16,10 +16,7 @@ import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
@@ -114,6 +111,9 @@ export function K8sBaseList<T>({
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return getAutoRefreshQueryKey(
|
||||
@@ -127,6 +127,7 @@ export function K8sBaseList<T>({
|
||||
JSON.stringify(groupBy),
|
||||
);
|
||||
}, [
|
||||
getAutoRefreshQueryKey,
|
||||
selectedTime,
|
||||
entity,
|
||||
pageSize,
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
} from 'antd';
|
||||
import { CornerDownRight } from 'lucide-react';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
|
||||
@@ -118,6 +115,9 @@ export function K8sExpandedRow<T>({
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return getAutoRefreshQueryKey(selectedTime, [
|
||||
@@ -126,7 +126,7 @@ export function K8sExpandedRow<T>({
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
]);
|
||||
}, [selectedTime, record.key, queryFilters, orderBy]);
|
||||
}, [getAutoRefreshQueryKey, selectedTime, record.key, queryFilters, orderBy]);
|
||||
|
||||
const { data, isFetching, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -17,7 +17,7 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import {
|
||||
useGlobalTimeQueryInvalidate,
|
||||
useIsGlobalTimeQueryRefreshing,
|
||||
} from 'hooks/globalTime';
|
||||
} from 'store/globalTime';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -101,14 +101,14 @@ function DateTimeSelection({
|
||||
if (modalInitialStartTime !== undefined) {
|
||||
initialModalStartTime = modalInitialStartTime;
|
||||
} else if (searchStartTime) {
|
||||
initialModalStartTime = parseInt(searchStartTime, 10);
|
||||
initialModalStartTime = Number.parseInt(searchStartTime, 10);
|
||||
}
|
||||
|
||||
let initialModalEndTime = 0;
|
||||
if (modalInitialEndTime !== undefined) {
|
||||
initialModalEndTime = modalInitialEndTime;
|
||||
} else if (searchEndTime) {
|
||||
initialModalEndTime = parseInt(searchEndTime, 10);
|
||||
initialModalEndTime = Number.parseInt(searchEndTime, 10);
|
||||
}
|
||||
|
||||
const [modalStartTime, setModalStartTime] = useState<number>(
|
||||
@@ -159,9 +159,11 @@ function DateTimeSelection({
|
||||
const getTime = useCallback((): [number, number] | undefined => {
|
||||
if (searchEndTime && searchStartTime) {
|
||||
const startDate = dayjs(
|
||||
new Date(parseInt(getTimeString(searchStartTime), 10)),
|
||||
new Date(Number.parseInt(getTimeString(searchStartTime), 10)),
|
||||
);
|
||||
const endDate = dayjs(
|
||||
new Date(Number.parseInt(getTimeString(searchEndTime), 10)),
|
||||
);
|
||||
const endDate = dayjs(new Date(parseInt(getTimeString(searchEndTime), 10)));
|
||||
|
||||
return [startDate.toDate().getTime() || 0, endDate.toDate().getTime() || 0];
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
|
||||
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
/**
|
||||
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
|
||||
*/
|
||||
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useCallback(async () => {
|
||||
return await queryClient.invalidateQueries({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
|
||||
});
|
||||
}, [queryClient]);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useIsFetching } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
/**
|
||||
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
|
||||
*/
|
||||
export function useIsGlobalTimeQueryRefreshing(): boolean {
|
||||
return (
|
||||
useIsFetching({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
|
||||
}) > 0
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
71
frontend/src/store/globalTime/GlobalTimeContext.tsx
Normal file
71
frontend/src/store/globalTime/GlobalTimeContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
createContext,
|
||||
ReactNode,
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import get from 'api/browser/localstorage/get';
|
||||
|
||||
import {
|
||||
createGlobalTimeStore,
|
||||
defaultGlobalTimeStore,
|
||||
GlobalTimeStoreApi,
|
||||
} from './globalTimeStore';
|
||||
import { GlobalTimeProviderOptions, GlobalTimeSelectedTime } from './types';
|
||||
import { usePersistence } from './usePersistence';
|
||||
import { useQueryCacheSync } from './useQueryCacheSync';
|
||||
import { useUrlSync } from './useUrlSync';
|
||||
import { useComputedMinMaxSync } from 'store/globalTime/useComputedMinMaxSync';
|
||||
|
||||
export const GlobalTimeContext = createContext<GlobalTimeStoreApi | null>(null);
|
||||
|
||||
export function GlobalTimeProvider({
|
||||
children,
|
||||
name,
|
||||
inheritGlobalTime = false,
|
||||
initialTime,
|
||||
enableUrlParams = false,
|
||||
removeQueryParamsOnUnmount = false,
|
||||
localStoragePersistKey,
|
||||
refreshInterval: initialRefreshInterval,
|
||||
}: GlobalTimeProviderOptions & { children: ReactNode }): JSX.Element {
|
||||
const parentStore = useContext(GlobalTimeContext);
|
||||
const globalStore = parentStore ?? defaultGlobalTimeStore;
|
||||
|
||||
const resolveInitialTime = (): GlobalTimeSelectedTime => {
|
||||
if (inheritGlobalTime) {
|
||||
return globalStore.getState().selectedTime;
|
||||
}
|
||||
if (localStoragePersistKey) {
|
||||
const stored = get(localStoragePersistKey);
|
||||
if (stored) {
|
||||
return stored as GlobalTimeSelectedTime;
|
||||
}
|
||||
}
|
||||
return initialTime ?? DEFAULT_TIME_RANGE;
|
||||
};
|
||||
|
||||
// Create isolated store (stable reference)
|
||||
const [store] = useState(() =>
|
||||
createGlobalTimeStore({
|
||||
name,
|
||||
selectedTime: resolveInitialTime(),
|
||||
refreshInterval: initialRefreshInterval ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
useComputedMinMaxSync(store);
|
||||
useQueryCacheSync(store);
|
||||
useUrlSync(store, enableUrlParams, removeQueryParamsOnUnmount);
|
||||
usePersistence(store, localStoragePersistKey);
|
||||
|
||||
return (
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import set from 'api/browser/localstorage/set';
|
||||
|
||||
import { GlobalTimeProvider } from '../GlobalTimeContext';
|
||||
import { useGlobalTime } from '../hooks';
|
||||
import { GlobalTimeProviderOptions } from '../types';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
jest.mock('api/browser/localstorage/set');
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createWrapper = (
|
||||
providerProps: GlobalTimeProviderOptions,
|
||||
nuqsProps?: { searchParams?: string },
|
||||
) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams={nuqsProps?.searchParams}>
|
||||
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('GlobalTimeProvider', () => {
|
||||
describe('name prop', () => {
|
||||
it('should pass name to store when provided', () => {
|
||||
const wrapper = createWrapper({ name: 'test-drawer' });
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe('test-drawer');
|
||||
});
|
||||
|
||||
it('should have undefined name when not provided', () => {
|
||||
const wrapper = createWrapper({});
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('store isolation', () => {
|
||||
it('should create isolated store for each provider', () => {
|
||||
const wrapper1 = createWrapper({ initialTime: '1h' });
|
||||
const wrapper2 = createWrapper({ initialTime: '15m' });
|
||||
|
||||
const { result: result1 } = renderHook(
|
||||
() => useGlobalTime((s) => s.selectedTime),
|
||||
{ wrapper: wrapper1 },
|
||||
);
|
||||
const { result: result2 } = renderHook(
|
||||
() => useGlobalTime((s) => s.selectedTime),
|
||||
{ wrapper: wrapper2 },
|
||||
);
|
||||
|
||||
expect(result1.current).toBe('1h');
|
||||
expect(result2.current).toBe('15m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inheritGlobalTime', () => {
|
||||
it('should inherit time from parent store when inheritGlobalTime is true', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime>{children}</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// Should inherit '6h' from parent provider
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should use initialTime when inheritGlobalTime is false', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// Should use its own initialTime, not parent's
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="?relativeTime=1h">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use inherited time when URL params are empty', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams="">
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// No URL params, should keep inherited value
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should prefer custom time URL params over inheritGlobalTime', async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
|
||||
const NestedWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
|
||||
>
|
||||
<GlobalTimeProvider initialTime="6h">
|
||||
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), {
|
||||
wrapper: NestedWrapper,
|
||||
});
|
||||
|
||||
// URL custom time params should override inherited time
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL sync', () => {
|
||||
it('should read relativeTime from URL on mount', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{ searchParams: '?relativeTime=1h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should read custom time from URL on mount', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom URL keys when provided', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{
|
||||
enableUrlParams: {
|
||||
relativeTimeKey: 'modalTime',
|
||||
},
|
||||
},
|
||||
{ searchParams: '?modalTime=3h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('3h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom startTimeKey and endTimeKey when provided', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{
|
||||
enableUrlParams: {
|
||||
startTimeKey: 'customStart',
|
||||
endTimeKey: 'customEnd',
|
||||
},
|
||||
},
|
||||
{ searchParams: `?customStart=${startTime}&customEnd=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT read from URL when enableUrlParams is false', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: false, initialTime: '15m' },
|
||||
{ searchParams: '?relativeTime=1h' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should use initialTime, not URL value
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should prefer startTime/endTime over relativeTime when both present in URL', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{
|
||||
searchParams: `?relativeTime=15m&startTime=${startTime}&endTime=${endTime}`,
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
// Should use startTime/endTime, not relativeTime
|
||||
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
|
||||
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
it('should use initialTime when URL has invalid time values', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true, initialTime: '15m' },
|
||||
{ searchParams: '?startTime=invalid&endTime=also-invalid' },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// parseAsInteger returns null for invalid values, so should fallback to initialTime
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should update store when custom time is set from URL with only startTime and endTime', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
const wrapper = createWrapper(
|
||||
{ enableUrlParams: true },
|
||||
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify selectedTime is a custom time range string
|
||||
expect(result.current.selectedTime).toContain('||_||');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeQueryParamsOnUnmount', () => {
|
||||
const createUnmountTestWrapper = (
|
||||
getQueryString: () => string,
|
||||
setQueryString: (qs: string) => void,
|
||||
) => {
|
||||
return function TestWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const queryClient = createTestQueryClient();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={getQueryString()}
|
||||
onUrlUpdate={(event): void => {
|
||||
setQueryString(event.queryString);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
it('should remove URL params when provider unmounts with removeQueryParamsOnUnmount=true', async () => {
|
||||
let currentQueryString = 'relativeTime=1h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// URL params should be removed
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('relativeTime');
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT remove URL params when provider unmounts with removeQueryParamsOnUnmount=false', async () => {
|
||||
let currentQueryString = 'relativeTime=1h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount={false}>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// Wait a tick to ensure cleanup effects would have run
|
||||
await waitFor(() => {
|
||||
// URL params should still be present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove custom time URL params on unmount', async () => {
|
||||
const startTime = 1700000000000;
|
||||
const endTime = 1700003600000;
|
||||
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime(), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('startTime');
|
||||
expect(currentQueryString).toContain('endTime');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// URL params should be removed
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('startTime');
|
||||
expect(currentQueryString).not.toContain('endTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove custom URL key params on unmount', async () => {
|
||||
let currentQueryString = 'modalTime=3h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider
|
||||
enableUrlParams={{
|
||||
relativeTimeKey: 'modalTime',
|
||||
}}
|
||||
removeQueryParamsOnUnmount
|
||||
>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('modalTime=3h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// URL params should be removed
|
||||
await waitFor(() => {
|
||||
expect(currentQueryString).not.toContain('modalTime');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT remove URL params when enableUrlParams is false', async () => {
|
||||
let currentQueryString = 'relativeTime=1h';
|
||||
const TestWrapper = createUnmountTestWrapper(
|
||||
() => currentQueryString,
|
||||
(qs) => {
|
||||
currentQueryString = qs;
|
||||
},
|
||||
);
|
||||
|
||||
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper: ({ children }) => (
|
||||
<TestWrapper>
|
||||
<GlobalTimeProvider enableUrlParams={false} removeQueryParamsOnUnmount>
|
||||
{children}
|
||||
</GlobalTimeProvider>
|
||||
</TestWrapper>
|
||||
),
|
||||
});
|
||||
|
||||
// Verify initial URL params are present
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
|
||||
// Unmount the provider
|
||||
unmount();
|
||||
|
||||
// Wait a tick
|
||||
await waitFor(() => {
|
||||
// URL params should still be present (enableUrlParams is false)
|
||||
expect(currentQueryString).toContain('relativeTime=1h');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage persistence', () => {
|
||||
const mockSet = set as jest.MockedFunction<typeof set>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockSet.mockClear();
|
||||
mockSet.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should read from localStorage on mount', () => {
|
||||
localStorage.setItem('test-time-key', '6h');
|
||||
|
||||
const wrapper = createWrapper({ localStoragePersistKey: 'test-time-key' });
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe('6h');
|
||||
});
|
||||
|
||||
it('should write to localStorage on selectedTime change', async () => {
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-persist-key',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('12h');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('test-persist-key', '12h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT write to localStorage when persistKey is undefined', async () => {
|
||||
const wrapper = createWrapper({ initialTime: '15m' });
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
// Wait a tick to ensure any async operations complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedTime).toBe('1h');
|
||||
});
|
||||
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only write to localStorage when selectedTime changes, not other state', async () => {
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-key',
|
||||
initialTime: '15m',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
// Change refreshInterval (not selectedTime)
|
||||
act(() => {
|
||||
result.current.setRefreshInterval(5000);
|
||||
});
|
||||
|
||||
// Wait to ensure subscription handler had a chance to run
|
||||
await waitFor(() => {
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
// Should NOT have written to localStorage for refreshInterval change
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
|
||||
// Now change selectedTime
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('test-key', '1h');
|
||||
});
|
||||
});
|
||||
|
||||
it('should fallback to initialTime when localStorage contains empty string', () => {
|
||||
localStorage.setItem('test-key', '');
|
||||
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-key',
|
||||
initialTime: '15m',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Empty string is falsy, should use initialTime
|
||||
expect(result.current).toBe('15m');
|
||||
});
|
||||
|
||||
it('should write custom time range to localStorage', async () => {
|
||||
const wrapper = createWrapper({
|
||||
localStoragePersistKey: 'test-custom-key',
|
||||
initialTime: '15m',
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
mockSet.mockClear();
|
||||
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('test-custom-key', customTime);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshInterval', () => {
|
||||
it('should initialize with provided refreshInterval', () => {
|
||||
const wrapper = createWrapper({ refreshInterval: 5000 });
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import {
|
||||
createGlobalTimeStore,
|
||||
defaultGlobalTimeStore,
|
||||
} from '../globalTimeStore';
|
||||
import { createCustomTimeRange } from '../utils';
|
||||
|
||||
describe('createGlobalTimeStore', () => {
|
||||
describe('factory function', () => {
|
||||
it('should create independent store instances', () => {
|
||||
const store1 = createGlobalTimeStore();
|
||||
const store2 = createGlobalTimeStore();
|
||||
|
||||
store1.getState().setSelectedTime('1h');
|
||||
|
||||
expect(store1.getState().selectedTime).toBe('1h');
|
||||
expect(store2.getState().selectedTime).toBe(DEFAULT_TIME_RANGE);
|
||||
});
|
||||
|
||||
it('should accept initial state', () => {
|
||||
const store = createGlobalTimeStore({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
expect(store.getState().selectedTime).toBe('15m');
|
||||
expect(store.getState().refreshInterval).toBe(5000);
|
||||
expect(store.getState().isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should compute isRefreshEnabled correctly for custom time', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const store = createGlobalTimeStore({
|
||||
selectedTime: customTime,
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
expect(store.getState().isRefreshEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultGlobalTimeStore', () => {
|
||||
it('should be a singleton', () => {
|
||||
expect(defaultGlobalTimeStore).toBeDefined();
|
||||
expect(defaultGlobalTimeStore.getState().selectedTime).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRefreshInterval', () => {
|
||||
it('should update refresh interval and enable refresh', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
|
||||
act(() => {
|
||||
store.getState().setRefreshInterval(10000);
|
||||
});
|
||||
|
||||
expect(store.getState().refreshInterval).toBe(10000);
|
||||
expect(store.getState().isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable refresh when interval is 0', () => {
|
||||
const store = createGlobalTimeStore({ refreshInterval: 5000 });
|
||||
|
||||
act(() => {
|
||||
store.getState().setRefreshInterval(0);
|
||||
});
|
||||
|
||||
expect(store.getState().refreshInterval).toBe(0);
|
||||
expect(store.getState().isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should not enable refresh for custom time range', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const store = createGlobalTimeStore({ selectedTime: customTime });
|
||||
|
||||
act(() => {
|
||||
store.getState().setRefreshInterval(10000);
|
||||
});
|
||||
|
||||
expect(store.getState().refreshInterval).toBe(10000);
|
||||
expect(store.getState().isRefreshEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store name', () => {
|
||||
it('should store name when provided', () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
expect(store.getState().name).toBe('drawer');
|
||||
});
|
||||
|
||||
it('should have undefined name when not provided', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
expect(store.getState().name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoRefreshQueryKey', () => {
|
||||
it('should generate key without name for unnamed store', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
const key = store
|
||||
.getState()
|
||||
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(key).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate key with name for named store', () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
const key = store
|
||||
.getState()
|
||||
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(key).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'drawer',
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle no query parts for named store', () => {
|
||||
const store = createGlobalTimeStore({ name: 'test' });
|
||||
const key = store.getState().getAutoRefreshQueryKey('1h');
|
||||
|
||||
expect(key).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'test',
|
||||
'1h',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle no query parts for unnamed store', () => {
|
||||
const store = createGlobalTimeStore();
|
||||
const key = store.getState().getAutoRefreshQueryKey('1h');
|
||||
|
||||
expect(key).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import { useGlobalTimeStore } from '../globalTimeStore';
|
||||
import { GlobalTimeSelectedTime } from '../types';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
describe('globalTimeStore', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
act(() => {
|
||||
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
|
||||
});
|
||||
|
||||
it('should have isRefreshEnabled as false by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should have refreshInterval as 0 by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedTime', () => {
|
||||
it('should update selectedTime', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
});
|
||||
|
||||
it('should update refreshInterval when provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should keep existing refreshInterval when not provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should enable refresh for relative time with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable refresh for relative time with refreshInterval = 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime, 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should handle various relative time formats', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const timeFormats: GlobalTimeSelectedTime[] = [
|
||||
'1m',
|
||||
'5m',
|
||||
'15m',
|
||||
'30m',
|
||||
'1h',
|
||||
'3h',
|
||||
'6h',
|
||||
'1d',
|
||||
'1w',
|
||||
];
|
||||
|
||||
timeFormats.forEach((time) => {
|
||||
act(() => {
|
||||
result.current.setSelectedTime(time, 10000);
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe(time);
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMinMaxTime', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return min/max time for custom time range', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
const { minTime: resultMin, maxTime: resultMax } =
|
||||
result.current.getMinMaxTime();
|
||||
expect(resultMin).toBe(minTime);
|
||||
expect(resultMax).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should compute fresh min/max time for relative time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
const now = Date.now() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(maxTime).toBe(now);
|
||||
expect(minTime).toBe(now - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should return different values on subsequent calls for relative time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
const first = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time by 1 second
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
const second = result.current.getMinMaxTime();
|
||||
|
||||
// maxTime should be different (1 second later)
|
||||
expect(second.maxTime).toBe(first.maxTime + 1000 * NANO_SECOND_MULTIPLIER);
|
||||
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store isolation', () => {
|
||||
it('should share state between multiple hook instances', () => {
|
||||
const { result: result1 } = renderHook(() => useGlobalTimeStore());
|
||||
const { result: result2 } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result1.current.setSelectedTime('1h', 10000);
|
||||
});
|
||||
|
||||
expect(result2.current.selectedTime).toBe('1h');
|
||||
expect(result2.current.refreshInterval).toBe(10000);
|
||||
expect(result2.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
868
frontend/src/store/globalTime/__tests__/globalTimeStore.test.tsx
Normal file
868
frontend/src/store/globalTime/__tests__/globalTimeStore.test.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
|
||||
import { createGlobalTimeStore, useGlobalTimeStore } from '../globalTimeStore';
|
||||
import { GlobalTimeContext } from '../GlobalTimeContext';
|
||||
import { useGlobalTime } from '../hooks';
|
||||
import { GlobalTimeSelectedTime, GlobalTimeState } from '../types';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
/**
|
||||
* Creates an isolated store wrapper for testing.
|
||||
* Each test gets its own store instance, avoiding test pollution.
|
||||
*/
|
||||
function createIsolatedWrapper(
|
||||
initialState?: Partial<GlobalTimeState>,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
const store = createGlobalTimeStore(initialState);
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<GlobalTimeContext.Provider value={store}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('globalTimeStore', () => {
|
||||
beforeEach(() => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
act(() => {
|
||||
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
|
||||
});
|
||||
|
||||
it('should have isRefreshEnabled as false by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should have refreshInterval as 0 by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.refreshInterval).toBe(0);
|
||||
});
|
||||
|
||||
it('should have lastRefreshTimestamp as 0 by default', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.lastRefreshTimestamp).toBe(0);
|
||||
});
|
||||
|
||||
it('should have lastComputedMinMax with default values', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual({
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedTime', () => {
|
||||
it('should update selectedTime', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe('15m');
|
||||
});
|
||||
|
||||
it('should update refreshInterval when provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should keep existing refreshInterval when not provided', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('1h');
|
||||
});
|
||||
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should enable refresh for relative time with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable refresh for relative time with refreshInterval = 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime, 5000);
|
||||
});
|
||||
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
expect(result.current.refreshInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should handle various relative time formats', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const timeFormats: GlobalTimeSelectedTime[] = [
|
||||
'1m',
|
||||
'5m',
|
||||
'15m',
|
||||
'30m',
|
||||
'1h',
|
||||
'3h',
|
||||
'6h',
|
||||
'1d',
|
||||
'1w',
|
||||
];
|
||||
|
||||
timeFormats.forEach((time) => {
|
||||
act(() => {
|
||||
result.current.setSelectedTime(time, 10000);
|
||||
});
|
||||
|
||||
expect(result.current.selectedTime).toBe(time);
|
||||
expect(result.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should compute and store lastComputedMinMax when selectedTime changes', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// setSelectedTime computes values on init (createIsolatedWrapper uses createGlobalTimeStore)
|
||||
// But initial store state has minTime/maxTime as 0 until first setSelectedTime is called
|
||||
const initialMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Now switch to a custom time range
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
// lastComputedMinMax should be updated to the custom range values
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual({
|
||||
minTime: 1000000000,
|
||||
maxTime: 2000000000,
|
||||
});
|
||||
expect(result.current.lastComputedMinMax).not.toStrictEqual(initialMinMax);
|
||||
});
|
||||
|
||||
it('should return fresh custom time values after switching from relative time', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Compute and cache values for relative time
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const relativeMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Switch to custom time range
|
||||
const customMinTime = 5000000000;
|
||||
const customMaxTime = 6000000000;
|
||||
const customTime = createCustomTimeRange(customMinTime, customMaxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return the custom time values, not cached relative values
|
||||
const returned = result.current.getMinMaxTime();
|
||||
|
||||
expect(returned.minTime).toBe(customMinTime);
|
||||
expect(returned.maxTime).toBe(customMaxTime);
|
||||
expect(returned).not.toStrictEqual(relativeMinMax);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMinMaxTime', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return min/max time for custom time range', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
const { minTime: resultMin, maxTime: resultMax } =
|
||||
result.current.getMinMaxTime();
|
||||
expect(resultMin).toBe(minTime);
|
||||
expect(resultMax).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should NOT round custom time range values to minute boundaries', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
|
||||
// If rounding occurred, these would change to 12:30:00.000
|
||||
const minTimeWithSeconds =
|
||||
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeWithSeconds =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
// What the values would be if rounded down to minute boundary
|
||||
const minTimeRounded =
|
||||
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeRounded =
|
||||
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
const customTime = createCustomTimeRange(
|
||||
minTimeWithSeconds,
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
|
||||
// Should return exact values, NOT rounded values
|
||||
expect(minTime).toBe(minTimeWithSeconds);
|
||||
expect(maxTime).toBe(maxTimeWithSeconds);
|
||||
expect(minTime).not.toBe(minTimeRounded);
|
||||
expect(maxTime).not.toBe(maxTimeRounded);
|
||||
});
|
||||
|
||||
it('should compute fresh min/max time for relative time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
const { minTime, maxTime } = result.current.getMinMaxTime();
|
||||
const now = Date.now() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(maxTime).toBe(now);
|
||||
expect(minTime).toBe(now - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should return same values on subsequent calls when refresh disabled (under minute boundary)', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0); // refresh disabled
|
||||
});
|
||||
|
||||
const first = result.current.getMinMaxTime();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(59000);
|
||||
});
|
||||
|
||||
const second = result.current.getMinMaxTime();
|
||||
|
||||
// With refresh disabled, should return cached lastComputedMinMax
|
||||
expect(second.maxTime).toBe(first.maxTime);
|
||||
expect(second.minTime).toBe(first.minTime);
|
||||
});
|
||||
|
||||
it('should return different values on subsequent calls when refresh disabled after minute boundary', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 0); // refresh disabled
|
||||
});
|
||||
|
||||
const first = result.current.getMinMaxTime();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Without refresh enabled, getMinMaxTime returns cached values
|
||||
// Need to call computeAndStoreMinMax to get new values
|
||||
const second = result.current.getMinMaxTime();
|
||||
expect(second.maxTime).toBe(first.maxTime);
|
||||
|
||||
// After computing, values should update
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const third = result.current.getMinMaxTime();
|
||||
expect(third.maxTime).toBe(first.maxTime + 60000 * NANO_SECOND_MULTIPLIER);
|
||||
expect(third.minTime).toBe(first.minTime + 60000 * NANO_SECOND_MULTIPLIER);
|
||||
});
|
||||
|
||||
it('should return stored lastComputedMinMax when available', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const stored = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Advance time by 5 seconds
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return stored values, not fresh computation
|
||||
const returned = result.current.getMinMaxTime();
|
||||
expect(returned).toStrictEqual(stored);
|
||||
});
|
||||
|
||||
describe('with isRefreshEnabled (isolated store)', () => {
|
||||
it('should compute fresh values when isRefreshEnabled is true (5s rounding)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// getMinMaxTime computes 5s-rounded values when refresh enabled
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time by 5 seconds to cross 5s boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return fresh values, not cached
|
||||
const freshValues = result.current.getMinMaxTime();
|
||||
|
||||
expect(freshValues.maxTime).toBe(
|
||||
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
expect(freshValues.minTime).toBe(
|
||||
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastComputedMinMax when values change (5s rounding)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values (uses 5s rounding when refresh enabled)
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time by 5 seconds to cross 5s boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Call getMinMaxTime - should update lastComputedMinMax
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(
|
||||
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(
|
||||
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp when values change', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = result.current.lastRefreshTimestamp;
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Call getMinMaxTime - should update timestamp
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBeGreaterThan(
|
||||
initialTimestamp,
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT update lastComputedMinMax when values have not changed (same 5s window)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values (triggers computation for 5s-rounded values)
|
||||
result.current.getMinMaxTime();
|
||||
|
||||
const initialMinMax = { ...result.current.lastComputedMinMax };
|
||||
const initialTimestamp = result.current.lastRefreshTimestamp;
|
||||
|
||||
// Advance time but stay within same 5-second window
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(4000);
|
||||
});
|
||||
|
||||
// Call getMinMaxTime - should NOT update store (same 5s boundary)
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
// Values should be unchanged (no unnecessary re-renders)
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual(initialMinMax);
|
||||
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
it('should return cached values when isRefreshEnabled is false', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 0, // Refresh disabled
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const storedMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// getMinMaxTime should return cached values since refresh is disabled
|
||||
const returned = result.current.getMinMaxTime();
|
||||
|
||||
expect(returned).toStrictEqual(storedMinMax);
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual(storedMinMax);
|
||||
});
|
||||
|
||||
it('should return same values for custom time range regardless of time passing', () => {
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: customTime,
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// isRefreshEnabled should be false for custom time ranges
|
||||
expect(result.current.isRefreshEnabled).toBe(false);
|
||||
|
||||
// Custom time ranges always return the fixed values, not relative to "now"
|
||||
const first = result.current.getMinMaxTime();
|
||||
expect(first.minTime).toBe(minTime);
|
||||
expect(first.maxTime).toBe(maxTime);
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Should still return the same fixed values (custom range doesn't drift)
|
||||
const second = result.current.getMinMaxTime();
|
||||
expect(second.minTime).toBe(minTime);
|
||||
expect(second.maxTime).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should handle multiple consecutive refetch intervals correctly (5s rounding)', () => {
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
|
||||
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Simulate 3 refetch intervals crossing 5-second boundaries
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.getMinMaxTime();
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(
|
||||
initialMinMax.maxTime + i * 5000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAndStoreMinMax', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should compute and store min/max values', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// maxTime should be the current time (no rounding when refresh disabled)
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(
|
||||
expectedMaxTime - fifteenMinutesNs,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
const beforeTimestamp = Date.now();
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBeGreaterThanOrEqual(
|
||||
beforeTimestamp,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the computed values', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
expect(returnedValue).toStrictEqual(result.current.lastComputedMinMax);
|
||||
});
|
||||
|
||||
it('should NOT round custom time range values to minute boundaries', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
|
||||
// If rounding occurred, these would change to 12:30:00.000
|
||||
const minTimeWithSeconds =
|
||||
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeWithSeconds =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
// What the values would be if rounded down to minute boundary
|
||||
const minTimeRounded =
|
||||
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeRounded =
|
||||
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
const customTime = createCustomTimeRange(
|
||||
minTimeWithSeconds,
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// Should return exact values, NOT rounded values
|
||||
expect(returnedValue?.minTime).toBe(minTimeWithSeconds);
|
||||
expect(returnedValue?.maxTime).toBe(maxTimeWithSeconds);
|
||||
expect(returnedValue?.minTime).not.toBe(minTimeRounded);
|
||||
expect(returnedValue?.maxTime).not.toBe(maxTimeRounded);
|
||||
|
||||
// lastComputedMinMax should also have exact values
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(minTimeWithSeconds);
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTimeWithSeconds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRefreshTimestamp', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp to current time', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.updateRefreshTimestamp();
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
|
||||
});
|
||||
|
||||
it('should not modify lastComputedMinMax', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const beforeMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
result.current.updateRefreshTimestamp();
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax).toStrictEqual(beforeMinMax);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store isolation', () => {
|
||||
it('should share state between multiple hook instances', () => {
|
||||
const { result: result1 } = renderHook(() => useGlobalTimeStore());
|
||||
const { result: result2 } = renderHook(() => useGlobalTimeStore());
|
||||
|
||||
act(() => {
|
||||
result1.current.setSelectedTime('1h', 10000);
|
||||
});
|
||||
|
||||
expect(result2.current.selectedTime).toBe('1h');
|
||||
expect(result2.current.refreshInterval).toBe(10000);
|
||||
expect(result2.current.isRefreshEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedTime (min/max computation)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should compute and store min/max for relative time on setSelectedTime', () => {
|
||||
const wrapper = createIsolatedWrapper();
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Initial state has 0 values
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
// Should have computed values immediately
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:00:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(
|
||||
expectedMaxTime - fifteenMinutesNs,
|
||||
);
|
||||
});
|
||||
|
||||
it('should compute and store min/max for custom time on setSelectedTime', () => {
|
||||
const wrapper = createIsolatedWrapper();
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime(customTime);
|
||||
});
|
||||
|
||||
expect(result.current.lastComputedMinMax.minTime).toBe(minTime);
|
||||
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp on setSelectedTime', () => {
|
||||
const wrapper = createIsolatedWrapper();
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m');
|
||||
});
|
||||
|
||||
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
|
||||
});
|
||||
|
||||
it('should skip update when same selectedTime and refreshInterval', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Set initial values
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
const initialTimestamp = result.current.lastRefreshTimestamp;
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// Try to set same values again
|
||||
act(() => {
|
||||
result.current.setSelectedTime('15m', 5000);
|
||||
});
|
||||
|
||||
// Should not have updated timestamp (no state change)
|
||||
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAndStoreMinMax (refresh behavior)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should skip computation and return lastComputedMinMax when refresh is enabled', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values via getMinMaxTime (which computes for refresh enabled)
|
||||
const initialMinMax = result.current.getMinMaxTime();
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// computeAndStoreMinMax should skip computation when refresh is enabled
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// Should return the current lastComputedMinMax, not fresh computation
|
||||
expect(returnedValue).toStrictEqual(initialMinMax);
|
||||
});
|
||||
|
||||
it('should compute fresh values when refresh is disabled', () => {
|
||||
const wrapper = createIsolatedWrapper({
|
||||
selectedTime: '15m',
|
||||
refreshInterval: 0, // Disabled
|
||||
});
|
||||
const { result } = renderHook(() => useGlobalTime(), { wrapper });
|
||||
|
||||
// Get initial values
|
||||
act(() => {
|
||||
result.current.computeAndStoreMinMax();
|
||||
});
|
||||
const initialMinMax = { ...result.current.lastComputedMinMax };
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// computeAndStoreMinMax should compute fresh values
|
||||
let returnedValue: { minTime: number; maxTime: number } | undefined;
|
||||
act(() => {
|
||||
returnedValue = result.current.computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
// Should return new values
|
||||
expect(returnedValue?.maxTime).toBe(
|
||||
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
190
frontend/src/store/globalTime/__tests__/hooks.test.tsx
Normal file
190
frontend/src/store/globalTime/__tests__/hooks.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { createGlobalTimeStore } from '../globalTimeStore';
|
||||
import { GlobalTimeContext } from '../GlobalTimeContext';
|
||||
import {
|
||||
useGlobalTime,
|
||||
useGlobalTimeStoreApi,
|
||||
useIsCustomTimeRange,
|
||||
useLastComputedMinMax,
|
||||
} from '../hooks';
|
||||
import { useComputedMinMaxSync } from '../useComputedMinMaxSync';
|
||||
import { createCustomTimeRange } from '../utils';
|
||||
|
||||
describe('useGlobalTime', () => {
|
||||
it('should return full store state without selector', () => {
|
||||
const { result } = renderHook(() => useGlobalTime());
|
||||
|
||||
expect(result.current.selectedTime).toBeDefined();
|
||||
expect(result.current.setSelectedTime).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should return selected value with selector', () => {
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime));
|
||||
|
||||
expect(typeof result.current).toBe('string');
|
||||
});
|
||||
|
||||
it('should use context store when provided', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '1h' });
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe('1h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useIsCustomTimeRange', () => {
|
||||
it('should return false for relative time', () => {
|
||||
const { result } = renderHook(() => useIsCustomTimeRange());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for custom time range', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: customTime });
|
||||
|
||||
const { result } = renderHook(() => useIsCustomTimeRange(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGlobalTimeStoreApi', () => {
|
||||
it('should return store API', () => {
|
||||
const { result } = renderHook(() => useGlobalTimeStoreApi());
|
||||
|
||||
expect(result.current.getState).toBeInstanceOf(Function);
|
||||
expect(result.current.subscribe).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLastComputedMinMax', () => {
|
||||
it('should return lastComputedMinMax from store', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
// Compute the min/max first
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
|
||||
const { result } = renderHook(() => useLastComputedMinMax(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toStrictEqual(
|
||||
contextStore.getState().lastComputedMinMax,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update when store changes', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
|
||||
const { result } = renderHook(() => useLastComputedMinMax(), {
|
||||
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<GlobalTimeContext.Provider value={contextStore}>
|
||||
{children}
|
||||
</GlobalTimeContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
const firstValue = { ...result.current };
|
||||
|
||||
// Change time and recompute
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000); // Advance 1 minute
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
expect(result.current).not.toStrictEqual(firstValue);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useComputedMinMaxSync', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should compute min/max on mount when store has zero values', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
expect(contextStore.getState().lastComputedMinMax).toStrictEqual({
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
renderHook(() => useComputedMinMaxSync(contextStore));
|
||||
|
||||
// Should have computed values now
|
||||
expect(contextStore.getState().lastComputedMinMax.maxTime).toBeGreaterThan(0);
|
||||
expect(contextStore.getState().lastComputedMinMax.minTime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should NOT recompute when store already has values', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
contextStore.getState().computeAndStoreMinMax();
|
||||
const initialMinMax = { ...contextStore.getState().lastComputedMinMax };
|
||||
const initialTimestamp = contextStore.getState().lastRefreshTimestamp;
|
||||
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
renderHook(() => useComputedMinMaxSync(contextStore));
|
||||
|
||||
// Should NOT have recomputed - values should be unchanged
|
||||
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
|
||||
initialMinMax,
|
||||
);
|
||||
expect(contextStore.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
it('should only compute on mount, not on re-renders', () => {
|
||||
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
|
||||
|
||||
const { rerender } = renderHook(() => useComputedMinMaxSync(contextStore));
|
||||
|
||||
const afterMountMinMax = { ...contextStore.getState().lastComputedMinMax };
|
||||
const afterMountTimestamp = contextStore.getState().lastRefreshTimestamp;
|
||||
|
||||
jest.advanceTimersByTime(60000);
|
||||
|
||||
rerender();
|
||||
|
||||
// Should NOT have recomputed on re-render
|
||||
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
|
||||
afterMountMinMax,
|
||||
);
|
||||
expect(contextStore.getState().lastRefreshTimestamp).toBe(
|
||||
afterMountTimestamp,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { GlobalTimeProvider } from '../GlobalTimeContext';
|
||||
import { useGlobalTime } from '../hooks';
|
||||
import { GlobalTimeProviderOptions } from '../types';
|
||||
import { useGlobalTimeQueryInvalidate } from '../useGlobalTimeQueryInvalidate';
|
||||
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
cacheTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createWrapper = (
|
||||
providerProps: GlobalTimeProviderOptions,
|
||||
queryClient: QueryClient,
|
||||
) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useGlobalTimeQueryInvalidate', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should return a function', () => {
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(typeof result.current).toBe('function');
|
||||
});
|
||||
|
||||
it('should call computeAndStoreMinMax before invalidating queries (refresh disabled)', async () => {
|
||||
const wrapper = createWrapper(
|
||||
{ initialTime: '15m', refreshInterval: 0 }, // refresh disabled so computeAndStoreMinMax computes fresh values
|
||||
queryClient,
|
||||
);
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
invalidate: useGlobalTimeQueryInvalidate(),
|
||||
globalTime: useGlobalTime(),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Initial computation - need to call computeAndStoreMinMax first
|
||||
act(() => {
|
||||
result.current.globalTime.computeAndStoreMinMax();
|
||||
});
|
||||
const initialMinMax = { ...result.current.globalTime.lastComputedMinMax };
|
||||
|
||||
// Advance time past minute boundary
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60000);
|
||||
});
|
||||
|
||||
// Call invalidate - should compute fresh values when refresh is disabled
|
||||
await act(async () => {
|
||||
await result.current.invalidate();
|
||||
});
|
||||
|
||||
// lastComputedMinMax should have been updated
|
||||
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
|
||||
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
});
|
||||
|
||||
it('should invalidate queries with AUTO_REFRESH_QUERY key', async () => {
|
||||
const mockQueryFn = jest.fn().mockResolvedValue({ data: 'test' });
|
||||
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
// Set up a query with AUTO_REFRESH_QUERY key
|
||||
const { result: queryResult } = renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
|
||||
queryFn: mockQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Wait for initial query to complete
|
||||
await waitFor(() => {
|
||||
expect(queryResult.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockQueryFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Now render the invalidate hook and call it
|
||||
const { result: invalidateResult } = renderHook(
|
||||
() => useGlobalTimeQueryInvalidate(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await invalidateResult.current();
|
||||
});
|
||||
|
||||
// Query should have been refetched
|
||||
await waitFor(() => {
|
||||
expect(mockQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT invalidate queries without AUTO_REFRESH_QUERY key', async () => {
|
||||
const autoRefreshQueryFn = jest.fn().mockResolvedValue({ data: 'auto' });
|
||||
const regularQueryFn = jest.fn().mockResolvedValue({ data: 'regular' });
|
||||
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
// Set up both types of queries
|
||||
const { result: autoRefreshQuery } = renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto-query'],
|
||||
queryFn: autoRefreshQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result: regularQuery } = renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: ['regular-query'],
|
||||
queryFn: regularQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Wait for initial queries to complete
|
||||
await waitFor(() => {
|
||||
expect(autoRefreshQuery.current.isSuccess).toBe(true);
|
||||
expect(regularQuery.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(1);
|
||||
expect(regularQueryFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Call invalidate
|
||||
const { result: invalidateResult } = renderHook(
|
||||
() => useGlobalTimeQueryInvalidate(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await invalidateResult.current();
|
||||
});
|
||||
|
||||
// Only auto-refresh query should be refetched
|
||||
await waitFor(() => {
|
||||
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Regular query should NOT be refetched
|
||||
expect(regularQueryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use exact custom time values (not rounded) when invalidating', async () => {
|
||||
// Use timestamps that are NOT on minute boundaries
|
||||
const minTimeWithSeconds =
|
||||
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const maxTimeWithSeconds =
|
||||
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
const customTime = createCustomTimeRange(
|
||||
minTimeWithSeconds,
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
|
||||
const wrapper = createWrapper({ initialTime: customTime }, queryClient);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
invalidate: useGlobalTimeQueryInvalidate(),
|
||||
globalTime: useGlobalTime(),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Call invalidate
|
||||
await act(async () => {
|
||||
await result.current.invalidate();
|
||||
});
|
||||
|
||||
// Verify custom time values are NOT rounded
|
||||
expect(result.current.globalTime.lastComputedMinMax.minTime).toBe(
|
||||
minTimeWithSeconds,
|
||||
);
|
||||
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
|
||||
maxTimeWithSeconds,
|
||||
);
|
||||
});
|
||||
|
||||
it('should invalidate multiple AUTO_REFRESH_QUERY queries at once', async () => {
|
||||
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
|
||||
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
|
||||
const queryFn3 = jest.fn().mockResolvedValue({ data: 'query3' });
|
||||
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
// Set up multiple auto-refresh queries
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
|
||||
queryFn: queryFn1,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
|
||||
queryFn: queryFn2,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query3'],
|
||||
queryFn: queryFn3,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Wait for initial queries
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(1);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(1);
|
||||
expect(queryFn3).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Call invalidate
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
// All queries should be refetched
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(2);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(2);
|
||||
expect(queryFn3).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoped invalidation with store name', () => {
|
||||
it('should only invalidate queries matching store name', async () => {
|
||||
const namedQueryFn = jest.fn().mockResolvedValue({ data: 'named' });
|
||||
const unnamedQueryFn = jest.fn().mockResolvedValue({ data: 'unnamed' });
|
||||
|
||||
const wrapper = createWrapper(
|
||||
{ name: 'drawer', initialTime: '15m' },
|
||||
queryClient,
|
||||
);
|
||||
|
||||
// Query with matching name
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'named-query'],
|
||||
queryFn: namedQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Query without name (different store)
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'unnamed-query'],
|
||||
queryFn: unnamedQueryFn,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(namedQueryFn).toHaveBeenCalledTimes(1);
|
||||
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Call invalidate
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
// Only named query should be refetched
|
||||
await waitFor(() => {
|
||||
expect(namedQueryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// Unnamed query should NOT be refetched
|
||||
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should invalidate all queries for unnamed store (backward compatible)', async () => {
|
||||
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
|
||||
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
|
||||
|
||||
// Unnamed store (no name prop)
|
||||
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
|
||||
queryFn: queryFn1,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
|
||||
queryFn: queryFn2,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(1);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current();
|
||||
});
|
||||
|
||||
// Both should be refetched
|
||||
await waitFor(() => {
|
||||
expect(queryFn1).toHaveBeenCalledTimes(2);
|
||||
expect(queryFn2).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { GlobalTimeProvider } from '../GlobalTimeContext';
|
||||
import { GlobalTimeProviderOptions } from '../types';
|
||||
import { useIsGlobalTimeQueryRefreshing } from '../useIsGlobalTimeQueryRefreshing';
|
||||
|
||||
const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createWrapper = (
|
||||
queryClient: QueryClient,
|
||||
): (({ children }: { children: ReactNode }) => JSX.Element) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const createProviderWrapper = (
|
||||
providerProps: GlobalTimeProviderOptions,
|
||||
queryClient: QueryClient,
|
||||
): (({ children }: { children: ReactNode }) => JSX.Element) => {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter>
|
||||
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
describe('useIsGlobalTimeQueryRefreshing', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should return false when no queries are fetching', () => {
|
||||
const wrapper = createWrapper(queryClient);
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when AUTO_REFRESH_QUERY is fetching', async () => {
|
||||
let resolveQuery: (value: unknown) => void;
|
||||
const queryPromise = new Promise((resolve) => {
|
||||
resolveQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start the auto-refresh query
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
|
||||
queryFn: () => queryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check if refreshing hook detects it
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true while fetching
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve the query
|
||||
act(() => {
|
||||
resolveQuery({ data: 'done' });
|
||||
});
|
||||
|
||||
// Should be false after fetching completes
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when non-AUTO_REFRESH_QUERY is fetching', async () => {
|
||||
let resolveQuery: (value: unknown) => void;
|
||||
const queryPromise = new Promise((resolve) => {
|
||||
resolveQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start a regular query (not auto-refresh)
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: ['regular-query'],
|
||||
queryFn: () => queryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check if refreshing hook detects it
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be false - not an auto-refresh query
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
act(() => {
|
||||
resolveQuery({ data: 'done' });
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true when multiple AUTO_REFRESH_QUERY queries are fetching', async () => {
|
||||
let resolveQuery1: (value: unknown) => void;
|
||||
let resolveQuery2: (value: unknown) => void;
|
||||
const queryPromise1 = new Promise((resolve) => {
|
||||
resolveQuery1 = resolve;
|
||||
});
|
||||
const queryPromise2 = new Promise((resolve) => {
|
||||
resolveQuery2 = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start multiple auto-refresh queries
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
|
||||
queryFn: () => queryPromise1,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
|
||||
queryFn: () => queryPromise2,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true while fetching
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve first query
|
||||
act(() => {
|
||||
resolveQuery1({ data: 'done1' });
|
||||
});
|
||||
|
||||
// Should still be true (second query still fetching)
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
// Resolve second query
|
||||
act(() => {
|
||||
resolveQuery2({ data: 'done2' });
|
||||
});
|
||||
|
||||
// Should be false after all complete
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should only track AUTO_REFRESH_QUERY, not other queries', async () => {
|
||||
let resolveAutoRefresh: (value: unknown) => void;
|
||||
let resolveRegular: (value: unknown) => void;
|
||||
const autoRefreshPromise = new Promise((resolve) => {
|
||||
resolveAutoRefresh = resolve;
|
||||
});
|
||||
const regularPromise = new Promise((resolve) => {
|
||||
resolveRegular = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createWrapper(queryClient);
|
||||
|
||||
// Start both types of queries
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto'],
|
||||
queryFn: () => autoRefreshPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: ['regular'],
|
||||
queryFn: () => regularPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true (auto-refresh is fetching)
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve auto-refresh query
|
||||
act(() => {
|
||||
resolveAutoRefresh({ data: 'done' });
|
||||
});
|
||||
|
||||
// Should be false even though regular query is still fetching
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
act(() => {
|
||||
resolveRegular({ data: 'done' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoped refreshing check with store name', () => {
|
||||
it('should return true only for queries matching store name', async () => {
|
||||
let resolveNamedQuery: (value: unknown) => void;
|
||||
const namedQueryPromise = new Promise((resolve) => {
|
||||
resolveNamedQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createProviderWrapper(
|
||||
{ name: 'drawer', initialTime: '15m' },
|
||||
queryClient,
|
||||
);
|
||||
|
||||
// Start query with matching name
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
|
||||
queryFn: () => namedQueryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check refreshing status
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be true - named query is fetching
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Resolve the query
|
||||
act(() => {
|
||||
resolveNamedQuery({ data: 'done' });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when only different store queries are fetching', async () => {
|
||||
let resolveOtherQuery: (value: unknown) => void;
|
||||
const otherQueryPromise = new Promise((resolve) => {
|
||||
resolveOtherQuery = resolve;
|
||||
});
|
||||
|
||||
const wrapper = createProviderWrapper(
|
||||
{ name: 'drawer', initialTime: '15m' },
|
||||
queryClient,
|
||||
);
|
||||
|
||||
// Start query with different name (belongs to different store)
|
||||
renderHook(
|
||||
() =>
|
||||
useQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
|
||||
queryFn: () => otherQueryPromise,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// Check refreshing status for 'drawer' store
|
||||
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Should be false - the fetching query belongs to 'other-store', not 'drawer'
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
act(() => {
|
||||
resolveOtherQuery({ data: 'done' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { createGlobalTimeStore, GlobalTimeStoreApi } from '../globalTimeStore';
|
||||
import { useQueryCacheSync } from '../useQueryCacheSync';
|
||||
|
||||
function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createWrapper(
|
||||
queryClient: QueryClient,
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('useQueryCacheSync', () => {
|
||||
let store: GlobalTimeStoreApi;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createGlobalTimeStore();
|
||||
queryClient = createTestQueryClient();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('should update lastRefreshTimestamp when auto-refresh query succeeds', async () => {
|
||||
// Initialize store
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
// Advance time
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
// Render the hook
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Simulate a successful auto-refresh query
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
|
||||
initialTimestamp,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update timestamp for non-auto-refresh queries', async () => {
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Simulate a regular query (not auto-refresh)
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: ['some-other-query'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
describe('store name filtering', () => {
|
||||
it('should update timestamp for named store when matching query succeeds', async () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Query with matching name
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
|
||||
initialTimestamp,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT update timestamp for named store when different name query succeeds', async () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Query with different name
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
|
||||
it('should NOT update timestamp for named store when unnamed query succeeds', async () => {
|
||||
const store = createGlobalTimeStore({ name: 'drawer' });
|
||||
|
||||
act(() => {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
});
|
||||
|
||||
const initialTimestamp = store.getState().lastRefreshTimestamp;
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
renderHook(() => useQueryCacheSync(store), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
// Query without name (unnamed store format)
|
||||
await act(async () => {
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
|
||||
queryFn: () => Promise.resolve({ data: 'test' }),
|
||||
});
|
||||
});
|
||||
|
||||
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import {
|
||||
computeRounded5sMinMax,
|
||||
createCustomTimeRange,
|
||||
CUSTOM_TIME_SEPARATOR,
|
||||
getAutoRefreshQueryKey,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
parseCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
roundDownTo5Seconds,
|
||||
} from '../utils';
|
||||
|
||||
describe('globalTime/utils', () => {
|
||||
@@ -136,4 +141,184 @@ describe('globalTime/utils', () => {
|
||||
expect(result.minTime).toBe(now - oneDayNs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundDownTo5Seconds', () => {
|
||||
it('should round down timestamp to 5-second boundary', () => {
|
||||
// 12:30:47.123Z -> 12:30:45.000Z
|
||||
const inputNano = 1705321847123 * NANO_SECOND_MULTIPLIER;
|
||||
const expectedNano = 1705321845000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
|
||||
});
|
||||
|
||||
it('should not change timestamp already at 5-second boundary', () => {
|
||||
const inputNano = 1705321845000 * NANO_SECOND_MULTIPLIER; // 12:30:45.000
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
|
||||
});
|
||||
|
||||
it('should round 12:30:04.999 down to 12:30:00.000', () => {
|
||||
const inputNano = 1705321804999 * NANO_SECOND_MULTIPLIER;
|
||||
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
|
||||
});
|
||||
|
||||
it('should round 12:30:09.999 down to 12:30:05.000', () => {
|
||||
const inputNano = 1705321809999 * NANO_SECOND_MULTIPLIER;
|
||||
const expectedNano = 1705321805000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
|
||||
});
|
||||
|
||||
it('should handle timestamp at exact 5-second intervals', () => {
|
||||
// Test 5, 10, 15, 20, 25... second marks
|
||||
const base = 1705321800000; // 12:30:00
|
||||
for (let sec = 0; sec < 60; sec += 5) {
|
||||
const inputNano = (base + sec * 1000) * NANO_SECOND_MULTIPLIER;
|
||||
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRounded5sMinMax', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15T12:30:47.123Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return maxTime rounded to 5-second boundary for relative time', () => {
|
||||
const result = computeRounded5sMinMax('15m');
|
||||
|
||||
// maxTime should be rounded down to 12:30:45.000
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
expect(result.maxTime).toBe(expectedMaxTime);
|
||||
});
|
||||
|
||||
it('should compute minTime based on 5s-rounded maxTime', () => {
|
||||
const result = computeRounded5sMinMax('15m');
|
||||
|
||||
const expectedMaxTime =
|
||||
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
|
||||
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
|
||||
expect(result.minTime).toBe(expectedMaxTime - fifteenMinutesNs);
|
||||
});
|
||||
|
||||
it('should return unchanged values for custom time range', () => {
|
||||
const minTime = 1000000000;
|
||||
const maxTime = 2000000000;
|
||||
const customTime = createCustomTimeRange(minTime, maxTime);
|
||||
|
||||
const result = computeRounded5sMinMax(customTime);
|
||||
|
||||
expect(result.minTime).toBe(minTime);
|
||||
expect(result.maxTime).toBe(maxTime);
|
||||
});
|
||||
|
||||
it('should preserve duration for 1h relative time', () => {
|
||||
const result = computeRounded5sMinMax('1h');
|
||||
|
||||
const oneHourNs = 60 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
|
||||
const duration = result.maxTime - result.minTime;
|
||||
|
||||
expect(duration).toBe(oneHourNs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoRefreshQueryKey', () => {
|
||||
it('should prefix with AUTO_REFRESH_QUERY constant', () => {
|
||||
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY');
|
||||
|
||||
expect(result[0]).toBe(REACT_QUERY_KEY.AUTO_REFRESH_QUERY);
|
||||
});
|
||||
|
||||
it('should append selectedTime at end', () => {
|
||||
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle no additional query parts', () => {
|
||||
const result = getAutoRefreshQueryKey('1h');
|
||||
|
||||
expect(result).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
|
||||
});
|
||||
|
||||
it('should handle custom time range as selectedTime', () => {
|
||||
const customTime = createCustomTimeRange(1000000000, 2000000000);
|
||||
const result = getAutoRefreshQueryKey(customTime, 'METRICS');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'METRICS',
|
||||
customTime,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle object query parts', () => {
|
||||
const params = { entityId: '123', filter: 'active' };
|
||||
const result = getAutoRefreshQueryKey('15m', 'ENTITY', params);
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'ENTITY',
|
||||
params,
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoRefreshQueryKey deprecation', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
beforeEach(() => {
|
||||
console.warn = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
console.warn = originalWarn;
|
||||
});
|
||||
|
||||
it('should log deprecation warning in development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
getAutoRefreshQueryKey('15m', 'TEST');
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('deprecated'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT log deprecation warning in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
getAutoRefreshQueryKey('15m', 'TEST');
|
||||
|
||||
expect(console.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still return correct query key format', () => {
|
||||
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
|
||||
|
||||
expect(result).toStrictEqual([
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
'MY_QUERY',
|
||||
'param1',
|
||||
'15m',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,144 @@
|
||||
import { createStore, StoreApi, useStore } from 'zustand';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
IGlobalTimeStoreActions,
|
||||
IGlobalTimeStoreState,
|
||||
GlobalTimeSelectedTime,
|
||||
GlobalTimeState,
|
||||
GlobalTimeStore,
|
||||
ParsedTimeRange,
|
||||
} from './types';
|
||||
import { isCustomTimeRange, parseSelectedTime } from './utils';
|
||||
import {
|
||||
computeRounded5sMinMax,
|
||||
isCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
} from './utils';
|
||||
|
||||
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
|
||||
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
|
||||
export type IGlobalTimeStore = GlobalTimeStore;
|
||||
|
||||
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
|
||||
selectedTime: DEFAULT_TIME_RANGE,
|
||||
isRefreshEnabled: false,
|
||||
refreshInterval: 0,
|
||||
setSelectedTime: (selectedTime, refreshInterval): void => {
|
||||
set((state) => {
|
||||
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
|
||||
const isCustom = isCustomTimeRange(selectedTime);
|
||||
function computeIsRefreshEnabled(
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
refreshInterval: number,
|
||||
): boolean {
|
||||
if (isCustomTimeRange(selectedTime)) {
|
||||
return false;
|
||||
}
|
||||
return refreshInterval > 0;
|
||||
}
|
||||
|
||||
return {
|
||||
selectedTime,
|
||||
refreshInterval: newRefreshInterval,
|
||||
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
getMinMaxTime: (selectedTime): ParsedTimeRange => {
|
||||
return parseSelectedTime(selectedTime || get().selectedTime);
|
||||
},
|
||||
}));
|
||||
export function createGlobalTimeStore(
|
||||
initialState?: Partial<GlobalTimeState>,
|
||||
): GlobalTimeStoreApi {
|
||||
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
|
||||
const refreshInterval = initialState?.refreshInterval ?? 0;
|
||||
const name = initialState?.name;
|
||||
|
||||
return createStore<GlobalTimeStore>((set, get) => ({
|
||||
name,
|
||||
selectedTime,
|
||||
refreshInterval,
|
||||
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
|
||||
lastRefreshTimestamp: 0,
|
||||
lastComputedMinMax: { minTime: 0, maxTime: 0 },
|
||||
|
||||
setSelectedTime: (
|
||||
time: GlobalTimeSelectedTime,
|
||||
newRefreshInterval?: number,
|
||||
): void => {
|
||||
const state = get();
|
||||
const interval = newRefreshInterval ?? state.refreshInterval;
|
||||
|
||||
if (time === state.selectedTime && interval === state.refreshInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
const computedMinMax = parseSelectedTime(time);
|
||||
|
||||
set({
|
||||
selectedTime: time,
|
||||
refreshInterval: interval,
|
||||
isRefreshEnabled: computeIsRefreshEnabled(time, interval),
|
||||
lastComputedMinMax: computedMinMax,
|
||||
lastRefreshTimestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
|
||||
setRefreshInterval: (interval: number): void => {
|
||||
set((state) => ({
|
||||
refreshInterval: interval,
|
||||
isRefreshEnabled: computeIsRefreshEnabled(state.selectedTime, interval),
|
||||
}));
|
||||
},
|
||||
|
||||
getMinMaxTime: (): ParsedTimeRange => {
|
||||
const state = get();
|
||||
|
||||
if (isCustomTimeRange(state.selectedTime)) {
|
||||
return parseSelectedTime(state.selectedTime);
|
||||
}
|
||||
|
||||
if (state.isRefreshEnabled) {
|
||||
const freshMinMax = computeRounded5sMinMax(state.selectedTime);
|
||||
|
||||
if (
|
||||
freshMinMax.minTime !== state.lastComputedMinMax.minTime ||
|
||||
freshMinMax.maxTime !== state.lastComputedMinMax.maxTime
|
||||
) {
|
||||
set({ lastComputedMinMax: freshMinMax, lastRefreshTimestamp: Date.now() });
|
||||
}
|
||||
|
||||
return freshMinMax;
|
||||
}
|
||||
|
||||
return state.lastComputedMinMax;
|
||||
},
|
||||
|
||||
computeAndStoreMinMax: (): ParsedTimeRange => {
|
||||
const state = get();
|
||||
|
||||
if (state.isRefreshEnabled) {
|
||||
return state.lastComputedMinMax;
|
||||
}
|
||||
|
||||
const computedMinMax = parseSelectedTime(state.selectedTime);
|
||||
|
||||
set({
|
||||
lastComputedMinMax: computedMinMax,
|
||||
lastRefreshTimestamp: Date.now(),
|
||||
});
|
||||
return computedMinMax;
|
||||
},
|
||||
|
||||
updateRefreshTimestamp: (): void => {
|
||||
set({ lastRefreshTimestamp: Date.now() });
|
||||
},
|
||||
|
||||
getAutoRefreshQueryKey: (
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
): unknown[] => {
|
||||
const storeName = get().name;
|
||||
if (storeName) {
|
||||
return [
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
storeName,
|
||||
...queryParts,
|
||||
selectedTime,
|
||||
];
|
||||
}
|
||||
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const defaultGlobalTimeStore = createGlobalTimeStore();
|
||||
|
||||
export const useGlobalTimeStore = <T = GlobalTimeStore>(
|
||||
selector?: (state: GlobalTimeStore) => T,
|
||||
): T => {
|
||||
return useStore(
|
||||
defaultGlobalTimeStore,
|
||||
selector ?? ((state) => state as unknown as T),
|
||||
);
|
||||
};
|
||||
|
||||
57
frontend/src/store/globalTime/hooks.ts
Normal file
57
frontend/src/store/globalTime/hooks.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
import { useContext } from 'react';
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import { GlobalTimeContext } from './GlobalTimeContext';
|
||||
import { defaultGlobalTimeStore, GlobalTimeStoreApi } from './globalTimeStore';
|
||||
import { GlobalTimeStore, ParsedTimeRange } from './types';
|
||||
import { isCustomTimeRange } from './utils';
|
||||
|
||||
/**
|
||||
* Access global time state with optional selector for performance.
|
||||
*
|
||||
* @example
|
||||
* // Full state (re-renders on any change)
|
||||
* const { selectedTime, setSelectedTime } = useGlobalTime();
|
||||
*
|
||||
* @example
|
||||
* // With selector (re-renders only when selectedTime changes)
|
||||
* const selectedTime = useGlobalTime(state => state.selectedTime);
|
||||
*/
|
||||
export function useGlobalTime<T = GlobalTimeStore>(
|
||||
selector?: (state: GlobalTimeStore) => T,
|
||||
equalityFn?: (a: T, b: T) => boolean,
|
||||
): T {
|
||||
const contextStore = useContext(GlobalTimeContext);
|
||||
const store = contextStore ?? defaultGlobalTimeStore;
|
||||
|
||||
return useStoreWithEqualityFn(
|
||||
store,
|
||||
selector ?? ((state) => state as unknown as T),
|
||||
equalityFn,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently using a custom time range.
|
||||
*/
|
||||
export function useIsCustomTimeRange(): boolean {
|
||||
const selectedTime = useGlobalTime((state) => state.selectedTime);
|
||||
return isCustomTimeRange(selectedTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the store API directly (for subscriptions or non-React contexts).
|
||||
*/
|
||||
export function useGlobalTimeStoreApi(): GlobalTimeStoreApi {
|
||||
const contextStore = useContext(GlobalTimeContext);
|
||||
return contextStore ?? defaultGlobalTimeStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last computed min/max time values.
|
||||
* Use this for display purposes to ensure consistency with query data.
|
||||
*/
|
||||
export function useLastComputedMinMax(): ParsedTimeRange {
|
||||
return useGlobalTime((state) => state.lastComputedMinMax);
|
||||
}
|
||||
@@ -1,9 +1,558 @@
|
||||
export { useGlobalTimeStore } from './globalTimeStore';
|
||||
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
|
||||
/**
|
||||
* # Global Time Store
|
||||
*
|
||||
* Centralized time management for the application with auto-refresh support.
|
||||
*
|
||||
* ## Quick Start
|
||||
*
|
||||
* ```tsx
|
||||
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
|
||||
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
|
||||
*
|
||||
* const { data } = useQuery({
|
||||
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY', params),
|
||||
* queryFn: () => {
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* return fetchData({ start, end });
|
||||
* },
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Core Concepts
|
||||
*
|
||||
* ### Time Formats
|
||||
*
|
||||
* | Format | Example | Description |
|
||||
* |--------|---------|-------------|
|
||||
* | Relative | `'15m'`, `'1h'`, `'1d'` | Duration from now, supports auto-refresh |
|
||||
* | Custom | `'1234567890||_||1234567899'` | Fixed range in nanoseconds, no auto-refresh |
|
||||
*
|
||||
* ### Time Units
|
||||
*
|
||||
* - Store values are in **nanoseconds**
|
||||
* - Most APIs expect **seconds**
|
||||
* - Convert to have seconds: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER / 1000)`
|
||||
* - Convert to have ms: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER)`
|
||||
*
|
||||
* ## Integration Guide
|
||||
*
|
||||
* ### Step 1: Get Store State
|
||||
*
|
||||
* Use selectors for optimal re-render performance:
|
||||
*
|
||||
* ```tsx
|
||||
* // Good - only re-renders when selectedTime changes
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
|
||||
*
|
||||
* // Avoid - re-renders on ANY store change
|
||||
* const store = useGlobalTime();
|
||||
* ```
|
||||
*
|
||||
* ### Step 2: Build Query Key
|
||||
*
|
||||
* Use the store's `getAutoRefreshQueryKey` to enable auto-refresh:
|
||||
*
|
||||
* ```tsx
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
*
|
||||
* const queryKey = useMemo(
|
||||
* () => getAutoRefreshQueryKey(
|
||||
* selectedTime, // Required - triggers invalidation
|
||||
* 'UNIQUE_KEY', // Your query identifier
|
||||
* ...otherParams // Additional cache-busting params
|
||||
* ),
|
||||
* [getAutoRefreshQueryKey, selectedTime, ...deps]
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* **Note:** For named providers (with `name` prop), query keys are automatically
|
||||
* scoped to that store, enabling isolated invalidation and refresh tracking.
|
||||
*
|
||||
* ### Step 3: Fetch Data
|
||||
*
|
||||
* **IMPORTANT**: Call `getMinMaxTime()` INSIDE `queryFn`:
|
||||
*
|
||||
* ```tsx
|
||||
* const { data } = useQuery({
|
||||
* queryKey,
|
||||
* queryFn: () => {
|
||||
* // Fresh time values computed here during auto-refresh
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* return api.fetch({ start, end });
|
||||
* },
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### Step 4: Add Refresh Button (Optional)
|
||||
*
|
||||
* ```tsx
|
||||
* import {
|
||||
* useGlobalTimeQueryInvalidate,
|
||||
* useIsGlobalTimeQueryRefreshing,
|
||||
* } from 'store/globalTime';
|
||||
*
|
||||
* function RefreshButton() {
|
||||
* const invalidate = useGlobalTimeQueryInvalidate();
|
||||
* const isRefreshing = useIsGlobalTimeQueryRefreshing();
|
||||
*
|
||||
* return (
|
||||
* <button onClick={invalidate} disabled={isRefreshing}>
|
||||
* {isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Avoiding Stale Data
|
||||
*
|
||||
* ### Problem: Time Drift During Refresh
|
||||
*
|
||||
* If multiple queries compute time independently, they may use different values:
|
||||
*
|
||||
* ```tsx
|
||||
* // BAD - each query gets different time
|
||||
* queryFn: () => {
|
||||
* const now = Date.now();
|
||||
* return fetchData({ end: now, start: now - duration });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Solution: Use getMinMaxTime()
|
||||
*
|
||||
* `getMinMaxTime()` ensures all queries use consistent timestamps:
|
||||
* - When auto-refresh is **disabled**: returns cached values from `computeAndStoreMinMax()`
|
||||
* - When auto-refresh is **enabled**: computes fresh values (rounded to 5-second boundaries)
|
||||
*
|
||||
* Since values are rounded to 5-second boundaries, all queries calling `getMinMaxTime()`
|
||||
* within the same 5-second window get identical timestamps.
|
||||
*
|
||||
* ```tsx
|
||||
* // GOOD - all queries get same time
|
||||
* queryFn: () => {
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* return fetchData({ start: minTime, end: maxTime });
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### How It Works
|
||||
*
|
||||
* **Manual refresh:**
|
||||
* 1. User clicks refresh
|
||||
* 2. `useGlobalTimeQueryInvalidate` calls `computeAndStoreMinMax()`
|
||||
* 3. Fresh min/max stored in `lastComputedMinMax`
|
||||
* 4. All queries re-run and call `getMinMaxTime()`
|
||||
* 5. All get the SAME cached values
|
||||
*
|
||||
* **Auto-refresh (when `isRefreshEnabled = true`):**
|
||||
* 1. React-query's `refetchInterval` triggers query re-execution
|
||||
* 2. `getMinMaxTime()` computes fresh values (rounded to 5 seconds)
|
||||
* 3. If values changed, updates `lastComputedMinMax` cache
|
||||
* 4. All queries within same 5-second window get consistent values
|
||||
*
|
||||
* ## Auto-Refresh Setup
|
||||
*
|
||||
* Auto-refresh is enabled when:
|
||||
* - `selectedTime` is a relative duration (e.g., `'15m'`)
|
||||
* - `refreshInterval > 0`
|
||||
*
|
||||
* ```tsx
|
||||
* // Auto-refresh configuration
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
|
||||
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
|
||||
*
|
||||
* useQuery({
|
||||
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY'),
|
||||
* queryFn: () => { ... },
|
||||
* // Enable periodic refetch
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ## API Reference
|
||||
*
|
||||
* ### Hooks
|
||||
*
|
||||
* | Hook | Returns | Description |
|
||||
* |------|---------|-------------|
|
||||
* | `useGlobalTime(selector?)` | `T` | Access store state with optional selector |
|
||||
* | `useGlobalTimeQueryInvalidate()` | `() => Promise<void>` | Invalidate all auto-refresh queries |
|
||||
* | `useIsGlobalTimeQueryRefreshing()` | `boolean` | Check if any query is refreshing |
|
||||
* | `useIsCustomTimeRange()` | `boolean` | Check if using fixed time range |
|
||||
* | `useLastComputedMinMax()` | `ParsedTimeRange` | Get cached min/max values |
|
||||
* | `useGlobalTimeStoreApi()` | `GlobalTimeStoreApi` | Get raw store API |
|
||||
*
|
||||
* ### Store Actions
|
||||
*
|
||||
* | Action | Description |
|
||||
* |--------|-------------|
|
||||
* | `setSelectedTime(time, interval?)` | Set time range and optional refresh interval (resets cache) |
|
||||
* | `setRefreshInterval(ms)` | Set auto-refresh interval |
|
||||
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
|
||||
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
|
||||
* | `getAutoRefreshQueryKey(time, ...parts)` | Build scoped query key for this store instance |
|
||||
*
|
||||
* ### Utilities
|
||||
*
|
||||
* | Function | Description |
|
||||
* |----------|-------------|
|
||||
* | `getAutoRefreshQueryKey(time, ...parts)` | **@deprecated** Use store action instead |
|
||||
* | `parseSelectedTime(time)` | Parse time string to min/max (fresh computation) |
|
||||
* | `isCustomTimeRange(time)` | Check if time is custom range format |
|
||||
* | `createCustomTimeRange(min, max)` | Create custom range string |
|
||||
*
|
||||
* ### Constants
|
||||
*
|
||||
* | Constant | Value | Description |
|
||||
* |----------|-------|-------------|
|
||||
* | `NANO_SECOND_MULTIPLIER` | `1000000` | Convert ms to ns |
|
||||
* | `CUSTOM_TIME_SEPARATOR` | `'||_||'` | Separator in custom range strings |
|
||||
*
|
||||
* ## Context & Composition
|
||||
*
|
||||
* ### Why Use Context?
|
||||
*
|
||||
* By default, `useGlobalTime()` uses a shared global store. Use `GlobalTimeProvider`
|
||||
* to create isolated time state for specific UI sections (modals, drawers, etc.).
|
||||
*
|
||||
* ### Provider Options
|
||||
*
|
||||
* | Option | Type | Description |
|
||||
* |--------|------|-------------|
|
||||
* | `name` | `string` | Scope query keys to this store (enables isolated invalidation) |
|
||||
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
|
||||
* | `initialTime` | `string` | Initial time if not inheriting |
|
||||
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
|
||||
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
|
||||
* | `localStoragePersistKey` | `string` | Persist time to localStorage |
|
||||
* | `refreshInterval` | `number` | Initial auto-refresh interval (ms) |
|
||||
*
|
||||
* ### Example 1: Isolated Time in Modal
|
||||
*
|
||||
* A modal with its own time picker that doesn't affect the main page:
|
||||
*
|
||||
* ```tsx
|
||||
* import { GlobalTimeProvider, useGlobalTime } from 'store/globalTime';
|
||||
*
|
||||
* function EntityDetailsModal({ entity, onClose }) {
|
||||
* return (
|
||||
* <Modal open onClose={onClose}>
|
||||
* // Isolated time context - changes here don't affect parent
|
||||
* <GlobalTimeProvider
|
||||
* inheritGlobalTime // Start with parent's current time
|
||||
* refreshInterval={0} // No auto-refresh in modal
|
||||
* >
|
||||
* <ModalContent entity={entity} />
|
||||
* </GlobalTimeProvider>
|
||||
* </Modal>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function ModalContent({ entity }) {
|
||||
* // This useGlobalTime reads from the modal's isolated store
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <DateTimePicker
|
||||
* value={selectedTime}
|
||||
* onChange={(time) => setSelectedTime(time)}
|
||||
* />
|
||||
* <EntityMetrics entity={entity} />
|
||||
* <EntityLogs entity={entity} />
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 2: List Page with Detail Drawer
|
||||
*
|
||||
* Main list uses global time, drawer has independent time:
|
||||
*
|
||||
* ```tsx
|
||||
* // Main list page - uses global time (no provider needed)
|
||||
* function K8sPodsList() {
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const [selectedPod, setSelectedPod] = useState(null);
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <PageHeader>
|
||||
* <DateTimeSelectionV3 /> // Controls global time
|
||||
* </PageHeader>
|
||||
*
|
||||
* <PodsTable
|
||||
* timeRange={selectedTime}
|
||||
* onRowClick={setSelectedPod}
|
||||
* />
|
||||
*
|
||||
* {selectedPod && (
|
||||
* <PodDetailsDrawer
|
||||
* pod={selectedPod}
|
||||
* onClose={() => setSelectedPod(null)}
|
||||
* />
|
||||
* )}
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Drawer with its own time context
|
||||
* function PodDetailsDrawer({ pod, onClose }) {
|
||||
* return (
|
||||
* <Drawer open onClose={onClose}>
|
||||
* <GlobalTimeProvider
|
||||
* name="pod-drawer" // Scopes queries - only this drawer's queries are invalidated
|
||||
* inheritGlobalTime // Start with list's time
|
||||
* removeQueryParamsOnUnmount // Clean up URL when drawer closes
|
||||
* enableUrlParams={{
|
||||
* relativeTimeKey: 'drawerTime',
|
||||
* startTimeKey: 'drawerStart',
|
||||
* endTimeKey: 'drawerEnd',
|
||||
* }}
|
||||
* >
|
||||
* <DrawerHeader>
|
||||
* <DateTimeSelectionV3 /> // Controls drawer's time only
|
||||
* </DrawerHeader>
|
||||
*
|
||||
* <Tabs>
|
||||
* <Tab label="Metrics"><PodMetrics pod={pod} /></Tab>
|
||||
* <Tab label="Logs"><PodLogs pod={pod} /></Tab>
|
||||
* <Tab label="Events"><PodEvents pod={pod} /></Tab>
|
||||
* </Tabs>
|
||||
* </GlobalTimeProvider>
|
||||
* </Drawer>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 3: Nested Contexts
|
||||
*
|
||||
* Contexts can be nested - each level creates isolation:
|
||||
*
|
||||
* ```tsx
|
||||
* // App level - global time
|
||||
* function App() {
|
||||
* return (
|
||||
* <QueryClientProvider>
|
||||
* // No provider here = uses defaultGlobalTimeStore
|
||||
* <Dashboard />
|
||||
* </QueryClientProvider>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Dashboard with comparison panel
|
||||
* function Dashboard() {
|
||||
* return (
|
||||
* <div className="dashboard">
|
||||
* // Main dashboard uses global time
|
||||
* <MainCharts />
|
||||
*
|
||||
* // Comparison panel has its own time
|
||||
* <GlobalTimeProvider initialTime="1h">
|
||||
* <ComparisonPanel />
|
||||
* </GlobalTimeProvider>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function ComparisonPanel() {
|
||||
* // This reads from ComparisonPanel's isolated store (1h)
|
||||
* // Not affected by global time changes
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* return <ComparisonCharts timeRange={selectedTime} />;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 4: URL Sync for Shareable Links
|
||||
*
|
||||
* Persist time selection to URL for shareable links:
|
||||
*
|
||||
* ```tsx
|
||||
* function TracesExplorer() {
|
||||
* return (
|
||||
* <GlobalTimeProvider
|
||||
* enableUrlParams={{
|
||||
* relativeTimeKey: 'time', // ?time=15m
|
||||
* startTimeKey: 'startTime', // ?startTime=1234567890
|
||||
* endTimeKey: 'endTime', // ?endTime=1234567899
|
||||
* }}
|
||||
* initialTime="15m" // Fallback if URL has no time params
|
||||
* >
|
||||
* <TracesContent />
|
||||
* </GlobalTimeProvider>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Example 5: localStorage Persistence
|
||||
*
|
||||
* Remember user's last selected time across sessions:
|
||||
*
|
||||
* ```tsx
|
||||
* function MetricsExplorer() {
|
||||
* return (
|
||||
* <GlobalTimeProvider
|
||||
* localStoragePersistKey="metrics-explorer-time"
|
||||
* initialTime="1h" // Fallback for first visit
|
||||
* >
|
||||
* <MetricsContent />
|
||||
* </GlobalTimeProvider>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Context Resolution Order
|
||||
*
|
||||
* When `useGlobalTime()` is called, it resolves the store in this order:
|
||||
*
|
||||
* 1. Nearest `GlobalTimeProvider` ancestor (if any)
|
||||
* 2. `defaultGlobalTimeStore` (global singleton)
|
||||
*
|
||||
* ```
|
||||
* App (no provider -> uses defaultGlobalTimeStore)
|
||||
* |-- Dashboard
|
||||
* |-- MainCharts (uses defaultGlobalTimeStore)
|
||||
* |-- GlobalTimeProvider (isolated store A)
|
||||
* |-- ComparisonPanel (uses store A)
|
||||
* |-- GlobalTimeProvider (isolated store B)
|
||||
* |-- NestedChart (uses store B)
|
||||
* ```
|
||||
*
|
||||
* ### Scoped Query Keys with `name`
|
||||
*
|
||||
* The `name` prop enables isolated query invalidation. When a provider has a name,
|
||||
* its queries are prefixed with that name, so invalidation only affects that store:
|
||||
*
|
||||
* ```tsx
|
||||
* // Main page - unnamed store
|
||||
* // Query keys: ['AUTO_REFRESH_QUERY', 'METRICS', ...]
|
||||
* function MainDashboard() {
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* // ...
|
||||
* }
|
||||
*
|
||||
* // Drawer - named store
|
||||
* // Query keys: ['AUTO_REFRESH_QUERY', 'drawer', 'METRICS', ...]
|
||||
* function DetailDrawer() {
|
||||
* return (
|
||||
* <GlobalTimeProvider name="drawer" inheritGlobalTime>
|
||||
* <DrawerContent />
|
||||
* </GlobalTimeProvider>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function DrawerContent() {
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const invalidate = useGlobalTimeQueryInvalidate();
|
||||
* // invalidate() only refreshes queries with 'drawer' prefix
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Complete Example
|
||||
*
|
||||
* ```tsx
|
||||
* import { useMemo } from 'react';
|
||||
* import { useQuery } from 'react-query';
|
||||
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
*
|
||||
* function MetricsPanel({ entityId }: { entityId: string }) {
|
||||
* // 1. Get store state with selectors
|
||||
* const selectedTime = useGlobalTime((s) => s.selectedTime);
|
||||
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
|
||||
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
|
||||
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
|
||||
*
|
||||
* // 2. Build query key (memoized) - automatically scoped if using named provider
|
||||
* const queryKey = useMemo(
|
||||
* () => getAutoRefreshQueryKey(selectedTime, 'METRICS', entityId),
|
||||
* [getAutoRefreshQueryKey, selectedTime, entityId]
|
||||
* );
|
||||
*
|
||||
* // 3. Query with auto-refresh
|
||||
* const { data, isLoading } = useQuery({
|
||||
* queryKey,
|
||||
* queryFn: () => {
|
||||
* // Get fresh time inside queryFn
|
||||
* const { minTime, maxTime } = getMinMaxTime();
|
||||
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
|
||||
*
|
||||
* return fetchMetrics({ entityId, start, end });
|
||||
* },
|
||||
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
* });
|
||||
*
|
||||
* return <Chart data={data} loading={isLoading} />;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @module store/globalTime
|
||||
*/
|
||||
|
||||
// Store
|
||||
export {
|
||||
createGlobalTimeStore,
|
||||
defaultGlobalTimeStore,
|
||||
useGlobalTimeStore,
|
||||
} from './globalTimeStore';
|
||||
export type { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
// Context & Provider
|
||||
export { GlobalTimeContext, GlobalTimeProvider } from './GlobalTimeContext';
|
||||
|
||||
// Hooks
|
||||
export {
|
||||
useGlobalTime,
|
||||
useGlobalTimeStoreApi,
|
||||
useIsCustomTimeRange,
|
||||
useLastComputedMinMax,
|
||||
} from './hooks';
|
||||
|
||||
// Query hooks for auto-refresh
|
||||
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
|
||||
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
CustomTimeRange,
|
||||
CustomTimeRangeSeparator,
|
||||
GlobalTimeActions,
|
||||
GlobalTimeProviderOptions,
|
||||
GlobalTimeSelectedTime,
|
||||
GlobalTimeState,
|
||||
GlobalTimeStore,
|
||||
IGlobalTimeStoreActions,
|
||||
IGlobalTimeStoreState,
|
||||
ParsedTimeRange,
|
||||
} from './types';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
createCustomTimeRange,
|
||||
CUSTOM_TIME_SEPARATOR,
|
||||
getAutoRefreshQueryKey,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
parseCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
} from './utils';
|
||||
|
||||
// Internal hooks (for advanced use cases)
|
||||
export { useQueryCacheSync } from './useQueryCacheSync';
|
||||
|
||||
@@ -44,9 +44,80 @@ export interface IGlobalTimeStoreActions {
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Get the current min/max time values parsed from selectedTime.
|
||||
* For durations, computes fresh values based on Date.now().
|
||||
* For custom ranges, extracts the stored values.
|
||||
* Get the current min/max time values.
|
||||
* - Custom time ranges: returns exact parsed values
|
||||
* - isRefreshEnabled true: computes 5s-rounded values and updates store
|
||||
* - isRefreshEnabled false: returns lastComputedMinMax
|
||||
*/
|
||||
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => ParsedTimeRange;
|
||||
getMinMaxTime: () => ParsedTimeRange;
|
||||
}
|
||||
|
||||
export interface GlobalTimeProviderOptions {
|
||||
/**
|
||||
* Optional name for the store instance.
|
||||
* Used to scope query keys - only queries with this store's prefix
|
||||
* will be tracked/invalidated by this store's hooks.
|
||||
*/
|
||||
name?: string;
|
||||
/** Initialize from parent/global time */
|
||||
inheritGlobalTime?: boolean;
|
||||
/** Initial time if not inheriting */
|
||||
initialTime?: GlobalTimeSelectedTime;
|
||||
/** URL sync configuration. When false/omitted, no URL sync. */
|
||||
enableUrlParams?:
|
||||
| boolean
|
||||
| {
|
||||
relativeTimeKey?: string;
|
||||
startTimeKey?: string;
|
||||
endTimeKey?: string;
|
||||
};
|
||||
removeQueryParamsOnUnmount?: boolean;
|
||||
localStoragePersistKey?: string;
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export interface GlobalTimeState {
|
||||
/**
|
||||
* Optional name for the store instance.
|
||||
* Used to scope query keys for auto-refresh queries.
|
||||
* Unnamed stores use the default prefix without a name.
|
||||
*/
|
||||
name?: string;
|
||||
selectedTime: GlobalTimeSelectedTime;
|
||||
refreshInterval: number;
|
||||
isRefreshEnabled: boolean;
|
||||
lastRefreshTimestamp: number;
|
||||
lastComputedMinMax: ParsedTimeRange;
|
||||
}
|
||||
|
||||
export interface GlobalTimeActions {
|
||||
setSelectedTime: (
|
||||
time: GlobalTimeSelectedTime,
|
||||
refreshInterval?: number,
|
||||
) => void;
|
||||
setRefreshInterval: (interval: number) => void;
|
||||
getMinMaxTime: () => ParsedTimeRange;
|
||||
/**
|
||||
* Compute fresh rounded min/max values, store them, and update refresh timestamp.
|
||||
* Call this before invalidating queries to ensure all queries use the same time values.
|
||||
*
|
||||
* @returns The newly computed ParsedTimeRange
|
||||
*/
|
||||
computeAndStoreMinMax: () => ParsedTimeRange;
|
||||
/**
|
||||
* Update the refresh timestamp to current time.
|
||||
* Called by QueryCache listener when auto-refresh queries complete.
|
||||
*/
|
||||
updateRefreshTimestamp: () => void;
|
||||
/**
|
||||
* Build query key for auto-refresh queries scoped to this store.
|
||||
* Named stores: ['AUTO_REFRESH_QUERY', name, ...parts, selectedTime]
|
||||
* Unnamed stores: ['AUTO_REFRESH_QUERY', ...parts, selectedTime]
|
||||
*/
|
||||
getAutoRefreshQueryKey: (
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
) => unknown[];
|
||||
}
|
||||
|
||||
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;
|
||||
|
||||
18
frontend/src/store/globalTime/useComputedMinMaxSync.ts
Normal file
18
frontend/src/store/globalTime/useComputedMinMaxSync.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
/**
|
||||
* Used to initialize computed min/max on mount when store has no values yet.
|
||||
* setSelectedTime now computes min/max on change, so subscription is no longer needed.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useComputedMinMaxSync(store: GlobalTimeStoreApi): void {
|
||||
useEffect(() => {
|
||||
const { lastComputedMinMax } = store.getState();
|
||||
if (lastComputedMinMax.minTime === 0 && lastComputedMinMax.maxTime === 0) {
|
||||
store.getState().computeAndStoreMinMax();
|
||||
}
|
||||
}, [store]);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGlobalTime } from './hooks';
|
||||
|
||||
/**
|
||||
* Use when you want to invalidate any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
|
||||
*
|
||||
* This hook computes fresh time values before invalidating queries,
|
||||
* ensuring all queries use the same min/max time during a refresh cycle.
|
||||
*
|
||||
* For named stores, only invalidates queries matching the store's name.
|
||||
* For unnamed stores, invalidates all AUTO_REFRESH_QUERY queries.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
const computeAndStoreMinMax = useGlobalTime((s) => s.computeAndStoreMinMax);
|
||||
const name = useGlobalTime((s) => s.name);
|
||||
|
||||
return useCallback(async () => {
|
||||
// Compute fresh time values BEFORE invalidating
|
||||
// This ensures all queries that re-run will use the same time values
|
||||
// If refresh is enabled, this will just be skipped
|
||||
computeAndStoreMinMax();
|
||||
|
||||
// Build scoped query key prefix
|
||||
const queryKey = name
|
||||
? [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, name]
|
||||
: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY];
|
||||
|
||||
return await queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, computeAndStoreMinMax, name]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useIsFetching } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGlobalTime } from './hooks';
|
||||
|
||||
/**
|
||||
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
|
||||
*
|
||||
* For named stores, only checks queries matching the store's name.
|
||||
* For unnamed stores, checks all AUTO_REFRESH_QUERY queries.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useIsGlobalTimeQueryRefreshing(): boolean {
|
||||
const name = useGlobalTime((s) => s.name);
|
||||
|
||||
const queryKey = name
|
||||
? [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, name]
|
||||
: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY];
|
||||
|
||||
return useIsFetching({ queryKey }) > 0;
|
||||
}
|
||||
32
frontend/src/store/globalTime/usePersistence.ts
Normal file
32
frontend/src/store/globalTime/usePersistence.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import set from 'api/browser/localstorage/set';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
/**
|
||||
* Used to keep the selected time persisted on localStorage
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function usePersistence(
|
||||
store: GlobalTimeStoreApi,
|
||||
persistKey: string | undefined,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!persistKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousSelectedTime = store.getState().selectedTime;
|
||||
|
||||
return store.subscribe((state) => {
|
||||
if (state.selectedTime === previousSelectedTime) {
|
||||
return;
|
||||
}
|
||||
previousSelectedTime = state.selectedTime;
|
||||
|
||||
set(persistKey, state.selectedTime);
|
||||
});
|
||||
}, [store, persistKey]);
|
||||
}
|
||||
51
frontend/src/store/globalTime/useQueryCacheSync.ts
Normal file
51
frontend/src/store/globalTime/useQueryCacheSync.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
|
||||
/**
|
||||
* Used to keep lastRefreshTimestamp in sync after every react query refresh.
|
||||
* For named stores, only tracks queries with matching store name.
|
||||
* For unnamed stores, tracks all AUTO_REFRESH_QUERY queries (backward compatible).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useQueryCacheSync(store: GlobalTimeStoreApi): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const queryCache = queryClient.getQueryCache();
|
||||
const storeName = store.getState().name;
|
||||
|
||||
return queryCache.subscribe((event) => {
|
||||
if (event?.type !== 'queryUpdated') {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = event.action as { type?: string };
|
||||
if (action?.type !== 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryKey = event.query.queryKey;
|
||||
if (!Array.isArray(queryKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this is created by getAutoRefreshQueryKey inside the store,
|
||||
// to track usages of global time store and autoRefresh
|
||||
if (queryKey[0] !== REACT_QUERY_KEY.AUTO_REFRESH_QUERY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Named store: only track queries with matching name at position [1]
|
||||
if (storeName && queryKey[1] !== storeName) {
|
||||
return;
|
||||
}
|
||||
// Unnamed store: track all AUTO_REFRESH_QUERY queries (backward compatible)
|
||||
store.getState().updateRefreshTimestamp();
|
||||
});
|
||||
}, [queryClient, store]);
|
||||
}
|
||||
145
frontend/src/store/globalTime/useUrlSync.ts
Normal file
145
frontend/src/store/globalTime/useUrlSync.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
|
||||
import { GlobalTimeStoreApi } from './globalTimeStore';
|
||||
import { GlobalTimeProviderOptions } from './types';
|
||||
import {
|
||||
createCustomTimeRange,
|
||||
isCustomTimeRange,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
parseCustomTimeRange,
|
||||
} from './utils';
|
||||
|
||||
interface UrlSyncConfig {
|
||||
relativeTimeKey: string;
|
||||
startTimeKey: string;
|
||||
endTimeKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to sync internal state with URL when URL params are enabled.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function useUrlSync(
|
||||
store: GlobalTimeStoreApi,
|
||||
enableUrlParams: GlobalTimeProviderOptions['enableUrlParams'],
|
||||
removeOnUnmount: boolean,
|
||||
): void {
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const keys: UrlSyncConfig =
|
||||
enableUrlParams && typeof enableUrlParams === 'object'
|
||||
? {
|
||||
relativeTimeKey: enableUrlParams.relativeTimeKey ?? 'relativeTime',
|
||||
startTimeKey: enableUrlParams.startTimeKey ?? 'startTime',
|
||||
endTimeKey: enableUrlParams.endTimeKey ?? 'endTime',
|
||||
}
|
||||
: {
|
||||
relativeTimeKey: 'relativeTime',
|
||||
startTimeKey: 'startTime',
|
||||
endTimeKey: 'endTime',
|
||||
};
|
||||
|
||||
const [urlState, setUrlState] = useQueryStates(
|
||||
{
|
||||
[keys.relativeTimeKey]: parseAsString,
|
||||
[keys.startTimeKey]: parseAsInteger,
|
||||
[keys.endTimeKey]: parseAsInteger,
|
||||
},
|
||||
{ history: 'replace' },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableUrlParams || !isInitialMount.current) {
|
||||
return;
|
||||
}
|
||||
isInitialMount.current = false;
|
||||
|
||||
const relativeTime = urlState[keys.relativeTimeKey];
|
||||
const startTime = urlState[keys.startTimeKey];
|
||||
const endTime = urlState[keys.endTimeKey];
|
||||
|
||||
if (typeof startTime === 'number' && typeof endTime === 'number') {
|
||||
const customTime = createCustomTimeRange(
|
||||
startTime * NANO_SECOND_MULTIPLIER,
|
||||
endTime * NANO_SECOND_MULTIPLIER,
|
||||
);
|
||||
store.getState().setSelectedTime(customTime);
|
||||
} else if (
|
||||
typeof relativeTime === 'string' &&
|
||||
isValidShortHandDateTimeFormat(relativeTime)
|
||||
) {
|
||||
store.getState().setSelectedTime(relativeTime as Time);
|
||||
}
|
||||
}, [
|
||||
urlState,
|
||||
keys?.startTimeKey,
|
||||
keys?.endTimeKey,
|
||||
keys?.relativeTimeKey,
|
||||
store,
|
||||
enableUrlParams,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableUrlParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousSelectedTime = store.getState().selectedTime;
|
||||
|
||||
return store.subscribe((state) => {
|
||||
if (state.selectedTime === previousSelectedTime) {
|
||||
return;
|
||||
}
|
||||
previousSelectedTime = state.selectedTime;
|
||||
|
||||
if (isCustomTimeRange(state.selectedTime)) {
|
||||
const parsed = parseCustomTimeRange(state.selectedTime);
|
||||
if (parsed) {
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: null,
|
||||
[keys.startTimeKey]: Math.floor(parsed.minTime / NANO_SECOND_MULTIPLIER),
|
||||
[keys.endTimeKey]: Math.floor(parsed.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: state.selectedTime,
|
||||
[keys.startTimeKey]: null,
|
||||
[keys.endTimeKey]: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [
|
||||
store,
|
||||
keys?.startTimeKey,
|
||||
keys?.endTimeKey,
|
||||
keys?.relativeTimeKey,
|
||||
setUrlState,
|
||||
enableUrlParams,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableUrlParams || !removeOnUnmount) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
void setUrlState({
|
||||
[keys.relativeTimeKey]: null,
|
||||
[keys.startTimeKey]: null,
|
||||
[keys.endTimeKey]: null,
|
||||
});
|
||||
};
|
||||
}, [
|
||||
removeOnUnmount,
|
||||
keys?.relativeTimeKey,
|
||||
keys?.startTimeKey,
|
||||
keys?.endTimeKey,
|
||||
setUrlState,
|
||||
enableUrlParams,
|
||||
]);
|
||||
}
|
||||
@@ -44,8 +44,8 @@ export function parseCustomTimeRange(
|
||||
}
|
||||
|
||||
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
|
||||
const minTime = parseInt(minStr, 10);
|
||||
const maxTime = parseInt(maxStr, 10);
|
||||
const minTime = Number.parseInt(minStr, 10);
|
||||
const maxTime = Number.parseInt(maxStr, 10);
|
||||
|
||||
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
|
||||
return null;
|
||||
@@ -79,11 +79,60 @@ export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to build your react-query key for auto-refresh queries
|
||||
* @deprecated Use store.getAutoRefreshQueryKey() instead.
|
||||
* Access via: const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
|
||||
*
|
||||
* This function only works with the default (unnamed) store prefix.
|
||||
* For named stores, use the store method to get properly scoped query keys.
|
||||
*/
|
||||
export function getAutoRefreshQueryKey(
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
...queryParts: unknown[]
|
||||
): unknown[] {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(
|
||||
'[globalTime] getAutoRefreshQueryKey from utils is deprecated. ' +
|
||||
'Use useGlobalTime((s) => s.getAutoRefreshQueryKey) instead.',
|
||||
);
|
||||
}
|
||||
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
|
||||
}
|
||||
|
||||
/**
|
||||
* Round timestamp down to the nearest 5-second boundary.
|
||||
* Used for tighter sync during auto-refresh scenarios.
|
||||
*
|
||||
* @param timestampNano - Timestamp in nanoseconds
|
||||
* @returns Timestamp rounded down to 5-second boundary in nanoseconds
|
||||
*/
|
||||
export function roundDownTo5Seconds(timestampNano: number): number {
|
||||
const msPerInterval = 5 * 1000;
|
||||
const timestampMs = Math.floor(timestampNano / NANO_SECOND_MULTIPLIER);
|
||||
const roundedMs = Math.floor(timestampMs / msPerInterval) * msPerInterval;
|
||||
return roundedMs * NANO_SECOND_MULTIPLIER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute min/max time with maxTime rounded down to 5-second boundary.
|
||||
* Used when isRefreshEnabled is true for tighter time sync.
|
||||
*
|
||||
* @param selectedTime - The selected time (relative like '15m' or custom range)
|
||||
* @returns ParsedTimeRange with 5-second rounded maxTime for relative times
|
||||
*/
|
||||
export function computeRounded5sMinMax(selectedTime: string): ParsedTimeRange {
|
||||
if (isCustomTimeRange(selectedTime)) {
|
||||
return parseSelectedTime(selectedTime);
|
||||
}
|
||||
|
||||
const nowNano = Date.now() * NANO_SECOND_MULTIPLIER;
|
||||
const roundedMaxTime = roundDownTo5Seconds(nowNano);
|
||||
|
||||
const { minTime: originalMin, maxTime: originalMax } =
|
||||
getMinMaxForSelectedTime(selectedTime as Time, 0, 0);
|
||||
const durationNano = originalMax - originalMin;
|
||||
|
||||
return {
|
||||
minTime: roundedMaxTime - durationNano,
|
||||
maxTime: roundedMaxTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -19141,6 +19146,11 @@ use-sidecar@^1.1.3:
|
||||
detect-node-es "^1.1.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
|
||||
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
|
||||
|
||||
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||
|
||||
93
pkg/apiserver/signozapiserver/llmpricingrule.go
Normal file
93
pkg/apiserver/signozapiserver/llmpricingrule.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
158
pkg/modules/llmpricingrule/impllmpricingrule/handler.go
Normal file
158
pkg/modules/llmpricingrule/impllmpricingrule/handler.go
Normal 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
|
||||
}
|
||||
24
pkg/modules/llmpricingrule/llmpricingrule.go
Normal file
24
pkg/modules/llmpricingrule/llmpricingrule.go
Normal 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)
|
||||
}
|
||||
@@ -4,6 +4,10 @@ const (
|
||||
TrueConditionLiteral = "true"
|
||||
SkipConditionLiteral = "__skip__"
|
||||
ErrorConditionLiteral = "__skip_because_of_error__"
|
||||
|
||||
// BodyFullTextSearchDefaultWarning is emitted when a full-text search or "body" searches are hit
|
||||
// with New JSON Body enhancements.
|
||||
BodyFullTextSearchDefaultWarning = "Full text searches default to `body.message:string`. Use `body.<key>` to search a different field inside body"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -362,6 +362,10 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
|
||||
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
if v.bodyJSONEnabled && v.fullTextColumn.Name == "body" {
|
||||
v.warnings = append(v.warnings, BodyFullTextSearchDefaultWarning)
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
@@ -717,6 +721,10 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
|
||||
if v.bodyJSONEnabled && v.fullTextColumn.Name == "body" {
|
||||
v.warnings = append(v.warnings, BodyFullTextSearchDefaultWarning)
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -283,6 +283,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RuleStateHistory,
|
||||
handlers.SpanMapperHandler,
|
||||
handlers.AlertmanagerHandler,
|
||||
handlers.LLMPricingRuleHandler,
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
),
|
||||
|
||||
@@ -894,12 +894,12 @@ func TestAdjustKey(t *testing.T) {
|
||||
|
||||
func TestStmtBuilderBodyField(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
enableUseJSONBody bool
|
||||
expected qbtypes.Statement
|
||||
expectedErr error
|
||||
expected qbtypes.Statement
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "body_exists",
|
||||
@@ -1039,15 +1039,15 @@ func TestStmtBuilderBodyField(t *testing.T) {
|
||||
|
||||
func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
enableUseJSONBody bool
|
||||
expected qbtypes.Statement
|
||||
expectedErr error
|
||||
expected qbtypes.Statement
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "body_contains",
|
||||
name: "fts",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
@@ -1056,13 +1056,30 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
|
||||
},
|
||||
enableUseJSONBody: true,
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE match(LOWER(body_v2.message), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE match(LOWER(body_v2.message), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{querybuilder.BodyFullTextSearchDefaultWarning},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "body_contains_disabled",
|
||||
name: "fts_2",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{Expression: "error"},
|
||||
Limit: 10,
|
||||
},
|
||||
enableUseJSONBody: true,
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE match(LOWER(body_v2.message), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"error", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{querybuilder.BodyFullTextSearchDefaultWarning},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "fts_disabled",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
|
||||
185
pkg/types/llmpricingruletypes/pricing.go
Normal file
185
pkg/types/llmpricingruletypes/pricing.go
Normal 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,
|
||||
}
|
||||
}
|
||||
16
pkg/types/llmpricingruletypes/store.go
Normal file
16
pkg/types/llmpricingruletypes/store.go
Normal 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
|
||||
}
|
||||
@@ -1212,13 +1212,21 @@ def test_message_searches(
|
||||
"aggregation": "count()",
|
||||
"validate": lambda r: len(get_rows(r)) == 2 and set(_body_messages(r)) == payment_messages,
|
||||
},
|
||||
# FTS — bare keyword
|
||||
# FTS — String bare keyword
|
||||
{
|
||||
"name": "msg.fts_quoted",
|
||||
"requestType": "raw",
|
||||
"expression": '"Payment"',
|
||||
"aggregation": "count()",
|
||||
"validate": lambda r: len(get_rows(r)) == 2 and all("Payment" in b.get("message", "") for b in _get_bodies(r)),
|
||||
"validate": lambda r: len(get_rows(r)) == 2 and all("Payment" in b.get("message", "") for b in _get_bodies(r)) and r.json().get("data", {}).get("warning") is not None,
|
||||
},
|
||||
# FTS — bare keyword
|
||||
{
|
||||
"name": "msg.fts_quoted_without_quotes",
|
||||
"requestType": "raw",
|
||||
"expression": "Payment",
|
||||
"aggregation": "count()",
|
||||
"validate": lambda r: len(get_rows(r)) == 2 and all("Payment" in b.get("message", "") for b in _get_bodies(r)) and r.json().get("data", {}).get("warning") is not None,
|
||||
},
|
||||
# = operator via body.message — tests exact match path
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user