mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-05 09:50:31 +01:00
Compare commits
140 Commits
issue/4817
...
infraM/v2_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e812277ec | ||
|
|
8b0ccc8ddc | ||
|
|
48a1e90402 | ||
|
|
1118136b69 | ||
|
|
e33c14a70b | ||
|
|
ae3f5114c4 | ||
|
|
8409a9798d | ||
|
|
de6e4890ae | ||
|
|
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 |
@@ -301,34 +301,20 @@ components:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesGettableAuthDomain:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
googleAuthConfig:
|
||||
$ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
oidcConfig:
|
||||
$ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
orgId:
|
||||
type: string
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
samlConfig:
|
||||
$ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
ssoType:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -589,7 +575,7 @@ components:
|
||||
- relation
|
||||
- object
|
||||
type: object
|
||||
AuthtypesUpdateableAuthDomain:
|
||||
AuthtypesUpdatableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
@@ -2609,6 +2595,88 @@ components:
|
||||
- requiredMetricsCheck
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesNodeCondition:
|
||||
enum:
|
||||
- ready
|
||||
- not_ready
|
||||
- ""
|
||||
type: string
|
||||
InframonitoringtypesNodeRecord:
|
||||
properties:
|
||||
condition:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
|
||||
failedPodCount:
|
||||
type: integer
|
||||
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
|
||||
pendingPodCount:
|
||||
type: integer
|
||||
readyNodesCount:
|
||||
type: integer
|
||||
runningPodCount:
|
||||
type: integer
|
||||
succeededPodCount:
|
||||
type: integer
|
||||
unknownPodCount:
|
||||
type: integer
|
||||
required:
|
||||
- nodeName
|
||||
- condition
|
||||
- readyNodesCount
|
||||
- notReadyNodesCount
|
||||
- pendingPodCount
|
||||
- runningPodCount
|
||||
- succeededPodCount
|
||||
- failedPodCount
|
||||
- unknownPodCount
|
||||
- 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
|
||||
@@ -2726,6 +2794,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:
|
||||
@@ -7079,20 +7173,20 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
|
||||
responses:
|
||||
"200":
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
@@ -7248,7 +7342,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesUpdateableAuthDomain'
|
||||
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
@@ -11656,6 +11750,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
|
||||
|
||||
@@ -19,8 +19,8 @@ import type {
|
||||
|
||||
import type {
|
||||
AuthtypesPostableAuthDomainDTO,
|
||||
AuthtypesUpdateableAuthDomainDTO,
|
||||
CreateAuthDomain200,
|
||||
AuthtypesUpdatableAuthDomainDTO,
|
||||
CreateAuthDomain201,
|
||||
DeleteAuthDomainPathParameters,
|
||||
GetAuthDomain200,
|
||||
GetAuthDomainPathParameters,
|
||||
@@ -126,7 +126,7 @@ export const createAuthDomain = (
|
||||
authtypesPostableAuthDomainDTO: BodyType<AuthtypesPostableAuthDomainDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateAuthDomain200>({
|
||||
return GeneratedAPIInstance<CreateAuthDomain201>({
|
||||
url: `/api/v1/domains`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -388,13 +388,13 @@ export const invalidateGetAuthDomain = async (
|
||||
*/
|
||||
export const updateAuthDomain = (
|
||||
{ id }: UpdateAuthDomainPathParameters,
|
||||
authtypesUpdateableAuthDomainDTO: BodyType<AuthtypesUpdateableAuthDomainDTO>,
|
||||
authtypesUpdatableAuthDomainDTO: BodyType<AuthtypesUpdatableAuthDomainDTO>,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/domains/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesUpdateableAuthDomainDTO,
|
||||
data: authtypesUpdatableAuthDomainDTO,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -407,7 +407,7 @@ export const getUpdateAuthDomainMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -416,7 +416,7 @@ export const getUpdateAuthDomainMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -433,7 +433,7 @@ export const getUpdateAuthDomainMutationOptions = <
|
||||
Awaited<ReturnType<typeof updateAuthDomain>>,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -448,7 +448,7 @@ export type UpdateAuthDomainMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateAuthDomain>>
|
||||
>;
|
||||
export type UpdateAuthDomainMutationBody =
|
||||
BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
export type UpdateAuthDomainMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -463,7 +463,7 @@ export const useUpdateAuthDomain = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -472,7 +472,7 @@ export const useUpdateAuthDomain = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateAuthDomainPathParameters;
|
||||
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
|
||||
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1641,109 +1641,32 @@ export interface AuthtypesCallbackAuthNSupportDTO {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type AuthtypesGettableAuthDomainDTO =
|
||||
| (AuthtypesSamlConfigDTO & {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
})
|
||||
| (AuthtypesGoogleConfigDTO & {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
})
|
||||
| (AuthtypesOIDCConfigDTO & {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
roleMapping?: AuthtypesRoleMappingDTO;
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: AuthtypesAuthNProviderDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
});
|
||||
export interface AuthtypesGettableAuthDomainDTO {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableObjectsDTO {
|
||||
resource: AuthtypesResourceDTO;
|
||||
@@ -2067,7 +1990,7 @@ export interface AuthtypesTransactionDTO {
|
||||
relation: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesUpdateableAuthDomainDTO {
|
||||
export interface AuthtypesUpdatableAuthDomainDTO {
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
}
|
||||
|
||||
@@ -4740,6 +4663,98 @@ 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 integer
|
||||
*/
|
||||
failedPodCount: number;
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
pendingPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
readyNodesCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
runningPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
succeededPodCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
unknownPodCount: 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',
|
||||
@@ -4870,6 +4885,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
|
||||
@@ -8432,8 +8475,8 @@ export type ListAuthDomains200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateAuthDomain200 = {
|
||||
data: AuthtypesGettableAuthDomainDTO;
|
||||
export type CreateAuthDomain201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9224,6 +9267,14 @@ export type ListHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNodes200 = {
|
||||
data: InframonitoringtypesNodesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPods200 = {
|
||||
data: InframonitoringtypesPodsDTO;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from '../utils';
|
||||
import { QuerySearchV2Context } from './context';
|
||||
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
|
||||
import { createExpressionStore } from './QuerySearchV2.store';
|
||||
|
||||
export interface QuerySearchV2ProviderProps {
|
||||
queryParamKey: string;
|
||||
initialExpression?: string;
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
persistOnUnmount?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component that creates a scoped zustand store and exposes
|
||||
* expression state to children via context.
|
||||
*/
|
||||
export function QuerySearchV2Provider({
|
||||
initialExpression = '',
|
||||
persistOnUnmount = false,
|
||||
queryParamKey,
|
||||
children,
|
||||
}: QuerySearchV2ProviderProps): JSX.Element {
|
||||
const storeRef = useRef(createExpressionStore());
|
||||
const store = storeRef.current;
|
||||
|
||||
const [urlExpression, setUrlExpression] = useQueryState(
|
||||
queryParamKey,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
const committedExpression = useStore(store, (s) => s.committedExpression);
|
||||
const setInputExpression = useStore(store, (s) => s.setInputExpression);
|
||||
const commitExpression = useStore(store, (s) => s.commitExpression);
|
||||
const initializeFromUrl = useStore(store, (s) => s.initializeFromUrl);
|
||||
const resetExpression = useStore(store, (s) => s.resetExpression);
|
||||
|
||||
const isInitialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current && urlExpression) {
|
||||
const cleanedExpression = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
urlExpression,
|
||||
);
|
||||
initializeFromUrl(cleanedExpression);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [urlExpression, initialExpression, initializeFromUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized.current || !urlExpression) {
|
||||
setUrlExpression(committedExpression || null);
|
||||
}
|
||||
}, [committedExpression, setUrlExpression, urlExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (!persistOnUnmount) {
|
||||
setUrlExpression(null);
|
||||
resetExpression();
|
||||
}
|
||||
};
|
||||
}, [persistOnUnmount, setUrlExpression, resetExpression]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(expression: string): void => {
|
||||
const userOnly = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
expression,
|
||||
);
|
||||
setInputExpression(userOnly);
|
||||
},
|
||||
[initialExpression, setInputExpression],
|
||||
);
|
||||
|
||||
const handleRun = useCallback(
|
||||
(expression: string): void => {
|
||||
const userOnly = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
expression,
|
||||
);
|
||||
commitExpression(userOnly);
|
||||
},
|
||||
[initialExpression, commitExpression],
|
||||
);
|
||||
|
||||
const combinedExpression = useMemo(
|
||||
() => combineInitialAndUserExpression(initialExpression, committedExpression),
|
||||
[initialExpression, committedExpression],
|
||||
);
|
||||
|
||||
const contextValue = useMemo<QuerySearchV2ContextValue>(
|
||||
() => ({
|
||||
expression: combinedExpression,
|
||||
userExpression: committedExpression,
|
||||
initialExpression,
|
||||
querySearchProps: {
|
||||
initialExpression: initialExpression.trim() ? initialExpression : undefined,
|
||||
onChange: handleChange,
|
||||
onRun: handleRun,
|
||||
},
|
||||
}),
|
||||
[
|
||||
combinedExpression,
|
||||
committedExpression,
|
||||
initialExpression,
|
||||
handleChange,
|
||||
handleRun,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<QuerySearchV2Context.Provider value={contextValue}>
|
||||
{children}
|
||||
</QuerySearchV2Context.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createStore, StoreApi } from 'zustand';
|
||||
|
||||
export type QuerySearchV2Store = {
|
||||
/**
|
||||
* User-typed expression (local state, updates on typing)
|
||||
*/
|
||||
inputExpression: string;
|
||||
/**
|
||||
* Committed expression (synced to URL, updates on submit)
|
||||
*/
|
||||
committedExpression: string;
|
||||
setInputExpression: (expression: string) => void;
|
||||
commitExpression: (expression: string) => void;
|
||||
resetExpression: () => void;
|
||||
initializeFromUrl: (urlExpression: string) => void;
|
||||
};
|
||||
|
||||
export interface QuerySearchProps {
|
||||
initialExpression: string | undefined;
|
||||
onChange: (expression: string) => void;
|
||||
onRun: (expression: string) => void;
|
||||
}
|
||||
|
||||
export interface QuerySearchV2ContextValue {
|
||||
/**
|
||||
* Combined expression: "initialExpression AND (userExpression)"
|
||||
*/
|
||||
expression: string;
|
||||
userExpression: string;
|
||||
initialExpression: string;
|
||||
querySearchProps: QuerySearchProps;
|
||||
}
|
||||
|
||||
export function createExpressionStore(): StoreApi<QuerySearchV2Store> {
|
||||
return createStore<QuerySearchV2Store>((set) => ({
|
||||
inputExpression: '',
|
||||
committedExpression: '',
|
||||
setInputExpression: (expression: string): void => {
|
||||
set({ inputExpression: expression });
|
||||
},
|
||||
commitExpression: (expression: string): void => {
|
||||
set({
|
||||
inputExpression: expression,
|
||||
committedExpression: expression,
|
||||
});
|
||||
},
|
||||
resetExpression: (): void => {
|
||||
set({
|
||||
inputExpression: '',
|
||||
committedExpression: '',
|
||||
});
|
||||
},
|
||||
initializeFromUrl: (urlExpression: string): void => {
|
||||
set({
|
||||
inputExpression: urlExpression,
|
||||
committedExpression: urlExpression,
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useQuerySearchV2Context } from '../context';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
QuerySearchV2ProviderProps,
|
||||
} from '../QuerySearchV2.provider';
|
||||
|
||||
const mockSetQueryState = jest.fn();
|
||||
let mockUrlValue: string | null = null;
|
||||
|
||||
jest.mock('nuqs', () => ({
|
||||
parseAsString: {},
|
||||
useQueryState: jest.fn(() => [mockUrlValue, mockSetQueryState]),
|
||||
}));
|
||||
|
||||
function createWrapper(
|
||||
props: Partial<QuerySearchV2ProviderProps> = {},
|
||||
): ({ children }: { children: ReactNode }) => JSX.Element {
|
||||
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<QuerySearchV2Provider queryParamKey="testExpression" {...props}>
|
||||
{children}
|
||||
</QuerySearchV2Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('QuerySearchExpressionProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlValue = null;
|
||||
});
|
||||
|
||||
it('should provide initial context values', () => {
|
||||
const { result } = renderHook(() => useQuerySearchV2Context(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.expression).toBe('');
|
||||
expect(result.current.userExpression).toBe('');
|
||||
expect(result.current.initialExpression).toBe('');
|
||||
});
|
||||
|
||||
it('should combine initialExpression with userExpression', () => {
|
||||
const { result } = renderHook(() => useQuerySearchV2Context(), {
|
||||
wrapper: createWrapper({ initialExpression: 'k8s.pod.name = "my-pod"' }),
|
||||
});
|
||||
|
||||
expect(result.current.expression).toBe('k8s.pod.name = "my-pod"');
|
||||
expect(result.current.initialExpression).toBe('k8s.pod.name = "my-pod"');
|
||||
|
||||
act(() => {
|
||||
result.current.querySearchProps.onChange('service = "api"');
|
||||
});
|
||||
act(() => {
|
||||
result.current.querySearchProps.onRun('service = "api"');
|
||||
});
|
||||
|
||||
expect(result.current.expression).toBe(
|
||||
'k8s.pod.name = "my-pod" AND (service = "api")',
|
||||
);
|
||||
expect(result.current.userExpression).toBe('service = "api"');
|
||||
});
|
||||
|
||||
it('should provide querySearchProps with correct callbacks', () => {
|
||||
const { result } = renderHook(() => useQuerySearchV2Context(), {
|
||||
wrapper: createWrapper({ initialExpression: 'initial' }),
|
||||
});
|
||||
|
||||
expect(result.current.querySearchProps.initialExpression).toBe('initial');
|
||||
expect(typeof result.current.querySearchProps.onChange).toBe('function');
|
||||
expect(typeof result.current.querySearchProps.onRun).toBe('function');
|
||||
});
|
||||
|
||||
it('should initialize from URL value on mount', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
|
||||
const { result } = renderHook(() => useQuerySearchV2Context(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 500');
|
||||
expect(result.current.expression).toBe('status = 500');
|
||||
});
|
||||
|
||||
it('should throw error when used outside provider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useQuerySearchV2Context());
|
||||
}).toThrow(
|
||||
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createExpressionStore } from '../QuerySearchV2.store';
|
||||
|
||||
describe('createExpressionStore', () => {
|
||||
it('should create a store with initial state', () => {
|
||||
const store = createExpressionStore();
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.inputExpression).toBe('');
|
||||
expect(state.committedExpression).toBe('');
|
||||
});
|
||||
|
||||
it('should update inputExpression via setInputExpression', () => {
|
||||
const store = createExpressionStore();
|
||||
|
||||
store.getState().setInputExpression('service.name = "api"');
|
||||
|
||||
expect(store.getState().inputExpression).toBe('service.name = "api"');
|
||||
expect(store.getState().committedExpression).toBe('');
|
||||
});
|
||||
|
||||
it('should update both expressions via commitExpression', () => {
|
||||
const store = createExpressionStore();
|
||||
|
||||
store.getState().setInputExpression('service.name = "api"');
|
||||
store.getState().commitExpression('service.name = "api"');
|
||||
|
||||
expect(store.getState().inputExpression).toBe('service.name = "api"');
|
||||
expect(store.getState().committedExpression).toBe('service.name = "api"');
|
||||
});
|
||||
|
||||
it('should reset all state via resetExpression', () => {
|
||||
const store = createExpressionStore();
|
||||
|
||||
store.getState().setInputExpression('service.name = "api"');
|
||||
store.getState().commitExpression('service.name = "api"');
|
||||
store.getState().resetExpression();
|
||||
|
||||
expect(store.getState().inputExpression).toBe('');
|
||||
expect(store.getState().committedExpression).toBe('');
|
||||
});
|
||||
|
||||
it('should initialize from URL value', () => {
|
||||
const store = createExpressionStore();
|
||||
|
||||
store.getState().initializeFromUrl('status = 500');
|
||||
|
||||
expect(store.getState().inputExpression).toBe('status = 500');
|
||||
expect(store.getState().committedExpression).toBe('status = 500');
|
||||
});
|
||||
|
||||
it('should create isolated store instances', () => {
|
||||
const store1 = createExpressionStore();
|
||||
const store2 = createExpressionStore();
|
||||
|
||||
store1.getState().setInputExpression('expr1');
|
||||
store2.getState().setInputExpression('expr2');
|
||||
|
||||
expect(store1.getState().inputExpression).toBe('expr1');
|
||||
expect(store2.getState().inputExpression).toBe('expr2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// eslint-disable-next-line no-restricted-imports -- React Context required for scoped store pattern
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
|
||||
|
||||
export const QuerySearchV2Context =
|
||||
createContext<QuerySearchV2ContextValue | null>(null);
|
||||
|
||||
export function useQuerySearchV2Context(): QuerySearchV2ContextValue {
|
||||
const context = useContext(QuerySearchV2Context);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { useQuerySearchV2Context } from './context';
|
||||
export type { QuerySearchV2ProviderProps } from './QuerySearchV2.provider';
|
||||
export { QuerySearchV2Provider } from './QuerySearchV2.provider';
|
||||
export type {
|
||||
QuerySearchProps,
|
||||
QuerySearchV2ContextValue,
|
||||
QuerySearchV2Store,
|
||||
} from './QuerySearchV2.store';
|
||||
@@ -19,6 +19,13 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.query-search-initial-scope-label {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.query-where-clause-editor {
|
||||
flex: 1;
|
||||
min-width: 400px;
|
||||
@@ -53,6 +60,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.hasInitialExpression .cm-editor .cm-content {
|
||||
padding-left: 22px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@@ -68,7 +79,6 @@
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 0px !important;
|
||||
background-color: var(--l1-background) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariabl
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { debounce, isNull } from 'lodash-es';
|
||||
import { Info, TriangleAlert } from 'lucide-react';
|
||||
import { Filter, Info, TriangleAlert } from 'lucide-react';
|
||||
import {
|
||||
IDetailedError,
|
||||
IQueryContext,
|
||||
@@ -47,6 +47,7 @@ import { validateQuery } from 'utils/queryValidationUtils';
|
||||
import { unquote } from 'utils/stringUtils';
|
||||
|
||||
import { queryExamples } from './constants';
|
||||
import { combineInitialAndUserExpression } from './utils';
|
||||
|
||||
import './QuerySearch.styles.scss';
|
||||
|
||||
@@ -85,6 +86,8 @@ interface QuerySearchProps {
|
||||
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
|
||||
onRun?: (query: string) => void;
|
||||
showFilterSuggestionsWithoutMetric?: boolean;
|
||||
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
|
||||
initialExpression?: string;
|
||||
}
|
||||
|
||||
function QuerySearch({
|
||||
@@ -96,6 +99,7 @@ function QuerySearch({
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
@@ -112,18 +116,26 @@ function QuerySearch({
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
const handleQueryValidation = useCallback((newExpression: string): void => {
|
||||
try {
|
||||
const validationResponse = validateQuery(newExpression);
|
||||
setValidation(validationResponse);
|
||||
} catch (error) {
|
||||
setValidation({
|
||||
isValid: false,
|
||||
message: 'Failed to process query',
|
||||
errors: [error as IDetailedError],
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const isScopedFilter = initialExpression !== undefined;
|
||||
|
||||
const validateExpressionForEditor = useCallback(
|
||||
(editorDoc: string): void => {
|
||||
const toValidate = isScopedFilter
|
||||
? combineInitialAndUserExpression(initialExpression ?? '', editorDoc)
|
||||
: editorDoc;
|
||||
try {
|
||||
const validationResponse = validateQuery(toValidate);
|
||||
setValidation(validationResponse);
|
||||
} catch (error) {
|
||||
setValidation({
|
||||
isValid: false,
|
||||
message: 'Failed to process query',
|
||||
errors: [error as IDetailedError],
|
||||
});
|
||||
}
|
||||
},
|
||||
[initialExpression, isScopedFilter],
|
||||
);
|
||||
|
||||
const getCurrentExpression = useCallback(
|
||||
(): string => editorRef.current?.state.doc.toString() || '',
|
||||
@@ -165,6 +177,8 @@ function QuerySearch({
|
||||
setIsEditorReady(true);
|
||||
}, []);
|
||||
|
||||
const prevQueryDataExpressionRef = useRef<string | undefined>();
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!isEditorReady) {
|
||||
@@ -173,13 +187,22 @@ function QuerySearch({
|
||||
|
||||
const newExpression = queryData.filter?.expression || '';
|
||||
const currentExpression = getCurrentExpression();
|
||||
const prevExpression = prevQueryDataExpressionRef.current;
|
||||
|
||||
// Do not update codemirror editor if the expression is the same
|
||||
if (newExpression !== currentExpression && !isFocused) {
|
||||
// Only sync editor when queryData.filter?.expression actually changed from external source
|
||||
// Not when focus changed (which would reset uncommitted user input)
|
||||
const queryDataExpressionChanged = prevExpression !== newExpression;
|
||||
prevQueryDataExpressionRef.current = newExpression;
|
||||
|
||||
if (
|
||||
queryDataExpressionChanged &&
|
||||
newExpression !== currentExpression &&
|
||||
!isFocused
|
||||
) {
|
||||
updateEditorValue(newExpression, { skipOnChange: true });
|
||||
if (newExpression) {
|
||||
handleQueryValidation(newExpression);
|
||||
}
|
||||
}
|
||||
if (!isFocused) {
|
||||
validateExpressionForEditor(currentExpression);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -284,7 +307,7 @@ function QuerySearch({
|
||||
}
|
||||
});
|
||||
}
|
||||
setKeySuggestions(Array.from(merged.values()));
|
||||
setKeySuggestions([...merged.values()]);
|
||||
|
||||
// Force reopen the completion if editor is available and focused
|
||||
if (editorRef.current) {
|
||||
@@ -337,7 +360,7 @@ function QuerySearch({
|
||||
// If value contains single quotes, escape them and wrap in single quotes
|
||||
if (value.includes("'")) {
|
||||
// Replace single quotes with escaped single quotes
|
||||
const escapedValue = value.replace(/'/g, "\\'");
|
||||
const escapedValue = value.replaceAll(/'/g, "\\'");
|
||||
return `'${escapedValue}'`;
|
||||
}
|
||||
|
||||
@@ -614,7 +637,7 @@ function QuerySearch({
|
||||
|
||||
const handleBlur = (): void => {
|
||||
const currentExpression = getCurrentExpression();
|
||||
handleQueryValidation(currentExpression);
|
||||
validateExpressionForEditor(currentExpression);
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
@@ -632,7 +655,6 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
const handleExampleClick = (exampleQuery: string): void => {
|
||||
// If there's an existing query, append the example with AND
|
||||
const currentExpression = getCurrentExpression();
|
||||
const newExpression = currentExpression
|
||||
? `${currentExpression} AND ${exampleQuery}`
|
||||
@@ -897,12 +919,12 @@ function QuerySearch({
|
||||
|
||||
// If we have previous pairs, we can prioritize keys that haven't been used yet
|
||||
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
|
||||
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
|
||||
const usedKeys = new Set(queryContext.queryPairs.map((pair) => pair.key));
|
||||
|
||||
// Add boost to unused keys to prioritize them
|
||||
options = options.map((option) => ({
|
||||
...option,
|
||||
boost: usedKeys.includes(option.label) ? -10 : 10,
|
||||
boost: usedKeys.has(option.label) ? -10 : 10,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1317,6 +1339,19 @@ function QuerySearch({
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-editor-container">
|
||||
{isScopedFilter ? (
|
||||
<Tooltip title={initialExpression || ''} placement="left">
|
||||
<div className="query-search-initial-scope-label">
|
||||
<Filter
|
||||
size={14}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip
|
||||
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
|
||||
placement="left"
|
||||
@@ -1356,6 +1391,7 @@ function QuerySearch({
|
||||
className={cx('query-where-clause-editor', {
|
||||
isValid: validation.isValid === true,
|
||||
hasErrors: validation.errors.length > 0,
|
||||
hasInitialExpression: isScopedFilter,
|
||||
})}
|
||||
extensions={[
|
||||
autocompletion({
|
||||
@@ -1390,7 +1426,12 @@ function QuerySearch({
|
||||
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
|
||||
run: (): boolean => {
|
||||
if (onRun && typeof onRun === 'function') {
|
||||
onRun(getCurrentExpression());
|
||||
const user = getCurrentExpression();
|
||||
onRun(
|
||||
isScopedFilter
|
||||
? combineInitialAndUserExpression(initialExpression ?? '', user)
|
||||
: user,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -1555,6 +1596,7 @@ QuerySearch.defaultProps = {
|
||||
placeholder:
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
|
||||
showFilterSuggestionsWithoutMetric: false,
|
||||
initialExpression: undefined,
|
||||
};
|
||||
|
||||
export default QuerySearch;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from '../utils';
|
||||
|
||||
describe('entityLogsExpression', () => {
|
||||
describe('combineInitialAndUserExpression', () => {
|
||||
it('returns user when initial is empty', () => {
|
||||
expect(combineInitialAndUserExpression('', 'body contains error')).toBe(
|
||||
'body contains error',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns initial when user is empty', () => {
|
||||
expect(combineInitialAndUserExpression('k8s.pod.name = "x"', '')).toBe(
|
||||
'k8s.pod.name = "x"',
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps user in parentheses with AND', () => {
|
||||
expect(
|
||||
combineInitialAndUserExpression('k8s.pod.name = "x"', 'body = "a"'),
|
||||
).toBe('k8s.pod.name = "x" AND (body = "a")');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserExpressionFromCombined', () => {
|
||||
it('returns empty when combined equals initial', () => {
|
||||
expect(
|
||||
getUserExpressionFromCombined('k8s.pod.name = "x"', 'k8s.pod.name = "x"'),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('extracts user from wrapped form', () => {
|
||||
expect(
|
||||
getUserExpressionFromCombined(
|
||||
'k8s.pod.name = "x"',
|
||||
'k8s.pod.name = "x" AND (body = "a")',
|
||||
),
|
||||
).toBe('body = "a"');
|
||||
});
|
||||
|
||||
it('extracts user from legacy AND without parens', () => {
|
||||
expect(
|
||||
getUserExpressionFromCombined(
|
||||
'k8s.pod.name = "x"',
|
||||
'k8s.pod.name = "x" AND body = "a"',
|
||||
),
|
||||
).toBe('body = "a"');
|
||||
});
|
||||
|
||||
it('returns full combined when initial is empty', () => {
|
||||
expect(getUserExpressionFromCombined('', 'service.name = "a"')).toBe(
|
||||
'service.name = "a"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export function combineInitialAndUserExpression(
|
||||
initial: string,
|
||||
user: string,
|
||||
): string {
|
||||
const i = initial.trim();
|
||||
const u = user.trim();
|
||||
if (!i) {
|
||||
return u;
|
||||
}
|
||||
if (!u) {
|
||||
return i;
|
||||
}
|
||||
return `${i} AND (${u})`;
|
||||
}
|
||||
|
||||
export function getUserExpressionFromCombined(
|
||||
initial: string,
|
||||
combined: string | null | undefined,
|
||||
): string {
|
||||
const i = initial.trim();
|
||||
const c = (combined ?? '').trim();
|
||||
if (!c) {
|
||||
return '';
|
||||
}
|
||||
if (!i) {
|
||||
return c;
|
||||
}
|
||||
if (c === i) {
|
||||
return '';
|
||||
}
|
||||
const wrappedPrefix = `${i} AND (`;
|
||||
if (c.startsWith(wrappedPrefix) && c.endsWith(')')) {
|
||||
return c.slice(wrappedPrefix.length, -1);
|
||||
}
|
||||
const plainPrefix = `${i} AND `;
|
||||
if (c.startsWith(plainPrefix)) {
|
||||
return c.slice(plainPrefix.length);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
14
frontend/src/components/QueryBuilderV2/index.ts
Normal file
14
frontend/src/components/QueryBuilderV2/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type {
|
||||
QuerySearchProps,
|
||||
QuerySearchV2ContextValue,
|
||||
QuerySearchV2ProviderProps,
|
||||
} from './QueryV2/QuerySearch/Provider';
|
||||
export {
|
||||
QuerySearchV2Provider,
|
||||
useQuerySearchV2Context,
|
||||
} from './QueryV2/QuerySearch/Provider';
|
||||
export { QueryBuilderV2 } from './QueryBuilderV2';
|
||||
export {
|
||||
QueryBuilderV2Provider,
|
||||
useQueryBuilderV2Context,
|
||||
} from './QueryBuilderV2Context';
|
||||
@@ -6,6 +6,7 @@ export enum Events {
|
||||
TOOLTIP_PINNED = 'TOOLTIP_PINNED',
|
||||
TOOLTIP_UNPINNED = 'TOOLTIP_UNPINNED',
|
||||
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
|
||||
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
|
||||
}
|
||||
|
||||
export enum InfraMonitoringEvents {
|
||||
|
||||
@@ -38,4 +38,5 @@ export enum LOCALSTORAGE {
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('BillingContainer', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.findByText('Cancel your subscription', { selector: 'span' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('BillingContainer', () => {
|
||||
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.findByText('Cancel your subscription', { selector: 'span' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -162,7 +162,7 @@ describe('BillingContainer', () => {
|
||||
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
|
||||
render(<BillingContainer />);
|
||||
await expect(
|
||||
screen.findByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.findByText('Cancel your subscription', { selector: 'span' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('BillingContainer', () => {
|
||||
);
|
||||
await screen.findByText('billing');
|
||||
expect(
|
||||
screen.queryByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.queryByText('Cancel your subscription', { selector: 'span' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('BillingContainer', () => {
|
||||
render(<BillingContainer />, {}, { appContextOverrides: overrides });
|
||||
await screen.findByText('billing');
|
||||
expect(
|
||||
screen.queryByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.queryByText('Cancel your subscription', { selector: 'span' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
padding: var(--padding-4);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--callout-error-border);
|
||||
background-color: var(--callout-error-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l2-background);
|
||||
margin: var(--spacing-4) 0 var(--spacing-12);
|
||||
}
|
||||
|
||||
@@ -15,21 +15,55 @@
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--callout-error-title);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--callout-error-icon);
|
||||
color: var(--l2-foreground);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.dialogBody {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-relaxed);
|
||||
color: var(--l2-foreground);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.dialogDescription {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialogConfirmLabel {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
background: var(--secondary-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
|
||||
|
||||
@@ -13,14 +13,16 @@ describe('CancelSubscriptionBanner', () => {
|
||||
it('renders banner with title and subtitle', () => {
|
||||
render(<CancelSubscriptionBanner />);
|
||||
expect(
|
||||
screen.getByText('Cancel Subscription', { selector: 'span' }),
|
||||
screen.getByText('Cancel your subscription', { selector: 'span' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Cancel your SigNoz subscription.'),
|
||||
screen.getByText(
|
||||
/When you cancel your SigNoz subscription, all your data will be deleted/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
|
||||
it('opens dialog with content when Cancel Subscription is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
@@ -30,17 +32,62 @@ describe('CancelSubscriptionBanner', () => {
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/reach out to our support team/i),
|
||||
screen.getByText(/Cancelling your subscription would stop your data/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Type/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /keep subscription/i }),
|
||||
screen.getByPlaceholderText(/Enter the word cancel/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /contact support/i }),
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
|
||||
it('keeps Cancel subscription button disabled until "cancel" is typed', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByRole('button', {
|
||||
name: /cancel subscription/i,
|
||||
});
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'canc');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
await user.type(input, 'el');
|
||||
expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('closes dialog and resets input when Go back is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'cancel');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /go back/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
const mockClick = jest.fn();
|
||||
const mockAnchor = { href: '', click: mockClick };
|
||||
@@ -57,7 +104,13 @@ describe('CancelSubscriptionBanner', () => {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /contact support/i }));
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'cancel');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
|
||||
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button, DialogWrapper } from '@signozhq/ui';
|
||||
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
|
||||
import { Button, DialogWrapper, Input } from '@signozhq/ui';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { pick } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import styles from './CancelSubscriptionBanner.module.scss';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
function CancelSubscriptionBanner(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const { user, org } = useAppContext();
|
||||
|
||||
const handleOpenCancelDialog = (): void => {
|
||||
@@ -53,6 +55,12 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
|
||||
link.click();
|
||||
setOpen(false);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setOpen(false);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const footer = (
|
||||
@@ -60,12 +68,19 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={(): void => setOpen(false)}
|
||||
prefix={<Undo2 size={14} />}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Keep Subscription
|
||||
Go back
|
||||
</Button>
|
||||
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
|
||||
Contact Support
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
prefix={<X size={14} />}
|
||||
disabled={confirmText !== 'cancel'}
|
||||
onClick={handleContactSupport}
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
@@ -74,30 +89,47 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
<>
|
||||
<div className={styles.banner}>
|
||||
<div className={styles.info}>
|
||||
<span className={styles.title}>Cancel Subscription</span>
|
||||
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
|
||||
<div className={styles.titleRow}>
|
||||
<SolidInfoCircle color={Color.BG_SAKURA_500} size={12} />
|
||||
<span className={styles.title}>Cancel your subscription</span>
|
||||
</div>
|
||||
<span className={styles.subtitle}>
|
||||
When you cancel your SigNoz subscription, all your data will be deleted
|
||||
immediately and removed from our servers.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
color="secondary"
|
||||
prefix={<X size={12} />}
|
||||
onClick={handleOpenCancelDialog}
|
||||
className={styles.cancelButton}
|
||||
>
|
||||
Cancel Subscription
|
||||
</Button>
|
||||
</div>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Cancel your subscription"
|
||||
onOpenChange={handleClose}
|
||||
title="Cancel your subscription?"
|
||||
width="narrow"
|
||||
showCloseButton={false}
|
||||
footer={footer}
|
||||
>
|
||||
<p className={styles.dialogBody}>
|
||||
To cancel your SigNoz subscription, please reach out to our support team.
|
||||
We'll be happy to assist you.
|
||||
</p>
|
||||
<div className={styles.dialogBody}>
|
||||
<p className={styles.dialogDescription}>
|
||||
Cancelling your subscription would stop your data from being ingested to
|
||||
SigNoz. All the data that has been already sent will also be deleted.
|
||||
</p>
|
||||
<p className={styles.dialogConfirmLabel}>
|
||||
Type <code>cancel</code> to confirm the cancellation.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Enter the word cancel..."
|
||||
value={confirmText}
|
||||
onChange={(e): void => setConfirmText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
.overview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.overview-settings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
|
||||
.name-icon-input {
|
||||
display: flex;
|
||||
.dashboard-image-input {
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-name-input {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-name {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.description-text-area {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.overview-settings-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.unsaved-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
.unsaved-changes {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px; /* 171.429% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
.footer-action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.discard-btn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-image-input {
|
||||
&.ant-select-dropdown {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.ant-select-item {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.ant-select-item-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Col, Input, Select, Space, Typography } from 'antd';
|
||||
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
|
||||
import './GeneralSettings.styles.scss';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
@@ -19,6 +27,13 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
|
||||
dashboardData?.id,
|
||||
);
|
||||
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(dashboardData?.id);
|
||||
|
||||
const selectedData = dashboardData?.data;
|
||||
|
||||
const {
|
||||
@@ -100,8 +115,8 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overview-content">
|
||||
<Col className="overview-settings">
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
@@ -112,27 +127,29 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Dashboard Name
|
||||
</Typography>
|
||||
<section className="name-icon-input">
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName="dashboard-image-input"
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img src={icon} alt="dashboard-icon" className="list-item-image" />
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className="dashboard-name-input"
|
||||
className={styles.dashboardNameInput}
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
@@ -140,41 +157,92 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Description
|
||||
</Typography>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className="description-text-area"
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
|
||||
Tags
|
||||
</Typography>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Sync Mode
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Synced Tooltip Series
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Show only series that intersect on group-by, or every series with the
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className="overview-settings-footer">
|
||||
<div className="unsaved">
|
||||
<div className="unsaved-dot" />
|
||||
<Typography.Text className="unsaved-changes">
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="footer-action-btns">
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
disabled={updateDashboardMutation.isLoading}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className="discard-btn"
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
@@ -188,7 +256,7 @@ function GeneralDashboardSettings(): JSX.Element {
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className="save-btn"
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
|
||||
@@ -33,11 +33,13 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: BarTooltipProps = {
|
||||
...props,
|
||||
id: config.getId(),
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
isStackedBarChart: isStackedBarChart,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <BarChartTooltip {...tooltipProps} />;
|
||||
},
|
||||
@@ -48,6 +50,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -29,11 +29,12 @@ export default function ChartWrapper({
|
||||
onClick,
|
||||
syncMode,
|
||||
syncKey,
|
||||
syncFilterMode,
|
||||
onDestroy = noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
groupBy,
|
||||
groupByPerQuery,
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
'data-testid': testId,
|
||||
@@ -69,9 +70,10 @@ export default function ChartWrapper({
|
||||
const syncMetadata = useMemo(
|
||||
() => ({
|
||||
yAxisUnit,
|
||||
groupBy,
|
||||
groupByPerQuery,
|
||||
filterMode: syncFilterMode,
|
||||
}),
|
||||
[yAxisUnit, groupBy],
|
||||
[yAxisUnit, groupByPerQuery, syncFilterMode],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,13 +24,21 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: HistogramTooltipProps = {
|
||||
...props,
|
||||
id: rest.config.getId(),
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <HistogramTooltip {...tooltipProps} />;
|
||||
},
|
||||
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
|
||||
[
|
||||
customTooltip,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { TimeSeriesChartProps } from '../types';
|
||||
|
||||
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
|
||||
const { children, customTooltip, ...rest } = props;
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
@@ -18,10 +18,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
}
|
||||
const tooltipProps: TimeSeriesTooltipProps = {
|
||||
...props,
|
||||
id: rest.config.getId(),
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <TimeSeriesTooltip {...tooltipProps} />;
|
||||
},
|
||||
@@ -31,15 +33,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
<ChartWrapper {...rest} customTooltip={renderTooltip}>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendConfig,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
TooltipClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
@@ -21,6 +26,7 @@ interface BaseChartProps {
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
@@ -30,6 +36,7 @@ interface UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
onDestroy?: (plot: uPlot) => void;
|
||||
children?: React.ReactNode;
|
||||
@@ -39,7 +46,7 @@ interface UPlotBasedChartProps {
|
||||
interface UPlotChartDataProps {
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
@@ -14,7 +20,7 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
|
||||
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import get from 'lodash/get';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -24,6 +30,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDragSelect,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
groupByPerQuery,
|
||||
} = props;
|
||||
const uPlotRef = useRef<uPlot | null>(null);
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -34,6 +41,10 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
|
||||
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -75,6 +86,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
panelMode,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
@@ -114,14 +130,20 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
uPlotRef.current = plot;
|
||||
}, []);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
}, [widget.query]);
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
|
||||
return (
|
||||
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={`${syncMode}-${syncFilterMode}`}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -133,11 +155,14 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
groupBy={groupBy}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
syncMode={syncMode}
|
||||
syncFilterMode={syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import Histogram from '../../charts/Histogram/Histogram';
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
} from './utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
|
||||
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -75,6 +79,20 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
widget.mergeAllActiveQueries,
|
||||
]);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
|
||||
return (
|
||||
<TooltipFooter
|
||||
id={widget.id}
|
||||
isPinned={isPinned}
|
||||
dismiss={dismiss}
|
||||
canDrilldown={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
@@ -97,6 +115,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
|
||||
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
|
||||
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendPosition,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
|
||||
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
|
||||
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -24,6 +30,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
onDragSelect,
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
groupByPerQuery,
|
||||
} = props;
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
@@ -33,6 +40,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
|
||||
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
|
||||
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
useEffect((): void => {
|
||||
const { startTime, endTime } = getTimeRange(queryResponse);
|
||||
|
||||
@@ -81,6 +92,11 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
|
||||
// Rebuild it on syncMode changes so the new chart instance starts from a
|
||||
// clean config — otherwise switching to "No Sync" would inherit stale sync
|
||||
// settings from the previous mode.
|
||||
syncMode,
|
||||
]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
@@ -105,14 +121,20 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
widget.decimalPrecision,
|
||||
]);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return get(widget, 'query.builder.queryData[0].groupBy', []);
|
||||
}, [widget.query]);
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
|
||||
return (
|
||||
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<TimeSeries
|
||||
key={`${syncMode}-${syncFilterMode}`}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -122,10 +144,13 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
groupBy={groupBy}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
syncMode={syncMode}
|
||||
syncFilterMode={syncFilterMode}
|
||||
layoutChildren={layoutChildren}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
|
||||
@@ -7,22 +7,25 @@ import Styles from './TooltipFooter.module.scss';
|
||||
import { MousePointerClick } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
interface TooltipFooterProps {
|
||||
id: string;
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
canDrilldown?: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export default function TooltipFooter({
|
||||
id,
|
||||
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
|
||||
isPinned,
|
||||
canDrilldown = true,
|
||||
dismiss,
|
||||
}: TooltipFooterProps): JSX.Element {
|
||||
const handleUnpinClick = (): void => {
|
||||
logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: id,
|
||||
});
|
||||
dismiss();
|
||||
};
|
||||
@@ -43,12 +46,14 @@ export default function TooltipFooter({
|
||||
</div>
|
||||
) : (
|
||||
<div className={Styles.hintList}>
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<Kbd>
|
||||
<MousePointerClick size={12} />
|
||||
</Kbd>
|
||||
<span>Click to drilldown</span>
|
||||
</div>
|
||||
{canDrilldown && (
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<Kbd>
|
||||
<MousePointerClick size={12} />
|
||||
</Kbd>
|
||||
<span>Click to drilldown</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={Styles.hint} data-active="false">
|
||||
<span>Press</span>
|
||||
<Kbd>{pinKey.toUpperCase()}</Kbd>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Events } from 'constants/events';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import TooltipFooter from '../TooltipFooter';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
describe('TooltipFooter', () => {
|
||||
const defaultProps = {
|
||||
id: 'panel-123',
|
||||
isPinned: false,
|
||||
dismiss: jest.fn(),
|
||||
};
|
||||
|
||||
describe('when not pinned', () => {
|
||||
it('renders the drilldown and pin hints by default', () => {
|
||||
render(<TooltipFooter {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
|
||||
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the drilldown hint when canDrilldown is false', () => {
|
||||
render(<TooltipFooter {...defaultProps} canDrilldown={false} />);
|
||||
|
||||
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a custom pin key in uppercase', () => {
|
||||
render(<TooltipFooter {...defaultProps} pinKey="x" />);
|
||||
|
||||
expect(screen.getByText('X')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the unpin button', () => {
|
||||
render(<TooltipFooter {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when pinned', () => {
|
||||
it('renders the unpin hint with pin key and Esc', () => {
|
||||
render(<TooltipFooter {...defaultProps} isPinned />);
|
||||
|
||||
expect(screen.getByText('to unpin')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Esc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the unpin button', () => {
|
||||
render(<TooltipFooter {...defaultProps} isPinned />);
|
||||
|
||||
expect(screen.getByTestId('uplot-tooltip-unpin')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /unpin tooltip/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the drilldown and pin-instruction hints', () => {
|
||||
render(<TooltipFooter {...defaultProps} isPinned />);
|
||||
|
||||
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls dismiss and logs the unpin event when the unpin button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const dismiss = jest.fn();
|
||||
|
||||
render(<TooltipFooter {...defaultProps} dismiss={dismiss} isPinned />);
|
||||
|
||||
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
id: 'panel-123',
|
||||
});
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [authnProvider, setAuthnProvider] = useState<
|
||||
AuthtypesAuthNProviderDTO | ''
|
||||
>(record?.ssoType || '');
|
||||
>(record?.config?.ssoType || '');
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
@@ -112,21 +112,26 @@ export function prepareInitialValues(
|
||||
};
|
||||
}
|
||||
|
||||
const config = record.config ?? {};
|
||||
return {
|
||||
...record,
|
||||
googleAuthConfig: record.googleAuthConfig
|
||||
name: record.name,
|
||||
ssoEnabled: config.ssoEnabled,
|
||||
ssoType: config.ssoType,
|
||||
samlConfig: config.samlConfig ?? undefined,
|
||||
oidcConfig: config.oidcConfig ?? undefined,
|
||||
googleAuthConfig: config.googleAuthConfig
|
||||
? {
|
||||
...record.googleAuthConfig,
|
||||
...config.googleAuthConfig,
|
||||
domainToAdminEmailList: convertDomainMappingsToList(
|
||||
record.googleAuthConfig.domainToAdminEmail,
|
||||
config.googleAuthConfig.domainToAdminEmail,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
roleMapping: record.roleMapping
|
||||
roleMapping: config.roleMapping
|
||||
? {
|
||||
...record.roleMapping,
|
||||
...config.roleMapping,
|
||||
groupMappingsList: convertGroupMappingsToList(
|
||||
record.roleMapping.groupMappings,
|
||||
config.roleMapping.groupMappings,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -43,11 +43,11 @@ function SSOEnforcementToggle({
|
||||
data: {
|
||||
config: {
|
||||
ssoEnabled: checked,
|
||||
ssoType: record.ssoType,
|
||||
googleAuthConfig: record.googleAuthConfig,
|
||||
oidcConfig: record.oidcConfig,
|
||||
samlConfig: record.samlConfig,
|
||||
roleMapping: record.roleMapping,
|
||||
ssoType: record.config?.ssoType,
|
||||
googleAuthConfig: record.config?.googleAuthConfig,
|
||||
oidcConfig: record.config?.oidcConfig,
|
||||
samlConfig: record.config?.samlConfig,
|
||||
roleMapping: record.config?.roleMapping,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -55,7 +55,10 @@ describe('SSOEnforcementToggle', () => {
|
||||
render(
|
||||
<SSOEnforcementToggle
|
||||
isDefaultChecked={false}
|
||||
record={{ ...mockGoogleAuthDomain, ssoEnabled: false }}
|
||||
record={{
|
||||
...mockGoogleAuthDomain,
|
||||
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
|
||||
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-1',
|
||||
name: 'signoz.io',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-1',
|
||||
@@ -28,12 +30,14 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-2',
|
||||
name: 'example.com',
|
||||
ssoEnabled: false,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.example.com/sso',
|
||||
samlEntity: 'urn:example:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
config: {
|
||||
ssoEnabled: false,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.example.com/sso',
|
||||
samlEntity: 'urn:example:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-2',
|
||||
@@ -44,12 +48,14 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-3',
|
||||
name: 'corp.io',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.corp.io',
|
||||
clientId: 'oidc-client-id',
|
||||
clientSecret: 'oidc-client-secret',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.corp.io',
|
||||
clientId: 'oidc-client-id',
|
||||
clientSecret: 'oidc-client-secret',
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-3',
|
||||
@@ -60,20 +66,22 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-4',
|
||||
name: 'enterprise.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.enterprise.com/sso',
|
||||
samlEntity: 'urn:enterprise:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'EDITOR',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: {
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.enterprise.com/sso',
|
||||
samlEntity: 'urn:enterprise:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'EDITOR',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: {
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
},
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
@@ -86,16 +94,18 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
|
||||
{
|
||||
id: 'domain-5',
|
||||
name: 'direct-role.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.direct-role.com',
|
||||
clientId: 'direct-role-client-id',
|
||||
clientSecret: 'direct-role-client-secret',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'VIEWER',
|
||||
useRoleAttribute: true,
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.direct-role.com',
|
||||
clientId: 'direct-role-client-id',
|
||||
clientSecret: 'direct-role-client-secret',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'VIEWER',
|
||||
useRoleAttribute: true,
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-5',
|
||||
@@ -106,20 +116,22 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
|
||||
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-6',
|
||||
name: 'oidc-claims.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.claims.com',
|
||||
issuerAlias: 'https://alias.claims.com',
|
||||
clientId: 'claims-client-id',
|
||||
clientSecret: 'claims-client-secret',
|
||||
insecureSkipEmailVerified: true,
|
||||
getUserInfo: true,
|
||||
claimMapping: {
|
||||
email: 'user_email',
|
||||
name: 'display_name',
|
||||
groups: 'user_groups',
|
||||
role: 'user_role',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.oidc,
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.claims.com',
|
||||
issuerAlias: 'https://alias.claims.com',
|
||||
clientId: 'claims-client-id',
|
||||
clientSecret: 'claims-client-secret',
|
||||
insecureSkipEmailVerified: true,
|
||||
getUserInfo: true,
|
||||
claimMapping: {
|
||||
email: 'user_email',
|
||||
name: 'display_name',
|
||||
groups: 'user_groups',
|
||||
role: 'user_role',
|
||||
},
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
@@ -131,17 +143,19 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-7',
|
||||
name: 'saml-attrs.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.saml-attrs.com/sso',
|
||||
samlEntity: 'urn:saml-attrs:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE_ATTRS',
|
||||
insecureSkipAuthNRequestsSigned: true,
|
||||
attributeMapping: {
|
||||
name: 'user_display_name',
|
||||
groups: 'member_of',
|
||||
role: 'signoz_role',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.saml,
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.saml-attrs.com/sso',
|
||||
samlEntity: 'urn:saml-attrs:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE_ATTRS',
|
||||
insecureSkipAuthNRequestsSigned: true,
|
||||
attributeMapping: {
|
||||
name: 'user_display_name',
|
||||
groups: 'member_of',
|
||||
role: 'signoz_role',
|
||||
},
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
@@ -154,19 +168,21 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
|
||||
{
|
||||
id: 'domain-8',
|
||||
name: 'google-groups.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'google-groups-client-id',
|
||||
clientSecret: 'google-groups-client-secret',
|
||||
insecureSkipEmailVerified: false,
|
||||
fetchGroups: true,
|
||||
serviceAccountJson: '{"type": "service_account"}',
|
||||
domainToAdminEmail: {
|
||||
'google-groups.com': 'admin@google-groups.com',
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: AuthtypesAuthNProviderDTO.google_auth,
|
||||
googleAuthConfig: {
|
||||
clientId: 'google-groups-client-id',
|
||||
clientSecret: 'google-groups-client-secret',
|
||||
insecureSkipEmailVerified: false,
|
||||
fetchGroups: true,
|
||||
serviceAccountJson: '{"type": "service_account"}',
|
||||
domainToAdminEmail: {
|
||||
'google-groups.com': 'admin@google-groups.com',
|
||||
},
|
||||
fetchTransitiveGroupMembership: true,
|
||||
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
|
||||
},
|
||||
fetchTransitiveGroupMembership: true,
|
||||
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-8',
|
||||
@@ -191,15 +207,19 @@ export const mockSingleDomainResponse = {
|
||||
data: [mockGoogleAuthDomain],
|
||||
};
|
||||
|
||||
// Mock success responses
|
||||
// Mock success responses. CreateAuthDomain returns just an Identifiable
|
||||
// (the new domain ID); clients re-Read to get the full domain.
|
||||
export const mockCreateSuccessResponse = {
|
||||
status: 'success',
|
||||
data: mockGoogleAuthDomain,
|
||||
data: { id: mockGoogleAuthDomain.id },
|
||||
};
|
||||
|
||||
export const mockUpdateSuccessResponse = {
|
||||
status: 'success',
|
||||
data: { ...mockGoogleAuthDomain, ssoEnabled: false },
|
||||
data: {
|
||||
...mockGoogleAuthDomain,
|
||||
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
|
||||
},
|
||||
};
|
||||
|
||||
export const mockDeleteSuccessResponse = {
|
||||
|
||||
@@ -158,7 +158,7 @@ function AuthDomain(): JSX.Element {
|
||||
onClick={(): void => setRecord(record)}
|
||||
variant="link"
|
||||
>
|
||||
Configure {SSOType.get(record.ssoType || '')}
|
||||
Configure {SSOType.get(record.config?.ssoType || '')}
|
||||
</Button>
|
||||
<Button
|
||||
className="auth-domain-list-action-link delete"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FC } from 'react';
|
||||
import { FC, useMemo } from 'react';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { PanelTypeVsPanelWrapper } from './constants';
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
@@ -30,6 +31,20 @@ function PanelWrapper({
|
||||
selectedGraph || widget.panelTypes
|
||||
] as FC<PanelWrapperProps>;
|
||||
|
||||
const groupByPerQuery = useMemo<Record<string, BaseAutocompleteData[]>>(() => {
|
||||
if (!widget.query.builder) {
|
||||
return {};
|
||||
}
|
||||
const { queryData } = widget.query.builder;
|
||||
return queryData.reduce<Record<string, BaseAutocompleteData[]>>(
|
||||
(acc, query) => {
|
||||
acc[query.queryName] = query.groupBy ?? [];
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}, [widget]);
|
||||
|
||||
if (!Component) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -60,6 +75,7 @@ function PanelWrapper({
|
||||
customSeries={customSeries}
|
||||
enableDrillDown={enableDrillDown}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import { WidgetGraphComponentProps } from 'container/GridCardLayout/GridCard/types';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
@@ -30,6 +31,7 @@ export type PanelWrapperProps = {
|
||||
enableDrillDown?: boolean;
|
||||
panelMode: PanelMode;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardCursorSyncMode } from '../useDashboardCursorSyncMode';
|
||||
import { useDashboardPreferencesStore } from '../useDashboardPreference';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.DASHBOARD_PREFERENCES;
|
||||
|
||||
describe('useDashboardCursorSyncMode', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('in DASHBOARD_VIEW mode', () => {
|
||||
it('uses Crosshair as the default cursor sync mode', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('reads the stored cursor sync mode for the dashboard', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('writes the value under the cursorSyncMode key in the store', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the value to localStorage', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}');
|
||||
expect(persisted.state.preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('treats the setter as a no-op when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode(undefined, PanelMode.DASHBOARD_VIEW),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a panelMode (e.g. dashboard settings call site)', () => {
|
||||
it('reads the stored value just like DASHBOARD_VIEW does', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('writes through the setter to the store', () => {
|
||||
const { result } = renderHook(() => useDashboardCursorSyncMode('dash-1'));
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([[PanelMode.DASHBOARD_EDIT], [PanelMode.STANDALONE_VIEW]])(
|
||||
'in %s mode (cursor sync disabled)',
|
||||
(panelMode) => {
|
||||
it('returns the Crosshair default and ignores any stored value', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', panelMode),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
|
||||
it('treats the setter as a no-op and does not write to the store', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardCursorSyncMode('dash-1', panelMode),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Crosshair);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import {
|
||||
useDashboardPreference,
|
||||
useDashboardPreferencesStore,
|
||||
} from '../useDashboardPreference';
|
||||
|
||||
const DEFAULT_MODE = DashboardCursorSync.Crosshair;
|
||||
|
||||
describe('useDashboardPreference', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
});
|
||||
|
||||
it('returns the default value when no preference is stored', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the default value when dashboardId is undefined', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('returns the stored value for the given dashboardId', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('persists the new value via the setter', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write when dashboardId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference(undefined, 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('keeps multiple hook instances in sync after a write', () => {
|
||||
const { result: writer } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: reader } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
writer.current[1](DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
expect(writer.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
expect(reader.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
});
|
||||
|
||||
it('isolates preferences across different dashboardIds', () => {
|
||||
const { result: dashOne } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
const { result: dashTwo } = renderHook(() =>
|
||||
useDashboardPreference('dash-2', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dashOne.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(dashOne.current[0]).toBe(DashboardCursorSync.None);
|
||||
expect(dashTwo.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
|
||||
it('does not overwrite preferences for other dashboards when writing', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: { 'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip } },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current[1](DashboardCursorSync.None);
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.None },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDashboardPreferencesStore.removePreferences', () => {
|
||||
beforeEach(() => {
|
||||
useDashboardPreferencesStore.setState({ preferences: {} });
|
||||
});
|
||||
|
||||
it('removes the preferences for the given dashboardId', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('leaves other dashboards untouched', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toStrictEqual({
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.None },
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op when the dashboardId is not present', () => {
|
||||
const initial = {
|
||||
'dash-2': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
};
|
||||
useDashboardPreferencesStore.setState({ preferences: initial });
|
||||
const before = useDashboardPreferencesStore.getState().preferences;
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
// Identity-preserving so subscribers reading `preferences` don't re-render.
|
||||
expect(useDashboardPreferencesStore.getState().preferences).toBe(before);
|
||||
});
|
||||
|
||||
it('causes subsequent reads via useDashboardPreference to fall back to the default', () => {
|
||||
useDashboardPreferencesStore.setState({
|
||||
preferences: {
|
||||
'dash-1': { cursorSyncMode: DashboardCursorSync.Tooltip },
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDashboardPreference('dash-1', 'cursorSyncMode', DEFAULT_MODE),
|
||||
);
|
||||
|
||||
expect(result.current[0]).toBe(DashboardCursorSync.Tooltip);
|
||||
|
||||
act(() => {
|
||||
useDashboardPreferencesStore.getState().removePreferences('dash-1');
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(DEFAULT_MODE);
|
||||
});
|
||||
});
|
||||
28
frontend/src/hooks/dashboard/useDashboardCursorSyncMode.ts
Normal file
28
frontend/src/hooks/dashboard/useDashboardCursorSyncMode.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardPreference } from './useDashboardPreference';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
|
||||
const DEFAULT_CURSOR_SYNC_MODE = DashboardCursorSync.Crosshair;
|
||||
|
||||
const NOOP = (): void => {};
|
||||
|
||||
export function useDashboardCursorSyncMode(
|
||||
dashboardId: string | undefined,
|
||||
panelMode?: PanelMode,
|
||||
): [DashboardCursorSync, (value: DashboardCursorSync) => void] {
|
||||
const [value, setValue] = useDashboardPreference(
|
||||
dashboardId,
|
||||
'cursorSyncMode',
|
||||
DEFAULT_CURSOR_SYNC_MODE,
|
||||
);
|
||||
|
||||
// Chart panels in edit / standalone modes don't participate in cross-panel
|
||||
// sync, so surface the default with a no-op setter for them. Callers without
|
||||
// a panelMode (e.g. dashboard settings) read/write the preference normally.
|
||||
if (panelMode && panelMode !== PanelMode.DASHBOARD_VIEW) {
|
||||
return [DEFAULT_CURSOR_SYNC_MODE, NOOP];
|
||||
}
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
88
frontend/src/hooks/dashboard/useDashboardPreference.ts
Normal file
88
frontend/src/hooks/dashboard/useDashboardPreference.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
// Per-dashboard preferences persisted in localStorage. Add new preference
|
||||
// fields here as they are introduced.
|
||||
export type DashboardPreferences = {
|
||||
cursorSyncMode?: DashboardCursorSync;
|
||||
syncTooltipFilterMode?: SyncTooltipFilterMode;
|
||||
};
|
||||
|
||||
interface DashboardPreferencesState {
|
||||
preferences: Record<string, DashboardPreferences>;
|
||||
setPreference: <K extends keyof DashboardPreferences>(
|
||||
dashboardId: string,
|
||||
key: K,
|
||||
value: NonNullable<DashboardPreferences[K]>,
|
||||
) => void;
|
||||
removePreferences: (dashboardId: string) => void;
|
||||
}
|
||||
|
||||
export const useDashboardPreferencesStore = create<DashboardPreferencesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
preferences: {},
|
||||
setPreference: (dashboardId, key, value): void => {
|
||||
set((state) => ({
|
||||
preferences: {
|
||||
...state.preferences,
|
||||
[dashboardId]: {
|
||||
...state.preferences[dashboardId],
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
removePreferences: (dashboardId): void => {
|
||||
set((state) => {
|
||||
if (!(dashboardId in state.preferences)) {
|
||||
return state;
|
||||
}
|
||||
const { [dashboardId]: _, ...rest } = state.preferences;
|
||||
return { preferences: rest };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{ name: LOCALSTORAGE.DASHBOARD_PREFERENCES },
|
||||
),
|
||||
);
|
||||
|
||||
export function useDashboardPreference<K extends keyof DashboardPreferences>(
|
||||
dashboardId: string | undefined,
|
||||
key: K,
|
||||
defaultValue: NonNullable<DashboardPreferences[K]>,
|
||||
): [
|
||||
NonNullable<DashboardPreferences[K]>,
|
||||
(value: NonNullable<DashboardPreferences[K]>) => void,
|
||||
] {
|
||||
type Value = NonNullable<DashboardPreferences[K]>;
|
||||
|
||||
const value = useDashboardPreferencesStore((state): Value => {
|
||||
if (!dashboardId) {
|
||||
return defaultValue;
|
||||
}
|
||||
return (
|
||||
(state.preferences[dashboardId]?.[key] as Value | undefined) ?? defaultValue
|
||||
);
|
||||
});
|
||||
|
||||
const setPreference = useDashboardPreferencesStore((s) => s.setPreference);
|
||||
|
||||
const updateValue = useCallback(
|
||||
(next: Value): void => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
setPreference(dashboardId, key, next);
|
||||
},
|
||||
[dashboardId, key, setPreference],
|
||||
);
|
||||
|
||||
return [value, updateValue];
|
||||
}
|
||||
@@ -5,10 +5,15 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardPreferencesStore } from './useDashboardPreference';
|
||||
|
||||
export const useDeleteDashboard = (
|
||||
id: string,
|
||||
): UseMutationResult<SuccessResponseV2<null>, APIError, void, unknown> => {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const removePreferences = useDashboardPreferencesStore(
|
||||
(state) => state.removePreferences,
|
||||
);
|
||||
|
||||
return useMutation<SuccessResponseV2<null>, APIError>({
|
||||
mutationKey: REACT_QUERY_KEY.DELETE_DASHBOARD,
|
||||
@@ -16,6 +21,9 @@ export const useDeleteDashboard = (
|
||||
deleteDashboard({
|
||||
id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
removePreferences(id);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
|
||||
15
frontend/src/hooks/dashboard/useSyncTooltipFilterMode.ts
Normal file
15
frontend/src/hooks/dashboard/useSyncTooltipFilterMode.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SyncTooltipFilterMode } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { useDashboardPreference } from './useDashboardPreference';
|
||||
|
||||
const DEFAULT_SYNC_TOOLTIP_FILTER_MODE = SyncTooltipFilterMode.Filtered;
|
||||
|
||||
export function useSyncTooltipFilterMode(
|
||||
dashboardId: string | undefined,
|
||||
): [SyncTooltipFilterMode, (value: SyncTooltipFilterMode) => void] {
|
||||
return useDashboardPreference(
|
||||
dashboardId,
|
||||
'syncTooltipFilterMode',
|
||||
DEFAULT_SYNC_TOOLTIP_FILTER_MODE,
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
isStackedBarChart: props.isStackedBarChart,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
syncFilterMode: props.syncFilterMode,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -26,6 +27,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
props.decimalPrecision,
|
||||
props.isStackedBarChart,
|
||||
props.syncedSeriesIndexes,
|
||||
props.syncFilterMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function HistogramTooltip(
|
||||
yAxisUnit: props.yAxisUnit ?? '',
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
syncFilterMode: props.syncFilterMode,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -26,6 +27,7 @@ export default function HistogramTooltip(
|
||||
props.yAxisUnit,
|
||||
props.decimalPrecision,
|
||||
props.syncedSeriesIndexes,
|
||||
props.syncFilterMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function TimeSeriesTooltip(
|
||||
yAxisUnit: props.yAxisUnit ?? '',
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
syncFilterMode: props.syncFilterMode,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -26,6 +27,7 @@ export default function TimeSeriesTooltip(
|
||||
props.yAxisUnit,
|
||||
props.decimalPrecision,
|
||||
props.syncedSeriesIndexes,
|
||||
props.syncFilterMode,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@ import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { TooltipProps } from '../types';
|
||||
import TooltipFooter from './components/TooltipFooter/TooltipFooter';
|
||||
import TooltipHeader from './components/TooltipHeader/TooltipHeader';
|
||||
import TooltipList from './components/TooltipList/TooltipList';
|
||||
|
||||
import Styles from './Tooltip.module.scss';
|
||||
|
||||
export default function Tooltip({
|
||||
id,
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
content,
|
||||
showTooltipHeader = true,
|
||||
isPinned,
|
||||
canPinTooltip,
|
||||
renderTooltipFooter,
|
||||
dismiss,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const tooltipContent = useMemo(() => content ?? [], [content]);
|
||||
@@ -31,7 +31,9 @@ export default function Tooltip({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, isPinned && Styles.pinned)}
|
||||
className={cx(Styles.container, {
|
||||
[Styles.pinned]: isPinned,
|
||||
})}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
{showHeader && (
|
||||
@@ -46,9 +48,9 @@ export default function Tooltip({
|
||||
|
||||
{showDivider && <span className={Styles.divider} />}
|
||||
|
||||
{showList && <TooltipList content={tooltipContent} />}
|
||||
{showList && <TooltipList id={id} content={tooltipContent} />}
|
||||
|
||||
{canPinTooltip && <TooltipFooter isPinned={isPinned} dismiss={dismiss} />}
|
||||
{renderTooltipFooter && renderTooltipFooter({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { render, RenderResult, screen } from 'tests/test-utils';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { TooltipContentItem } from '../../types';
|
||||
import { IRenderTooltipFooterArgs, TooltipContentItem } from '../../types';
|
||||
import Tooltip from '../Tooltip';
|
||||
|
||||
type MockVirtuosoProps = {
|
||||
@@ -83,6 +83,7 @@ function createUPlotInstance(cursorIdx: number | null): uPlot {
|
||||
|
||||
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
|
||||
const defaultProps: TooltipTestProps = {
|
||||
id: 'tooltip-1',
|
||||
uPlotInstance: createUPlotInstance(null),
|
||||
timezone: { value: 'UTC', name: 'UTC', offset: '0', searchIndex: '0' },
|
||||
content: [],
|
||||
@@ -192,63 +193,88 @@ describe('Tooltip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip footer hint', () => {
|
||||
describe('Tooltip renderTooltipFooter', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders footer with "Press P to pin the tooltip" hint when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
it('does not render footer content when renderTooltipFooter is not provided', () => {
|
||||
renderTooltip();
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toBeInTheDocument();
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('to pin the tooltip');
|
||||
expect(screen.queryByTestId('custom-tooltip-footer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer with "Press P or Esc to unpin" hint when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
it('renders content returned by renderTooltipFooter', () => {
|
||||
const renderTooltipFooter = jest.fn(
|
||||
(): JSX.Element => <div data-testid="custom-tooltip-footer">Footer</div>,
|
||||
);
|
||||
|
||||
const footer = screen.getByTestId('uplot-tooltip-footer');
|
||||
expect(footer).toHaveTextContent('Press');
|
||||
expect(footer).toHaveTextContent('P');
|
||||
expect(footer).toHaveTextContent('Esc');
|
||||
expect(footer).toHaveTextContent('to unpin');
|
||||
renderTooltip({ renderTooltipFooter });
|
||||
|
||||
expect(screen.getByTestId('custom-tooltip-footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Unpin button when not pinned', () => {
|
||||
renderTooltip({ isPinned: false, canPinTooltip: true });
|
||||
it('calls renderTooltipFooter with isPinned=false when tooltip is not pinned', () => {
|
||||
const renderTooltipFooter = jest.fn(() => null);
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
|
||||
renderTooltip({ renderTooltipFooter, isPinned: false });
|
||||
|
||||
expect(renderTooltipFooter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isPinned: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders Unpin button when pinned', () => {
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true });
|
||||
it('calls renderTooltipFooter with isPinned=true when tooltip is pinned', () => {
|
||||
const renderTooltipFooter = jest.fn(() => null);
|
||||
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
expect(unpinBtn).toBeInTheDocument();
|
||||
expect(unpinBtn).toHaveAttribute('aria-label', 'Unpin tooltip');
|
||||
renderTooltip({ renderTooltipFooter, isPinned: true });
|
||||
|
||||
expect(renderTooltipFooter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isPinned: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls dismiss when Unpin button is clicked', async () => {
|
||||
it('calls renderTooltipFooter with the dismiss callback', () => {
|
||||
const dismiss = jest.fn();
|
||||
renderTooltip({ isPinned: true, canPinTooltip: true, dismiss });
|
||||
const renderTooltipFooter = jest.fn(() => null);
|
||||
|
||||
renderTooltip({ renderTooltipFooter, dismiss });
|
||||
|
||||
expect(renderTooltipFooter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dismiss }),
|
||||
);
|
||||
});
|
||||
|
||||
it('footer content reflects pinned state via renderTooltipFooter args', () => {
|
||||
const renderTooltipFooter = jest.fn(
|
||||
({ isPinned }: IRenderTooltipFooterArgs): JSX.Element => (
|
||||
<div data-testid="footer-state">{isPinned ? 'Pinned' : 'Not pinned'}</div>
|
||||
),
|
||||
);
|
||||
|
||||
renderTooltip({ renderTooltipFooter, isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('footer-state')).toHaveTextContent('Pinned');
|
||||
});
|
||||
|
||||
it('dismiss is callable when invoked from renderTooltipFooter', async () => {
|
||||
const dismiss = jest.fn();
|
||||
const renderTooltipFooter = jest.fn(
|
||||
({ dismiss: onDismiss }: IRenderTooltipFooterArgs): JSX.Element => (
|
||||
<button data-testid="dismiss-btn" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
renderTooltip({ renderTooltipFooter, isPinned: true, dismiss });
|
||||
|
||||
const user = userEvent.setup();
|
||||
const unpinBtn = screen.getByTestId('uplot-tooltip-unpin');
|
||||
await user.click(unpinBtn);
|
||||
await user.click(screen.getByTestId('dismiss-btn'));
|
||||
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('footer has role="status" for screen reader announcements', () => {
|
||||
renderTooltip({ canPinTooltip: true });
|
||||
|
||||
const footer = screen.getByRole('status');
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltip header status pill', () => {
|
||||
|
||||
@@ -11,11 +11,10 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4) var(--spacing-4) 0 var(--spacing-4);
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.status {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
.container {
|
||||
padding-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.list {
|
||||
width: 100%;
|
||||
:global(div[data-viewport-type='element']) {
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 0px var(--spacing-2) 0 var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-2) var(--spacing-4) var(--spacing-4);
|
||||
|
||||
[data-test-id='virtuoso-item-list'] > * + * {
|
||||
margin-top: var(--spacing-2);
|
||||
|
||||
@@ -9,17 +9,18 @@ import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
|
||||
import Styles from './TooltipList.module.scss';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
// Fallback per-item height before Virtuoso reports the real total.
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
const LIST_MAX_HEIGHT = 300;
|
||||
|
||||
interface TooltipListProps {
|
||||
id: string;
|
||||
content: TooltipContentItem[];
|
||||
}
|
||||
|
||||
export default function TooltipList({
|
||||
id,
|
||||
content,
|
||||
}: TooltipListProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -41,23 +42,25 @@ export default function TooltipList({
|
||||
if (!isScrollEventTriggered.current) {
|
||||
// TODO: remove event in July 2026
|
||||
logEvent(Events.TOOLTIP_CONTENT_SCROLLED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id,
|
||||
});
|
||||
isScrollEventTriggered.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={content}
|
||||
onScroll={handleScroll}
|
||||
style={{ height }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={false} />
|
||||
)}
|
||||
/>
|
||||
<div className={Styles.container}>
|
||||
<Virtuoso
|
||||
className={cx(Styles.list, !isDarkMode && Styles.listLightMode)}
|
||||
data-testid="uplot-tooltip-list"
|
||||
data={content}
|
||||
onScroll={handleScroll}
|
||||
style={{ height }}
|
||||
totalListHeightChanged={setTotalListHeight}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<TooltipItem item={item} isItemActive={item.isHighlighted === true} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import uPlot, { AlignedData, Series } from 'uplot';
|
||||
|
||||
import { SyncTooltipFilterMode } from '../../plugins/TooltipPlugin/types';
|
||||
import { TooltipContentItem } from '../types';
|
||||
|
||||
export const FALLBACK_SERIES_COLOR = '#000000';
|
||||
@@ -63,6 +64,7 @@ export function buildTooltipContent({
|
||||
decimalPrecision,
|
||||
isStackedBarChart,
|
||||
syncedSeriesIndexes,
|
||||
syncFilterMode,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
series: Series[];
|
||||
@@ -73,10 +75,16 @@ export function buildTooltipContent({
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isStackedBarChart?: boolean;
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
}): TooltipContentItem[] {
|
||||
const items: TooltipContentItem[] = [];
|
||||
const allowedIndexes =
|
||||
const matchedIndexes =
|
||||
syncedSeriesIndexes != null ? new Set(syncedSeriesIndexes) : null;
|
||||
const filterMode = syncFilterMode ?? SyncTooltipFilterMode.Filtered;
|
||||
// In Filtered mode the matched indexes act as a whitelist; in All mode every
|
||||
// series renders and matched indexes only drive row highlighting.
|
||||
const allowedIndexes =
|
||||
filterMode === SyncTooltipFilterMode.All ? null : matchedIndexes;
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
|
||||
const seriesItem = series[seriesIndex];
|
||||
@@ -89,6 +97,7 @@ export function buildTooltipContent({
|
||||
|
||||
const dataIndex = dataIndexes[seriesIndex];
|
||||
const isSync = allowedIndexes != null;
|
||||
const isHighlighted = matchedIndexes?.has(seriesIndex) ?? false;
|
||||
|
||||
if (dataIndex === null) {
|
||||
if (isSync) {
|
||||
@@ -98,6 +107,7 @@ export function buildTooltipContent({
|
||||
tooltipValue: 'No Data',
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: false,
|
||||
isHighlighted,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -118,6 +128,7 @@ export function buildTooltipContent({
|
||||
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: seriesIndex === activeSeriesIndex,
|
||||
isHighlighted,
|
||||
});
|
||||
} else if (isSync) {
|
||||
items.push({
|
||||
@@ -126,6 +137,7 @@ export function buildTooltipContent({
|
||||
tooltipValue: 'No Data',
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: false,
|
||||
isHighlighted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
* Props for the Plot component
|
||||
@@ -58,17 +59,31 @@ export interface TooltipRenderArgs {
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
viaSync: boolean;
|
||||
/** In Tooltip sync mode, limits which series are rendered in the receiver tooltip.
|
||||
* null = no filtering; [] = no matches (tooltip hidden upstream); [...] = allowed indexes */
|
||||
/** In Tooltip sync mode, identifies receiver series that match the source's
|
||||
* focused series on the shared groupBy keys.
|
||||
* Filtered mode: limits which series are rendered (null = no filter,
|
||||
* [] = no matches/tooltip hidden upstream, [...] = allowed indexes).
|
||||
* All mode: same indexes are interpreted as a highlight set; non-matching
|
||||
* series still render. */
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
}
|
||||
|
||||
export interface IRenderTooltipFooterArgs {
|
||||
pinKey?: string;
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
id: string;
|
||||
showTooltipHeader?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
content?: TooltipContentItem[];
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => ReactNode;
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
@@ -106,4 +121,9 @@ export interface TooltipContentItem {
|
||||
tooltipValue: string;
|
||||
color: string;
|
||||
isActive: boolean;
|
||||
/** Synced receiver series whose metric matches the source's focused series
|
||||
* on the shared groupBy keys, in 'all' filter mode. List rendering uses this
|
||||
* to apply the active highlight to matching rows while non-matching rows
|
||||
* stay dimmed. */
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
DEFAULT_PIN_TOOLTIP_KEY,
|
||||
SyncTooltipFilterMode,
|
||||
TooltipControllerContext,
|
||||
TooltipControllerState,
|
||||
TooltipLayoutInfo,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
import { Events } from 'constants/events';
|
||||
|
||||
import Styles from './TooltipPlugin.module.scss';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
// Delay before hiding an unpinned tooltip when the cursor briefly leaves
|
||||
// the plot – this avoids flicker when moving between nearby points.
|
||||
@@ -199,10 +199,14 @@ export default function TooltipPlugin({
|
||||
if (!controller.hoverActive || !plot) {
|
||||
return null;
|
||||
}
|
||||
// In Tooltip sync mode, suppress the receiver tooltip entirely when
|
||||
// no receiver series match the source panel's focused series.
|
||||
const filterMode =
|
||||
syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
|
||||
// In Filtered Tooltip sync mode, suppress the receiver tooltip entirely
|
||||
// when no receiver series match the source panel's focused series. In
|
||||
// All mode the tooltip still renders with every series visible.
|
||||
if (
|
||||
syncTooltipWithDashboard &&
|
||||
filterMode === SyncTooltipFilterMode.Filtered &&
|
||||
controller.cursorDrivenBySync &&
|
||||
Array.isArray(controller.syncedSeriesIndexes) &&
|
||||
controller.syncedSeriesIndexes.length === 0
|
||||
@@ -217,6 +221,7 @@ export default function TooltipPlugin({
|
||||
dismiss: dismissTooltip,
|
||||
viaSync: controller.cursorDrivenBySync,
|
||||
syncedSeriesIndexes: controller.syncedSeriesIndexes,
|
||||
syncFilterMode: filterMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,7 +309,7 @@ export default function TooltipPlugin({
|
||||
if (event.key === 'Escape') {
|
||||
if (controller.pinned) {
|
||||
logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: config.getId(),
|
||||
});
|
||||
dismissTooltip();
|
||||
}
|
||||
@@ -318,7 +323,7 @@ export default function TooltipPlugin({
|
||||
// Toggle off: P pressed while already pinned.
|
||||
if (controller.pinned) {
|
||||
logEvent(Events.TOOLTIP_UNPINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: config.getId(),
|
||||
});
|
||||
dismissTooltip();
|
||||
return;
|
||||
@@ -352,7 +357,7 @@ export default function TooltipPlugin({
|
||||
controller.clickData = buildClickData(syntheticEvent, plot);
|
||||
controller.pinned = true;
|
||||
logEvent(Events.TOOLTIP_PINNED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
id: config.getId(),
|
||||
});
|
||||
scheduleRender(true);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,33 @@ import uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { syncCursorRegistry } from './syncCursorRegistry';
|
||||
import type { TooltipControllerState, TooltipSyncMetadata } from './types';
|
||||
import {
|
||||
SyncTooltipFilterMode,
|
||||
type TooltipControllerState,
|
||||
type TooltipSyncMetadata,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Returns the dimension keys present in both groupBy arrays.
|
||||
* Flattens per-query groupBys into a deduped set of dimension keys.
|
||||
* A panel's effective groupBy is the union across all of its queries.
|
||||
*/
|
||||
function collectGroupByKeys(
|
||||
groupByPerQuery: TooltipSyncMetadata['groupByPerQuery'],
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
if (!groupByPerQuery) {
|
||||
return keys;
|
||||
}
|
||||
for (const groupBy of Object.values(groupByPerQuery)) {
|
||||
for (const dim of groupBy) {
|
||||
keys.add(dim.key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dimension keys present in both panels' groupBys.
|
||||
* An empty result means no overlap — series highlighting should not run.
|
||||
*
|
||||
* exact [A, B] vs [A, B] → [A, B] one match
|
||||
@@ -14,24 +37,28 @@ import type { TooltipControllerState, TooltipSyncMetadata } from './types';
|
||||
* partial [A, B] vs [B, C] → [B]
|
||||
*/
|
||||
function getCommonGroupByKeys(
|
||||
a: TooltipSyncMetadata['groupBy'],
|
||||
b: TooltipSyncMetadata['groupBy'],
|
||||
a: TooltipSyncMetadata['groupByPerQuery'],
|
||||
b: TooltipSyncMetadata['groupByPerQuery'],
|
||||
): string[] {
|
||||
if (
|
||||
!Array.isArray(a) ||
|
||||
a.length === 0 ||
|
||||
!Array.isArray(b) ||
|
||||
b.length === 0
|
||||
) {
|
||||
const aKeys = collectGroupByKeys(a);
|
||||
const bKeys = collectGroupByKeys(b);
|
||||
if (aKeys.size === 0 || bKeys.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const bKeys = new Set(b.map((g) => g.key));
|
||||
return a.filter((g) => bKeys.has(g.key)).map((g) => g.key);
|
||||
const common: string[] = [];
|
||||
aKeys.forEach((key) => {
|
||||
if (bKeys.has(key)) {
|
||||
common.push(key);
|
||||
}
|
||||
});
|
||||
return common;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 1-based indexes of every series whose metric matches
|
||||
* sourceMetric on all commonKeys.
|
||||
* Returns the 1-based indexes of every visible series whose metric matches
|
||||
* sourceMetric on all commonKeys. Hidden series (toggled off in the legend)
|
||||
* are excluded so the synced tooltip is suppressed when no visible series
|
||||
* would match.
|
||||
*/
|
||||
function findMatchingSeriesIndexes(
|
||||
series: uPlot.Series[],
|
||||
@@ -39,7 +66,7 @@ function findMatchingSeriesIndexes(
|
||||
commonKeys: string[],
|
||||
): number[] {
|
||||
return series.reduce<number[]>((acc, s, i) => {
|
||||
if (i === 0) {
|
||||
if (i === 0 || s.show === false) {
|
||||
return acc;
|
||||
}
|
||||
const metric = (s as ExtendedSeries).metric;
|
||||
@@ -76,10 +103,15 @@ function applySourceSync({
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns:
|
||||
* null – no groupBy filtering configured or cursor off-chart (no-op for tooltip)
|
||||
* [] – groupBy configured but no receiver series match the source (hide synced tooltip)
|
||||
* number[] – 1-based indexes of matching receiver series (show only these)
|
||||
* Computes receiver-side series filtering / highlighting for Tooltip sync.
|
||||
*
|
||||
* Returns the indexes that the tooltip render path should treat per
|
||||
* `syncMetadata.filterMode`:
|
||||
* - Filtered (default): null = no filter, [] = no matches (suppress tooltip),
|
||||
* number[] = allowed indexes (show only these).
|
||||
* - All: null = no highlight (show all), number[] = highlight set (show all,
|
||||
* emphasize matching rows). Never returns [] in this mode so the synced
|
||||
* tooltip is not suppressed when matches are missing.
|
||||
*/
|
||||
function applyReceiverSync({
|
||||
uPlotInstance,
|
||||
@@ -99,8 +131,13 @@ function applyReceiverSync({
|
||||
yCrosshairEl.style.display =
|
||||
sourceMetadata?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
|
||||
|
||||
const filterMode = syncMetadata?.filterMode ?? SyncTooltipFilterMode.Filtered;
|
||||
const noMatchResult: number[] | null =
|
||||
filterMode === SyncTooltipFilterMode.All ? null : [];
|
||||
|
||||
if (commonKeys.length === 0) {
|
||||
return null;
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
}
|
||||
|
||||
if ((uPlotInstance.cursor.left ?? -1) < 0) {
|
||||
@@ -111,7 +148,7 @@ function applyReceiverSync({
|
||||
const sourceSeriesMetric = syncCursorRegistry.getActiveSeriesMetric(syncKey);
|
||||
if (sourceSeriesMetric == null) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
return noMatchResult;
|
||||
}
|
||||
|
||||
const matchingIdxs = findMatchingSeriesIndexes(
|
||||
@@ -122,7 +159,7 @@ function applyReceiverSync({
|
||||
|
||||
if (matchingIdxs.length === 0) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
return noMatchResult;
|
||||
}
|
||||
|
||||
uPlotInstance.setSeries(matchingIdxs[0], { focus: true });
|
||||
@@ -140,7 +177,7 @@ export function createSyncDisplayHook(
|
||||
|
||||
// groupBy on both panels is stable (set at config time). Recompute the
|
||||
// intersection only when the source panel's groupBy reference changes.
|
||||
let lastSourceGroupBy: TooltipSyncMetadata['groupBy'];
|
||||
let lastSourceGroupBy: TooltipSyncMetadata['groupByPerQuery'];
|
||||
let cachedCommonKeys: string[] = [];
|
||||
|
||||
return (u: uPlot): void => {
|
||||
@@ -165,11 +202,11 @@ export function createSyncDisplayHook(
|
||||
// inside applyReceiverSync.
|
||||
const sourceMetadata = syncCursorRegistry.getMetadata(syncKey);
|
||||
|
||||
if (sourceMetadata?.groupBy !== lastSourceGroupBy) {
|
||||
lastSourceGroupBy = sourceMetadata?.groupBy;
|
||||
if (sourceMetadata?.groupByPerQuery !== lastSourceGroupBy) {
|
||||
lastSourceGroupBy = sourceMetadata?.groupByPerQuery;
|
||||
cachedCommonKeys = getCommonGroupByKeys(
|
||||
sourceMetadata?.groupBy,
|
||||
syncMetadata?.groupBy,
|
||||
sourceMetadata?.groupByPerQuery,
|
||||
syncMetadata?.groupByPerQuery,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,18 @@ export const TOOLTIP_OFFSET = 10;
|
||||
export const DEFAULT_PIN_TOOLTIP_KEY = 'p';
|
||||
|
||||
export enum DashboardCursorSync {
|
||||
Crosshair,
|
||||
None,
|
||||
Tooltip,
|
||||
Crosshair = 'crosshair',
|
||||
None = 'none',
|
||||
Tooltip = 'tooltip',
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls whether a synced tooltip filters series by groupBy intersection
|
||||
* or shows every series with the matching ones highlighted.
|
||||
*/
|
||||
export enum SyncTooltipFilterMode {
|
||||
Filtered = 'filtered',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export interface TooltipViewState {
|
||||
@@ -40,7 +49,8 @@ export interface TooltipLayoutInfo {
|
||||
|
||||
export interface TooltipSyncMetadata {
|
||||
yAxisUnit?: string;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
filterMode?: SyncTooltipFilterMode;
|
||||
}
|
||||
|
||||
export interface TooltipPluginProps {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { syncCursorRegistry } from '../TooltipPlugin/syncCursorRegistry';
|
||||
import { createSyncDisplayHook } from '../TooltipPlugin/syncDisplayHook';
|
||||
import type {
|
||||
TooltipControllerState,
|
||||
TooltipSyncMetadata,
|
||||
} from '../TooltipPlugin/types';
|
||||
|
||||
const SYNC_KEY = 'test-sync';
|
||||
|
||||
function makeController(): TooltipControllerState {
|
||||
return {
|
||||
plot: null,
|
||||
hoverActive: false,
|
||||
isAnySeriesActive: false,
|
||||
pinned: false,
|
||||
clickData: null,
|
||||
style: {},
|
||||
horizontalOffset: 0,
|
||||
verticalOffset: 0,
|
||||
seriesIndexes: [],
|
||||
focusedSeriesIndex: null,
|
||||
syncedSeriesIndexes: null,
|
||||
cursorDrivenBySync: false,
|
||||
plotWithinViewport: true,
|
||||
windowWidth: 1024,
|
||||
windowHeight: 768,
|
||||
pendingPinnedUpdate: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakePlot(
|
||||
series: ExtendedSeries[],
|
||||
cursorEvent: Record<string, unknown> | null = null,
|
||||
): uPlot {
|
||||
const root = document.createElement('div');
|
||||
const yCrosshair = document.createElement('div');
|
||||
yCrosshair.className = 'u-cursor-y';
|
||||
root.appendChild(yCrosshair);
|
||||
|
||||
return {
|
||||
root,
|
||||
series,
|
||||
cursor: { event: cursorEvent, left: 50 },
|
||||
setSeries: jest.fn(),
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
const SERVICE_NAME_KEY: BaseAutocompleteData = {
|
||||
key: 'service.name',
|
||||
type: 'tag',
|
||||
};
|
||||
|
||||
const groupByService: TooltipSyncMetadata = {
|
||||
groupByPerQuery: { queryName: [SERVICE_NAME_KEY] },
|
||||
};
|
||||
|
||||
function seedSourcePanel(activeMetric: Record<string, string>): void {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, groupByService);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, activeMetric);
|
||||
}
|
||||
|
||||
function makeReceiverSeries(
|
||||
entries: { name: string; show?: boolean }[],
|
||||
): ExtendedSeries[] {
|
||||
return [
|
||||
{} as ExtendedSeries,
|
||||
...entries.map(
|
||||
(e) =>
|
||||
({
|
||||
show: e.show ?? true,
|
||||
metric: { 'service.name': e.name },
|
||||
}) as unknown as ExtendedSeries,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
describe('createSyncDisplayHook (receiver-side filtering)', () => {
|
||||
beforeEach(() => {
|
||||
syncCursorRegistry.setMetadata(SYNC_KEY, undefined);
|
||||
syncCursorRegistry.setActiveSeriesMetric(SYNC_KEY, null);
|
||||
});
|
||||
|
||||
it('returns indexes of visible matching series only', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('treats all matching series being hidden as no match → empty array', () => {
|
||||
seedSourcePanel({ 'service.name': 'frontendproxy' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: true },
|
||||
{ name: 'frontendproxy', show: false },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([]);
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(null, { focus: false });
|
||||
});
|
||||
|
||||
it('excludes hidden series and keeps the visible matches', () => {
|
||||
seedSourcePanel({ 'service.name': 'flagd' });
|
||||
|
||||
const series = makeReceiverSeries([
|
||||
{ name: 'flagd', show: false },
|
||||
{ name: 'frontend', show: true },
|
||||
{ name: 'flagd', show: true },
|
||||
]);
|
||||
const plot = makeFakePlot(series, null);
|
||||
const controller = makeController();
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toStrictEqual([3]);
|
||||
// Focuses the first visible match, not the hidden one at index 1.
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(3, { focus: true });
|
||||
});
|
||||
|
||||
it('returns null (no filtering) when the hook runs on the source panel', () => {
|
||||
const series = makeReceiverSeries([{ name: 'flagd', show: true }]);
|
||||
// cursor.event != null marks this invocation as the source panel.
|
||||
const plot = makeFakePlot(series, { type: 'mousemove' });
|
||||
const controller = makeController();
|
||||
controller.focusedSeriesIndex = 1;
|
||||
(series[1] as ExtendedSeries).metric = { 'service.name': 'flagd' };
|
||||
|
||||
createSyncDisplayHook(SYNC_KEY, groupByService, controller)(plot);
|
||||
|
||||
expect(controller.syncedSeriesIndexes).toBeNull();
|
||||
expect(syncCursorRegistry.getActiveSeriesMetric(SYNC_KEY)).toStrictEqual({
|
||||
'service.name': 'flagd',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,9 +34,9 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint creates an auth domain",
|
||||
Request: new(authtypes.PostableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(authtypes.GettableAuthDomain),
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
@@ -66,7 +66,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Update auth domain",
|
||||
Description: "This endpoint updates an auth domain",
|
||||
Request: new(authtypes.UpdateableAuthDomain),
|
||||
Request: new(authtypes.UpdatableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
body := new(authtypes.UpdateableAuthDomain)
|
||||
body := new(authtypes.UpdatableAuthDomain)
|
||||
if err := binding.JSON.BindBody(r.Body, body); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -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,98 @@ 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
|
||||
}
|
||||
|
||||
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
|
||||
// Start/End/Filter/GroupBy from PostablePods.
|
||||
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
|
||||
Start: req.Start,
|
||||
End: req.End,
|
||||
Filter: req.Filter,
|
||||
GroupBy: req.GroupBy,
|
||||
}, pageGroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isNodeNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, nodeNameAttrKey)
|
||||
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCounts, podPhaseCounts)
|
||||
resp.Warning = queryResp.Warning
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
308
pkg/modules/inframonitoring/implinframonitoring/nodes.go
Normal file
308
pkg/modules/inframonitoring/implinframonitoring/nodes.go
Normal file
@@ -0,0 +1,308 @@
|
||||
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,
|
||||
nodeConditionCounts map[string]nodeConditionCounts,
|
||||
podPhaseCounts map[string]podPhaseCounts,
|
||||
) []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 := nodeConditionCounts[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 pc, ok := podPhaseCounts[compositeKey]; ok {
|
||||
record.PendingPodCount = pc.Pending
|
||||
record.RunningPodCount = pc.Running
|
||||
record.SucceededPodCount = pc.Succeeded
|
||||
record.FailedPodCount = pc.Failed
|
||||
record.UnknownPodCount = pc.Unknown
|
||||
}
|
||||
|
||||
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,133 @@
|
||||
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 and pod.phase also.
|
||||
var nodesTableMetricNamesList = []string{
|
||||
"k8s.node.cpu.usage",
|
||||
"k8s.node.allocatable_cpu",
|
||||
"k8s.node.memory.working_set",
|
||||
"k8s.node.allocatable_memory",
|
||||
"k8s.node.condition_ready",
|
||||
"k8s.pod.phase",
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ var (
|
||||
|
||||
type GettableAuthDomain struct {
|
||||
StorableAuthDomain
|
||||
AuthDomainConfig
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ type PostableAuthDomain struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type UpdateableAuthDomain struct {
|
||||
type UpdatableAuthDomain struct {
|
||||
Config AuthDomainConfig `json:"config"`
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func NewAuthDomainFromStorableAuthDomain(storableAuthDomain *StorableAuthDomain)
|
||||
func NewGettableAuthDomainFromAuthDomain(authDomain *AuthDomain, authNProviderInfo *AuthNProviderInfo) *GettableAuthDomain {
|
||||
return &GettableAuthDomain{
|
||||
StorableAuthDomain: *authDomain.StorableAuthDomain(),
|
||||
AuthDomainConfig: *authDomain.AuthDomainConfig(),
|
||||
Config: *authDomain.AuthDomainConfig(),
|
||||
AuthNProviderInfo: authNProviderInfo,
|
||||
}
|
||||
}
|
||||
|
||||
108
pkg/types/inframonitoringtypes/nodes.go
Normal file
108
pkg/types/inframonitoringtypes/nodes.go
Normal file
@@ -0,0 +1,108 @@
|
||||
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"`
|
||||
PendingPodCount int `json:"pendingPodCount" required:"true"`
|
||||
RunningPodCount int `json:"runningPodCount" required:"true"`
|
||||
SucceededPodCount int `json:"succeededPodCount" required:"true"`
|
||||
FailedPodCount int `json:"failedPodCount" required:"true"`
|
||||
UnknownPodCount int `json:"unknownPodCount" 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ def test_create_and_get_domain(
|
||||
"domain-google.integration.test",
|
||||
"domain-saml.integration.test",
|
||||
]
|
||||
assert domain["ssoType"] in ["google_auth", "saml"]
|
||||
assert domain["config"]["ssoType"] in ["google_auth", "saml"]
|
||||
|
||||
|
||||
def test_create_invalid(
|
||||
|
||||
Reference in New Issue
Block a user