mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-30 23:50:27 +01:00
Compare commits
132 Commits
fix/dashbo
...
infraM/v2_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0d3ea484 | ||
|
|
1c9e31ad00 | ||
|
|
8b0e8f666e | ||
|
|
1d89b03f10 | ||
|
|
3a61a78986 | ||
|
|
46e833faba | ||
|
|
4bd7492629 | ||
|
|
401701e036 | ||
|
|
3bec0df0ad | ||
|
|
24fe9a986d | ||
|
|
520e92049c | ||
|
|
92d297ac9d | ||
|
|
eff29aefba | ||
|
|
ca73453c9e | ||
|
|
1d836d674f | ||
|
|
83724b0cde | ||
|
|
3050e37ec7 | ||
|
|
bdbaa32485 | ||
|
|
55b2215025 | ||
|
|
e90378e618 | ||
|
|
4592b78f48 | ||
|
|
d81c99feae | ||
|
|
65a456ff9e | ||
|
|
264577b673 | ||
|
|
b35c6676f9 | ||
|
|
c78c9a42db | ||
|
|
1095caa123 | ||
|
|
9043b49762 | ||
|
|
d4084a7494 | ||
|
|
27c564b3bf | ||
|
|
f02c491828 | ||
|
|
3d53b8f77f | ||
|
|
dffe94fec4 | ||
|
|
b7d4f18aae | ||
|
|
9ad2ec428a | ||
|
|
c9360fcf13 | ||
|
|
b5ab45db20 | ||
|
|
08f76aca78 | ||
|
|
983d4fe4f2 | ||
|
|
833af794c3 | ||
|
|
21b51d1fcc | ||
|
|
56f22682c8 | ||
|
|
9c8359940c | ||
|
|
4050880275 | ||
|
|
5e775f64f2 | ||
|
|
0189f23f46 | ||
|
|
49a36d4e3d | ||
|
|
9407d658ab | ||
|
|
5035712485 | ||
|
|
bab17c3615 | ||
|
|
37b44f4db9 | ||
|
|
99dd6e5f1e | ||
|
|
9c7131fa6a | ||
|
|
ad889a2e1d | ||
|
|
a4f6d0cbf5 | ||
|
|
589bed7c16 | ||
|
|
93843a1f48 | ||
|
|
88c43108fc | ||
|
|
ed4cf540e8 | ||
|
|
9e2dfa9033 | ||
|
|
d98d5d68ee | ||
|
|
2cb1c3b73b | ||
|
|
ae7ca497ad | ||
|
|
a579916961 | ||
|
|
4a16d56abf | ||
|
|
642b5ac3f0 | ||
|
|
a12112619c | ||
|
|
014785f1bc | ||
|
|
58ee797b10 | ||
|
|
82d236742f | ||
|
|
397e1ad5be | ||
|
|
8d6b25ca9b | ||
|
|
5fa6bd8b8d | ||
|
|
bd9977483b | ||
|
|
50fbdfeeef | ||
|
|
e2b1b73e87 | ||
|
|
cb9f3fd3e5 | ||
|
|
232acc343d | ||
|
|
2025afdccc | ||
|
|
d2f4d4af93 | ||
|
|
47ff7bbb8e | ||
|
|
724071c5dc | ||
|
|
4d24979358 | ||
|
|
042943b10a | ||
|
|
48a9be7ec8 | ||
|
|
a9504b2120 | ||
|
|
8755887c4a | ||
|
|
4cb4662b3a | ||
|
|
e6900dabc8 | ||
|
|
c1ba389b63 | ||
|
|
3a1f40234f | ||
|
|
2e4891fa63 | ||
|
|
04ebc0bec7 | ||
|
|
271f9b81ed | ||
|
|
6fa815c294 | ||
|
|
63ec518efb | ||
|
|
c4ca20dd90 | ||
|
|
e56cc4222b | ||
|
|
07d2944d7c | ||
|
|
dea01ae36a | ||
|
|
62ea5b54e2 | ||
|
|
e549a7e42f | ||
|
|
90e2ebb11f | ||
|
|
61baa1be7a | ||
|
|
b946fa665f | ||
|
|
2e049556e4 | ||
|
|
492a5e70d7 | ||
|
|
ba1f2771e8 | ||
|
|
7458fb4855 | ||
|
|
5f55f3938b | ||
|
|
3e8102485c | ||
|
|
861c682ea5 | ||
|
|
c8e5895dff | ||
|
|
82d72e7edb | ||
|
|
a3f8ecaaf1 | ||
|
|
19aada656c | ||
|
|
b21bb4280f | ||
|
|
bc0a4fdb5c | ||
|
|
37fb0e9254 | ||
|
|
aecfa1a174 | ||
|
|
b869d23d94 | ||
|
|
6ee3d44f76 | ||
|
|
462e554107 | ||
|
|
66afa73e6f | ||
|
|
54c604bcf4 | ||
|
|
c1be02ba54 | ||
|
|
d3c7ba8f45 | ||
|
|
039c4a0496 | ||
|
|
51a94b6bbc | ||
|
|
bbfbb94f52 | ||
|
|
d1eb9ef16f | ||
|
|
3db00f8bc3 |
@@ -2474,6 +2474,73 @@ components:
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesNodeCondition:
|
||||
enum:
|
||||
- ready
|
||||
- not_ready
|
||||
- ""
|
||||
type: string
|
||||
InframonitoringtypesNodeRecord:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
|
||||
meta:
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: object
|
||||
nodeCPU:
|
||||
format: double
|
||||
type: number
|
||||
nodeCPUAllocatable:
|
||||
format: double
|
||||
type: number
|
||||
nodeMemory:
|
||||
format: double
|
||||
type: number
|
||||
nodeMemoryAllocatable:
|
||||
format: double
|
||||
type: number
|
||||
nodeName:
|
||||
type: string
|
||||
notReadyNodesCount:
|
||||
type: integer
|
||||
readyNodesCount:
|
||||
type: integer
|
||||
required:
|
||||
- nodeName
|
||||
- condition
|
||||
- readyNodesCount
|
||||
- notReadyNodesCount
|
||||
- nodeCPU
|
||||
- nodeCPUAllocatable
|
||||
- nodeMemory
|
||||
- nodeMemoryAllocatable
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNodes:
|
||||
properties:
|
||||
endTimeBeforeRetention:
|
||||
type: boolean
|
||||
records:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
|
||||
nullable: true
|
||||
type: array
|
||||
requiredMetricsCheck:
|
||||
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
|
||||
total:
|
||||
type: integer
|
||||
type:
|
||||
$ref: '#/components/schemas/InframonitoringtypesResponseType'
|
||||
warning:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
|
||||
required:
|
||||
- type
|
||||
- records
|
||||
- total
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesPodPhase:
|
||||
enum:
|
||||
- pending
|
||||
@@ -2591,6 +2658,32 @@ components:
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostableNodes:
|
||||
properties:
|
||||
end:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
orderBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
start:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- start
|
||||
- end
|
||||
- limit
|
||||
type: object
|
||||
InframonitoringtypesPostablePods:
|
||||
properties:
|
||||
end:
|
||||
@@ -2632,158 +2725,6 @@ 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:
|
||||
@@ -7827,218 +7768,6 @@ 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
|
||||
@@ -11464,6 +11193,76 @@ paths:
|
||||
summary: List Hosts for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/nodes:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Returns a paginated list of Kubernetes nodes with key metrics:
|
||||
CPU usage, CPU allocatable, memory working set, memory allocatable, and per-group
|
||||
readyNodesCount / notReadyNodesCount derived from each node''s latest k8s.node.condition_ready
|
||||
value in the window. Each node includes metadata attributes (k8s.node.uid,
|
||||
k8s.cluster.name). The response type is ''list'' for the default k8s.node.name
|
||||
grouping (each row is one node with its current condition string: ready /
|
||||
not_ready / '''') or ''grouped_list'' for custom groupBy keys (each row aggregates
|
||||
nodes in the group with readyNodesCount and notReadyNodesCount; condition
|
||||
stays empty). Supports filtering via a filter expression, custom groupBy,
|
||||
ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination
|
||||
via offset/limit. Also reports missing required metrics and whether the requested
|
||||
time range falls before the data retention boundary. Numeric metric fields
|
||||
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
|
||||
as a sentinel when no data is available for that field.'
|
||||
operationId: ListNodes
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPostableNodes'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodes'
|
||||
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 Nodes for Infra Monitoring
|
||||
tags:
|
||||
- inframonitoring
|
||||
/api/v2/infra_monitoring/pods:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -231,7 +231,6 @@
|
||||
"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",
|
||||
@@ -267,4 +266,4 @@
|
||||
"tmp": "0.2.4",
|
||||
"vite": "npm:rolldown-vite@7.3.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,10 @@ import type {
|
||||
|
||||
import type {
|
||||
InframonitoringtypesPostableHostsDTO,
|
||||
InframonitoringtypesPostableNodesDTO,
|
||||
InframonitoringtypesPostablePodsDTO,
|
||||
ListHosts200,
|
||||
ListNodes200,
|
||||
ListPods200,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -106,6 +108,90 @@ export const useListHosts = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, and per-group readyNodesCount / notReadyNodesCount derived from each node's latest k8s.node.condition_ready value in the window. Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / '') or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group with readyNodesCount and notReadyNodesCount; condition stays empty). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const listNodes = (
|
||||
inframonitoringtypesPostableNodesDTO: BodyType<InframonitoringtypesPostableNodesDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListNodes200>({
|
||||
url: `/api/v2/infra_monitoring/nodes`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: inframonitoringtypesPostableNodesDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListNodesMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['listNodes'];
|
||||
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 listNodes>>,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return listNodes(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ListNodesMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listNodes>>
|
||||
>;
|
||||
export type ListNodesMutationBody =
|
||||
BodyType<InframonitoringtypesPostableNodesDTO>;
|
||||
export type ListNodesMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List Nodes for Infra Monitoring
|
||||
*/
|
||||
export const useListNodes = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof listNodes>>,
|
||||
TError,
|
||||
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getListNodesMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
|
||||
* @summary List Pods for Infra Monitoring
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
/**
|
||||
* ! 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;
|
||||
};
|
||||
@@ -3243,6 +3243,78 @@ export interface InframonitoringtypesHostsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesNodeConditionDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
'' = '',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesNodeRecordDTOMeta = {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export interface InframonitoringtypesNodeRecordDTO {
|
||||
condition: InframonitoringtypesNodeConditionDTO;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
meta: InframonitoringtypesNodeRecordDTOMeta;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeCPU: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeCPUAllocatable: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeMemory: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
nodeMemoryAllocatable: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nodeName: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
notReadyNodesCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
readyNodesCount: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNodesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
endTimeBeforeRetention: boolean;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
records: InframonitoringtypesNodeRecordDTO[] | null;
|
||||
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
total: number;
|
||||
type: InframonitoringtypesResponseTypeDTO;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesPodPhaseDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
@@ -3373,6 +3445,34 @@ export interface InframonitoringtypesPostableHostsDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableNodesDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
orderBy?: Querybuildertypesv5OrderByDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostablePodsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -3413,170 +3513,6 @@ 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
|
||||
@@ -7168,41 +7104,6 @@ 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
|
||||
@@ -7716,6 +7617,14 @@ export type ListHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNodes200 = {
|
||||
data: InframonitoringtypesNodesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPods200 = {
|
||||
data: InframonitoringtypesPodsDTO;
|
||||
/**
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
NANO_SECOND_MULTIPLIER,
|
||||
} from 'store/globalTime/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
@@ -187,19 +190,16 @@ 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(
|
||||
() => ({
|
||||
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
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 [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
@@ -246,7 +246,7 @@ function K8sBaseDetails<T>({
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
selectedItem,
|
||||
),
|
||||
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
|
||||
[queryKeyPrefix, selectedItem, selectedTime],
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -16,7 +16,10 @@ import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
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';
|
||||
@@ -111,9 +114,6 @@ 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,7 +127,6 @@ export function K8sBaseList<T>({
|
||||
JSON.stringify(groupBy),
|
||||
);
|
||||
}, [
|
||||
getAutoRefreshQueryKey,
|
||||
selectedTime,
|
||||
entity,
|
||||
pageSize,
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from 'antd';
|
||||
import { CornerDownRight } from 'lucide-react';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import {
|
||||
getAutoRefreshQueryKey,
|
||||
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';
|
||||
@@ -115,9 +118,6 @@ 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),
|
||||
]);
|
||||
}, [getAutoRefreshQueryKey, selectedTime, record.key, queryFilters, orderBy]);
|
||||
}, [selectedTime, record.key, queryFilters, orderBy]);
|
||||
|
||||
const { data, isFetching, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
|
||||
@@ -240,7 +240,7 @@ function DashboardsList(): JSX.Element {
|
||||
isLocked: !!e.locked || false,
|
||||
lastUpdatedBy: e.updatedBy,
|
||||
image: e.data.image || Base64Icons[0],
|
||||
variables: e.data.variables ?? {},
|
||||
variables: e.data.variables,
|
||||
widgets: e.data.widgets,
|
||||
layout: e.data.layout,
|
||||
panelMap: e.data.panelMap,
|
||||
|
||||
@@ -17,7 +17,7 @@ import dayjs, { Dayjs } from 'dayjs';
|
||||
import {
|
||||
useGlobalTimeQueryInvalidate,
|
||||
useIsGlobalTimeQueryRefreshing,
|
||||
} from 'store/globalTime';
|
||||
} from 'hooks/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 = Number.parseInt(searchStartTime, 10);
|
||||
initialModalStartTime = parseInt(searchStartTime, 10);
|
||||
}
|
||||
|
||||
let initialModalEndTime = 0;
|
||||
if (modalInitialEndTime !== undefined) {
|
||||
initialModalEndTime = modalInitialEndTime;
|
||||
} else if (searchEndTime) {
|
||||
initialModalEndTime = Number.parseInt(searchEndTime, 10);
|
||||
initialModalEndTime = parseInt(searchEndTime, 10);
|
||||
}
|
||||
|
||||
const [modalStartTime, setModalStartTime] = useState<number>(
|
||||
@@ -159,11 +159,9 @@ function DateTimeSelection({
|
||||
const getTime = useCallback((): [number, number] | undefined => {
|
||||
if (searchEndTime && searchStartTime) {
|
||||
const startDate = dayjs(
|
||||
new Date(Number.parseInt(getTimeString(searchStartTime), 10)),
|
||||
);
|
||||
const endDate = dayjs(
|
||||
new Date(Number.parseInt(getTimeString(searchEndTime), 10)),
|
||||
new Date(parseInt(getTimeString(searchStartTime), 10)),
|
||||
);
|
||||
const endDate = dayjs(new Date(parseInt(getTimeString(searchEndTime), 10)));
|
||||
|
||||
return [startDate.toDate().getTime() || 0, endDate.toDate().getTime() || 0];
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
expect(orders).toContain(0);
|
||||
expect(orders).toContain(1);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.order).toBe(5);
|
||||
expect(result.data.variables.v1.order).toBe(5);
|
||||
});
|
||||
|
||||
it('assigns unique orders across multiple variables that all lack an order', () => {
|
||||
@@ -97,7 +97,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
const orders = Object.values(result.data.variables!).map((v) => v.order);
|
||||
const orders = Object.values(result.data.variables).map((v) => v.order);
|
||||
// All three newly assigned orders must be distinct
|
||||
expect(new Set(orders).size).toBe(3);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.id).toMatch(
|
||||
expect(result.data.variables.v1.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
@@ -125,7 +125,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.id).toBe('keep-me');
|
||||
expect(result.data.variables.v1.id).toBe('keep-me');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('hello');
|
||||
expect(result.data.variables.v1.defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('does not overwrite an existing defaultValue', () => {
|
||||
@@ -163,7 +163,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.defaultValue).toBe('keep');
|
||||
expect(result.data.variables.v1.defaultValue).toBe('keep');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('staging');
|
||||
expect(result.data.variables.v1.selectedValue).toBe('staging');
|
||||
});
|
||||
|
||||
it('applies localStorage allSelected over DB value', () => {
|
||||
@@ -196,7 +196,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('sets selectedValue from URL and clears allSelected when showALLOption is true', () => {
|
||||
@@ -237,8 +237,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(false);
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not set allSelected=false when showALLOption is false', () => {
|
||||
@@ -258,8 +258,8 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables!.v1.allSelected).toBe(true);
|
||||
expect(result.data.variables.v1.selectedValue).toBe('dev');
|
||||
expect(result.data.variables.v1.allSelected).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes array URL value to single value for single-select variable', () => {
|
||||
@@ -277,7 +277,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('prod');
|
||||
expect(result.data.variables.v1.selectedValue).toBe('prod');
|
||||
});
|
||||
|
||||
it('wraps single URL value in array for multi-select variable', () => {
|
||||
@@ -292,7 +292,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toStrictEqual(['prod']);
|
||||
expect(result.data.variables.v1.selectedValue).toStrictEqual(['prod']);
|
||||
});
|
||||
|
||||
it('looks up URL variable by variable id when name is absent', () => {
|
||||
@@ -306,7 +306,7 @@ describe('useTransformDashboardVariables', () => {
|
||||
|
||||
const result = transformDashboardVariables(dashboard);
|
||||
|
||||
expect(result.data.variables!.v1.selectedValue).toBe('fallback');
|
||||
expect(result.data.variables.v1.selectedValue).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,11 +327,11 @@ describe('useTransformDashboardVariables', () => {
|
||||
const dashboard = makeDashboard({
|
||||
v1: makeVariable({ id: 'id1', name: 'env', selectedValue: 'prod' }),
|
||||
});
|
||||
const originalValue = dashboard.data.variables!.v1.selectedValue;
|
||||
const originalValue = dashboard.data.variables.v1.selectedValue;
|
||||
|
||||
transformDashboardVariables(dashboard);
|
||||
|
||||
expect(dashboard.data.variables!.v1.selectedValue).toBe(originalValue);
|
||||
expect(dashboard.data.variables.v1.selectedValue).toBe(originalValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,12 +22,11 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
localStorageVariables: any,
|
||||
): Dashboard => {
|
||||
const updatedData = data;
|
||||
const variables = data?.data?.variables;
|
||||
if (data && localStorageVariables && variables) {
|
||||
const updatedVariables = variables;
|
||||
if (data && localStorageVariables) {
|
||||
const updatedVariables = data.data.variables;
|
||||
const variablesFromUrl = getUrlVariables();
|
||||
Object.keys(variables).forEach((variable) => {
|
||||
const variableData = variables[variable];
|
||||
Object.keys(data.data.variables).forEach((variable) => {
|
||||
const variableData = data.data.variables[variable];
|
||||
|
||||
// values from url
|
||||
const urlVariable = variableData?.name
|
||||
@@ -35,7 +34,7 @@ export function useTransformDashboardVariables(dashboardId: string): Pick<
|
||||
: variablesFromUrl[variableData.id];
|
||||
|
||||
let updatedVariable = {
|
||||
...variables[variable],
|
||||
...data.data.variables[variable],
|
||||
...localStorageVariables[variableData.name as any],
|
||||
};
|
||||
|
||||
|
||||
2
frontend/src/hooks/globalTime/index.ts
Normal file
2
frontend/src/hooks/globalTime/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
|
||||
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
|
||||
@@ -0,0 +1,16 @@
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ function DashboardWidgetInternal({
|
||||
setDashboardData(updatedDashboardData);
|
||||
setDashboardVariablesStore({
|
||||
dashboardId,
|
||||
variables: updatedDashboardData.data.variables ?? {},
|
||||
variables: updatedDashboardData.data.variables,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,20 +41,6 @@ describe('dashboardVariablesStoreUtils', () => {
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when variables is undefined', () => {
|
||||
const result = buildSortedVariablesArray(
|
||||
undefined as unknown as IDashboardVariables,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when variables is null', () => {
|
||||
const result = buildSortedVariablesArray(
|
||||
null as unknown as IDashboardVariables,
|
||||
);
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should create copies of variables (not references)', () => {
|
||||
const original = createVariable({ name: 'a', order: 0 });
|
||||
const variables: IDashboardVariables = { a: original };
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
* Build a sorted array of variables by their order property
|
||||
*/
|
||||
export function buildSortedVariablesArray(
|
||||
variables?: IDashboardVariables,
|
||||
variables: IDashboardVariables,
|
||||
): IDashboardVariable[] {
|
||||
const sortedVariablesArray: IDashboardVariable[] = [];
|
||||
|
||||
Object.values(variables ?? {}).forEach((value) => {
|
||||
Object.values(variables).forEach((value) => {
|
||||
sortedVariablesArray.push({ ...value });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,693 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
202
frontend/src/store/globalTime/__tests__/globalTimeStore.test.ts
Normal file
202
frontend/src/store/globalTime/__tests__/globalTimeStore.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,868 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,381 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,323 +0,0 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
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,15 +1,10 @@
|
||||
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', () => {
|
||||
@@ -141,184 +136,4 @@ 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,144 +1,32 @@
|
||||
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 {
|
||||
GlobalTimeSelectedTime,
|
||||
GlobalTimeState,
|
||||
GlobalTimeStore,
|
||||
IGlobalTimeStoreActions,
|
||||
IGlobalTimeStoreState,
|
||||
ParsedTimeRange,
|
||||
} from './types';
|
||||
import {
|
||||
computeRounded5sMinMax,
|
||||
isCustomTimeRange,
|
||||
parseSelectedTime,
|
||||
} from './utils';
|
||||
import { isCustomTimeRange, parseSelectedTime } from './utils';
|
||||
|
||||
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
|
||||
export type IGlobalTimeStore = GlobalTimeStore;
|
||||
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
|
||||
|
||||
function computeIsRefreshEnabled(
|
||||
selectedTime: GlobalTimeSelectedTime,
|
||||
refreshInterval: number,
|
||||
): boolean {
|
||||
if (isCustomTimeRange(selectedTime)) {
|
||||
return false;
|
||||
}
|
||||
return refreshInterval > 0;
|
||||
}
|
||||
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);
|
||||
|
||||
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),
|
||||
);
|
||||
};
|
||||
return {
|
||||
selectedTime,
|
||||
refreshInterval: newRefreshInterval,
|
||||
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
getMinMaxTime: (selectedTime): ParsedTimeRange => {
|
||||
return parseSelectedTime(selectedTime || get().selectedTime);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// 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,558 +1,9 @@
|
||||
/**
|
||||
* # 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 { useGlobalTimeStore } from './globalTimeStore';
|
||||
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
|
||||
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,80 +44,9 @@ export interface IGlobalTimeStoreActions {
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
getMinMaxTime: () => ParsedTimeRange;
|
||||
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => 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;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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 = Number.parseInt(minStr, 10);
|
||||
const maxTime = Number.parseInt(maxStr, 10);
|
||||
const minTime = parseInt(minStr, 10);
|
||||
const maxTime = parseInt(maxStr, 10);
|
||||
|
||||
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
|
||||
return null;
|
||||
@@ -79,60 +79,11 @@ export function parseSelectedTime(selectedTime: string): ParsedTimeRange {
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.
|
||||
* Use to build your react-query key for auto-refresh queries
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface DashboardData {
|
||||
title: string;
|
||||
layout?: Layout[];
|
||||
panelMap?: Record<string, { widgets: Layout[]; collapsed: boolean }>;
|
||||
variables?: Record<string, IDashboardVariable>;
|
||||
variables: Record<string, IDashboardVariable>;
|
||||
version?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
@@ -19146,11 +19146,6 @@ 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"
|
||||
|
||||
@@ -48,5 +48,24 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/infra_monitoring/nodes", handler.New(
|
||||
provider.authZ.ViewAccess(provider.infraMonitoringHandler.ListNodes),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListNodes",
|
||||
Tags: []string{"inframonitoring"},
|
||||
Summary: "List Nodes for Infra Monitoring",
|
||||
Description: "Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, and per-group readyNodesCount / notReadyNodesCount derived from each node's latest k8s.node.condition_ready value in the window. Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / '') or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group with readyNodesCount and notReadyNodesCount; condition stays empty). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.",
|
||||
Request: new(inframonitoringtypes.PostableNodes),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(inframonitoringtypes.Nodes),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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,7 +17,6 @@ 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"
|
||||
@@ -68,7 +67,6 @@ type provider struct {
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -98,7 +96,6 @@ 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] {
|
||||
@@ -133,7 +130,6 @@ func NewFactory(
|
||||
ruleStateHistoryHandler,
|
||||
spanMapperHandler,
|
||||
alertmanagerHandler,
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
)
|
||||
@@ -170,7 +166,6 @@ func newProvider(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
@@ -207,7 +202,6 @@ func newProvider(
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
}
|
||||
|
||||
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
|
||||
@@ -320,10 +314,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -69,3 +69,27 @@ func (h *handler) ListPods(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) ListNodes(rw http.ResponseWriter, req *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(req.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
var parsedReq inframonitoringtypes.PostableNodes
|
||||
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.ListNodes(req.Context(), orgID, &parsedReq)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -23,3 +23,9 @@ type podPhaseCounts struct {
|
||||
Failed int
|
||||
Unknown int
|
||||
}
|
||||
|
||||
// nodeConditionCounts holds per-group node counts bucketed by latest condition_ready in window.
|
||||
type nodeConditionCounts struct {
|
||||
Ready int
|
||||
NotReady int
|
||||
}
|
||||
|
||||
@@ -242,3 +242,86 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error) {
|
||||
if err := req.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &inframonitoringtypes.Nodes{}
|
||||
|
||||
if req.OrderBy == nil {
|
||||
req.OrderBy = &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: inframonitoringtypes.NodesOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []qbtypes.GroupByKey{nodeNameGroupByKey}
|
||||
resp.Type = inframonitoringtypes.ResponseTypeList
|
||||
} else {
|
||||
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
|
||||
}
|
||||
|
||||
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, nodesTableMetricNamesList)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(missingMetrics) > 0 {
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
|
||||
resp.Records = []inframonitoringtypes.NodeRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
if req.End < int64(minFirstReportedUnixMilli) {
|
||||
resp.EndTimeBeforeRetention = true
|
||||
resp.Records = []inframonitoringtypes.NodeRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
|
||||
|
||||
metadataMap, err := m.getNodesTableMetadata(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
pageGroups, err := m.getTopNodeGroups(ctx, orgID, req, metadataMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
resp.Records = []inframonitoringtypes.NodeRecord{}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
|
||||
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isNodeNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, nodeNameAttrKey)
|
||||
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, conditionCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
299
pkg/modules/inframonitoring/implinframonitoring/nodes.go
Normal file
299
pkg/modules/inframonitoring/implinframonitoring/nodes.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// buildNodeRecords assembles the page records. Condition counts come from
|
||||
// conditionCounts in both modes. In list mode (isNodeNameInGroupBy=true) each
|
||||
// group is one node, so exactly one count is 1; Condition is derived from
|
||||
// which one. In grouped_list mode Condition stays NodeConditionNone.
|
||||
func buildNodeRecords(
|
||||
isNodeNameInGroupBy bool,
|
||||
resp *qbtypes.QueryRangeResponse,
|
||||
pageGroups []map[string]string,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
metadataMap map[string]map[string]string,
|
||||
conditionCounts map[string]nodeConditionCounts,
|
||||
) []inframonitoringtypes.NodeRecord {
|
||||
metricsMap := parseFullQueryResponse(resp, groupBy)
|
||||
|
||||
records := make([]inframonitoringtypes.NodeRecord, 0, len(pageGroups))
|
||||
for _, labels := range pageGroups {
|
||||
compositeKey := compositeKeyFromLabels(labels, groupBy)
|
||||
nodeName := labels[nodeNameAttrKey]
|
||||
|
||||
record := inframonitoringtypes.NodeRecord{ // initialize with default values
|
||||
NodeName: nodeName,
|
||||
Condition: inframonitoringtypes.NodeConditionNone,
|
||||
NodeCPU: -1,
|
||||
NodeCPUAllocatable: -1,
|
||||
NodeMemory: -1,
|
||||
NodeMemoryAllocatable: -1,
|
||||
Meta: map[string]any{},
|
||||
}
|
||||
|
||||
if metrics, ok := metricsMap[compositeKey]; ok {
|
||||
if v, exists := metrics["A"]; exists {
|
||||
record.NodeCPU = v
|
||||
}
|
||||
if v, exists := metrics["B"]; exists {
|
||||
record.NodeCPUAllocatable = v
|
||||
}
|
||||
if v, exists := metrics["C"]; exists {
|
||||
record.NodeMemory = v
|
||||
}
|
||||
if v, exists := metrics["D"]; exists {
|
||||
record.NodeMemoryAllocatable = v
|
||||
}
|
||||
}
|
||||
|
||||
if conditionCountsForGroup, ok := conditionCounts[compositeKey]; ok {
|
||||
record.ReadyNodesCount = conditionCountsForGroup.Ready
|
||||
record.NotReadyNodesCount = conditionCountsForGroup.NotReady
|
||||
|
||||
// In list mode each group is one node; the count==1 bucket identifies the condition.
|
||||
if isNodeNameInGroupBy {
|
||||
switch {
|
||||
case conditionCountsForGroup.Ready == 1:
|
||||
record.Condition = inframonitoringtypes.NodeConditionReady
|
||||
case conditionCountsForGroup.NotReady == 1:
|
||||
record.Condition = inframonitoringtypes.NodeConditionNotReady
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if attrs, ok := metadataMap[compositeKey]; ok {
|
||||
for k, v := range attrs {
|
||||
record.Meta[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func (m *module) getTopNodeGroups(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableNodes,
|
||||
metadataMap map[string]map[string]string,
|
||||
) ([]map[string]string, error) {
|
||||
orderByKey := req.OrderBy.Key.Name
|
||||
queryNamesForOrderBy := orderByToNodesQueryNames[orderByKey]
|
||||
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
|
||||
|
||||
topReq := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(req.Start),
|
||||
End: uint64(req.End),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, envelope := range m.newNodesTableListQuery().CompositeQuery.Queries {
|
||||
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
|
||||
continue
|
||||
}
|
||||
copied := envelope
|
||||
if copied.Type == qbtypes.QueryTypeBuilder {
|
||||
existingExpr := ""
|
||||
if f := copied.GetFilter(); f != nil {
|
||||
existingExpr = f.Expression
|
||||
}
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
|
||||
copied.SetFilter(&qbtypes.Filter{Expression: merged})
|
||||
copied.SetGroupBy(req.GroupBy)
|
||||
}
|
||||
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
|
||||
}
|
||||
|
||||
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
|
||||
}
|
||||
|
||||
func (m *module) getNodesTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
|
||||
var nonGroupByAttrs []string
|
||||
for _, key := range nodeAttrKeysForMetadata {
|
||||
if !isKeyInGroupByAttrs(req.GroupBy, key) {
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
// getPerGroupNodeConditionCounts computes per-group node counts bucketed by each
|
||||
// node's latest condition_ready value (0 / 1) in the requested window.
|
||||
// Pipeline:
|
||||
//
|
||||
// timeSeriesFPs: fp ↔ (node_name, groupBy cols) from the time_series table.
|
||||
// User filter + page-groups filter applied here.
|
||||
// latestConditionPerNode: INNER JOIN samples × timeSeriesFPs, collapsed to
|
||||
// the latest condition value per node via argMax(value, unix_milli).
|
||||
// countNodesPerCondition: per-group uniqExactIf into ready/not_ready buckets.
|
||||
//
|
||||
// Groups absent from the result map have implicit zero counts (caller default).
|
||||
func (m *module) getPerGroupNodeConditionCounts(
|
||||
ctx context.Context,
|
||||
req *inframonitoringtypes.PostableNodes,
|
||||
pageGroups []map[string]string,
|
||||
) (map[string]nodeConditionCounts, error) {
|
||||
if len(pageGroups) == 0 || len(req.GroupBy) == 0 {
|
||||
return map[string]nodeConditionCounts{}, nil
|
||||
}
|
||||
|
||||
// Merged filter expression (user filter + page-groups IN clauses).
|
||||
reqFilterExpr := ""
|
||||
if req.Filter != nil {
|
||||
reqFilterExpr = req.Filter.Expression
|
||||
}
|
||||
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
|
||||
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
|
||||
|
||||
// Resolve tables. Same convention as pods.
|
||||
adjustedStart, adjustedEnd, _, localTimeSeriesTable := telemetrymetrics.WhichTSTableToUse(
|
||||
uint64(req.Start), uint64(req.End), nil,
|
||||
)
|
||||
samplesTable := telemetrymetrics.WhichSamplesTableToUse(
|
||||
uint64(req.Start), uint64(req.End),
|
||||
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil,
|
||||
)
|
||||
valueCol := telemetrymetrics.ValueColumnForSamplesTable(samplesTable)
|
||||
|
||||
// ----- timeSeriesFPs -----
|
||||
timeSeriesFPs := sqlbuilder.NewSelectBuilder()
|
||||
timeSeriesFPsSelectCols := []string{
|
||||
"fingerprint",
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS node_name", timeSeriesFPs.Var(nodeNameAttrKey)),
|
||||
}
|
||||
for _, key := range req.GroupBy {
|
||||
timeSeriesFPsSelectCols = append(timeSeriesFPsSelectCols,
|
||||
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", timeSeriesFPs.Var(key.Name), quoteIdentifier(key.Name)),
|
||||
)
|
||||
}
|
||||
timeSeriesFPs.Select(timeSeriesFPsSelectCols...)
|
||||
timeSeriesFPs.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
|
||||
timeSeriesFPs.Where(
|
||||
timeSeriesFPs.E("metric_name", nodeConditionMetricName),
|
||||
timeSeriesFPs.GE("unix_milli", adjustedStart),
|
||||
timeSeriesFPs.L("unix_milli", adjustedEnd),
|
||||
)
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filterClause != nil {
|
||||
timeSeriesFPs.AddWhereClause(filterClause)
|
||||
}
|
||||
}
|
||||
timeSeriesFPsGroupBy := []string{"fingerprint", "node_name"}
|
||||
for _, key := range req.GroupBy {
|
||||
timeSeriesFPsGroupBy = append(timeSeriesFPsGroupBy, quoteIdentifier(key.Name))
|
||||
}
|
||||
timeSeriesFPs.GroupBy(timeSeriesFPsGroupBy...)
|
||||
timeSeriesFPsSQL, timeSeriesFPsArgs := timeSeriesFPs.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- latestConditionPerNode -----
|
||||
latestConditionPerNode := sqlbuilder.NewSelectBuilder()
|
||||
latestConditionPerNodeSelectCols := []string{"tsfp.node_name AS node_name"}
|
||||
latestConditionPerNodeGroupBy := []string{"node_name"}
|
||||
for _, key := range req.GroupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols, fmt.Sprintf("tsfp.%s AS %s", col, col))
|
||||
latestConditionPerNodeGroupBy = append(latestConditionPerNodeGroupBy, col)
|
||||
}
|
||||
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols,
|
||||
fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS condition_value", valueCol),
|
||||
)
|
||||
latestConditionPerNode.Select(latestConditionPerNodeSelectCols...)
|
||||
latestConditionPerNode.From(fmt.Sprintf(
|
||||
"%s.%s AS samples INNER JOIN time_series_fps AS tsfp ON samples.fingerprint = tsfp.fingerprint",
|
||||
telemetrymetrics.DBName, samplesTable,
|
||||
))
|
||||
latestConditionPerNode.Where(
|
||||
latestConditionPerNode.E("samples.metric_name", nodeConditionMetricName),
|
||||
latestConditionPerNode.GE("samples.unix_milli", req.Start),
|
||||
latestConditionPerNode.L("samples.unix_milli", req.End),
|
||||
"tsfp.node_name != ''",
|
||||
)
|
||||
latestConditionPerNode.GroupBy(latestConditionPerNodeGroupBy...)
|
||||
latestConditionPerNodeSQL, latestConditionPerNodeArgs := latestConditionPerNode.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// ----- countNodesPerCondition (outer SELECT) -----
|
||||
countNodesPerConditionSelectCols := make([]string, 0, len(req.GroupBy)+2)
|
||||
countNodesPerConditionGroupBy := make([]string, 0, len(req.GroupBy))
|
||||
for _, key := range req.GroupBy {
|
||||
col := quoteIdentifier(key.Name)
|
||||
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols, col)
|
||||
countNodesPerConditionGroupBy = append(countNodesPerConditionGroupBy, col)
|
||||
}
|
||||
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols,
|
||||
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS ready_count", inframonitoringtypes.NodeConditionNumReady),
|
||||
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS not_ready_count", inframonitoringtypes.NodeConditionNumNotReady),
|
||||
)
|
||||
countNodesPerConditionSQL := fmt.Sprintf(
|
||||
"SELECT %s FROM latest_condition_per_node GROUP BY %s",
|
||||
strings.Join(countNodesPerConditionSelectCols, ", "),
|
||||
strings.Join(countNodesPerConditionGroupBy, ", "),
|
||||
)
|
||||
|
||||
// Combine CTEs + outer.
|
||||
cteFragments := []string{
|
||||
fmt.Sprintf("time_series_fps AS (%s)", timeSeriesFPsSQL),
|
||||
fmt.Sprintf("latest_condition_per_node AS (%s)", latestConditionPerNodeSQL),
|
||||
}
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + countNodesPerConditionSQL
|
||||
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, nil)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]nodeConditionCounts)
|
||||
for rows.Next() {
|
||||
groupVals := make([]string, len(req.GroupBy))
|
||||
scanPtrs := make([]any, 0, len(req.GroupBy)+2)
|
||||
for i := range groupVals {
|
||||
scanPtrs = append(scanPtrs, &groupVals[i])
|
||||
}
|
||||
var ready, notReady uint64
|
||||
scanPtrs = append(scanPtrs, &ready, ¬Ready)
|
||||
|
||||
if err := rows.Scan(scanPtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[compositeKeyFromList(groupVals)] = nodeConditionCounts{
|
||||
Ready: int(ready),
|
||||
NotReady: int(notReady),
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
nodeNameAttrKey = "k8s.node.name"
|
||||
nodeConditionMetricName = "k8s.node.condition_ready"
|
||||
)
|
||||
|
||||
var nodeNameGroupByKey = qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: nodeNameAttrKey,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
|
||||
// nodesTableMetricNamesList drives the existence/retention check.
|
||||
// Includes condition_ready so the response short-circuits cleanly when a
|
||||
// cluster doesn't ship the metric — even though condition_ready isn't part
|
||||
// of the QB composite query (it's queried separately via getPerGroupNodeConditionCounts).
|
||||
var nodesTableMetricNamesList = []string{
|
||||
"k8s.node.cpu.usage",
|
||||
"k8s.node.allocatable_cpu",
|
||||
"k8s.node.memory.working_set",
|
||||
"k8s.node.allocatable_memory",
|
||||
"k8s.node.condition_ready",
|
||||
}
|
||||
|
||||
var nodeAttrKeysForMetadata = []string{
|
||||
"k8s.node.uid",
|
||||
"k8s.cluster.name",
|
||||
}
|
||||
|
||||
var orderByToNodesQueryNames = map[string][]string{
|
||||
inframonitoringtypes.NodesOrderByCPU: {"A"},
|
||||
inframonitoringtypes.NodesOrderByCPUAllocatable: {"B"},
|
||||
inframonitoringtypes.NodesOrderByMemory: {"C"},
|
||||
inframonitoringtypes.NodesOrderByMemoryAllocatable: {"D"},
|
||||
}
|
||||
|
||||
// newNodesTableListQuery builds the composite QB v5 request for the nodes list.
|
||||
// Node condition is derived separately via getPerGroupNodeConditionCounts (works
|
||||
// for both list and grouped_list modes), so no condition query is included here.
|
||||
func (m *module) newNodesTableListQuery() *qbtypes.QueryRangeRequest {
|
||||
queries := []qbtypes.QueryEnvelope{
|
||||
// Query A: CPU usage
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.node.cpu.usage",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query B: CPU allocatable.
|
||||
// TimeAggregationLatest is the closest v5 equivalent of v1's AnyLast;
|
||||
// allocatable values change rarely so divergence in practice is negligible.
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.node.allocatable_cpu",
|
||||
TimeAggregation: metrictypes.TimeAggregationLatest,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query C: Memory working set
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.node.memory.working_set",
|
||||
TimeAggregation: metrictypes.TimeAggregationAvg,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
// Query D: Memory allocatable. Same Latest caveat as Query B.
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "k8s.node.allocatable_memory",
|
||||
TimeAggregation: metrictypes.TimeAggregationLatest,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationSum,
|
||||
ReduceTo: qbtypes.ReduceToAvg,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
|
||||
Disabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: queries,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
type Handler interface {
|
||||
ListHosts(http.ResponseWriter, *http.Request)
|
||||
ListPods(http.ResponseWriter, *http.Request)
|
||||
ListNodes(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type Module interface {
|
||||
ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error)
|
||||
ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error)
|
||||
ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error)
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -22,8 +22,6 @@ 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"
|
||||
@@ -79,7 +77,6 @@ type Handlers struct {
|
||||
AlertmanagerHandler alertmanager.Handler
|
||||
TraceDetail tracedetail.Handler
|
||||
RulerHandler ruler.Handler
|
||||
LLMPricingRuleHandler llmpricingrule.Handler
|
||||
}
|
||||
|
||||
func NewHandlers(
|
||||
@@ -124,6 +121,5 @@ func NewHandlers(
|
||||
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
|
||||
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(nil, providerSettings),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ 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"
|
||||
@@ -78,7 +77,6 @@ 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,7 +283,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RuleStateHistory,
|
||||
handlers.SpanMapperHandler,
|
||||
handlers.AlertmanagerHandler,
|
||||
handlers.LLMPricingRuleHandler,
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
),
|
||||
|
||||
103
pkg/types/inframonitoringtypes/nodes.go
Normal file
103
pkg/types/inframonitoringtypes/nodes.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
type Nodes struct {
|
||||
Type ResponseType `json:"type" required:"true"`
|
||||
Records []NodeRecord `json:"records" required:"true"`
|
||||
Total int `json:"total" required:"true"`
|
||||
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
|
||||
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
|
||||
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
|
||||
}
|
||||
|
||||
type NodeRecord struct {
|
||||
NodeName string `json:"nodeName" required:"true"`
|
||||
Condition NodeCondition `json:"condition" required:"true"`
|
||||
ReadyNodesCount int `json:"readyNodesCount" required:"true"`
|
||||
NotReadyNodesCount int `json:"notReadyNodesCount" required:"true"`
|
||||
NodeCPU float64 `json:"nodeCPU" required:"true"`
|
||||
NodeCPUAllocatable float64 `json:"nodeCPUAllocatable" required:"true"`
|
||||
NodeMemory float64 `json:"nodeMemory" required:"true"`
|
||||
NodeMemoryAllocatable float64 `json:"nodeMemoryAllocatable" required:"true"`
|
||||
Meta map[string]interface{} `json:"meta" required:"true"`
|
||||
}
|
||||
|
||||
// PostableNodes is the request body for the v2 nodes list API.
|
||||
type PostableNodes struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableNodes contains acceptable values.
|
||||
func (req *PostableNodes) Validate() error {
|
||||
if req == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
|
||||
}
|
||||
|
||||
if req.Start <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid start time %d: start must be greater than 0",
|
||||
req.Start,
|
||||
)
|
||||
}
|
||||
|
||||
if req.End <= 0 {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid end time %d: end must be greater than 0",
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Start >= req.End {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid time range: start (%d) must be less than end (%d)",
|
||||
req.Start,
|
||||
req.End,
|
||||
)
|
||||
}
|
||||
|
||||
if req.Limit < 1 || req.Limit > 5000 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
|
||||
}
|
||||
|
||||
if req.Offset < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(NodesValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
}
|
||||
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON validates input immediately after decoding.
|
||||
func (req *PostableNodes) UnmarshalJSON(data []byte) error {
|
||||
type raw PostableNodes
|
||||
var decoded raw
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*req = PostableNodes(decoded)
|
||||
return req.Validate()
|
||||
}
|
||||
42
pkg/types/inframonitoringtypes/nodes_constants.go
Normal file
42
pkg/types/inframonitoringtypes/nodes_constants.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
type NodeCondition struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
NodeConditionReady = NodeCondition{valuer.NewString("ready")}
|
||||
NodeConditionNotReady = NodeCondition{valuer.NewString("not_ready")}
|
||||
NodeConditionNone = NodeCondition{valuer.NewString("")}
|
||||
)
|
||||
|
||||
func (NodeCondition) Enum() []any {
|
||||
return []any{
|
||||
NodeConditionReady,
|
||||
NodeConditionNotReady,
|
||||
NodeConditionNone,
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric values emitted by the k8s.node.condition_ready metric
|
||||
// (source: OTel kubeletstats receiver).
|
||||
const (
|
||||
NodeConditionNumReady = 1
|
||||
NodeConditionNumNotReady = 0
|
||||
)
|
||||
|
||||
const (
|
||||
NodesOrderByCPU = "cpu"
|
||||
NodesOrderByCPUAllocatable = "cpu_allocatable"
|
||||
NodesOrderByMemory = "memory"
|
||||
NodesOrderByMemoryAllocatable = "memory_allocatable"
|
||||
)
|
||||
|
||||
var NodesValidOrderByKeys = []string{
|
||||
NodesOrderByCPU,
|
||||
NodesOrderByCPUAllocatable,
|
||||
NodesOrderByMemory,
|
||||
NodesOrderByMemoryAllocatable,
|
||||
}
|
||||
255
pkg/types/inframonitoringtypes/nodes_test.go
Normal file
255
pkg/types/inframonitoringtypes/nodes_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPostableNodes_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *PostableNodes
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid request",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nil request",
|
||||
req: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time zero",
|
||||
req: &PostableNodes{
|
||||
Start: 0,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time negative",
|
||||
req: &PostableNodes{
|
||||
Start: -1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "end time zero",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 0,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time greater than end time",
|
||||
req: &PostableNodes{
|
||||
Start: 2000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "start time equal to end time",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 1000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit zero",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 0,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit negative",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: -10,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "limit exceeds max",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 5001,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "offset negative",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: -5,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy nil is valid",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key cpu and direction asc",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: NodesOrderByCPU,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key cpu_allocatable and direction desc",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: NodesOrderByCPUAllocatable,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key memory_allocatable and direction asc",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: NodesOrderByMemoryAllocatable,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionAsc,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "orderBy with condition key is rejected",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "condition",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy with invalid key",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "unknown",
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "orderBy with valid key but invalid direction",
|
||||
req: &PostableNodes{
|
||||
Start: 1000,
|
||||
End: 2000,
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
OrderBy: &qbtypes.OrderBy{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: NodesOrderByMemory,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirection{String: valuer.NewString("invalid")},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.req.Validate()
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Ast(err, errors.TypeInvalidInput), "expected error to be of type InvalidInput")
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user