Compare commits

..

55 Commits

Author SHA1 Message Date
Naman Verma
93387b38d2 fix: add empty list tolerance to v1 variables 2026-07-09 09:46:24 +05:30
Srikanth Chekuri
d4384eba1b Merge branch 'main' into nv/dashboard-migration 2026-07-08 20:04:56 +05:30
Naman Verma
6151467b3e fix: add default aggregation count() if none is present in logs/traces 2026-07-07 11:18:49 +05:30
Naman Verma
daa999d1d6 Merge branch 'main' into nv/dashboard-migration 2026-07-07 10:10:53 +05:30
Naman Verma
b400846193 fix: ditch dynamic vars with no attr, rearrange overlapping layouts 2026-07-04 01:43:13 +05:30
Naman Verma
f5220d078f chore: add explanation comment for continuing on empty id widget 2026-07-04 00:16:12 +05:30
Naman Verma
ab750ffffd chore: better comments and names in layout migrator 2026-07-04 00:06:04 +05:30
Naman Verma
4a2b508636 chore: improve variable names 2026-07-03 23:52:07 +05:30
Naman Verma
33795999cf fix: match shape safe version to existing version 2026-07-03 23:38:59 +05:30
Naman Verma
9aac37eb94 fix: remove unused method 2026-07-03 22:48:31 +05:30
Naman Verma
12642e27da fix: validate dashboard spec after migration 2026-07-03 21:21:51 +05:30
Naman Verma
82f8bf88fc fix: add nil check on limit value when normalizing page size 2026-07-03 20:23:01 +05:30
Naman Verma
3befc9e8c6 fix: make none the default fill mode during migration 2026-07-03 20:22:36 +05:30
Naman Verma
ee5b85f7a4 revert: remove md file 2026-07-03 20:22:10 +05:30
Naman Verma
a05164f225 fix: fix go lint issue 2026-07-03 17:56:33 +05:30
Naman Verma
35e4013826 chore: reuse transition package (adds dependency, need to see what to do) 2026-07-03 17:40:50 +05:30
Naman Verma
5884c6aa90 Merge branch 'main' into nv/dashboard-migration 2026-07-03 16:38:22 +05:30
Naman Verma
533a430714 fix: add more malformation handling 2026-07-03 15:25:09 +05:30
Naman Verma
ba6af34714 fix: add more malformed query handling 2026-07-02 13:49:49 +05:30
Naman Verma
851c7b0ad7 chore: add temporary doc for other malformation handlers needed 2026-07-02 13:19:51 +05:30
Naman Verma
ef5a67495c fix: normalize telemetry field keys in query 2026-07-02 12:58:14 +05:30
Naman Verma
9f540ca84b fix: remove metric aggregation from non metric queries (malformed json) 2026-07-02 12:25:57 +05:30
Naman Verma
40a6b22aed fix: normalize having array from v4 schema 2026-07-02 11:38:37 +05:30
Naman Verma
6f16416f27 fix: add better error note down for failed conversions 2026-07-02 11:10:02 +05:30
Naman Verma
f8aa1c1c34 Merge branch 'main' into nv/dashboard-migration 2026-07-02 10:52:00 +05:30
Naman Verma
65835394c0 Merge branch 'main' into nv/dashboard-migration 2026-06-30 18:39:53 +05:30
Naman Verma
f132b7e53a fix: handle list of widget IDs in panel maps 2026-06-29 23:51:55 +05:30
Naman Verma
d4ae156dc4 fix: make threshold value non-nullable 2026-06-29 23:18:17 +05:30
Naman Verma
d6bdf9c2b2 fix: catch error from decodeTelemetryFields 2026-06-29 23:02:40 +05:30
Naman Verma
7ea654f1aa test: fix unit tests 2026-06-29 22:57:40 +05:30
Naman Verma
3fd7d013a1 fix: adjust to schema changes in variables 2026-06-29 22:56:36 +05:30
Naman Verma
fb921dd381 fix: read threshold value properly during migration 2026-06-29 22:56:12 +05:30
Naman Verma
58020d9e00 fix: note error in query parsing 2026-06-29 22:53:40 +05:30
Naman Verma
7a5933e822 fix: ignore react placeholders in layout 2026-06-29 22:52:48 +05:30
Naman Verma
2533683de6 fix: allow zero value for threshold values 2026-06-29 22:51:56 +05:30
Naman Verma
2670d53170 Merge branch 'main' into nv/dashboard-migration 2026-06-29 20:28:39 +05:30
Naman Verma
8943a9454b Merge branch 'main' into nv/dashboard-migration 2026-06-26 02:15:22 +05:30
Naman Verma
9a7ed5b711 feat: note down all errors in migration 2026-06-26 02:07:06 +05:30
Naman Verma
2d75e3d32d chore: separate error type for migration 2026-06-26 01:07:59 +05:30
Naman Verma
1d6eabf927 chore: remove spec md file 2026-06-25 22:37:52 +05:30
Naman Verma
082d7b1b77 test: fix ut to check for v2 internal name 2026-06-25 22:34:39 +05:30
Naman Verma
5019dee2d7 Merge branch 'main' into nv/dashboard-migration 2026-06-25 22:30:55 +05:30
Naman Verma
216de973fb fix: remove nil nil return 2026-06-25 03:07:58 +05:30
Naman Verma
18c0eec5e2 chore: catch typecast errors 2026-06-19 15:49:01 +05:30
Naman Verma
2ccdeb3631 chore: add a catch all panic check to log migration error 2026-06-19 15:15:17 +05:30
Naman Verma
ad12e50bbc fix: extract row and widget positions to build expanded sections 2026-06-19 15:00:32 +05:30
Naman Verma
e247bf3864 fix: sanitize tags instead of throwing error 2026-06-19 14:17:36 +05:30
Naman Verma
f4651ea134 fix: match with lower case signal for variables 2026-06-19 12:17:42 +05:30
Naman Verma
d449a2dbf2 fix: generate internal name from title 2026-06-19 11:24:40 +05:30
Naman Verma
d4b9f91062 Merge branch 'main' into nv/dashboard-migration 2026-06-18 12:28:22 +05:30
Naman Verma
530710b7bc Merge branch 'nv/dashboard-migration' of https://github.com/SigNoz/signoz into nv/dashboard-migration 2026-06-17 12:42:24 +05:30
Naman Verma
4fb5eec08d fix: move WrapInV5Envelope to types package 2026-06-17 12:42:20 +05:30
Naman Verma
f889d36f0f Merge branch 'main' into nv/dashboard-migration 2026-06-17 12:32:08 +05:30
Naman Verma
db12d44523 Merge branch 'main' into nv/dashboard-migration 2026-06-17 07:30:34 +05:30
Naman Verma
86fc0e81ba chore: add migration script from current to perses dashboard 2026-06-14 22:58:08 +05:30
315 changed files with 5363 additions and 33143 deletions

View File

@@ -129,7 +129,7 @@ sqlstore:
# The timeout for the sqlite database to wait for a lock.
busy_timeout: 10s
# The default transaction locking behavior. Supported values: deferred, immediate, exclusive.
transaction_mode: immediate
transaction_mode: deferred
##################### APIServer #####################
apiserver:

View File

@@ -2682,7 +2682,6 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -3649,7 +3648,6 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -3686,7 +3684,6 @@ components:
unit:
type: string
value:
format: double
type: number
required:
- value
@@ -4189,7 +4186,6 @@ components:
- namespaces
- clusters
- volumes
- kube_containers
type: string
InframonitoringtypesChecks:
properties:
@@ -4295,158 +4291,6 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesContainerCountsByReady:
properties:
notReady:
type: integer
ready:
type: integer
required:
- ready
- notReady
type: object
InframonitoringtypesContainerCountsByStatus:
properties:
completed:
type: integer
containerCannotRun:
type: integer
containerCreating:
type: integer
crashLoopBackOff:
type: integer
createContainerConfigError:
type: integer
errImagePull:
type: integer
error:
type: integer
imagePullBackOff:
type: integer
oomKilled:
type: integer
running:
type: integer
terminated:
type: integer
unknown:
type: integer
waiting:
type: integer
required:
- running
- waiting
- terminated
- crashLoopBackOff
- imagePullBackOff
- errImagePull
- createContainerConfigError
- containerCreating
- oomKilled
- completed
- error
- containerCannotRun
- unknown
type: object
InframonitoringtypesContainerReady:
enum:
- ready
- not_ready
- no_data
type: string
InframonitoringtypesContainerRecord:
properties:
containerCountsByReady:
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByReady'
containerCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesContainerCountsByStatus'
containerName:
type: string
cpu:
format: double
type: number
cpuLimitUtilization:
format: double
type: number
cpuRequestUtilization:
format: double
type: number
memory:
format: double
type: number
memoryLimitUtilization:
format: double
type: number
memoryRequestUtilization:
format: double
type: number
meta:
additionalProperties:
type: string
nullable: true
type: object
podUID:
type: string
ready:
$ref: '#/components/schemas/InframonitoringtypesContainerReady'
restarts:
format: int64
type: integer
status:
$ref: '#/components/schemas/InframonitoringtypesContainerStatus'
required:
- podUID
- containerName
- status
- containerCountsByStatus
- ready
- containerCountsByReady
- restarts
- cpu
- cpuRequestUtilization
- cpuLimitUtilization
- memory
- memoryRequestUtilization
- memoryLimitUtilization
- meta
type: object
InframonitoringtypesContainerStatus:
enum:
- running
- waiting
- terminated
- crashloopbackoff
- imagepullbackoff
- errimagepull
- createcontainerconfigerror
- containercreating
- oomkilled
- completed
- error
- containercannotrun
- unknown
- no_data
type: string
InframonitoringtypesContainers:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesContainerRecord'
type: array
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesDaemonSetRecord:
properties:
currentNodes:
@@ -5121,32 +4965,6 @@ components:
- end
- limit
type: object
InframonitoringtypesPostableContainers:
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
InframonitoringtypesPostableDaemonSets:
properties:
end:
@@ -16278,86 +16096,6 @@ paths:
summary: List Jobs for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/kube_containers:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes containers with key kubeletstats
metrics: CPU usage (cores), CPU request/limit utilization, memory working
set, and memory request/limit utilization. Each container also reports health
signals from the k8s_cluster receiver: status (kubectl-style display status
derived from k8s.container.status.state + k8s.container.status.reason), restarts
(absolute count from k8s.container.restarts), and ready (ready/not_ready from
k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name),
stable across container restarts. Each container includes metadata attributes
(k8s.container.name, k8s.pod.name, container.image.name, container.image.tag,
k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such
as deployment/statefulset/daemonset/job). The response type is ''list'' for
the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container
with its current status and ready state) or ''grouped_list'' for custom groupBy
keys (each row aggregates containers in the group with per-status counts under
containerCountsByStatus, per-readiness counts under containerCountsByReady,
and restarts as the group sum). Status requires the optional k8s.container.status.state
and k8s.container.status.reason metrics; when either is missing, status is
omitted and a warning is returned while restarts and ready are still computed.
Supports filtering via a filter expression, custom groupBy, ordering by any
of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit),
and pagination via offset/limit. Also reports whether the requested time range
falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization,
cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization)
and restarts return -1 as a sentinel when no data is available for that field.'
operationId: ListContainers
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableContainers'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesContainers'
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 Kubernetes Containers for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/namespaces:
post:
deprecated: false

View File

@@ -116,15 +116,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
infraMonitoringV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseInfraMonitoringV2, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseInfraMonitoringV2.String()),
Active: infraMonitoringV2,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -269,170 +269,3 @@ export function mockQueryRangeV5WithEventsResponse({
),
);
}
export type MockTracesOptions = {
offset?: number;
pageSize?: number;
hasMore?: boolean;
delay?: number;
customTraces?: Array<{
serviceName?: string;
name?: string;
durationNano?: number;
httpMethod?: string;
responseStatusCode?: string;
}>;
onReceiveRequest?: (
req: RestRequest,
) =>
| undefined
| void
| Omit<MockTracesOptions, 'onReceiveRequest'>
| Promise<Omit<MockTracesOptions, 'onReceiveRequest'>>
| Promise<void>;
};
const createTracesResponse = ({
offset = 0,
pageSize = 10,
hasMore = true,
customTraces,
}: MockTracesOptions): MetricRangePayloadV5 => {
const serviceNames = ['frontend', 'backend', 'database', 'api-gateway'];
const spanNames = [
'GET /api/users',
'POST /api/orders',
'SELECT * FROM users',
];
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE'];
const statusCodes = ['200', '201', '400', '404', '500'];
const rows = customTraces
? customTraces.map((trace, index) => {
const baseTimestamp = new Date('2026-04-21T17:54:33Z').getTime();
const currentTimestamp = new Date(baseTimestamp - index * 60000);
return {
timestamp: currentTimestamp.toISOString(),
data: {
serviceName:
trace.serviceName ?? serviceNames[index % serviceNames.length],
name: trace.name ?? spanNames[index % spanNames.length],
durationNano: trace.durationNano ?? 1000000 + index * 100000,
httpMethod: trace.httpMethod ?? httpMethods[index % httpMethods.length],
responseStatusCode:
trace.responseStatusCode ?? statusCodes[index % statusCodes.length],
traceID: `trace-id-${index}`,
spanID: `span-id-${index}`,
},
};
})
: Array.from(
{ length: hasMore ? pageSize : Math.ceil(pageSize / 2) },
(_, index) => {
const cumulativeIndex = offset + index;
const baseTimestamp = new Date('2026-04-21T17:54:33Z').getTime();
const currentTimestamp = new Date(baseTimestamp - cumulativeIndex * 60000);
return {
timestamp: currentTimestamp.toISOString(),
data: {
serviceName: serviceNames[cumulativeIndex % serviceNames.length],
name: spanNames[cumulativeIndex % spanNames.length],
durationNano: 1000000 + cumulativeIndex * 100000,
httpMethod: httpMethods[cumulativeIndex % httpMethods.length],
responseStatusCode: statusCodes[cumulativeIndex % statusCodes.length],
traceID: `trace-id-${cumulativeIndex}`,
spanID: `span-id-${cumulativeIndex}`,
},
};
},
);
return {
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
nextCursor: hasMore ? 'next-cursor-token' : '',
rows,
},
],
},
meta: {
bytesScanned: 9682976,
durationMs: 295,
rowsScanned: 34198,
stepIntervals: { A: 170 },
},
},
};
};
export function mockQueryRangeV5WithTracesResponse({
hasMore = true,
offset = 0,
pageSize = 10,
delay = 0,
customTraces,
onReceiveRequest,
}: MockTracesOptions = {}): void {
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) =>
res(
...(delay ? [ctx.delay(delay)] : []),
ctx.status(200),
ctx.json(
createTracesResponse(
(await onReceiveRequest?.(req)) ?? {
hasMore,
pageSize,
offset,
customTraces,
},
),
),
),
),
);
}
export function mockQueryRangeV5WithEmptyTraces(): void {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
type: 'raw',
data: {
results: [{ queryName: 'A', nextCursor: '', rows: [] }],
},
meta: {
bytesScanned: 0,
durationMs: 10,
rowsScanned: 0,
stepIntervals: {},
},
},
}),
),
),
);
}
export function mockQueryRangeV5WithKeyNotFoundError(): void {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(
ctx.status(400),
ctx.json({
error: {
code: 'invalid_input',
errors: [{ message: 'key not found' }],
},
}),
),
),
);
}

View File

@@ -21,7 +21,6 @@ import type {
GetChecks200,
GetChecksParams,
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableContainersDTO,
InframonitoringtypesPostableDaemonSetsDTO,
InframonitoringtypesPostableDeploymentsDTO,
InframonitoringtypesPostableHostsDTO,
@@ -32,7 +31,6 @@ import type {
InframonitoringtypesPostableStatefulSetsDTO,
InframonitoringtypesPostableVolumesDTO,
ListClusters200,
ListContainers200,
ListDaemonSets200,
ListDeployments200,
ListHosts200,
@@ -550,89 +548,6 @@ export const useListJobs = <
> => {
return useMutation(getListJobsMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes containers with key kubeletstats metrics: CPU usage (cores), CPU request/limit utilization, memory working set, and memory request/limit utilization. Each container also reports health signals from the k8s_cluster receiver: status (kubectl-style display status derived from k8s.container.status.state + k8s.container.status.reason), restarts (absolute count from k8s.container.restarts), and ready (ready/not_ready from k8s.container.ready). The row identity is (k8s.pod.uid, k8s.container.name), stable across container restarts. Each container includes metadata attributes (k8s.container.name, k8s.pod.name, container.image.name, container.image.tag, k8s.namespace.name, k8s.node.name, k8s.cluster.name, and workload owner such as deployment/statefulset/daemonset/job). The response type is 'list' for the default (k8s.pod.uid, k8s.container.name) grouping (each row is one container with its current status and ready state) or 'grouped_list' for custom groupBy keys (each row aggregates containers in the group with per-status counts under containerCountsByStatus, per-readiness counts under containerCountsByReady, and restarts as the group sum). Status requires the optional k8s.container.status.state and k8s.container.status.reason metrics; when either is missing, status is omitted and a warning is returned while restarts and ready are still computed. Supports filtering via a filter expression, custom groupBy, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, cpuRequestUtilization, cpuLimitUtilization, memory, memoryRequestUtilization, memoryLimitUtilization) and restarts return -1 as a sentinel when no data is available for that field.
* @summary List Kubernetes Containers for Infra Monitoring
*/
export const listContainers = (
inframonitoringtypesPostableContainersDTO?: BodyType<InframonitoringtypesPostableContainersDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListContainers200>({
url: `/api/v2/infra_monitoring/kube_containers`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableContainersDTO,
signal,
});
};
export const getListContainersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
> => {
const mutationKey = ['listContainers'];
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 listContainers>>,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> }
> = (props) => {
const { data } = props ?? {};
return listContainers(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListContainersMutationResult = NonNullable<
Awaited<ReturnType<typeof listContainers>>
>;
export type ListContainersMutationBody =
| BodyType<InframonitoringtypesPostableContainersDTO>
| undefined;
export type ListContainersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Kubernetes Containers for Infra Monitoring
*/
export const useListContainers = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listContainers>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableContainersDTO> },
TContext
> => {
return useMutation(getListContainersMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes namespaces with key aggregated pod metrics: CPU usage and memory working set (summed across pods in the group), plus per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value in the window). Each namespace includes metadata attributes (k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.namespace.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / memory, and pagination via offset/limit. Also reports whether the requested time range falls before the data retention boundary. Numeric metric fields (namespaceCPU, namespaceMemory) return -1 as a sentinel when no data is available for that field.
* @summary List Namespaces for Infra Monitoring

View File

@@ -3395,7 +3395,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -3923,7 +3922,6 @@ export interface DashboardtypesComparisonThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -4212,7 +4210,6 @@ export interface DashboardtypesTableThresholdDTO {
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
@@ -5577,7 +5574,6 @@ export enum InframonitoringtypesCheckTypeDTO {
namespaces = 'namespaces',
clusters = 'clusters',
volumes = 'volumes',
kube_containers = 'kube_containers',
}
export interface InframonitoringtypesMissingMetricsComponentEntryDTO {
associatedComponent: InframonitoringtypesAssociatedComponentDTO;
@@ -5857,174 +5853,6 @@ export interface InframonitoringtypesClustersDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesContainerCountsByReadyDTO {
/**
* @type integer
*/
notReady: number;
/**
* @type integer
*/
ready: number;
}
export interface InframonitoringtypesContainerCountsByStatusDTO {
/**
* @type integer
*/
completed: number;
/**
* @type integer
*/
containerCannotRun: number;
/**
* @type integer
*/
containerCreating: number;
/**
* @type integer
*/
crashLoopBackOff: number;
/**
* @type integer
*/
createContainerConfigError: number;
/**
* @type integer
*/
errImagePull: number;
/**
* @type integer
*/
error: number;
/**
* @type integer
*/
imagePullBackOff: number;
/**
* @type integer
*/
oomKilled: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
terminated: number;
/**
* @type integer
*/
unknown: number;
/**
* @type integer
*/
waiting: number;
}
export enum InframonitoringtypesContainerReadyDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesContainerRecordDTOMeta =
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesContainerStatusDTO {
running = 'running',
waiting = 'waiting',
terminated = 'terminated',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
unknown = 'unknown',
no_data = 'no_data',
}
export interface InframonitoringtypesContainerRecordDTO {
containerCountsByReady: InframonitoringtypesContainerCountsByReadyDTO;
containerCountsByStatus: InframonitoringtypesContainerCountsByStatusDTO;
/**
* @type string
*/
containerName: string;
/**
* @type number
* @format double
*/
cpu: number;
/**
* @type number
* @format double
*/
cpuLimitUtilization: number;
/**
* @type number
* @format double
*/
cpuRequestUtilization: number;
/**
* @type number
* @format double
*/
memory: number;
/**
* @type number
* @format double
*/
memoryLimitUtilization: number;
/**
* @type number
* @format double
*/
memoryRequestUtilization: number;
/**
* @type object,null
*/
meta: InframonitoringtypesContainerRecordDTOMeta;
/**
* @type string
*/
podUID: string;
ready: InframonitoringtypesContainerReadyDTO;
/**
* @type integer
* @format int64
*/
restarts: number;
status: InframonitoringtypesContainerStatusDTO;
}
export interface InframonitoringtypesContainersDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
*/
records: InframonitoringtypesContainerRecordDTO[];
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6607,33 +6435,6 @@ export interface InframonitoringtypesPostableClustersDTO {
start: number;
}
export interface InframonitoringtypesPostableContainersDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostableDaemonSetsDTO {
/**
* @type integer
@@ -11237,14 +11038,6 @@ export type ListJobs200 = {
status: string;
};
export type ListContainers200 = {
data: InframonitoringtypesContainersDTO;
/**
* @type string
*/
status: string;
};
export type ListNamespaces200 = {
data: InframonitoringtypesNamespacesDTO;
/**

View File

@@ -12,8 +12,6 @@ type BadgeColor =
interface HttpStatusBadgeProps {
statusCode: string | number;
testId?: string;
className?: string;
}
function getStatusCodeColor(statusCode: number): BadgeColor {
@@ -37,8 +35,6 @@ function getStatusCodeColor(statusCode: number): BadgeColor {
function HttpStatusBadge({
statusCode,
testId,
className,
}: HttpStatusBadgeProps): JSX.Element | null {
const numericStatusCode = Number(statusCode);
@@ -49,12 +45,7 @@ function HttpStatusBadge({
const color = getStatusCodeColor(numericStatusCode);
return (
<Badge
color={color}
variant="outline"
data-testid={testId}
className={className}
>
<Badge color={color} variant="outline">
{statusCode}
</Badge>
);

View File

@@ -65,9 +65,7 @@ function TagKeyValueInput({
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
// Plain Enter adds the tag; let Cmd/Ctrl+Enter pass through so a host form
// (e.g. a modal) can submit on it.
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
@@ -95,17 +93,11 @@ function TagKeyValueInput({
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
// Plain Enter commits the edit; let Cmd/Ctrl+Enter pass through so a host
// form (e.g. a modal) can submit on it.
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
commitEdit();
} else if (e.key === 'Escape') {
// Contain Escape so it cancels the inline edit instead of bubbling up and
// closing the host drawer/modal.
e.preventDefault();
e.stopPropagation();
cancelEdit();
}
};

View File

@@ -11,7 +11,6 @@ export enum FeatureKeys {
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
USE_DASHBOARD_V2 = 'use_dashboard_v2',
USE_INFRA_MONITORING_V2 = 'use_infra_monitoring_v2',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
}

View File

@@ -1,4 +0,0 @@
.wrapper :global(button[data-color]) {
--checkbox-checked-background: var(--series-color);
--checkbox-border-color: var(--series-color);
}

View File

@@ -3,7 +3,6 @@ import { Checkbox } from '@signozhq/ui/checkbox';
import { CSSProperties } from 'react';
import { CheckBoxProps } from '../types';
import styles from './CustomCheckBox.module.scss';
function CustomCheckBox({
data,
@@ -16,11 +15,12 @@ function CustomCheckBox({
const isChecked = graphVisibilityState[index] || false;
const colorStyle = {
'--series-color': color,
'--checkbox-checked-background': color,
'--checkbox-border-color': color,
} as CSSProperties;
return (
<span className={styles.wrapper} style={colorStyle}>
<span style={colorStyle}>
<Checkbox
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
value={isChecked}

View File

@@ -1,20 +1,15 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import {
DashboardtypesListOrderDTO,
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { Dashboard } from 'types/api/dashboard/getAll';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
@@ -22,13 +17,6 @@ import dialsUrl from '@/assets/Icons/dials.svg';
import { getItemIcon } from '../constants';
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
interface RecentDashboard {
id: string;
title: string;
tags: string[];
}
export default function Dashboards({
onUpdateChecklistDoneItem,
loadingUserPreferences,
@@ -38,58 +26,33 @@ export default function Dashboards({
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { user } = useAppContext();
const isDashboardV2 = useIsDashboardV2();
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
// selects; the inactive one stays disabled so it never fires.
const [sortedDashboards, setSortedDashboards] = useState<Dashboard[]>([]);
// Fetch Dashboards
const {
data: v1List,
isLoading: v1Loading,
isError: v1Error,
} = useGetAllDashboard({ enabled: !isDashboardV2 });
const {
data: v2List,
isLoading: v2Loading,
isError: v2Error,
} = useListDashboardsForUserV2(
{
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
limit: 5,
offset: 0,
},
{ query: { enabled: isDashboardV2 } },
);
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
if (isDashboardV2) {
return (v2List?.data?.dashboards ?? []).map((d) => ({
id: d.id,
title: d.spec?.display?.name ?? d.name,
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
}));
}
return [...(v1List?.data ?? [])]
.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.slice(0, 5)
.map((d) => ({
id: d.id,
title: d.data.title,
tags: d.data.tags ?? [],
}));
}, [isDashboardV2, v1List, v2List]);
data: dashboardsList,
isLoading: isDashboardListLoading,
isError: isDashboardListError,
} = useGetAllDashboard();
useEffect(() => {
if (!dashboardsList) {
return;
}
const sortedDashboards = dashboardsList.data.sort((a, b) => {
const aUpdateAt = new Date(a.updatedAt).getTime();
const bUpdateAt = new Date(b.updatedAt).getTime();
return bUpdateAt - aUpdateAt;
});
if (sortedDashboards.length > 0 && !loadingUserPreferences) {
onUpdateChecklistDoneItem('SETUP_DASHBOARDS');
}
}, [sortedDashboards, onUpdateChecklistDoneItem, loadingUserPreferences]);
setSortedDashboards(sortedDashboards.slice(0, 5));
}, [dashboardsList, onUpdateChecklistDoneItem, loadingUserPreferences]);
const emptyStateCard = (): JSX.Element => (
<div className="empty-state-container">
@@ -150,7 +113,7 @@ export default function Dashboards({
event.stopPropagation();
logEvent('Homepage: Dashboard clicked', {
dashboardId: dashboard.id,
dashboardName: dashboard.title,
dashboardName: dashboard.data.title,
});
if (event.metaKey || event.ctrlKey) {
openInNewTab(getLink());
@@ -180,12 +143,12 @@ export default function Dashboards({
/>
<div className="alert-rule-item-name home-data-item-name">
{dashboard.title}
{dashboard.data.title}
</div>
</div>
<div className="alert-rule-item-description home-data-item-tag">
{dashboard.tags.map((tag) => (
{dashboard.data.tags?.map((tag) => (
<Badge color="sienna" variant="outline" key={tag}>
{tag}
</Badge>

View File

@@ -216,6 +216,8 @@ function Hosts(): JSX.Element {
queryKeyPrefix="hosts"
tabsConfig={{
showEvents: false,
showContainers: true,
showProcesses: true,
}}
/>
</>

View File

@@ -1,225 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import K8sBaseDetails from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringPageListing,
} from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { ResizableBox } from 'periscope/components/ResizableBox';
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
import { useAppContext } from 'providers/App/App';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import {
fetchHostEntityData,
fetchHostListData,
getHostMetricsQueryPayload,
hostDetailsMetadataConfig,
hostGetEntityName,
hostGetSelectedItemFilters,
hostInitialEventsFilter,
hostInitialLogTracesFilter,
hostWidgetInfo,
} from './constants';
import {
getHostItemKey,
getHostRowKey,
hostColumnsConfig,
} from './table.config';
import { getHostsQuickFiltersConfig } from './utils';
import styles from './InfraMonitoringHosts.module.scss';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
const QUICK_FILTERS_MIN_WIDTH = 240;
const QUICK_FILTERS_MAX_WIDTH = 500;
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const {
initialWidth: quickFiltersInitialWidth,
persistWidth: persistQuickFiltersWidth,
} = usePanelWidth({
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
minWidth: QUICK_FILTERS_MIN_WIDTH,
maxWidth: QUICK_FILTERS_MAX_WIDTH,
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
// Track previous urlFilters to only sync when the value actually changes
// (not when handleChangeQueryData changes due to query updates)
const prevUrlFiltersRef = useRef<string | null>(null);
useEffect(() => {
const currentFiltersJson = urlFilters ? JSON.stringify(urlFilters) : null;
// Only sync if urlFilters value has actually changed
if (prevUrlFiltersRef.current !== currentFiltersJson) {
prevUrlFiltersRef.current = currentFiltersJson;
// Sync filters to query builder, using empty filter when urlFilters is null
handleChangeQueryData('filters', urlFilters || { items: [], op: 'and' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlFilters]); // handleChangeQueryData intentionally omitted - we call the current version but don't re-run when it changes
const handleFilterVisibilityChange = (): void => {
setShowFilters(!showFilters);
};
const handleQuickFiltersChange = (query: Query): void => {
const filters = query.builder.queryData[0].filters;
// Nuqs batches these calls into a single URL update
// The useEffect will sync filters to query builder
setUrlFilters(filters || null);
setCurrentPage(1);
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.HostEntity,
page: InfraMonitoringEvents.ListPage,
});
};
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
return fetchHostListData(filters, signal);
},
[],
);
const fetchEntityData = useCallback(
async (
filters: Parameters<typeof fetchHostEntityData>[0],
signal?: AbortSignal,
) => fetchHostEntityData(filters, signal),
[],
);
const getSelectedItemFilters = useCallback(
(selectedItem: string) =>
hostGetSelectedItemFilters(selectedItem, dotMetricsEnabled),
[dotMetricsEnabled],
);
const getInitialLogTracesFilters = useCallback(
(host: import('api/infraMonitoring/getHostLists').HostData) =>
hostInitialLogTracesFilter(host, dotMetricsEnabled),
[dotMetricsEnabled],
);
const controlListPrefix = !showFilters ? (
<div className={styles.quickFiltersToggleContainer}>
<Button
className="periscope-btn ghost"
type="text"
size="small"
onClick={handleFilterVisibilityChange}
>
<Filter size={14} />
</Button>
</div>
) : undefined;
return (
<>
<div className={styles.infraMonitoringContainer}>
<div className={styles.infraContentRow}>
{showFilters && (
<ResizableBox
handle="right"
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
initialWidth={quickFiltersInitialWidth}
minWidth={QUICK_FILTERS_MIN_WIDTH}
maxWidth={QUICK_FILTERS_MAX_WIDTH}
onResize={persistQuickFiltersWidth}
resetToDefaultOnDoubleClick
withHandle
className={styles.quickFiltersContainer}
handleTestId="quick-filters-resize-handle"
>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ rotate: '270deg', cursor: 'pointer' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleQuickFiltersChange}
/>
</ResizableBox>
)}
<div
className={`${styles.listContainer}${
showFilters ? ` ${styles.listContainerFiltersVisible}` : ''
}`}
>
<K8sBaseList
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.HOSTS}
tableColumns={hostColumnsConfig}
fetchListData={fetchListData}
getRowKey={getHostRowKey}
getItemKey={getHostItemKey}
eventCategory={InfraMonitoringEvents.HostEntity}
/>
</div>
</div>
</div>
<K8sBaseDetails
category={InfraMonitoringEntity.HOSTS}
eventCategory={InfraMonitoringEvents.HostEntity}
getSelectedItemFilters={getSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={hostGetEntityName}
getInitialLogTracesFilters={getInitialLogTracesFilters}
getInitialEventsFilters={hostInitialEventsFilter}
metadataConfig={hostDetailsMetadataConfig}
entityWidgetInfo={hostWidgetInfo}
getEntityQueryPayload={getHostMetricsQueryPayload}
queryKeyPrefix="hosts"
tabsConfig={{
showEvents: false,
}}
/>
</>
);
}
export default Hosts;

View File

@@ -1,171 +0,0 @@
.infraMonitoringContainer {
display: flex;
height: 100%;
flex-direction: column;
}
.infraContentRow {
display: flex;
flex-direction: row;
height: calc(100vh - 45px);
width: 100%;
position: relative;
overflow-y: auto;
:global(.periscope-btn) {
&.ghost:not(:disabled) {
border: none;
background: transparent;
&:hover {
background: transparent;
color: var(--bg-robin-500) !important;
font-weight: 500;
}
}
}
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
}
.quickFiltersContainer {
// Width is owned by ResizableBox (inline style). No overflow here: the panel
// must not scroll (QuickFilters scrolls its own list internally) — an
// overflow on the root would also clip the edge-mounted resize grip.
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
:global(.resizable-box__content) {
display: flex;
flex-direction: column;
}
:global(.quick-filters-container) {
flex: 1;
min-height: 0;
}
:global(.ant-collapse-header) {
border-bottom: 1px solid var(--l1-border);
padding: 12px 8px;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
padding-block: 0 !important;
:global(.quick-filters .checkbox-filter) {
padding-left: 18px;
}
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 0.1rem;
height: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
}
}
.quickFiltersContainerHeader {
padding: 8px;
border-bottom: 1px solid var(--l1-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.listContainer {
flex: 1;
min-width: 0;
max-width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
> * {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
> :global(.ant-table-wrapper) {
width: 100%;
max-width: 100%;
:global(.ant-spin-container) {
display: flex !important;
flex-direction: column;
}
:global(.ant-table),
:global(.ant-table-container) {
max-width: 100%;
}
}
}
.listContainerFiltersVisible {
// Width is driven by flex now that the filters panel is resizable; the list
// fills whatever space the (variable-width) panel leaves.
max-width: 100%;
}
.quickFiltersToggleContainer {
padding: 0 8px;
}
.infraMonitoringTags {
width: fit-content;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
border-radius: 50px;
padding: 2px 8px;
}
.tagsActive {
color: var(--bg-forest-500, #25e192);
border: 1px solid rgba(37, 225, 146, 0.2);
background: rgba(37, 225, 146, 0.1);
}
.tagsInactive {
color: var(--bg-slate-50, #62687c);
border: 1px solid rgba(98, 104, 124, 0.2);
background: rgba(98, 104, 124, 0.1);
}

View File

@@ -1,191 +0,0 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Badge } from '@signozhq/ui/badge';
import { Progress } from '@signozhq/ui/progress';
import { Typography } from '@signozhq/ui/typography';
import {
getHostLists,
HostData,
HostListPayload,
} from 'api/infraMonitoring/getHostLists';
import {
createFilterItem,
K8sDetailsFilters,
K8sDetailsMetadataConfig,
} from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
import {
getHostQueryPayload,
hostWidgetInfo,
} from 'container/LogDetailedView/InfraMetrics/constants';
import {
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { getHostListsQuery } from './utils';
import infraHostsStyles from './InfraMonitoringHosts.module.scss';
export function getProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_SAKURA_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export function getMemoryProgressColor(percent: number): string {
if (percent >= 90) {
return Color.BG_CHERRY_500;
}
if (percent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
}
export const hostDetailsMetadataConfig: K8sDetailsMetadataConfig<HostData>[] = [
{
label: 'STATUS',
getValue: (h): string => (h.active ? 'ACTIVE' : 'INACTIVE'),
render: (value, h): React.ReactNode => (
<Badge
variant="outline"
className={`${infraHostsStyles.infraMonitoringTags} ${
h.active ? infraHostsStyles.tagsActive : infraHostsStyles.tagsInactive
}`}
>
{value}
</Badge>
),
},
{
label: 'OPERATING SYSTEM',
getValue: (h): string => h.os || '-',
render: (value): React.ReactNode =>
value !== '-' ? (
<Badge variant="outline" className={infraHostsStyles.infraMonitoringTags}>
{value}
</Badge>
) : (
<Typography.Text>-</Typography.Text>
),
},
{
label: 'CPU USAGE',
getValue: (h): number => h.cpu * 100,
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getProgressColor(Number(value))}
showInfo
/>
),
},
{
label: 'MEMORY USAGE',
getValue: (h): number => h.memory * 100,
render: (value): React.ReactNode => (
<Progress
percent={Number(Number(value).toFixed(1))}
strokeColor={getMemoryProgressColor(Number(value))}
showInfo
/>
),
},
];
export function getHostMetricsQueryPayload(
host: HostData,
start: number,
end: number,
dotMetricsEnabled: boolean,
): ReturnType<typeof getHostQueryPayload> {
return getHostQueryPayload(host.hostName, start, end, dotMetricsEnabled);
}
export { hostWidgetInfo };
export function hostGetSelectedItemFilters(
selectedItem: string,
dotMetricsEnabled: boolean,
): TagFilter {
const hostKey = dotMetricsEnabled ? 'host.name' : 'host_name';
return {
op: 'AND',
items: [createFilterItem(hostKey, selectedItem)],
};
}
export function hostInitialLogTracesFilter(
host: HostData,
dotMetricsEnabled: boolean,
): TagFilterItem[] {
const hostKey = dotMetricsEnabled ? 'host.name' : 'host_name';
return [createFilterItem(hostKey, host.hostName || '')];
}
export function hostInitialEventsFilter(_host: HostData): TagFilterItem[] {
return [];
}
export const hostGetEntityName = (host: HostData): string => host.hostName;
export async function fetchHostListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): Promise<{
data: HostData[];
total: number;
error?: string | null;
rawData?: unknown;
}> {
const baseQuery = getHostListsQuery();
const payload: HostListPayload = {
...baseQuery,
limit: filters.limit,
offset: filters.offset,
filters: filters.filters ?? { items: [], op: 'and' },
orderBy: filters.orderBy,
start: filters.start,
end: filters.end,
groupBy: filters.groupBy ?? [],
};
const response = await getHostLists(payload, signal);
return {
data: response.payload?.data?.records || [],
total: response.payload?.data?.total || 0,
error: response.error,
rawData: response.payload?.data,
};
}
export async function fetchHostEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: HostData | null; error?: string | null }> {
const response = await getHostLists(
{
...getHostListsQuery(),
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
groupBy: [],
},
signal,
);
const records = response.payload?.data?.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
}

View File

@@ -1,69 +0,0 @@
import { useEffect } from 'react';
import * as Sentry from '@sentry/react';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { DataSource } from 'types/common/queryBuilder';
import Hosts from './Hosts';
function InfraMonitoringHosts(): JSX.Element {
const {
updateAllQueriesOperators,
handleSetConfig,
setSupersetQuery,
setLastUsedQuery,
currentQuery,
resetQuery,
} = useQueryBuilder();
useEffect(() => {
const newQuery = updateAllQueriesOperators(
initialQueriesMap.metrics,
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
);
setSupersetQuery(newQuery);
setLastUsedQuery(0);
handleSetConfig(PANEL_TYPES.TIME_SERIES, DataSource.METRICS);
return (): void => {
setLastUsedQuery(0);
};
}, [
updateAllQueriesOperators,
setSupersetQuery,
setLastUsedQuery,
handleSetConfig,
]);
useEffect(() => {
const updatedCurrentQuery = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: {
items: [],
op: 'AND',
},
},
],
},
};
resetQuery(updatedCurrentQuery);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<Hosts />
</Sentry.ErrorBoundary>
);
}
export default InfraMonitoringHosts;

View File

@@ -1,201 +0,0 @@
import React from 'react';
import { Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { HostData } from 'api/infraMonitoring/getHostLists';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8sV2/Base/utils';
import {
EntityProgressBar,
ExpandButtonWrapper,
ValidateColumnValueWrapper,
} from 'container/InfraMonitoringK8sV2/components';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { useInfraMonitoringGroupBy } from 'container/InfraMonitoringK8sV2/hooks';
import EntityGroupHeader from 'container/InfraMonitoringK8sV2/Base/EntityGroupHeader';
import { HostnameCell } from './utils';
import styles from './table.module.scss';
import { Container, Info } from '@signozhq/icons';
function hostRowSource(host: HostData): { meta: Record<string, string> } {
return {
meta: {
...(host.meta ?? {}),
host_name: host.hostName ?? '',
'host.name': host.hostName ?? '',
os_type: host.os ?? '',
'os.type': host.os ?? '',
},
};
}
export function getHostRowKey(host: HostData): string {
return host.hostName || 'unknown';
}
export function getHostItemKey(host: HostData): string {
return host.hostName ?? '';
}
function HostGroupCell({ row }: { row: HostData }): JSX.Element {
const [groupBy] = useInfraMonitoringGroupBy();
const synthetic = hostRowSource(row);
return getGroupByEl(synthetic, groupBy) as JSX.Element;
}
export const hostColumnsConfig: TableColumnDef<HostData>[] = [
{
id: 'hostGroup',
header: (): React.ReactNode => <EntityGroupHeader title="HOST GROUP" />,
accessorFn: (row): string => row.hostName ?? '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row, isExpanded, toggleExpanded }): React.ReactNode => (
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
<HostGroupCell row={row} />
</ExpandButtonWrapper>
),
},
{
id: 'hostName',
header: (): React.ReactNode => (
<EntityGroupHeader title="Hostname" icon={<Container size={14} />} />
),
accessorFn: (row): string => row.hostName ?? '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<HostnameCell hostName={value as string} />
),
},
{
id: 'active',
header: (): React.ReactNode => (
<div className={styles.statusHeader}>
Status
<Tooltip title="Sent system metrics in last 10 mins">
<Info size="md" />
</Tooltip>
</div>
),
accessorFn: (row): boolean => row.active,
width: { min: 150, default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const active = value as boolean;
return (
<Badge
className={`${styles.statusTag} ${
active ? styles.statusTagActive : styles.statusTagInactive
}`}
>
{active ? 'ACTIVE' : 'INACTIVE'}
</Badge>
);
},
},
{
id: 'cpu',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>CPU Usage</div>
),
accessorFn: (row): number => row.cpu,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.HOSTS}
attribute="CPU metric"
>
<EntityProgressBar value={cpu} type="cpu" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'memory',
header: (): React.ReactNode => (
<div className={`${styles.columnHeaderRight} ${styles.memoryUsageHeader}`}>
Memory Usage
<Tooltip title="Excluding cache memory">
<Info size="md" />
</Tooltip>
</div>
),
accessorFn: (row): number => row.memory,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.HOSTS}
attribute="memory metric"
>
<EntityProgressBar value={memory} type="memory" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'wait',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>IOWait</div>
),
accessorFn: (row): number => row.wait,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const wait = value as number;
return (
<ValidateColumnValueWrapper
value={wait}
entity={InfraMonitoringEntity.HOSTS}
attribute="IOWait metric"
>
<TanStackTable.Text>{`${Number((wait * 100).toFixed(1))}%`}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'load15',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>Load Avg</div>
),
accessorFn: (row): number => row.load15,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const load15 = value as number;
return (
<ValidateColumnValueWrapper
value={load15}
entity={InfraMonitoringEntity.HOSTS}
attribute="load average metric"
>
<TanStackTable.Text>{load15}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,68 +0,0 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.hostnameColumnHeader {
display: block;
}
.statusHeader {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
width: 100%;
}
.columnHeaderRight {
text-align: right;
}
.memoryUsageHeader {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-2);
width: 100%;
}
.statusTag {
width: fit-content;
font-family: Inter, sans-serif;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
border-radius: 50px;
padding: 2px 8px;
}
.statusTagActive {
color: var(--Forest-500, #25e192);
border: 1px solid rgba(37, 225, 146, 0.2);
background: rgba(37, 225, 146, 0.1);
}
.statusTagInactive {
color: var(--Slate-50, #62687c);
border: 1px solid rgba(98, 104, 124, 0.2);
background: rgba(98, 104, 124, 0.1);
}
.progressContainer {
display: flex;
align-items: center;
& > div {
width: 100%;
}
}
.progressBar {
flex: 1;
margin-right: 8px;
}

View File

@@ -1,156 +0,0 @@
import React from 'react';
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
import {
FiltersType,
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import { TriangleAlert } from '@signozhq/icons';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
const HOSTNAME_DOCS_URL =
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#host-name-is-blankempty';
export function HostnameCell({
hostName,
}: {
hostName?: string | null;
}): React.ReactElement {
const isEmpty = !hostName || !hostName.trim();
if (!isEmpty) {
return <div className="hostname-column-value">{hostName}</div>;
}
return (
<div className="hostname-cell-missing">
<Typography.Text color="muted" className="hostname-cell-placeholder">
-
</Typography.Text>
<Tooltip
title={
<div>
Missing host.name metadata.
<br />
<a
href={HOSTNAME_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
onClick={(e): void => e.stopPropagation()}
>
Learn how to configure
</a>
</div>
}
trigger={['hover', 'focus']}
>
<span
className="hostname-cell-warning-icon"
tabIndex={0}
role="img"
aria-label="Missing host.name metadata"
onClick={(e): void => e.stopPropagation()}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
>
<TriangleAlert size={14} color={Color.BG_CHERRY_500} />
</span>
</Tooltip>
</div>
);
}
export const getHostListsQuery = (): HostListPayload => ({
filters: {
items: [],
op: 'and',
},
groupBy: [],
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const HostsQuickFiltersConfig: IQuickFiltersConfig[] = [
{
type: FiltersType.CHECKBOX,
title: 'Host Name',
attributeKey: {
key: 'host_name',
dataType: DataTypes.String,
type: 'resource',
},
aggregateOperator: 'noop',
aggregateAttribute: 'system_cpu_load_average_15m',
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'OS Type',
attributeKey: {
key: 'os_type',
dataType: DataTypes.String,
type: 'resource',
},
aggregateOperator: 'noop',
aggregateAttribute: 'system_cpu_load_average_15m',
dataSource: DataSource.METRICS,
defaultOpen: true,
},
];
export function getHostsQuickFiltersConfig(
dotMetricsEnabled: boolean,
): IQuickFiltersConfig[] {
const hostNameKey = dotMetricsEnabled ? 'host.name' : 'host_name';
const osTypeKey = dotMetricsEnabled ? 'os.type' : 'os_type';
const metricName = dotMetricsEnabled
? 'system.cpu.load_average.15m'
: 'system_cpu_load_average_15m';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
type: FiltersType.CHECKBOX,
title: 'Host Name',
attributeKey: {
key: hostNameKey,
dataType: DataTypes.String,
type: 'resource',
},
aggregateOperator: 'noop',
aggregateAttribute: metricName,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'OS Type',
attributeKey: {
key: osTypeKey,
dataType: DataTypes.String,
type: 'resource',
},
aggregateOperator: 'noop',
aggregateAttribute: metricName,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: true,
},
];
}

View File

@@ -35,6 +35,7 @@ import {
ChevronsLeftRight,
Compass,
DraftingCompass,
Package2,
ScrollText,
X,
} from '@signozhq/icons';
@@ -53,10 +54,12 @@ import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
import EntityContainers from '../EntityDetailsUtils/EntityContainers';
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityProcesses from '../EntityDetailsUtils/EntityProcesses';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
@@ -117,6 +120,8 @@ export interface K8sBaseDetailsProps<T> {
showLogs?: boolean;
showTraces?: boolean;
showEvents?: boolean;
showContainers?: boolean;
showProcesses?: boolean;
};
customTabs?: Array<{
key: string;
@@ -254,6 +259,8 @@ export default function K8sBaseDetails<T>({
showLogs: true,
showTraces: true,
showEvents: true,
showContainers: false,
showProcesses: false,
...tabsConfig,
}),
[tabsConfig],
@@ -568,6 +575,32 @@ export default function K8sBaseDetails<T>({
},
]
: []),
...(tabVisibility.showContainers
? [
{
value: VIEW_TYPES.CONTAINERS,
label: (
<div className="view-title">
<Package2 size={14} />
Containers
</div>
),
},
]
: []),
...(tabVisibility.showProcesses
? [
{
value: VIEW_TYPES.PROCESSES,
label: (
<div className="view-title">
<ChevronsLeftRight size={14} />
Processes
</div>
),
},
]
: []),
...(customTabs?.map((tab) => ({
value: tab.key,
label: (
@@ -647,6 +680,11 @@ export default function K8sBaseDetails<T>({
initialExpression={eventsInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.CONTAINERS &&
tabVisibility.showContainers && <EntityContainers />}
{effectiveView === VIEW_TYPES.PROCESSES && tabVisibility.showProcesses && (
<EntityProcesses />
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
<React.Fragment key={tab.key}>

View File

@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next';
import { Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
import broomUrl from '@/assets/Icons/broom.svg';
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
import 'components/HostMetricsDetail/Containers/Containers.styles.scss';
const { Text } = Typography;
function EntityContainers(): JSX.Element {
const { t } = useTranslation(['infraMonitoring']);
return (
<Space direction="vertical" className="host-containers" size={24}>
<div className="infra-container-card-container">
<div className="dev-status-container">
<div className="infra-container-card">
<img
src={infraContainersUrl}
alt="infra-container"
width={32}
height={32}
/>
<Text className="infra-container-card-text">
{t('containers_visualization_message')}
</Text>
</div>
<div className="infra-container-working-msg">
<Space>
<img src={broomUrl} alt="broom" width={24} height={24} />
<Text className="infra-container-card-text">{t('working_message')}</Text>
</Space>
</div>
</div>
<WaitlistFragment entityType="containers" />
</div>
</Space>
);
}
export default EntityContainers;

View File

@@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next';
import { Space } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
import broomUrl from '@/assets/Icons/broom.svg';
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
import 'components/HostMetricsDetail/Processes/Processes.styles.scss';
const { Text } = Typography;
function EntityProcesses(): JSX.Element {
const { t } = useTranslation(['infraMonitoring']);
return (
<Space direction="vertical" className="host-processes" size={24}>
<div className="infra-container-card-container">
<div className="dev-status-container">
<div className="infra-container-card">
<img
src={infraContainersUrl}
alt="infra-container"
width={32}
height={32}
/>
<Text className="infra-container-card-text">
{t('processes_visualization_message')}
</Text>
</div>
<div className="infra-container-working-msg">
<Space>
<img src={broomUrl} alt="broom" width={24} height={24} />
<Text className="infra-container-card-text">{t('working_message')}</Text>
</Space>
</div>
</div>
<WaitlistFragment entityType="processes" />
</div>
</Space>
);
}
export default EntityProcesses;

View File

@@ -1,98 +0,0 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces, verifyQueryPayload } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Default Behavior', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should fetch traces using V5 API on initial render', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 0,
});
});
it('should pass time range to API (converted to milliseconds)', async () => {
const timeRange = { startTime: 1000, endTime: 2000 };
act(() => {
renderEntityTraces({ timeRange });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedTimeRange: {
start: timeRange.startTime * 1000,
end: timeRange.endTime * 1000,
},
});
});
it('should order results by timestamp desc', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
order?: Array<{ key: { name: string }; direction: string }>;
};
const timestampOrder = spec.order?.find((o) => o.key.name === 'timestamp');
expect(timestampOrder?.direction).toBe('desc');
});
it('should use TRACES signal type', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const query = capturedPayloads[0].compositeQuery.queries[0];
const spec = query?.spec as { signal?: string };
expect(spec?.signal).toBe('traces');
});
it('should use raw request type for traces list view', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
expect(capturedPayloads[0].requestType).toBe('raw');
});
});

View File

@@ -1,38 +0,0 @@
import { act, screen } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithEmptyTraces } from '__tests__/query_range_v5.util';
describe('EntityTraces - Empty State', () => {
it('should show empty state when no traces returned', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
});
it('should not show table when traces are empty', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('should not show pagination controls when traces are empty', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
expect(screen.queryByText(/1 - 10/)).not.toBeInTheDocument();
});
});

View File

@@ -1,44 +0,0 @@
import { act, screen } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import {
mockQueryRangeV5WithError,
mockQueryRangeV5WithKeyNotFoundError,
} from '__tests__/query_range_v5.util';
describe('EntityTraces - Error State', () => {
it('should show error state on API error', async () => {
mockQueryRangeV5WithError('Internal server error', 500);
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/Something went wrong/i),
).resolves.toBeInTheDocument();
});
it('should show empty state for key not found error', async () => {
mockQueryRangeV5WithKeyNotFoundError();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
});
it('should not show table on API error', async () => {
mockQueryRangeV5WithError('Internal server error', 500);
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/Something went wrong/i),
).resolves.toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
});

View File

@@ -1,55 +0,0 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Expression', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should include expression in query filter', async () => {
const expression = 'k8s.pod.name = "my-pod"';
act(() => {
renderEntityTraces({ expression });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
filter?: { expression: string };
};
expect(spec.filter?.expression).toContain('k8s.pod.name');
});
it('should include complex expression with multiple conditions', async () => {
const expression = 'k8s.pod.name = "my-pod" AND service.name = "api"';
act(() => {
renderEntityTraces({ expression });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
filter?: { expression: string };
};
expect(spec.filter?.expression).toContain('k8s.pod.name');
expect(spec.filter?.expression).toContain('service.name');
});
});

View File

@@ -1,39 +0,0 @@
import { act, screen, waitFor } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Loading State', () => {
it('should show loading state while fetching', async () => {
mockQueryRangeV5WithTracesResponse({ delay: 1000 });
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/pending_data_placeholder/i),
).resolves.toBeInTheDocument();
});
it('should hide loading state after data loads', async () => {
mockQueryRangeV5WithTracesResponse({ delay: 100 });
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/pending_data_placeholder/i),
).resolves.toBeInTheDocument();
await waitFor(
() => {
expect(
screen.queryByText(/pending_data_placeholder/i),
).not.toBeInTheDocument();
},
{ timeout: 3000 },
);
});
});

View File

@@ -1,96 +0,0 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces, verifyQueryPayload } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Pagination', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should use default limit when no pagination provided', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
limit?: number;
};
expect(spec.limit).toBe(10);
});
it('should use custom offset from pagination param', async () => {
act(() => {
renderEntityTraces({ pagination: { offset: 20, limit: 10 } });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 20,
});
});
it('should use custom limit from pagination param', async () => {
act(() => {
renderEntityTraces({ pagination: { offset: 0, limit: 50 } });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedLimit: 50,
});
});
it('should default offset to 0', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 0,
});
});
it('should combine offset and limit correctly', async () => {
act(() => {
renderEntityTraces({ pagination: { offset: 30, limit: 20 } });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 30,
expectedLimit: 20,
});
});
});

View File

@@ -1,185 +0,0 @@
import { act, screen, within } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
// Trace list columns are hidden below the antd `md` breakpoint. The global
// matchMedia mock reports `matches: false`, which drops every column, so make
// all breakpoints match for these rendering tests.
jest.spyOn(window, 'matchMedia').mockImplementation(
(query: string) =>
({
matches: true,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}) as unknown as MediaQueryList,
);
describe('EntityTraces - Table Rendering', () => {
it('should render table with all column headers', async () => {
mockQueryRangeV5WithTracesResponse({ pageSize: 3 });
act(() => {
renderEntityTraces();
});
await expect(screen.findByText('Service Name')).resolves.toBeInTheDocument();
const headerTexts = within(screen.getByRole('table'))
.getAllByRole('columnheader')
.map((header) => header.textContent);
expect(headerTexts).toStrictEqual(
expect.arrayContaining([
'Timestamp',
'Service Name',
'Name',
'Duration',
'HTTP Method',
'Status Code',
]),
);
});
it('should render trace values in table cells', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [
{
serviceName: 'checkout-service',
name: 'GET /api/checkout',
durationNano: 5000000,
httpMethod: 'GET',
responseStatusCode: '200',
},
],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByTestId('serviceName')).resolves.toHaveTextContent(
'checkout-service',
);
expect(screen.getByTestId('name')).toHaveTextContent('GET /api/checkout');
expect(screen.getByTestId('durationNano')).toHaveTextContent('5.00ms');
expect(screen.getByTestId('httpMethod')).toHaveTextContent('GET');
expect(screen.getByTestId('responseStatusCode')).toHaveTextContent('200');
});
it('should render http method as robin colored outline badge', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ httpMethod: 'POST', responseStatusCode: '200' }],
});
act(() => {
renderEntityTraces();
});
const badge = await screen.findByTestId('httpMethod');
expect(badge).toHaveTextContent('POST');
expect(badge).toHaveAttribute('data-color', 'robin');
expect(badge).toHaveAttribute('data-variant', 'outline');
});
it('should render N/A when http method is empty', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ httpMethod: '', responseStatusCode: '200' }],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByText('N/A')).resolves.toBeInTheDocument();
expect(screen.queryByTestId('httpMethod')).not.toBeInTheDocument();
});
it('should render status code badge color per status range', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [
{ responseStatusCode: '200' },
{ responseStatusCode: '302' },
{ responseStatusCode: '404' },
{ responseStatusCode: '500' },
{ responseStatusCode: '100' },
],
});
act(() => {
renderEntityTraces();
});
const badges = await screen.findAllByTestId('responseStatusCode');
const badgeColors = badges.map((badge) => badge.getAttribute('data-color'));
expect(badgeColors).toStrictEqual([
'forest', // 2xx -> success
'robin', // 3xx -> redirect
'amber', // 4xx -> client error
'cherry', // 5xx -> server error
'vanilla', // 1xx -> informational
]);
});
it('should render non-numeric status code as plain text without badge', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ responseStatusCode: 'unknown' }],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByText('unknown')).resolves.toBeInTheDocument();
expect(screen.queryByTestId('responseStatusCode')).not.toBeInTheDocument();
});
it('should link cells to trace details page', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [{ serviceName: 'frontend' }],
});
act(() => {
renderEntityTraces();
});
await expect(screen.findByTestId('serviceName')).resolves.toBeInTheDocument();
const links = within(screen.getByRole('table')).getAllByRole('link');
expect(links.length).toBeGreaterThan(0);
links.forEach((link) => {
expect(link).toHaveAttribute(
'href',
expect.stringContaining('/trace/trace-id-0'),
);
});
});
it('should render one row per trace', async () => {
mockQueryRangeV5WithTracesResponse({
customTraces: [
{ serviceName: 'frontend' },
{ serviceName: 'backend' },
{ serviceName: 'database' },
],
});
act(() => {
renderEntityTraces();
});
const serviceCells = await screen.findAllByTestId('serviceName');
expect(serviceCells.map((cell) => cell.textContent)).toStrictEqual([
'frontend',
'backend',
'database',
]);
});
});

View File

@@ -1,13 +1,13 @@
import { mockQueryRangeV5WithEventsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { act, render, screen, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityEvents from '../EntityEvents';
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY } from '../hooks';
import EntityTraces from '../EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
function verifyEntityEventsV5Request({
function verifyEntityTracesV5Request({
payload,
expectedOffset,
initialTimeRange,
@@ -48,12 +48,12 @@ jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
),
}));
describe('EntityEvents', () => {
describe('EntityTraces', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedQueryRangePayloads = [];
mockQueryRangeV5WithEventsResponse({
mockQueryRangeV5WithLogsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedQueryRangePayloads.push(body);
@@ -62,13 +62,13 @@ describe('EntityEvents', () => {
});
});
it('should use V5 API for fetching events', async () => {
it('should use V5 API for fetching traces', async () => {
act(() => {
render(
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
searchParams={`${K8S_ENTITY_TRACES_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<EntityEvents
<EntityTraces
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
@@ -92,7 +92,7 @@ describe('EntityEvents', () => {
});
const firstPayload = capturedQueryRangePayloads[0];
verifyEntityEventsV5Request({
verifyEntityTracesV5Request({
payload: firstPayload,
expectedOffset: 0,
});

View File

@@ -1,121 +0,0 @@
import { ENVIRONMENT } from 'constants/env';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, RenderResult } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityTraces from '../EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
// QuerySearch fires autocomplete requests on mount; without handlers MSW
// passes them through to the real network and the resulting AxiosError fails
// whichever test happens to be running.
beforeEach(() => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
);
});
export interface RenderEntityTracesOptions {
expression?: string;
timeRange?: { startTime: number; endTime: number };
category?: InfraMonitoringEntity;
selectedInterval?: '5m' | '15m' | '30m' | '1h';
pagination?: { offset: number; limit: number };
}
export function renderEntityTraces({
expression = 'k8s.pod.name = "test-pod"',
timeRange = { startTime: 1, endTime: 2 },
category = InfraMonitoringEntity.PODS,
selectedInterval = '5m',
pagination,
}: RenderEntityTracesOptions = {}): RenderResult {
const encodedExpression = encodeURIComponent(expression);
let searchParams = `${K8S_ENTITY_TRACES_EXPRESSION_KEY}=${encodedExpression}`;
if (pagination) {
const paginationStr = encodeURIComponent(JSON.stringify(pagination));
searchParams += `&pagination=${paginationStr}`;
}
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<EntityTraces
timeRange={timeRange}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval={selectedInterval}
queryKey="test"
category={category}
initialExpression={expression}
/>
</NuqsTestingAdapter>,
);
}
export function verifyQueryPayload({
payload,
expectedOffset,
expectedLimit,
expectedTimeRange,
}: {
payload: QueryRangePayloadV5;
expectedOffset?: number;
expectedLimit?: number;
expectedTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
limit?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
if (expectedOffset !== undefined) {
expect(spec.offset).toBe(expectedOffset);
}
if (expectedLimit !== undefined) {
expect(spec.limit).toBe(expectedLimit);
}
if (expectedTimeRange) {
expect(payload.start).toBe(expectedTimeRange.start);
expect(payload.end).toBe(expectedTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
),
}));

View File

@@ -1,7 +0,0 @@
.pointer {
cursor: pointer;
}
.cellText {
color: var(--l2-foreground);
}

View File

@@ -1,7 +1,6 @@
import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
@@ -10,7 +9,6 @@ import {
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import styles from './traceListColumns.module.scss';
const keyToLabelMap: Record<string, string> = {
timestamp: 'Timestamp',
@@ -50,9 +48,8 @@ const getValueForKey = (data: Record<string, any>, key: string): any => {
const aliases = keyAliases[primaryKey];
if (aliases) {
for (const alias of aliases) {
const aliasedValue = data[alias] || data.resource?.[alias];
if (aliasedValue !== undefined) {
return aliasedValue;
if (data[alias] !== undefined) {
return data[alias];
}
}
}
@@ -81,7 +78,7 @@ export const getTraceListColumns = (
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography.Text className={styles.cellText}>{date}</Typography.Text>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
}
@@ -89,81 +86,34 @@ export const getTraceListColumns = (
if (value === '') {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key} className={styles.cellText}>
N/A
</Typography>
<Typography data-testid={key}>N/A</Typography>
</BlockLink>
);
}
if (primaryKey === 'httpMethod') {
const httpMethod = getValueForKey(itemData, key);
if (!httpMethod) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography className={styles.cellText}>N/A</Typography>
</BlockLink>
);
}
if (primaryKey === 'httpMethod' || primaryKey === 'responseStatusCode') {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Badge
data-testid={key}
color="robin"
variant="outline"
className={styles.pointer}
>
{httpMethod}
<Badge data-testid={key} color="sakura">
{getValueForKey(itemData, key)}
</Badge>
</BlockLink>
);
}
if (primaryKey === 'responseStatusCode') {
const statusCode = getValueForKey(itemData, key);
const numericCode = Number(statusCode);
const isValidCode = !Number.isNaN(numericCode) && numericCode > 0;
if (!isValidCode) {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography className={styles.cellText}>
{numericCode === 0 || !statusCode ? 'N/A' : statusCode}
</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<HttpStatusBadge
statusCode={statusCode}
testId={key}
className={styles.pointer}
/>
</BlockLink>
);
}
if (primaryKey === 'durationNano') {
const durationNano = getValueForKey(itemData, key);
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key} className={styles.cellText}>
{getMs(durationNano)}ms
</Typography>
<Typography data-testid={key}>{getMs(durationNano)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key} className={styles.cellText}>
{getValueForKey(itemData, key)}
</Typography>
<Typography data-testid={key}>{getValueForKey(itemData, key)}</Typography>
</BlockLink>
);
},

View File

@@ -34,6 +34,8 @@ export const VIEW_TYPES = {
METRICS: VIEWS.METRICS,
LOGS: VIEWS.LOGS,
TRACES: VIEWS.TRACES,
CONTAINERS: VIEWS.CONTAINERS,
PROCESSES: VIEWS.PROCESSES,
EVENTS: VIEWS.EVENTS,
};
@@ -392,6 +394,41 @@ export function GetClustersQuickFiltersConfig(
];
}
export function GetContainersQuickFiltersConfig(
dotMetricsEnabled: boolean,
): IQuickFiltersConfig[] {
const containerKey = dotMetricsEnabled
? 'k8s.container.name'
: 'k8s_container_name';
const environmentKey = dotMetricsEnabled
? 'deployment.environment'
: 'deployment_environment';
return [
{
type: FiltersType.CHECKBOX,
title: 'Container',
attributeKey: {
key: containerKey,
dataType: DataTypes.String,
type: 'resource',
id: `${containerKey}--string--resource`,
},
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: environmentKey,
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: true,
},
];
}
export function GetVolumesQuickFiltersConfig(
dotMetricsEnabled: boolean,
): IQuickFiltersConfig[] {

View File

@@ -1,5 +0,0 @@
.entityGroupHeader {
display: flex;
align-items: center;
gap: var(--spacing-5);
}

View File

@@ -1,21 +0,0 @@
import { Group } from '@signozhq/icons';
import styles from './EntityGroupHeader.module.scss';
interface EntityGroupHeaderProps {
title: string;
icon?: React.ReactNode;
}
function EntityGroupHeader({
title,
icon,
}: EntityGroupHeaderProps): JSX.Element {
return (
<div className={styles.entityGroupHeader}>
{icon || <Group size={14} data-hide-expanded="true" />} {title}
</div>
);
}
export default EntityGroupHeader;

View File

@@ -1,666 +0,0 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
BarChart,
ChevronsLeftRight,
Compass,
DraftingCompass,
ScrollText,
X,
} from '@signozhq/icons';
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import {
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringSelectedItem,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import LoadingContainer from '../LoadingContainer';
import '../EntityDetailsUtils/entityDetails.styles.scss';
import { parseAsString, useQueryState } from 'nuqs';
const TimeRangeOffset = 1000000000;
export interface K8sDetailsMetadataConfig<T> {
label: string;
getValue: (entity: T) => string | number;
render?: (value: string | number, entity: T) => React.ReactNode;
}
export interface K8sDetailsFilters {
filters: TagFilter;
start: number;
end: number;
}
export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: string;
// Data fetching configuration
getSelectedItemFilters: (selectedItem: string) => TagFilter;
fetchEntityData: (
filters: K8sDetailsFilters,
signal?: AbortSignal,
) => Promise<{ data: T | null; error?: string | null }>;
// Entity configuration
getEntityName: (entity: T) => string;
getInitialLogTracesFilters: (entity: T) => TagFilterItem[];
getInitialEventsFilters: (entity: T) => TagFilterItem[];
metadataConfig: K8sDetailsMetadataConfig<T>[];
entityWidgetInfo: {
title: string;
yAxisUnit: string;
}[];
getEntityQueryPayload: (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
queryKeyPrefix: string;
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
hideDetailViewTabs?: boolean;
tabsConfig?: {
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
showEvents?: boolean;
};
customTabs?: Array<{
key: string;
label: string;
icon: React.ReactNode;
render: (props: {
entity: T;
timeRange: { startTime: number; endTime: number };
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}) => React.ReactNode;
}>;
}
export function createFilterItem(
key: string,
value: string,
dataType: DataTypes = DataTypes.String,
): TagFilterItem {
return {
id: uuidv4(),
key: {
key,
dataType,
type: 'resource',
id: `${key}--string--resource--false`,
},
op: '=',
value,
};
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetails<T>({
category,
eventCategory,
getSelectedItemFilters,
fetchEntityData,
getEntityName,
getInitialLogTracesFilters,
getInitialEventsFilters,
metadataConfig,
entityWidgetInfo,
getEntityQueryPayload,
queryKeyPrefix,
hideDetailViewTabs = false,
tabsConfig,
customTabs,
}: K8sBaseDetailsProps<T>): JSX.Element {
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const isDarkMode = useIsDarkMode();
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
const entityQueryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[queryKeyPrefix, selectedItem, selectedTime, getAutoRefreshQueryKey],
);
const {
data: entityResponse,
isLoading: isEntityLoading,
isError: isEntityError,
error: entityError,
} = useQuery({
queryKey: entityQueryKey,
queryFn: ({ signal }) => {
if (!selectedItem) {
return { data: null };
}
const filters = getSelectedItemFilters(selectedItem);
const { minTime, maxTime } = getMinMaxTime();
return fetchEntityData(
{
filters,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
},
signal,
);
},
enabled: !!selectedItem,
});
const entity = entityResponse?.data ?? null;
const hasResponseError = !!entityResponse?.error;
const logsAndTracesInitialExpression = useMemo(() => {
if (!entity) {
return '';
}
const primaryFiltersOnly = {
op: 'AND' as const,
items: getInitialLogTracesFilters(entity),
};
return convertFiltersToExpression(primaryFiltersOnly).expression;
}, [entity, getInitialLogTracesFilters]);
const eventsInitialExpression = useMemo(() => {
if (!entity) {
return '';
}
const primaryFiltersOnly = {
op: 'AND' as const,
items: getInitialEventsFilters(entity),
};
return convertFiltersToExpression(primaryFiltersOnly).expression;
}, [entity, getInitialEventsFilters]);
const handleClose = useCallback((): void => {
setSelectedItem(null);
}, [setSelectedItem]);
const entityName = entity ? getEntityName(entity) : '';
// Content state (previously in K8sBaseDetailsContent)
const tabVisibility = useMemo(
() => ({
showMetrics: true,
showLogs: true,
showTraces: true,
showEvents: true,
...tabsConfig,
}),
[tabsConfig],
);
const { startMs, endMs } = useMemo(
() => ({
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
}),
[lastComputedMinMax],
);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
endTime: endMs,
}));
// TODO(h4ad): Remove this and use context/zustand
const lastSelectedInterval = useRef<Time | null>(null);
const [selectedInterval, setSelectedInterval] = useState<Time>(
lastSelectedInterval.current
? lastSelectedInterval.current
: isCustomTimeRange(selectedTime)
? DEFAULT_TIME_RANGE
: selectedTime,
);
const [selectedView, setSelectedView] = useInfraMonitoringView();
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
const [userLogsExpression] = useQueryState(
K8S_ENTITY_LOGS_EXPRESSION_KEY,
parseAsString,
);
const [userTracesExpression] = useQueryState(
K8S_ENTITY_TRACES_EXPRESSION_KEY,
parseAsString,
);
useEffect(() => {
if (entity) {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
});
}
}, [entity, eventCategory]);
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
if (!isCustomTimeRange(currentSelectedInterval)) {
setSelectedInterval(currentSelectedInterval);
const { minTime, maxTime } = getMinMaxTime();
setModalTimeRange({
startTime: Math.floor(minTime / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
}, [getMinMaxTime, selectedTime]);
const handleTabChange = (value: string): void => {
setSelectedView(value);
setLogFiltersParam(null);
setTracesFiltersParam(null);
setEventsFiltersParam(null);
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: value,
});
};
const handleTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
lastSelectedInterval.current = interval as Time;
setSelectedInterval(interval as Time);
if (interval === 'custom' && dateTimeRange) {
setModalTimeRange({
startTime: Math.floor(dateTimeRange[0] / 1000),
endTime: Math.floor(dateTimeRange[1] / 1000),
});
} else {
const { maxTime, minTime } = GetMinMax(interval);
setModalTimeRange({
startTime: Math.floor(minTime / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
interval,
view: effectiveView,
});
},
[eventCategory, effectiveView],
);
const handleExplorePagesRedirect = (): void => {
const urlQuery = new URLSearchParams();
if (selectedInterval !== 'custom') {
urlQuery.set(QueryParams.relativeTime, selectedInterval);
} else {
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
}
logEvent(InfraMonitoringEvents.ExploreClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: selectedView,
});
if (selectedView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userLogsExpression || '',
);
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userTracesExpression || '',
);
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
aggregateOperator: TracesAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}
};
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{entityName ||
((isEntityError || hasResponseError) &&
'Failed to load entity details') ||
(isEntityLoading && 'Loading...') ||
'-'}
</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!selectedItem}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="entity-detail-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{isEntityLoading && <LoadingContainer />}
{(isEntityError || hasResponseError) && (
<div className="entity-error-container">
<Typography.Text color="danger">
{entityResponse?.error ||
(entityError instanceof Error
? entityError.message
: 'Failed to load entity details')}
</Typography.Text>
</div>
)}
{entity && !isEntityLoading && !hasResponseError && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
<div className="labels-row">
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
color="muted"
className="entity-details-metadata-label"
>
{config.label}
</Typography.Text>
))}
</div>
<div className="values-row">
{metadataConfig.map((config) => {
const value = config.getValue(entity);
const displayValue = String(value);
return (
<Typography.Text
key={config.label}
className="entity-details-metadata-value"
>
{config.render ? (
config.render(value, entity)
) : (
<Tooltip title={displayValue}>{displayValue}</Tooltip>
)}
</Typography.Text>
);
})}
</div>
</div>
</div>
{!hideDetailViewTabs && (
<div className="views-tabs-container">
<ToggleGroupSimple
type="single"
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
items={[
...(tabVisibility.showMetrics
? [
{
value: VIEW_TYPES.METRICS,
label: (
<div className="view-title">
<BarChart size={14} />
Metrics
</div>
),
},
]
: []),
...(tabVisibility.showLogs
? [
{
value: VIEW_TYPES.LOGS,
label: (
<div className="view-title">
<ScrollText size={14} />
Logs
</div>
),
},
]
: []),
...(tabVisibility.showTraces
? [
{
value: VIEW_TYPES.TRACES,
label: (
<div className="view-title">
<DraftingCompass size={14} />
Traces
</div>
),
},
]
: []),
...(tabVisibility.showEvents
? [
{
value: VIEW_TYPES.EVENTS,
label: (
<div className="view-title">
<ChevronsLeftRight size={14} />
Events
</div>
),
},
]
: []),
...(customTabs?.map((tab) => ({
value: tab.key,
label: (
<div className="view-title">
{tab.icon}
{tab.label}
</div>
),
})) ?? []),
]}
/>
{selectedView === VIEW_TYPES.LOGS && (
<Tooltip title="Go to Logs Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
)}
{selectedView === VIEW_TYPES.TRACES && (
<Tooltip title="Go to Traces Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
)}
</div>
)}
{effectiveView === VIEW_TYPES.METRICS && (
<EntityMetrics<T>
entity={entity}
selectedInterval={selectedInterval}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
/>
)}
{effectiveView === VIEW_TYPES.LOGS && (
<EntityLogs
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.TRACES && (
<EntityTraces
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
queryKey={`${queryKeyPrefix}Traces`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
<EntityEvents
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
selectedInterval={selectedInterval}
category={category}
queryKey={`${queryKeyPrefix}Events`}
initialExpression={eventsInitialExpression}
/>
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
<React.Fragment key={tab.key}>
{tab.render({
entity,
timeRange: modalTimeRange,
selectedInterval,
handleTimeChange,
})}
</React.Fragment>
) : null,
)}
</>
)}
</Drawer>
);
}

View File

@@ -1,40 +0,0 @@
.emptyStateContainer {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 400px;
}
.k8SListTable {
padding-left: var(--spacing-2);
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 5px;
--tanstack-cell-padding-right-override: 5px;
--tanstack-expansion-first-col-padding-left: 30px;
&[data-has-group-by='false'] {
--tanstack-cell-padding-left-first-column: 28px;
}
}
.paginationContainer {
padding-bottom: var(--spacing-8);
padding-right: var(--spacing-8);
ul {
margin: 0;
}
}

View File

@@ -1,297 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import TanStackTable, {
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { parseAsString, useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { openInNewTab } from 'utils/navigation';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPageSizeListing,
} from '../hooks';
import { K8sEmptyState } from './K8sEmptyState';
import { K8sExpandedRow } from './K8sExpandedRow';
import K8sHeader from './K8sHeader';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
export type K8sBaseListEmptyStateContext = {
isError: boolean;
error?: string | null;
totalCount: number;
hasFilters: boolean;
isLoading: boolean;
rawData?: unknown;
};
/** Base type constraint for K8s entity data */
export type K8sEntityData = { meta?: Record<string, string> };
export type K8sBaseListProps<T extends K8sEntityData> = {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
tableColumns: TableColumnDef<T>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
) => Promise<{
data: T[];
total: number;
error?: string | null;
rawData?: unknown;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
) => React.ReactNode | null;
};
export function K8sBaseList<T extends K8sEntityData>({
controlListPrefix,
entity,
tableColumns,
fetchListData,
getRowKey,
getItemKey,
eventCategory,
renderEmptyState,
}: K8sBaseListProps<T>): JSX.Element {
const [queryFilters] = useInfraMonitoringFiltersK8s();
const [currentPage] = useInfraMonitoringPageListing();
const [currentPageSize] = useInfraMonitoringPageSizeListing();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
const columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
'k8sBaseList',
entity,
String(currentPageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
currentPageSize,
currentPage,
queryFilters,
orderBy,
groupBy,
]);
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return fetchListData(
{
limit: currentPageSize,
offset: (currentPage - 1) * currentPageSize,
filters: queryFilters || { items: [], op: 'AND' },
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
orderBy: orderBy || undefined,
groupBy: groupBy?.length > 0 ? groupBy : undefined,
},
signal,
);
},
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const pageData = data?.data ?? [];
const totalCount = data?.total || 0;
const hasFilters = (queryFilters?.items?.length ?? 0) > 0;
const getGroupKeyFn = useCallback(
(item: T) => getGroupedByMeta(item, groupBy),
[groupBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
total: totalCount,
});
}, [eventCategory, totalCount]);
const handleRowClick = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length === 0) {
setSelectedItem(itemKey);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[eventCategory, groupBy.length, setSelectedItem],
);
const handleRowClickNewTab = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length > 0) {
return;
}
// Build URL with selectedItem param
const url = new URL(window.location.href);
url.searchParams.set('selectedItem', itemKey);
openInNewTab(url.pathname + url.search);
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[eventCategory, groupBy.length],
);
const isGroupedByAttribute = groupBy.length > 0;
// Filter columns for expanded row based on parent's hidden columns
const expandedRowColumns = useMemo(
() => tableColumns.filter((col) => !hiddenColumnIds.includes(col.id)),
[tableColumns, hiddenColumnIds],
);
const renderExpandedRow = useCallback(
(
_record: T,
rowKey: string,
groupMeta?: Record<string, string>,
): JSX.Element => (
<K8sExpandedRow<T>
rowKey={rowKey}
groupMeta={groupMeta}
entity={entity}
tableColumns={expandedRowColumns}
fetchListData={fetchListData}
getRowKey={getRowKey}
getItemKey={getItemKey}
/>
),
[entity, fetchListData, getRowKey, getItemKey, expandedRowColumns],
);
const getRowCanExpand = useCallback(
(): boolean => isGroupedByAttribute,
[isGroupedByAttribute],
);
const showTableLoadingState = isLoading;
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
isError,
error: data?.error,
totalCount,
hasFilters,
isLoading: showTableLoadingState,
rawData: data?.rawData,
}) || (
<K8sEmptyState
isError={isError}
error={data?.error}
isLoading={showTableLoadingState}
rawData={data?.rawData}
/>
);
const showEmptyState = !showTableLoadingState && pageData.length === 0;
return (
<>
<K8sHeader
controlListPrefix={controlListPrefix}
entity={entity}
showAutoRefresh={!selectedItem}
columns={tableColumns}
columnStorageKey={columnStorageKey}
/>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
{showEmptyState ? (
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
) : (
<TanStackTable<T>
data={pageData}
columns={tableColumns}
columnStorageKey={columnStorageKey}
isLoading={showTableLoadingState}
getRowKey={getRowKey}
getItemKey={getItemKey}
groupBy={groupBy}
getGroupKey={getGroupKeyFn}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
renderExpandedRow={isGroupedByAttribute ? renderExpandedRow : undefined}
getRowCanExpand={isGroupedByAttribute ? getRowCanExpand : undefined}
className={cx(styles.k8SListTable, expandedRowColumns)}
enableQueryParams={{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
limit: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE,
orderBy: INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
expanded: INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED,
}}
pagination={{
total: totalCount,
defaultLimit: 10,
defaultPage: 1,
showTotalCount: true,
totalCountLabel: entity.charAt(0).toUpperCase() + entity.slice(1),
}}
paginationClassname={styles.paginationContainer}
/>
)}
</>
);
}

View File

@@ -1,56 +0,0 @@
.container {
height: 30vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
width: fit-content;
max-width: 500px;
padding: 24px;
text-align: center;
}
.title {
font-weight: 500;
margin: 0;
font-size: var(--periscope-font-size-medium);
}
.message {
max-width: 400px;
}
.noDataMessage {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.emptyStateSvg {
width: 32px;
max-width: 100%;
}
.eyesEmoji {
height: 32px;
width: 32px;
}
.errorIcon {
color: var(--danger-background);
}
.actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
}

View File

@@ -1,146 +0,0 @@
import { useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { LifeBuoy, TriangleAlert } from '@signozhq/icons';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
import type { K8sBaseListEmptyStateContext } from './K8sBaseList';
import styles from './K8sEmptyState.module.scss';
export interface K8sListResponseMetadata {
sentAnyHostMetricsData?: boolean;
isSendingK8SAgentMetrics?: boolean;
endTimeBeforeRetention?: boolean;
}
type K8sEmptyStateProps = Partial<K8sBaseListEmptyStateContext>;
const handleContactSupport = (isCloudUser: boolean): void => {
if (isCloudUser) {
history.push('/support');
} else {
window.open('https://signoz.io/slack', '_blank');
}
};
export function K8sEmptyState({
isError,
error,
isLoading,
rawData,
}: K8sEmptyStateProps): JSX.Element | null {
const { isCloudUser } = useGetTenantLicense();
const handleSupport = useCallback(() => {
handleContactSupport(isCloudUser);
}, [isCloudUser]);
if (isLoading) {
return null;
}
if (isError || error) {
return (
<div className={styles.container}>
<div className={styles.content}>
<TriangleAlert size={32} className={styles.errorIcon} />
<span className={styles.message}>
{error || 'An error occurred while fetching data.'}
</span>
<p>
Our team is getting on top to resolve this. Please reach out to support if
the issue persists.
</p>
<div className={styles.actions}>
<Button
onClick={handleSupport}
variant="solid"
color="secondary"
prefix={<LifeBuoy size={14} />}
>
Contact Support
</Button>
</div>
</div>
</div>
);
}
const metadata = rawData as K8sListResponseMetadata | undefined;
if (metadata?.sentAnyHostMetricsData === false) {
return (
<div className={styles.container}>
<div className={styles.content}>
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
<div className={styles.noDataMessage}>
<h5 className={styles.title}>No host metrics data received yet</h5>
<span className={styles.message}>
Please refer to{' '}
<a
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
target="_blank"
rel="noreferrer"
>
our documentation
</a>{' '}
to learn how to send host metrics.
</span>
</div>
</div>
</div>
);
}
if (metadata?.isSendingK8SAgentMetrics) {
return (
<div className={styles.container}>
<div className={styles.content}>
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
<span className={styles.message}>
To see K8s metrics, upgrade to the latest version of SigNoz k8s-infra
chart. Please contact support if you need help.
</span>
</div>
</div>
);
}
if (metadata?.endTimeBeforeRetention) {
return (
<div className={styles.container}>
<div className={styles.content}>
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
<div className={styles.noDataMessage}>
<h5 className={styles.title}>
Queried time range is before earliest K8s metrics
</h5>
<span className={styles.message}>
Your requested end time is earlier than the earliest detected time of K8s
metrics data, please adjust your end time.
</span>
</div>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.content}>
<img
src={emptyStateUrl}
alt="empty-state"
className={styles.emptyStateSvg}
/>
<span className={styles.message}>
This query had no results. Edit your query and try again!
</span>
</div>
</div>
);
}

View File

@@ -1,35 +0,0 @@
.expandedTableContainer {
overflow-x: auto;
}
.expandedTable {
--tanstack-table-header-cell-bg: var(--l1-background);
--tanstack-table-header-cell-color: var(--l1-foreground);
--tanstack-table-cell-bg: var(--l1-background);
--tanstack-table-cell-color: var(--l1-foreground);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-active-bg: var(--l1-background-active);
--tanstack-table-resize-handle-bg: var(--l1-background);
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
--tanstack-table-row-height: 36px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-padding-right-override: 15px;
& [data-hide-expanded='true'] {
display: none;
}
}
.expandedTableFooter {
display: flex;
justify-content: flex-start;
gap: var(--spacing-4);
background-color: var(--l1-background);
}
.viewAllButton {
display: flex;
align-items: center;
margin: var(--spacing-4);
}

View File

@@ -1,238 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import TanStackTable, {
SortState,
TableColumnDef,
TanStackTableStateProvider,
} from 'components/TanStackTableView';
import { CornerDownRight } from '@signozhq/icons';
import { useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItem,
} from '../hooks';
import { K8sBaseFilters } from './types';
import styles from './K8sExpandedRow.module.scss';
const EXPANDED_ROW_LIMIT = 10;
export type K8sExpandedRowProps<T> = {
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
rowKey: string;
/** Group metadata for building filters */
groupMeta?: Record<string, string>;
entity: InfraMonitoringEntity;
tableColumns: TableColumnDef<T>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
) => Promise<{
data: T[];
total: number;
error?: string | null;
rawData?: unknown;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
};
export function K8sExpandedRow<T>({
rowKey,
groupMeta,
entity,
tableColumns,
fetchListData,
getRowKey,
getItemKey,
}: K8sExpandedRowProps<T>): JSX.Element {
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [queryFilters, setFilters] = useInfraMonitoringFiltersK8s();
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const orderByParamKey = useMemo(
() => `orderBy_${rowKey.replace(/[^a-zA-Z0-9]/g, '_')}`,
[rowKey],
);
const [orderBy, setOrderBy] = useQueryState(
orderByParamKey,
parseAsJsonNoValidate<SortState | null>()
.withDefault(null as never)
.withOptions({
history: 'push',
}),
);
useEffect(() => {
return (): void => {
void setOrderBy(null);
};
}, [setOrderBy]);
const storageKey = `k8s-${entity}-columns-expanded`;
const createFiltersForRecord = useCallback((): NonNullable<
IBuilderQuery['filters']
> => {
const baseFilters: IBuilderQuery['filters'] = {
items: [...(queryFilters?.items || [])],
op: 'and',
};
const metaKeys = groupMeta ?? {};
for (const key of Object.keys(metaKeys)) {
const value = metaKeys[key];
// Skip empty values to avoid creating invalid filters
if (value === '' || value === undefined || value === null) {
continue;
}
baseFilters.items.push({
key: {
key,
type: 'resource',
},
op: '=',
value,
id: key,
});
}
return baseFilters;
}, [queryFilters?.items, groupMeta]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
entity,
'k8sExpandedRow',
JSON.stringify(groupMeta),
rowKey,
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
groupMeta,
rowKey,
queryFilters,
orderBy,
]);
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: async ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return await fetchListData(
{
limit: EXPANDED_ROW_LIMIT,
offset: 0,
filters: createFiltersForRecord(),
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
orderBy: orderBy || undefined,
groupBy: undefined,
},
signal,
);
},
staleTime: 1000 * 60 * 30,
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const expandedData = data?.data ?? [];
const handleRowClick = useCallback(
(_row: T, itemKey: string): void => {
setSelectedItem(itemKey);
},
[setSelectedItem],
);
const handleViewAllClick = (): void => {
const filters = createFiltersForRecord();
setGroupBy([]);
setCurrentPage(1);
setFilters(filters);
if (orderBy) {
setMainOrderBy(orderBy);
}
};
const total = data?.total ?? 0;
const hasMoreItems = total > EXPANDED_ROW_LIMIT;
const footerContent = hasMoreItems ? (
<Button
type="button"
color="secondary"
variant="outlined"
className={styles.viewAllButton}
onClick={handleViewAllClick}
prefix={<CornerDownRight size={14} />}
>
View All
</Button>
) : null;
return (
<div
className={styles.expandedTableContainer}
data-testid="expanded-table-container"
>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
<div data-testid="expanded-table">
<TanStackTableStateProvider>
<TanStackTable<T>
data={expandedData}
columns={tableColumns}
columnStorageKey={storageKey}
isLoading={isLoading}
getRowKey={getRowKey}
getItemKey={getItemKey}
onRowClick={handleRowClick}
enableQueryParams={{
orderBy: orderByParamKey,
}}
tableScrollerProps={{
className: styles.expandedTable,
}}
disableVirtualScroll
cellTypographySize="medium"
/>
</TanStackTableStateProvider>
{!isLoading && expandedData.length > 0 && (
<div className={styles.expandedTableFooter}>{footerContent}</div>
)}
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
.drawer {
--dialog-description-padding: 0px 0px var(--spacing-8) 0px;
}
.columnItem {
display: flex;
align-items: center;
}
.columnsTitle {
color: var(--l2-foreground);
font-size: var(--periscope-font-size-small, 11px);
font-weight: var(--periscope-font-weight-medium, 500);
text-transform: uppercase;
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l2-border);
&:not(:first-of-type) {
border-top: 1px solid var(--l2-border);
}
}
.columnsList {
display: flex;
flex-direction: column;
}
.columnItem {
justify-content: flex-start !important;
width: 100%;
}
.horizontalDivider {
border-top: 1px solid var(--l1-border);
}

View File

@@ -1,142 +0,0 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import {
hideColumn,
showColumn,
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './K8sFiltersSidePanel.module.scss';
type ColumnPickerItem = {
id: string;
label: string;
canBeHidden: boolean;
visibilityBehavior:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
};
/**
* Converts TableColumnDef to column picker item format
*/
function toColumnPickerItems<T>(
columns: TableColumnDef<T>[],
): ColumnPickerItem[] {
return columns.map((col) => ({
id: col.id,
label: typeof col.header === 'string' ? col.header : col.id,
canBeHidden: col.canBeHidden !== false && col.enableRemove !== false,
visibilityBehavior: col.visibilityBehavior ?? 'always-visible',
}));
}
function K8sFiltersSidePanel<TData>({
open,
onClose,
columns,
storageKey,
}: {
open: boolean;
onClose: () => void;
columns: TableColumnDef<TData>[];
storageKey: string;
}): JSX.Element {
const columnPickerItems = useMemo(
() => toColumnPickerItems(columns),
[columns],
);
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const addedColumns = useMemo(
() =>
columnPickerItems.filter(
(column) =>
!hiddenColumnIds.includes(column.id) &&
column.visibilityBehavior !== 'hidden-on-collapse',
),
[columnPickerItems, hiddenColumnIds],
);
const hiddenColumns = useMemo(
() =>
columnPickerItems.filter((column) => hiddenColumnIds.includes(column.id)),
[columnPickerItems, hiddenColumnIds],
);
const handleRemoveColumn = (columnId: string): void => {
hideColumn(storageKey, columnId);
};
const handleAddColumn = (columnId: string): void => {
showColumn(storageKey, columnId);
};
const drawerContent = (
<>
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
<div className={styles.columnsList}>
{addedColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => handleRemoveColumn(column.id)}
>
{column.label}
</Button>
</div>
))}
</div>
<div className={styles.horizontalDivider} />
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
<div className={styles.columnsList}>
{hiddenColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => handleAddColumn(column.id)}
tabIndex={0}
>
{column.label}
</Button>
</div>
))}
</div>
</>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="Columns"
direction="right"
showCloseButton
showOverlay={false}
className={styles.drawer}
>
{drawerContent}
</DrawerWrapper>
);
}
export default K8sFiltersSidePanel;

View File

@@ -1,17 +0,0 @@
import { getGroupByEl } from './utils';
import { useInfraMonitoringGroupBy } from '../hooks';
interface K8sEntityWithMeta {
meta?: Record<string, string>;
}
function K8sGroupCell<T extends K8sEntityWithMeta>({
row,
}: {
row: T;
}): JSX.Element {
const [groupBy] = useInfraMonitoringGroupBy();
return getGroupByEl(row, groupBy) as JSX.Element;
}
export default K8sGroupCell;

View File

@@ -1,80 +0,0 @@
.k8SListControls {
padding: var(--spacing-4);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-4);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--border) !important;
background-color: var(--l2-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.k8SListControlsLeft {
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-4);
.k8SQbSearchContainer {
flex: 1;
min-width: 240px;
max-width: 60%;
}
}
.k8SAttributeSearchContainer {
flex: 1;
min-width: 240px;
max-width: 40%;
display: flex;
align-items: center;
}
.groupByLabel {
min-width: max-content;
font-size: var(--periscope-font-size-base, 13px);
font-weight: var(--periscope-font-weight-regular, 400);
line-height: 18px;
letter-spacing: -0.07px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--border);
border-right: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
display: flex;
height: 32px;
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-4);
justify-content: center;
align-items: center;
gap: var(--spacing-2);
}
.groupBySelect {
:global(.ant-select-selector) {
border-left: none;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
.k8SListControlsRight {
min-width: 240px;
display: flex;
align-items: center;
gap: var(--spacing-2);
}

View File

@@ -1,232 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Select } from 'antd';
import logEvent from 'api/common/logEvent';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { initialQueriesMap } from 'constants/queryBuilder';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { SlidersHorizontal } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
GetK8sEntityToAggregateAttribute,
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
useInfraMonitoringPageListing,
} from '../hooks';
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
import styles from './K8sHeader.module.scss';
interface K8sHeaderProps<TData> {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
showAutoRefresh: boolean;
columns: TableColumnDef<TData>[];
columnStorageKey: string;
}
function K8sHeader<TData>({
controlListPrefix,
entity,
showAutoRefresh,
columns,
columnStorageKey,
}: K8sHeaderProps<TData>): JSX.Element {
const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false);
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const currentQuery = initialQueriesMap[DataSource.METRICS];
const updatedCurrentQuery = useMemo(() => {
let { filters } = currentQuery.builder.queryData[0];
if (urlFilters) {
filters = urlFilters;
}
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters,
},
],
},
};
}, [currentQuery, urlFilters]);
const query = useMemo(
() => updatedCurrentQuery?.builder?.queryData[0] || null,
[updatedCurrentQuery],
);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
setUrlFilters(value || null);
handleChangeQueryData('filters', value);
setCurrentPage(1);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
}
},
[handleChangeQueryData, setCurrentPage, setUrlFilters],
);
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const { data: groupByFiltersData, isLoading: isLoadingGroupByFilters } =
useGetAggregateKeys(
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
entity,
dotMetricsEnabled,
),
aggregateOperator: 'noop',
searchText: '',
tagType: '',
},
{
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true,
entity,
);
const groupByOptions = useMemo(
() =>
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
[groupByFiltersData],
);
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = value[index] as unknown as string;
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(k) => k.key === element,
);
if (key) {
newGroupBy.push(key);
}
}
// Reset pagination on switching to groupBy
setCurrentPage(1);
setGroupBy(newGroupBy);
logEvent(InfraMonitoringEvents.GroupByChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: InfraMonitoringEvents.Pod,
});
},
[groupByFiltersData, setCurrentPage, setGroupBy],
);
const onClickOutside = useCallback(() => {
setIsFiltersSidePanelOpen(false);
}, []);
return (
<div className={styles.k8SListControls}>
<div className={styles.k8SListControlsLeft}>
{controlListPrefix}
<div className={styles.k8SQbSearchContainer}>
<QueryBuilderSearch
query={query as IBuilderQuery}
onChange={handleChangeTagFilters}
isInfraMonitoring
disableNavigationShortcuts
entity={entity}
/>
</div>
<div className={styles.k8SAttributeSearchContainer}>
<div className={styles.groupByLabel}> Group by </div>
<Select
className={styles.groupBySelect}
loading={isLoadingGroupByFilters}
mode="multiple"
value={groupBy}
allowClear
maxTagCount="responsive"
placeholder="Search for attribute"
style={{ width: '100%' }}
options={groupByOptions}
onChange={handleGroupByChange}
/>
</div>
</div>
<div className={styles.k8SListControlsRight}>
<DateTimeSelectionV2
showAutoRefresh={showAutoRefresh}
showRefreshText={false}
hideShareModal
/>
<Button
type="button"
variant="ghost"
size="icon"
color="none"
data-testid="k8s-list-filters-button"
onClick={(): void => setIsFiltersSidePanelOpen(true)}
>
<SlidersHorizontal size={14} />
</Button>
</div>
<K8sFiltersSidePanel
open={isFiltersSidePanelOpen}
columns={columns}
storageKey={columnStorageKey}
onClose={onClickOutside}
/>
</div>
);
}
export default K8sHeader;

View File

@@ -1,912 +0,0 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
import { VirtuosoMockContext } from 'react-virtuoso';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfraMonitoringEvents } from 'constants/events';
import {
NuqsTestingAdapter,
OnUrlUpdateFunction,
UrlUpdateEvent,
} from 'nuqs/adapters/testing';
import { AppProvider } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
import store from 'store';
import { openInNewTab } from 'utils/navigation';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEntity } from '../../constants';
import { K8sBaseList, K8sBaseListProps, K8sEntityData } from '../K8sBaseList';
jest.mock('utils/navigation', () => ({
...jest.requireActual('utils/navigation'),
openInNewTab: jest.fn(),
}));
const openInNewTabMock = openInNewTab as jest.Mock;
// Mock Date.now to prevent flaky tests due to time-dependent values
const MOCK_NOW = 1700000000000; // Fixed timestamp
jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW);
// Mock DrawerWrapper to avoid CSS issues with jsdom
// SyntaxError: 'div#radix-:rbv,,._dialog__content_qf8bf_22 :focus' is not a valid selector
jest.mock('@signozhq/ui/drawer', () => {
const actual = jest.requireActual('@signozhq/ui/drawer');
return {
...actual,
DrawerWrapper: ({
open,
children,
title,
}: {
open: boolean;
children: React.ReactNode;
title: string;
onOpenChange?: (isOpen: boolean) => void;
}): JSX.Element | null =>
open ? (
<div data-testid="drawer-wrapper" data-title={title}>
{children}
</div>
) : null,
};
});
// Test data types that satisfy K8sEntityData constraint
type TestItemWithTitle = {
id: string;
title: string;
meta?: Record<string, string>;
};
type TestItem = { id: string; meta?: Record<string, string> };
type TestItemWithName = {
id: string;
name: string;
desc: string;
meta?: Record<string, string>;
};
type TestItemWithGroup = {
id: string;
name: string;
group: string;
meta?: Record<string, string>;
};
// Helper to create TanStack columns for tests
function createTestColumnsWithTitle(): TableColumnDef<TestItemWithTitle>[] {
return [
{
id: 'id',
header: (): React.ReactNode => 'Id',
accessorFn: (row): string => row.id,
cell: ({ value }): React.ReactNode => <>{value}</>,
enableSort: true,
},
{
id: 'title',
header: (): React.ReactNode => 'Title',
accessorFn: (row): string => row.title,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
];
}
function createTestColumns(): TableColumnDef<TestItem>[] {
return [
{
id: 'id',
header: (): React.ReactNode => 'Id',
accessorFn: (row): string => row.id,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
];
}
function createTestColumnsWithName(): TableColumnDef<TestItemWithName>[] {
return [
{
id: 'id',
header: (): React.ReactNode => 'Id',
accessorFn: (row): string => row.id,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
{
id: 'name',
header: (): React.ReactNode => 'Name',
accessorFn: (row): string => row.name,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
{
id: 'desc',
header: (): React.ReactNode => 'Description',
accessorFn: (row): string => row.desc,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
];
}
function createTestColumnsWithGroup(): TableColumnDef<TestItemWithGroup>[] {
return [
{
id: 'id',
header: (): React.ReactNode => 'Id',
accessorFn: (row): string => row.id,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
{
id: 'name',
header: (): React.ReactNode => 'Name',
accessorFn: (row): string => row.name,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
{
id: 'group',
header: (): React.ReactNode => 'Group',
accessorFn: (row): string => row.group,
cell: ({ value }): React.ReactNode => <>{value}</>,
},
];
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function renderComponent<T extends K8sEntityData>({
queryParams,
onUrlUpdate,
...props
}: K8sBaseListProps<T> & {
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<MemoryRouter>
<MemoryRouterV5>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<AppProvider>
<Provider store={store}>
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 800, itemHeight: 50 }}
>
<TooltipProvider>
<K8sBaseList {...props} />
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>
</Provider>
</AppProvider>
</QueryClientProvider>
</TimezoneProvider>
</MemoryRouterV5>
</MemoryRouter>,
);
}
describe('K8sBaseList', () => {
describe('with items in the list', () => {
const itemId = Math.random().toString(36).slice(7);
const itemId2 = Math.random().toString(36).slice(7);
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItemWithTitle>['fetchListData']>,
Parameters<K8sBaseListProps<TestItemWithTitle>['fetchListData']>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
openInNewTabMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{ id: `PodId:${itemId}`, title: `PodTitle:${itemId}` },
{ id: `PodId:${itemId2}`, title: `PodTitle:${itemId2}` },
],
total: 25,
error: null,
});
renderComponent<TestItemWithTitle>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithTitle(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should render all the items in the list', async () => {
await waitFor(async () => {
await expect(
screen.findByText(`PodId:${itemId}`),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(`PodTitle:${itemId}`),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(`PodId:${itemId2}`),
).resolves.toBeInTheDocument();
await expect(
screen.findByText(`PodTitle:${itemId2}`),
).resolves.toBeInTheDocument();
});
});
it('should call fetchListData with default filters', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.limit).toBe(10);
expect(filters.offset).toBe(0);
expect(filters.filters).toStrictEqual({ items: [], op: 'AND' });
expect(filters.groupBy).toBeUndefined();
expect(filters.orderBy).toBeUndefined();
});
it('should click to open the row details and update selectedItem in URL', async () => {
const user = userEvent.setup();
const firstRowEl = await screen.findByText(`PodId:${itemId}`);
await user.click(firstRowEl);
await waitFor(() => {
const selectedItem = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('selectedItem'))
.filter(Boolean)
.pop();
expect(selectedItem).toBe(`PodId:${itemId}`);
});
});
it('should update orderBy in URL when clicking sortable column header', async () => {
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
// TanStackTable renders a sort button with title attribute
const sortButton = screen.getByTitle('Id');
await user.click(sortButton);
await waitFor(() => {
const lastOrderBy = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
const parsed = JSON.parse(lastOrderBy as string);
expect(parsed.columnName).toBe('id');
expect(parsed.order).toBe('asc');
});
});
it('should toggle sort order in URL on subsequent header clicks', async () => {
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
// Track orderBy calls
const getOrderByCalls = (): string[] =>
onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('orderBy'))
.filter(Boolean) as string[];
// First click - should set ascending
const sortButton = screen.getByTitle('Id');
expect(sortButton).toHaveAttribute('data-sort', 'none');
fireEvent.click(sortButton);
// Wait for URL to show ascending
await waitFor(() => {
const calls = getOrderByCalls();
expect(calls.length).toBeGreaterThan(0);
const parsed = JSON.parse(calls[calls.length - 1]);
expect(parsed.order).toBe('asc');
});
// Wait for button to have ascending state
await waitFor(() => {
expect(screen.getByTitle('Id')).toHaveAttribute('data-sort', 'ascending');
});
const callsAfterFirstClick = getOrderByCalls().length;
// Verify only one button exists with title 'Id'
const allIdButtons = screen.getAllByTitle('Id');
expect(allIdButtons).toHaveLength(1);
// Second click - should set descending
const ascendingButton = screen.getByTitle('Id');
expect(ascendingButton).toHaveAttribute('data-sort', 'ascending');
fireEvent.click(ascendingButton);
// Wait for URL to show descending (must be a new call)
await waitFor(() => {
const calls = getOrderByCalls();
expect(calls.length).toBeGreaterThan(callsAfterFirstClick);
const parsed = JSON.parse(calls[calls.length - 1]);
expect(parsed.order).toBe('desc');
});
// Verify DOM updated
await waitFor(() => {
expect(screen.getByTitle('Id')).toHaveAttribute('data-sort', 'descending');
});
});
it('should update page in URL when clicking pagination', async () => {
const user = userEvent.setup();
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
// Find pagination navigation and page 2 button
const nav = screen.getByRole('navigation');
const page2Button = Array.from(nav.querySelectorAll('button')).find(
(btn) => btn.textContent?.trim() === '2',
);
if (!page2Button) {
throw new Error('Page 2 button not found in pagination');
}
await user.click(page2Button);
await waitFor(() => {
const lastPage = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
});
});
it('should open row in new tab when ctrl+click on row', async () => {
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
const firstRow = screen.getByText(`PodId:${itemId}`);
// Ctrl+click to open in new tab
fireEvent.click(firstRow, { ctrlKey: true });
await waitFor(() => {
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
expect(openInNewTabMock).toHaveBeenCalledWith(
expect.stringContaining(`selectedItem=PodId%3A${itemId}`),
);
});
});
it('should open row in new tab when meta+click (cmd on Mac) on row', async () => {
await waitFor(() => {
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
});
const firstRow = screen.getByText(`PodId:${itemId}`);
// Meta+click (cmd on Mac) to open in new tab
fireEvent.click(firstRow, { metaKey: true });
await waitFor(() => {
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
expect(openInNewTabMock).toHaveBeenCalledWith(
expect.stringContaining(`selectedItem=PodId%3A${itemId}`),
);
});
});
});
describe('with URL params (orderBy, groupBy, pagination)', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
const groupByValue = [
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
];
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{ id: 'namespace-default', meta: { 'k8s.namespace.name': 'default' } },
],
total: 50,
error: null,
});
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
groupBy: JSON.stringify(groupByValue),
page: '3',
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should call fetchListData with orderBy/groupBy/offset/limit from URL', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.orderBy).toStrictEqual({ columnName: 'cpu', order: 'desc' });
expect(filters.groupBy).toStrictEqual(groupByValue);
expect(filters.offset).toBe(20); // (3 - 1) * 10 = 20
expect(filters.limit).toBe(10);
});
it('should render expand icons when groupBy is set', async () => {
await waitFor(() => {
expect(screen.getByText('namespace-default')).toBeInTheDocument();
});
const expandButtons = screen.getAllByRole('button');
expect(expandButtons.length).toBeGreaterThan(0);
});
it('should render data with groupBy params', async () => {
await waitFor(() => {
expect(screen.getByText('namespace-default')).toBeInTheDocument();
});
// Verify the call was made with correct groupBy
const callWithGroupBy = fetchListDataMock.mock.calls.find(
(c) => c[0].groupBy && c[0].groupBy.length > 0,
);
expect(callWithGroupBy).toBeDefined();
expect(callWithGroupBy?.[0].groupBy).toStrictEqual(groupByValue);
});
});
describe('with empty data', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: null,
rawData: {
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
},
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should display empty state when no data is returned', async () => {
await waitFor(() => {
expect(screen.getByText(/This query had no results/i)).toBeInTheDocument();
});
});
it('should still call fetchListData', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
});
});
describe('with error response', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: 'Failed to fetch pods',
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should call fetchListData even when error occurs', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
});
it('should display error message when data.error is set', async () => {
await waitFor(() => {
expect(screen.getByText(/Failed to fetch pods/i)).toBeInTheDocument();
});
});
});
describe('with no metrics data (sentAnyHostMetricsData=false)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: null,
rawData: {
sentAnyHostMetricsData: false,
isSendingK8SAgentMetrics: false,
},
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should display no metrics data message', async () => {
await waitFor(() => {
expect(
screen.getByText(/No host metrics data received yet/i),
).toBeInTheDocument();
});
});
it('should display link to documentation', async () => {
await waitFor(() => {
const link = screen.getByRole('link', { name: /our documentation/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
);
});
});
});
describe('with incorrect K8s agent metrics (isSendingK8SAgentMetrics=true)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: null,
rawData: {
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: true,
},
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should display upgrade message', async () => {
await waitFor(() => {
expect(
screen.getByText(/upgrade to the latest version of SigNoz k8s-infra/i),
).toBeInTheDocument();
});
});
});
describe('with end time before retention (endTimeBeforeRetention=true)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
total: 0,
error: null,
rawData: {
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: true,
},
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should display time range before retention message', async () => {
await waitFor(() => {
expect(
screen.getByText(/Queried time range is before earliest K8s metrics/i),
).toBeInTheDocument();
expect(
screen.getByText(/please adjust your end time/i),
).toBeInTheDocument();
});
});
});
describe('column visibility based on TanStack columns', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItemWithName>['fetchListData']>,
Parameters<K8sBaseListProps<TestItemWithName>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [{ id: 'item-1', name: 'Item 1', desc: 'Description 1' }],
total: 1,
error: null,
});
});
it('should show all columns defined in tableColumns', async () => {
renderComponent<TestItemWithName>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithName(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
});
// All columns should be visible
expect(
screen.getByRole('columnheader', { name: /id/i }),
).toBeInTheDocument();
expect(
screen.getByRole('columnheader', { name: /name/i }),
).toBeInTheDocument();
});
});
describe('column behavior with groupBy (expanded/collapsed)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItemWithGroup>['fetchListData']>,
Parameters<K8sBaseListProps<TestItemWithGroup>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{
id: 'item-1',
name: 'Item 1',
group: 'Group A',
meta: { 'k8s.namespace.name': 'default' },
},
],
total: 1,
error: null,
});
});
it('should show columns when NOT grouped', async () => {
renderComponent<TestItemWithGroup>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {},
tableColumns: createTestColumnsWithGroup(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
});
// Columns should be visible
expect(
screen.getByRole('columnheader', { name: /id/i }),
).toBeInTheDocument();
});
it('should show columns when grouped', async () => {
const groupByValue = [
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
];
renderComponent<TestItemWithGroup>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
groupBy: JSON.stringify(groupByValue),
},
tableColumns: createTestColumnsWithGroup(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
});
// Id should be visible
expect(
screen.getByRole('columnheader', { name: /id/i }),
).toBeInTheDocument();
});
});
describe('column visibility in expanded row (nested table)', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
>();
const groupByValue = [
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
];
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{ id: 'namespace-default', meta: { 'k8s.namespace.name': 'default' } },
],
total: 50,
error: null,
});
renderComponent<TestItem>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: {
groupBy: JSON.stringify(groupByValue),
},
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should render table with groupBy params and enable expansion', async () => {
await waitFor(() => {
expect(screen.getByText('namespace-default')).toBeInTheDocument();
});
// Verify fetch was called with groupBy
const callWithGroupBy = fetchListDataMock.mock.calls.find(
(c) => c[0].groupBy && c[0].groupBy.length > 0,
);
expect(callWithGroupBy).toBeDefined();
});
});
describe('TanStack table column rendering', () => {
const fetchListDataMock = jest.fn<
ReturnType<K8sBaseListProps<TestItemWithName>['fetchListData']>,
Parameters<K8sBaseListProps<TestItemWithName>['fetchListData']>
>();
beforeEach(() => {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [
{ id: 'item-1', name: 'Item 1', desc: 'Description 1' },
{ id: 'item-2', name: 'Item 2', desc: 'Description 2' },
],
total: 2,
error: null,
});
});
it('should render all defined columns', async () => {
renderComponent<TestItemWithName>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithName(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
});
// All columns should be visible
expect(
screen.getByRole('columnheader', { name: /id/i }),
).toBeInTheDocument();
expect(
screen.getByRole('columnheader', { name: /^name$/i }),
).toBeInTheDocument();
});
it('should render data in cells correctly', async () => {
renderComponent<TestItemWithName>({
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumnsWithName(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
await waitFor(() => {
expect(screen.getByText('item-1')).toBeInTheDocument();
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('item-2')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
});
});
});
});

View File

@@ -1,26 +0,0 @@
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { OrderBySchemaType } from '../schemas';
export type K8sBaseFilters = {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
start: number;
end: number;
orderBy?: OrderBySchemaType;
};
/**
* Type for table row data with required key fields.
* Used when rendering raw data in the table.
*/
export type K8sTableRowData<T> = T & {
key: string;
id: string;
itemKey: string;
/** Metadata about which attributes were used for grouping */
groupedByMeta?: Record<string, string>;
};

View File

@@ -1,114 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface IEntityColumn {
label: string;
value: string;
id: string;
defaultVisibility: boolean;
canBeHidden: boolean;
behavior: 'hidden-on-expand' | 'hidden-on-collapse' | 'always-visible';
}
export interface IInfraMonitoringTableColumnsStore {
columns: Record<string, IEntityColumn[]>;
columnsHidden: Record<string, string[]>;
addColumn: (page: string, columnId: string) => void;
removeColumn: (page: string, columnId: string) => void;
initializePageColumns: (page: string, columns: IEntityColumn[]) => void;
}
export const useInfraMonitoringTableColumnsStore =
create<IInfraMonitoringTableColumnsStore>()(
persist(
(set, get) => ({
columns: {},
columnsHidden: {},
addColumn: (page, columnId): void => {
const state = get();
const columnDefinition = state.columns[page]?.find(
(c) => c.id === columnId,
);
if (!columnDefinition) {
return;
}
if (!columnDefinition.canBeHidden) {
return;
}
const columnsHidden = state.columnsHidden[page];
if (columnsHidden.includes(columnId)) {
set({
columnsHidden: {
...state.columnsHidden,
[page]: columnsHidden.filter((id) => id !== columnId),
},
});
}
},
removeColumn: (page, columnId): void => {
const state = get();
const columnDefinition = state.columns[page]?.find(
(c) => c.id === columnId,
);
if (!columnDefinition) {
return;
}
if (!columnDefinition.canBeHidden) {
return;
}
const columnsHidden = state.columnsHidden[page];
if (!columnsHidden.includes(columnId)) {
set({
columnsHidden: {
...state.columnsHidden,
[page]: [...columnsHidden, columnId],
},
});
}
},
initializePageColumns: (page, columns): void => {
const state = get();
set({
columns: {
...state.columns,
[page]: columns,
},
});
if (state.columnsHidden[page] === undefined) {
set({
columnsHidden: {
...state.columnsHidden,
[page]: columns
.filter((c) => c.defaultVisibility === false)
.map((c) => c.id),
},
});
}
},
}),
{
name: '@signoz/infra-monitoring-columns',
},
),
);
export const useInfraMonitoringTableColumnsForPage = (
page: string,
): [columns: IEntityColumn[], columnsHidden: string[]] => {
const state = useInfraMonitoringTableColumnsStore((s) => s.columns);
const columnsHidden = useInfraMonitoringTableColumnsStore(
(s) => s.columnsHidden,
);
return [state[page] ?? [], columnsHidden[page] ?? []];
};

View File

@@ -1,18 +0,0 @@
.itemDataGroup {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemDataGroupTagItem {
display: block !important;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,100 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import styles from './utils.module.scss';
const dotToUnder: Record<string, string> = {
'os.type': 'os_type',
'host.name': 'host_name',
'deployment.environment': 'deployment_environment',
'k8s.node.name': 'k8s_node_name',
'k8s.cluster.name': 'k8s_cluster_name',
'k8s.node.uid': 'k8s_node_uid',
'k8s.cronjob.name': 'k8s_cronjob_name',
'k8s.daemonset.name': 'k8s_daemonset_name',
'k8s.deployment.name': 'k8s_deployment_name',
'k8s.job.name': 'k8s_job_name',
'k8s.namespace.name': 'k8s_namespace_name',
'k8s.pod.name': 'k8s_pod_name',
'k8s.pod.uid': 'k8s_pod_uid',
'k8s.statefulset.name': 'k8s_statefulset_name',
'k8s.persistentvolumeclaim.name': 'k8s_persistentvolumeclaim_name',
};
export function getGroupedByMeta<T extends { meta?: Record<string, string> }>(
itemData: T,
groupBy: BaseAutocompleteData[],
): Record<string, string> {
const result: Record<string, string> = {};
const meta = itemData.meta ?? {};
groupBy.forEach((group) => {
const rawKey = group.key as string;
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
result[rawKey] = (meta[metaKey] || meta[rawKey]) ?? '';
});
return result;
}
export function getRowKey<T extends { meta?: Record<string, string> }>(
itemData: T,
getItemIdentifier: () => string,
groupBy: BaseAutocompleteData[],
): string {
const nodeIdentifier = getItemIdentifier();
const meta = itemData.meta ?? {};
if (groupBy.length === 0) {
return nodeIdentifier || JSON.stringify(meta);
}
const groupedMeta = getGroupedByMeta(itemData, groupBy);
const groupKey = Object.values(groupedMeta).join('-');
if (groupKey && nodeIdentifier) {
return `${groupKey}-${nodeIdentifier}`;
}
if (groupKey) {
return groupKey;
}
if (nodeIdentifier) {
return nodeIdentifier;
}
return JSON.stringify(meta);
}
export function getGroupByEl<T extends { meta?: Record<string, string> }>(
itemData: T,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode {
const groupByValues: string[] = [];
const meta = itemData.meta ?? {};
groupBy.forEach((group) => {
const rawKey = group.key as string;
// Choose mapped key if present, otherwise use rawKey
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
const value = meta[metaKey] || meta[rawKey] || '<no-value>';
groupByValues.push(value);
});
return (
<div className={styles.itemDataGroup}>
{groupByValues.map((value, index) => (
<Badge
// oxlint-disable-next-line react/no-array-index-key
key={`${index}-${value}`}
color="secondary"
className={styles.itemDataGroupTagItem}
>
{value === '' ? '<no-value>' : value}
</Badge>
))}
</div>
);
}

View File

@@ -1,117 +0,0 @@
import React, { useCallback } from 'react';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { getK8sClustersList, K8sClusterData } from './api';
import {
clusterWidgetInfo,
getClusterMetricsQueryPayload,
k8sClusterDetailsMetadataConfig,
k8sClusterGetEntityName,
k8sClusterGetSelectedItemFilters,
k8sClusterInitialEventsFilter,
k8sClusterInitialLogTracesFilter,
} from './constants';
import {
getK8sClusterItemKey,
getK8sClusterRowKey,
k8sClustersColumnsConfig,
} from './table.config';
function K8sClustersList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
const response = await getK8sClustersList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
rawData: response.payload?.data,
};
},
[dotMetricsEnabled],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sClusterData | null; error?: string | null }> => {
const response = await getK8sClustersList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
undefined,
dotMetricsEnabled,
);
const records = response.payload?.data.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
);
return (
<>
<K8sBaseList<K8sClusterData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.CLUSTERS}
tableColumns={k8sClustersColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sClusterRowKey}
getItemKey={getK8sClusterItemKey}
eventCategory={InfraMonitoringEvents.Cluster}
/>
<K8sBaseDetails<K8sClusterData>
category={InfraMonitoringEntity.CLUSTERS}
eventCategory={InfraMonitoringEvents.Cluster}
getSelectedItemFilters={k8sClusterGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sClusterGetEntityName}
getInitialLogTracesFilters={k8sClusterInitialLogTracesFilter}
getInitialEventsFilters={k8sClusterInitialEventsFilter}
metadataConfig={k8sClusterDetailsMetadataConfig}
entityWidgetInfo={clusterWidgetInfo}
getEntityQueryPayload={getClusterMetricsQueryPayload}
queryKeyPrefix="cluster"
/>
</>
);
}
export default K8sClustersList;

View File

@@ -1,125 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
export interface K8sClustersListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sClusterData {
clusterUID: string;
cpuUsage: number;
cpuAllocatable: number;
memoryUsage: number;
memoryAllocatable: number;
meta: {
k8s_cluster_name: string;
k8s_cluster_uid: string;
};
}
export interface K8sClustersListResponse {
status: string;
data: {
type: string;
records: K8sClusterData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const clustersMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.cluster.uid', under: 'k8s_cluster_uid' },
] as const;
export function mapClustersMeta(
raw: Record<string, unknown>,
): K8sClusterData['meta'] {
const out: Record<string, unknown> = { ...raw };
clustersMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sClusterData['meta'];
}
export const getK8sClustersList = async (
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sClustersListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/clusters/list', requestProps, {
signal,
headers,
});
const payload: K8sClustersListResponse = response.data;
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapClustersMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,156 +0,0 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sClusterData, K8sClustersListPayload } from './api';
import { Boxes } from '@signozhq/icons';
export function getK8sClusterRowKey(cluster: K8sClusterData): string {
return (
cluster.clusterUID ||
cluster.meta.k8s_cluster_uid ||
cluster.meta.k8s_cluster_name
);
}
export function getK8sClusterItemKey(cluster: K8sClusterData): string {
return cluster.meta.k8s_cluster_name;
}
export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
filters: {
items: [],
op: 'and',
},
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sClustersColumnsConfig: TableColumnDef<K8sClusterData>[] = [
{
id: 'clusterGroup',
header: (): React.ReactNode => <EntityGroupHeader title="CLUSTER GROUP" />,
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'clusterName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Cluster Name"
icon={<Boxes data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const clusterName = value as string;
return (
<Tooltip title={clusterName}>
<TanStackTable.Text>{clusterName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="CPU metric"
>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_allocatable',
header: 'CPU Alloc (cores)',
accessorFn: (row): number => row.cpuAllocatable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuAllocatable = value as number;
return (
<ValidateColumnValueWrapper
value={cpuAllocatable}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="CPU allocatable metric"
>
<TanStackTable.Text>{cpuAllocatable}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Memory Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="memory metric"
>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_allocatable',
header: 'Memory Allocatable',
accessorFn: (row): number => row.memoryAllocatable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryAllocatable = value as number;
return (
<ValidateColumnValueWrapper
value={memoryAllocatable}
entity={InfraMonitoringEntity.CLUSTERS}
attribute="memory allocatable metric"
>
<TanStackTable.Text>{formatBytes(memoryAllocatable)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,117 +0,0 @@
import React, { useCallback } from 'react';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { getK8sDaemonSetsList, K8sDaemonSetsData } from './api';
import {
daemonSetWidgetInfo,
getDaemonSetMetricsQueryPayload,
k8sDaemonSetDetailsMetadataConfig,
k8sDaemonSetGetEntityName,
k8sDaemonSetGetSelectedItemFilters,
k8sDaemonSetInitialEventsFilter,
k8sDaemonSetInitialLogTracesFilter,
} from './constants';
import {
getK8sDaemonSetItemKey,
getK8sDaemonSetRowKey,
k8sDaemonSetsColumnsConfig,
} from './table.config';
function K8sDaemonSetsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
const response = await getK8sDaemonSetsList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
rawData: response.payload?.data,
};
},
[dotMetricsEnabled],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sDaemonSetsData | null; error?: string | null }> => {
const response = await getK8sDaemonSetsList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
undefined,
dotMetricsEnabled,
);
const records = response.payload?.data.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
);
return (
<>
<K8sBaseList<K8sDaemonSetsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DAEMONSETS}
tableColumns={k8sDaemonSetsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDaemonSetRowKey}
getItemKey={getK8sDaemonSetItemKey}
eventCategory={InfraMonitoringEvents.DaemonSet}
/>
<K8sBaseDetails<K8sDaemonSetsData>
category={InfraMonitoringEntity.DAEMONSETS}
eventCategory={InfraMonitoringEvents.DaemonSet}
getSelectedItemFilters={k8sDaemonSetGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sDaemonSetGetEntityName}
getInitialLogTracesFilters={k8sDaemonSetInitialLogTracesFilter}
getInitialEventsFilters={k8sDaemonSetInitialEventsFilter}
metadataConfig={k8sDaemonSetDetailsMetadataConfig}
entityWidgetInfo={daemonSetWidgetInfo}
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}
queryKeyPrefix="daemonset"
/>
</>
);
}
export default K8sDaemonSetsList;

View File

@@ -1,119 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { K8sBaseFilters } from '../Base/types';
export interface K8sDaemonSetsData {
daemonSetName: string;
cpuUsage: number;
memoryUsage: number;
cpuRequest: number;
memoryRequest: number;
cpuLimit: number;
memoryLimit: number;
restarts: number;
desiredNodes: number;
availableNodes: number;
meta: {
k8s_cluster_name: string;
k8s_daemonset_name: string;
k8s_namespace_name: string;
};
}
export interface K8sDaemonSetsListResponse {
status: string;
data: {
type: string;
records: K8sDaemonSetsData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const daemonSetsMetaMap = [
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapDaemonSetsMeta(
raw: Record<string, unknown>,
): K8sDaemonSetsData['meta'] {
const out: Record<string, unknown> = { ...raw };
daemonSetsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sDaemonSetsData['meta'];
}
export const getK8sDaemonSetsList = async (
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sDaemonSetsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/daemonsets/list', requestProps, {
signal,
headers,
});
const payload: K8sDaemonSetsListResponse = response.data;
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapDaemonSetsMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,697 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
createFilterItem,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { QUERY_KEYS } from '../EntityDetailsUtils/utils';
import { K8sDaemonSetsData } from './api';
export const k8sDaemonSetGetSelectedItemFilters = (
selectedItemId: string,
): TagFilter => ({
op: 'AND',
items: [
{
id: 'k8s_daemonset_name',
key: {
key: 'k8s_daemonset_name',
type: null,
},
op: '=',
value: selectedItemId,
},
],
});
export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDaemonSetsData>[] =
[
{
label: 'Daemonset Name',
getValue: (p): string => p.meta.k8s_daemonset_name,
},
{
label: 'Cluster Name',
getValue: (p): string => p.meta.k8s_cluster_name,
},
{
label: 'Namespace Name',
getValue: (p): string => p.meta.k8s_namespace_name,
},
];
export const k8sDaemonSetInitialEventsFilter = (
item: K8sDaemonSetsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_OBJECT_KIND, 'DaemonSet'),
createFilterItem(QUERY_KEYS.K8S_OBJECT_NAME, item.meta.k8s_daemonset_name),
];
export const k8sDaemonSetInitialLogTracesFilter = (
item: K8sDaemonSetsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_DAEMON_SET_NAME, item.meta.k8s_daemonset_name),
createFilterItem(QUERY_KEYS.K8S_NAMESPACE_NAME, item.meta.k8s_namespace_name),
];
export const k8sDaemonSetGetEntityName = (item: K8sDaemonSetsData): string =>
item.meta.k8s_daemonset_name;
export const daemonSetWidgetInfo = [
{
title: 'CPU usage, request, limits',
yAxisUnit: '',
},
{
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
},
{
title: 'Network IO',
yAxisUnit: 'binBps',
},
{
title: 'Network errors count',
yAxisUnit: '',
},
];
export const getDaemonSetMetricsQueryPayload = (
daemonSet: K8sDaemonSetsData,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sPodCpuUtilizationKey = dotMetricsEnabled
? 'k8s.pod.cpu.usage'
: 'k8s_pod_cpu_usage';
const k8sContainerCpuRequestKey = dotMetricsEnabled
? 'k8s.container.cpu_request'
: 'k8s_container_cpu_request';
const k8sContainerCpuLimitKey = dotMetricsEnabled
? 'k8s.container.cpu_limit'
: 'k8s_container_cpu_limit';
const k8sPodMemoryUsageKey = dotMetricsEnabled
? 'k8s.pod.memory.usage'
: 'k8s_pod_memory_usage';
const k8sContainerMemoryRequestKey = dotMetricsEnabled
? 'k8s.container.memory_request'
: 'k8s_container_memory_request';
const k8sContainerMemoryLimitKey = dotMetricsEnabled
? 'k8s.container.memory_limit'
: 'k8s_container_memory_limit';
const k8sPodNetworkIoKey = dotMetricsEnabled
? 'k8s.pod.network.io'
: 'k8s_pod_network_io';
const k8sPodNetworkErrorsKey = dotMetricsEnabled
? 'k8s.pod.network.errors'
: 'k8s_pod_network_errors';
const k8sDaemonSetNameKey = dotMetricsEnabled
? 'k8s.daemonset.name'
: 'k8s_daemonset_name';
const k8sPodNameKey = dotMetricsEnabled ? 'k8s.pod.name' : 'k8s_pod_name';
const k8sNamespaceNameKey = dotMetricsEnabled
? 'k8s.namespace.name'
: 'k8s_namespace_name';
return [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_cpu_usage--float64--Gauge--true',
key: k8sPodCpuUtilizationKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '745a486f',
key: {
dataType: DataTypes.String,
id: 'k8s_daemonset_name--string--tag--false',
key: k8sDaemonSetNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_cpu_request--float64--Gauge--true',
key: k8sContainerCpuRequestKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
filters: {
items: [
{
id: '148dffa7',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'requests',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_cpu_limit--float64--Gauge--true',
key: k8sContainerCpuLimitKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'C',
filters: {
items: [
{
id: 'd420a02b',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'limits',
limit: null,
orderBy: [],
queryName: 'C',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_memory_usage--float64--Gauge--true',
key: k8sPodMemoryUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '6d3283ce',
key: {
dataType: DataTypes.String,
id: 'k8s_daemonset_name--string--tag--false',
key: k8sDaemonSetNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_memory_request--float64--Gauge--true',
key: k8sContainerMemoryRequestKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
filters: {
items: [
{
id: 'a334f5c2',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'requests',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_memory_limit--float64--Gauge--true',
key: k8sContainerMemoryLimitKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'C',
filters: {
items: [
{
id: 'fde3c631',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'limits',
limit: null,
orderBy: [],
queryName: 'C',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_io--float64--Sum--true',
key: k8sPodNetworkIoKey,
type: 'Sum',
},
aggregateOperator: 'rate',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'ccbdbd6a',
key: {
dataType: DataTypes.String,
id: 'k8s_daemonset_name--string--tag--false',
key: k8sDaemonSetNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'rate',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_errors--float64--Sum--true',
key: k8sPodNetworkErrorsKey,
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '581a85fb',
key: {
dataType: DataTypes.String,
id: 'k8s_daemonset_name--string--tag--false',
key: k8sDaemonSetNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_daemonset_name,
},
{
id: '47b3adae',
key: {
dataType: DataTypes.String,
id: 'k8s_namespace_name--string--tag--false',
key: k8sNamespaceNameKey,
type: 'tag',
},
op: '=',
value: daemonSet.meta.k8s_namespace_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'increase',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};

View File

@@ -1,241 +0,0 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sDaemonSetsData } from './api';
import { Group } from '@signozhq/icons';
export function getK8sDaemonSetRowKey(daemonSet: K8sDaemonSetsData): string {
return (
daemonSet.daemonSetName ||
daemonSet.meta.k8s_daemonset_name ||
`${daemonSet.meta.k8s_namespace_name}-${daemonSet.meta.k8s_daemonset_name}`
);
}
export function getK8sDaemonSetItemKey(daemonSet: K8sDaemonSetsData): string {
return daemonSet.meta.k8s_daemonset_name;
}
export const k8sDaemonSetsColumnsConfig: TableColumnDef<K8sDaemonSetsData>[] = [
{
id: 'daemonSetGroup',
header: (): React.ReactNode => <EntityGroupHeader title="DAEMONSET GROUP" />,
accessorFn: (row): string => row.meta.k8s_daemonset_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'daemonsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="DaemonSet Name"
icon={<Group data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_daemonset_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const daemonsetName = value as string;
return (
<Tooltip title={daemonsetName}>
<TanStackTable.Text>{daemonsetName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 100 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'available_nodes',
header: 'Available',
accessorFn: (row): number => row.availableNodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const availableNodes = value as number;
return (
<ValidateColumnValueWrapper
value={availableNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="available node"
>
<TanStackTable.Text>{availableNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'desired_nodes',
header: 'Desired',
accessorFn: (row): number => row.desiredNodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const desiredNodes = value as number;
return (
<ValidateColumnValueWrapper
value={desiredNodes}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="desired node"
>
<TanStackTable.Text>{desiredNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 190 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU metric"
>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 190 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 180 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="memory metric"
>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,117 +0,0 @@
import React, { useCallback } from 'react';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
import { K8sBaseList } from '../Base/K8sBaseList';
import { K8sBaseFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { getK8sDeploymentsList, K8sDeploymentsData } from './api';
import {
deploymentWidgetInfo,
getDeploymentMetricsQueryPayload,
k8sDeploymentDetailsMetadataConfig,
k8sDeploymentGetEntityName,
k8sDeploymentGetSelectedItemFilters,
k8sDeploymentInitialEventsFilter,
k8sDeploymentInitialLogTracesFilter,
} from './constants';
import {
getK8sDeploymentItemKey,
getK8sDeploymentRowKey,
k8sDeploymentsColumnsConfig,
} from './table.config';
function K8sDeploymentsList({
controlListPrefix,
}: {
controlListPrefix?: React.ReactNode;
}): JSX.Element {
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const fetchListData = useCallback(
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
filters.orderBy ||= {
columnName: 'cpu',
order: 'desc',
};
const response = await getK8sDeploymentsList(
filters,
signal,
undefined,
dotMetricsEnabled,
);
return {
data: response.payload?.data.records || [],
total: response.payload?.data.total || 0,
error: response.error,
rawData: response.payload?.data,
};
},
[dotMetricsEnabled],
);
const fetchEntityData = useCallback(
async (
filters: K8sDetailsFilters,
signal?: AbortSignal,
): Promise<{ data: K8sDeploymentsData | null; error?: string | null }> => {
const response = await getK8sDeploymentsList(
{
filters: filters.filters,
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
undefined,
dotMetricsEnabled,
);
const records = response.payload?.data.records || [];
return {
data: records.length > 0 ? records[0] : null,
error: response.error,
};
},
[dotMetricsEnabled],
);
return (
<>
<K8sBaseList<K8sDeploymentsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DEPLOYMENTS}
tableColumns={k8sDeploymentsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDeploymentRowKey}
getItemKey={getK8sDeploymentItemKey}
eventCategory={InfraMonitoringEvents.Deployment}
/>
<K8sBaseDetails<K8sDeploymentsData>
category={InfraMonitoringEntity.DEPLOYMENTS}
eventCategory={InfraMonitoringEvents.Deployment}
getSelectedItemFilters={k8sDeploymentGetSelectedItemFilters}
fetchEntityData={fetchEntityData}
getEntityName={k8sDeploymentGetEntityName}
getInitialLogTracesFilters={k8sDeploymentInitialLogTracesFilter}
getInitialEventsFilters={k8sDeploymentInitialEventsFilter}
metadataConfig={k8sDeploymentDetailsMetadataConfig}
entityWidgetInfo={deploymentWidgetInfo}
getEntityQueryPayload={getDeploymentMetricsQueryPayload}
queryKeyPrefix="deployment"
/>
</>
);
}
export default K8sDeploymentsList;

View File

@@ -1,132 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
export interface K8sDeploymentsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sDeploymentsData {
deploymentName: string;
cpuUsage: number;
memoryUsage: number;
desiredPods: number;
availablePods: number;
cpuRequest: number;
memoryRequest: number;
cpuLimit: number;
memoryLimit: number;
restarts: number;
meta: {
k8s_cluster_name: string;
k8s_deployment_name: string;
k8s_namespace_name: string;
};
}
export interface K8sDeploymentsListResponse {
status: string;
data: {
type: string;
records: K8sDeploymentsData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const deploymentsMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
] as const;
export function mapDeploymentsMeta(
raw: Record<string, unknown>,
): K8sDeploymentsData['meta'] {
const out: Record<string, unknown> = { ...raw };
deploymentsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sDeploymentsData['meta'];
}
export const getK8sDeploymentsList = async (
props: K8sBaseFilters,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sDeploymentsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/deployments/list', requestProps, {
signal,
headers,
});
const payload: K8sDeploymentsListResponse = response.data;
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapDeploymentsMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,608 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import {
createFilterItem,
K8sDetailsMetadataConfig,
} from '../Base/K8sBaseDetails';
import { QUERY_KEYS } from '../EntityDetailsUtils/utils';
import { K8sDeploymentsData } from './api';
export const k8sDeploymentGetSelectedItemFilters = (
selectedItemId: string,
): TagFilter => ({
op: 'AND',
items: [
{
id: 'k8s_deployment_name',
key: {
key: 'k8s_deployment_name',
type: null,
},
op: '=',
value: selectedItemId,
},
],
});
export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDeploymentsData>[] =
[
{
label: 'Deployment Name',
getValue: (p): string => p.meta.k8s_deployment_name,
},
{
label: 'Cluster Name',
getValue: (p): string => p.meta.k8s_cluster_name,
},
{
label: 'Namespace Name',
getValue: (p): string => p.meta.k8s_namespace_name,
},
];
export const k8sDeploymentInitialEventsFilter = (
item: K8sDeploymentsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_OBJECT_KIND, 'Deployment'),
createFilterItem(QUERY_KEYS.K8S_OBJECT_NAME, item.meta.k8s_deployment_name),
];
export const k8sDeploymentInitialLogTracesFilter = (
item: K8sDeploymentsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(
QUERY_KEYS.K8S_DEPLOYMENT_NAME,
item.meta.k8s_deployment_name,
),
createFilterItem(QUERY_KEYS.K8S_NAMESPACE_NAME, item.meta.k8s_namespace_name),
];
export const k8sDeploymentGetEntityName = (item: K8sDeploymentsData): string =>
item.meta.k8s_deployment_name;
export const deploymentWidgetInfo = [
{
title: 'CPU usage, request, limits',
yAxisUnit: '',
},
{
title: 'Memory usage, request, limits',
yAxisUnit: 'bytes',
},
{
title: 'Network IO',
yAxisUnit: 'binBps',
},
{
title: 'Network error count',
yAxisUnit: '',
},
];
export const getDeploymentMetricsQueryPayload = (
deployment: K8sDeploymentsData,
start: number,
end: number,
dotMetricsEnabled: boolean,
): GetQueryResultsProps[] => {
const k8sPodCpuUtilizationKey = dotMetricsEnabled
? 'k8s.pod.cpu.usage'
: 'k8s_pod_cpu_usage';
const k8sContainerCpuRequestKey = dotMetricsEnabled
? 'k8s.container.cpu_request'
: 'k8s_container_cpu_request';
const k8sContainerCpuLimitKey = dotMetricsEnabled
? 'k8s.container.cpu_limit'
: 'k8s_container_cpu_limit';
const k8sPodMemoryUsageKey = dotMetricsEnabled
? 'k8s.pod.memory.usage'
: 'k8s_pod_memory_usage';
const k8sContainerMemoryRequestKey = dotMetricsEnabled
? 'k8s.container.memory_request'
: 'k8s_container_memory_request';
const k8sContainerMemoryLimitKey = dotMetricsEnabled
? 'k8s.container.memory_limit'
: 'k8s_container_memory_limit';
const k8sPodNetworkIoKey = dotMetricsEnabled
? 'k8s.pod.network.io'
: 'k8s_pod_network_io';
const k8sPodNetworkErrorsKey = dotMetricsEnabled
? 'k8s.pod.network.errors'
: 'k8s_pod_network_errors';
const k8sDeploymentNameKey = dotMetricsEnabled
? 'k8s.deployment.name'
: 'k8s_deployment_name';
const k8sPodNameKey = dotMetricsEnabled ? 'k8s.pod.name' : 'k8s_pod_name';
return [
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_cpu_usage--float64--Gauge--true',
key: k8sPodCpuUtilizationKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'aec60cba',
key: {
dataType: DataTypes.String,
id: 'k8s_deployment_name--string--tag--false',
key: k8sDeploymentNameKey,
type: 'tag',
},
op: '=',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_cpu_request--float64--Gauge--true',
key: k8sContainerCpuRequestKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
filters: {
items: [
{
id: 'd047ec14',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'requests',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_cpu_limit--float64--Gauge--true',
key: k8sContainerCpuLimitKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'C',
filters: {
items: [
{
id: '750b7856',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'limits',
limit: null,
orderBy: [],
queryName: 'C',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_memory_usage--float64--Gauge--true',
key: k8sPodMemoryUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '768c2f47',
key: {
dataType: DataTypes.String,
id: 'k8s_deployment_name--string--tag--false',
key: k8sDeploymentNameKey,
type: 'tag',
},
op: '=',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'usage',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_memory_request--float64--Gauge--true',
key: k8sContainerMemoryRequestKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
filters: {
items: [
{
id: '1a96fa81',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'requests',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_container_memory_limit--float64--Gauge--true',
key: k8sContainerMemoryLimitKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'C',
filters: {
items: [
{
id: 'e69a2b7e',
key: {
dataType: DataTypes.String,
id: 'k8s_pod_name--string--tag--false',
key: k8sPodNameKey,
type: 'tag',
},
op: 'contains',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [],
having: [],
legend: 'limits',
limit: null,
orderBy: [],
queryName: 'C',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_io--float64--Sum--true',
key: k8sPodNetworkIoKey,
type: 'Sum',
},
aggregateOperator: 'rate',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: '8b550f6d',
key: {
dataType: DataTypes.String,
id: 'k8s_deployment_name--string--tag--false',
key: k8sDeploymentNameKey,
type: 'tag',
},
op: '=',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'rate',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'k8s_pod_network_errors--float64--Sum--true',
key: k8sPodNetworkErrorsKey,
type: 'Sum',
},
aggregateOperator: 'increase',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'A',
filters: {
items: [
{
id: 'e16c1e4a',
key: {
dataType: DataTypes.String,
id: 'k8s_deployment_name--string--tag--false',
key: k8sDeploymentNameKey,
type: 'tag',
},
op: '=',
value: deployment.meta.k8s_deployment_name,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'direction--string--tag--false',
key: 'direction',
type: 'tag',
},
{
dataType: DataTypes.String,
id: 'interface--string--tag--false',
key: 'interface',
type: 'tag',
},
],
having: [],
legend: '{{direction}} :: {{interface}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'increase',
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
id: v4(),
promql: [
{
disabled: false,
legend: '',
name: 'A',
query: '',
},
],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};

View File

@@ -1,246 +0,0 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sDeploymentsData } from './api';
import { Computer } from '@signozhq/icons';
export function getK8sDeploymentRowKey(deployment: K8sDeploymentsData): string {
return deployment.meta.k8s_deployment_name || deployment.deploymentName;
}
export function getK8sDeploymentItemKey(
deployment: K8sDeploymentsData,
): string {
return deployment.meta.k8s_deployment_name;
}
export const k8sDeploymentsColumnsConfig: TableColumnDef<K8sDeploymentsData>[] =
[
{
id: 'deploymentGroup',
header: (): React.ReactNode => (
<EntityGroupHeader title="DEPLOYMENT GROUP" />
),
accessorFn: (row): string => row.meta.k8s_deployment_name || '',
width: { min: 220 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'deploymentName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Deployment Name"
icon={<Computer data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_deployment_name || '',
width: { min: 210 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const deploymentName = value as string;
return (
<Tooltip title={deploymentName}>
<TanStackTable.Text>{deploymentName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 220 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'available_pods',
header: 'Available',
accessorFn: (row): number => row.availablePods,
width: { min: 100 },
enableSort: false,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const availablePods = value as number;
return (
<ValidateColumnValueWrapper
value={availablePods}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="available pod"
>
<TanStackTable.Text>{availablePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'desired_pods',
header: 'Desired',
accessorFn: (row): number => row.desiredPods,
width: { min: 80 },
enableSort: false,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const desiredPods = value as number;
return (
<ValidateColumnValueWrapper
value={desiredPods}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="desired pod"
>
<TanStackTable.Text>{desiredPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU metric"
>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 140 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="memory metric"
>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -1,32 +0,0 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
.content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.icon {
height: 32px;
width: 32px;
}
.title {
font-weight: 600;
}
.subtitle {
font-weight: 400;
color: var(--muted-foreground);
}

View File

@@ -1,32 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import styles from './EntityEmptyState.module.scss';
interface EntityEmptyStateProps {
hasFilters: boolean;
}
export default function EntityEmptyState({
hasFilters,
}: EntityEmptyStateProps): JSX.Element {
return (
<div className={styles.container}>
<div className={styles.content}>
<img src={emptyStateUrl} alt="empty-state" className={styles.icon} />
{hasFilters ? (
<Typography.Text>
<span className={styles.title}>This query had no results. </span>
Edit your query and try again!
</Typography.Text>
) : (
<Typography.Text>
<span className={styles.title}>No data yet. </span>
When we receive data, it will show up here.
</Typography.Text>
)}
</div>
</div>
);
}

View File

@@ -1,40 +0,0 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
.content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.icon {
height: 32px;
width: 32px;
}
.title {
font-weight: 600;
}
.contactSupport {
display: flex;
align-items: center;
margin-top: 8px;
gap: 4px;
cursor: pointer;
}
.contactSupportText {
color: var(--text-robin-400);
font-weight: 500;
}

View File

@@ -1,50 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { ArrowRight } from '@signozhq/icons';
import { openInNewTab } from 'utils/navigation';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import styles from './EntityError.module.scss';
export default function EntityError(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const handleContactSupport = (): void => {
if (isCloudUserVal) {
history.push('/support');
} else {
openInNewTab('https://signoz.io/slack');
}
};
return (
<div className={styles.container}>
<div className={styles.content}>
<img src={awwSnapUrl} alt="error" className={styles.icon} />
<Typography.Text>
<span className={styles.title}>Aw snap :/ </span>
Something went wrong. Please try again or contact support.
</Typography.Text>
<div
className={styles.contactSupport}
onClick={handleContactSupport}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
handleContactSupport();
}
}}
>
<Typography.Link className={styles.contactSupportText}>
Contact Support
</Typography.Link>
<ArrowRight size={14} />
</div>
</div>
</div>
);
}

View File

@@ -1,101 +0,0 @@
.container {
margin-top: 1rem;
}
.filterContainer {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--spacing-6);
border-radius: var(--radius);
border: 1px solid var(--l1-border);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.filterContainerTime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.filterQuerySearch {
flex: 1;
}
.controls {
width: 100%;
display: flex;
justify-content: flex-end;
}
.eventsTable {
margin-top: var(--spacing-8);
:global(.ant-table) {
:global(.ant-table-thead) > tr > th {
padding: 12px;
font-weight: 500;
font-size: 11px;
line-height: 18px;
background: var(--card);
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-style: normal;
font-weight: 600;
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
background-color: transparent;
}
}
:global(.ant-table-cell) {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: var(--card);
border-bottom: none;
}
:global(.ant-table-tbody) > tr:hover > td {
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
}
:global(.ant-table-tbody) > tr > td {
border-bottom: none;
}
:global(.ant-table-thead)
> tr
> th:not(:last-child):not(:global(.ant-table-selection-column)):not(
:global(.ant-table-row-expand-icon-cell)
):not([colspan])::before {
background-color: transparent;
}
:global(.ant-empty-normal) {
visibility: hidden;
}
}
}
.expandIcon {
cursor: pointer;
}

View File

@@ -1,334 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Table, TableColumnsType } from 'antd';
import logEvent from 'api/common/logEvent';
import {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { EventContents } from './EventsContent';
import EventsNotConfigured from './EventsNotConfigured';
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY, useEntityEvents } from './hooks';
import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
import styles from './EntityEvents.module.scss';
import { useTimezone } from 'providers/Timezone';
interface EventDataType {
key: string;
timestamp: string;
body: string;
id: string;
severity: string;
attributes_string?: Record<string, string>;
resources_string?: Record<string, string>;
}
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
const PAGE_SIZE_OPTIONS = [10, 20, 50];
const handleExpandRow = (record: EventDataType): JSX.Element => (
<EventContents
data={{ ...record.attributes_string, ...record.resources_string }}
/>
);
function EntityEventsContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
const initialExpression = useInitialExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const [pagination, setPagination] = useQueryState(
'eventsPagination',
parseAsJsonNoValidate<{ offset: number; limit: number }>(),
);
const pageSize = pagination?.limit || PAGE_SIZE_OPTIONS[0];
const offset = pagination?.offset || 0;
const {
events,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
} = useEntityEvents({
queryKey,
timeRange,
expression,
offset,
pageSize,
});
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(initialExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
initialExpression
? combineInitialAndUserExpression(initialExpression, newUserExpression)
: newUserExpression || '',
);
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.EventsView,
});
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
() =>
getEntityEventsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression: userExpression || '',
}).queryData,
[timeRange.startTime, timeRange.endTime, userExpression],
);
const formattedEvents = useMemo<EventDataType[]>(
() =>
events.map((event) => ({
key: event.data.id,
id: event.data.id,
timestamp: event.timestamp,
body: event.data.body,
severity: event.data.severity_text,
attributes_string: event.data.attributes_string,
resources_string: event.data.resources_string,
})),
[events],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: TableColumnsType<EventDataType> = useMemo(
() => [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
render: (value: string | number): string =>
formatTimezoneAdjustedTimestamp(
typeof value === 'string' ? value : value / 1e6,
),
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
],
[formatTimezoneAdjustedTimestamp],
);
const handleExpandRowIcon = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: EventDataType,
e: React.MouseEvent<HTMLElement, MouseEvent>,
) => void;
record: EventDataType;
}): JSX.Element => {
const handleClick = (e: React.MouseEvent<SVGSVGElement>): void => {
onExpand(record, e as unknown as React.MouseEvent<HTMLElement, MouseEvent>);
};
return expanded ? (
<ChevronDown className={styles.expandIcon} size={14} onClick={handleClick} />
) : (
<ChevronRight
className={styles.expandIcon}
size={14}
onClick={handleClick}
/>
);
};
useEffect(() => {
return (): void => {
void setPagination(null);
};
}, [setPagination]);
const isDataEmpty =
!isLoading && !isFetching && !isError && formattedEvents.length === 0;
const hasAdditionalFilters = !!userExpression?.trim();
return (
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterQuerySearch}>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.LOGS}
onRun={handleRunQuery}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
{isLoading && formattedEvents.length === 0 && <LoadingContainer />}
{isDataEmpty && <EntityEmptyState hasFilters={hasAdditionalFilters} />}
{isError && !isLoading && isEventsKeyNotFoundError(error) && (
<EventsNotConfigured />
)}
{isError && !isLoading && !isEventsKeyNotFoundError(error) && (
<EntityError />
)}
{!isLoading && !isError && formattedEvents.length > 0 && (
<div className={styles.eventsTable}>
<div className={styles.controls}>
<Controls
totalCount={hasMore ? currentCount + 1 : currentCount}
countPerPage={pageSize}
offset={offset}
perPageOptions={PAGE_SIZE_OPTIONS}
isLoading={isFetching}
handleNavigatePrevious={(): void => {
void setPagination({
offset: Math.max(0, offset - pageSize),
limit: pageSize,
});
}}
handleNavigateNext={(): void => {
void setPagination({
offset: offset + pageSize,
limit: pageSize,
});
}}
handleCountItemsPerPageChange={(value): void => {
void setPagination({
offset: 0,
limit: value,
});
}}
/>
</div>
<Table<EventDataType>
loading={isFetching && formattedEvents.length === 0}
columns={columns}
expandable={{
expandedRowRender: handleExpandRow,
rowExpandable: (record): boolean => record.body !== 'Not Expandable',
expandIcon: handleExpandRowIcon,
}}
dataSource={formattedEvents}
pagination={false}
rowKey={(record): string => record.id}
/>
</div>
)}
</div>
);
}
function EntityEvents({ initialExpression, ...rest }: Props): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={K8S_ENTITY_EVENTS_EXPRESSION_KEY}
initialExpression={initialExpression}
persistOnUnmount
>
<EntityEventsContent {...rest} />
</QuerySearchV2Provider>
);
}
export default EntityEvents;

View File

@@ -1,74 +0,0 @@
.eventContentContainer {
padding: var(--spacing-6);
:global(.ant-table) {
background: var(--l1-background);
:global(.ant-table-row:hover) {
:global(.ant-table-cell .value-field .action-btn) {
display: flex;
position: absolute;
top: 50%;
right: 16px;
transform: translateY(-50%);
gap: 4px;
}
}
:global(.ant-table-cell) {
border: 1px solid var(--l2-border) !important;
}
:global(.attribute-name .ant-btn:hover) {
background-color: transparent !important;
}
:global(.attribute-pin .log-attribute-pin) {
padding: 8px;
display: flex;
justify-content: center;
align-items: center;
}
:global(.attribute-pin .pin-attribute-icon) {
border: none;
}
:global(.attribute-pin .pin-attribute-icon.pinned svg) {
fill: var(--bg-robin-500);
}
:global(.value-field-container) {
background: var(--l3-background);
}
:global(.attribute-pin.value-field-container) {
background: var(--l1-background);
}
:global(.value-field-container .value-field) {
font-family: 'Geist Mono';
position: relative;
}
:global(.value-field-container .action-btn) {
display: none;
width: max-content;
position: absolute;
right: 0;
}
:global(.value-field-container .action-btn .filter-btn) {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--l3-background);
padding: 2px 3px;
gap: 3px;
height: 18px;
width: 20px;
}
}
}

View File

@@ -1,52 +0,0 @@
import { useMemo } from 'react';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import styles from './EventsContent.module.scss';
export function EventContents({
data,
}: {
data: Record<string, string> | undefined;
}): JSX.Element {
const tableData = useMemo(
() =>
data ? Object.keys(data).map((key) => ({ key, value: data[key] })) : [],
[data],
);
const columns: ColumnsType<DataType> = [
{
title: 'Key',
dataIndex: 'key',
key: 'key',
width: 50,
align: 'left',
className: 'attribute-pin value-field-container',
render: (field: string): JSX.Element => <FieldRenderer field={field} />,
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 50,
align: 'left',
ellipsis: true,
className: 'attribute-name',
render: (field: string): JSX.Element => <FieldRenderer field={field} />,
},
];
return (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
className={styles.eventContentContainer}
/>
);
}

View File

@@ -1,40 +0,0 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
.content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.icon {
height: 32px;
width: 32px;
}
.title {
font-weight: 600;
}
.learnMore {
display: flex;
align-items: center;
margin-top: 8px;
gap: 4px;
cursor: pointer;
}
.learnMoreText {
color: var(--text-robin-400);
font-weight: 500;
}

View File

@@ -1,46 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { ArrowRight } from '@signozhq/icons';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import styles from './EventsNotConfigured.module.scss';
const K8S_EVENTS_DOCS_URL =
'https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/';
export default function EventsNotConfigured(): JSX.Element {
const handleLearnMore = (): void => {
openInNewTab(K8S_EVENTS_DOCS_URL);
};
return (
<div className={styles.container}>
<div className={styles.content}>
<img src={emptyStateUrl} alt="not-configured" className={styles.icon} />
<Typography.Text>
<span className={styles.title}>No Kubernetes events received yet. </span>
To view events, enable the k8s events receiver in your OpenTelemetry
Collector.
</Typography.Text>
<div
className={styles.learnMore}
onClick={handleLearnMore}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
handleLearnMore();
}
}}
>
<Typography.Link className={styles.learnMoreText}>
Learn how to configure
</Typography.Link>
<ArrowRight size={14} />
</div>
</div>
</div>
);
}

View File

@@ -1,122 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getEntityEventsQueryPayload } from './utils';
export const K8S_ENTITY_EVENTS_EXPRESSION_KEY = 'k8sEntityEventsExpression';
export interface EventRowData {
id: string;
body: string;
severity_text: string;
attributes_string?: Record<string, string>;
resources_string?: Record<string, string>;
}
export interface EventRow {
timestamp: string;
data: EventRowData;
}
export function useEntityEvents({
queryKey,
timeRange,
expression,
offset = 0,
pageSize = 10,
}: {
queryKey: string;
timeRange: { startTime: number; endTime: number };
expression: string;
offset?: number;
pageSize?: number;
}): {
events: EventRow[];
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error?: unknown;
currentCount: number;
hasMore: boolean;
refetch: () => void;
cancel: () => void;
reactQueryKey: unknown[];
} {
const reactQueryKey = useMemo(
() => [
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
[
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
);
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: reactQueryKey,
queryFn: async ({ signal }) => {
const { query } = getEntityEventsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression,
offset,
pageSize,
});
return GetMetricQueryRange(query, ENTITY_VERSION_V5, undefined, signal);
},
enabled: !!expression?.trim(),
});
const result = data?.payload?.data?.newResult?.data?.result?.[0];
const events = useMemo<EventRow[]>(() => {
const list = result?.list;
if (!list) {
return [];
}
return list.map((item) => ({
data: item.data as EventRowData,
timestamp: item.timestamp,
}));
}, [result?.list]);
const currentCount = result?.list?.length || 0;
const hasMore = !!result?.nextCursor || currentCount >= pageSize;
const queryClient = useQueryClient();
const cancel = useCallback(() => {
void queryClient.cancelQueries({
queryKey: reactQueryKey,
});
}, [queryClient, reactQueryKey]);
return {
events,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
reactQueryKey,
};
}

View File

@@ -1,3 +0,0 @@
import Events from './EntityEvents';
export default Events;

View File

@@ -1,130 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
LogsAggregatorOperator,
ReduceOperators,
} from 'types/common/queryBuilder';
import APIError from 'types/api/error';
import { v4 as uuidv4 } from 'uuid';
const K8S_EVENT_KEYS = ['k8s.object.kind', 'k8s.object.name'];
export function isEventsKeyNotFoundError(error: unknown): boolean {
if (!(error instanceof APIError)) {
return false;
}
const errorDetails = error.getErrorDetails();
if (errorDetails.error.code !== 'invalid_input') {
return false;
}
const errors = errorDetails.error.errors || [];
return errors.some((err) =>
K8S_EVENT_KEYS.some((key) =>
err.message?.includes(`key \`${key}\` not found`),
),
);
}
export interface EntityEventsQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
function buildEntityEventsQueryData(
expression: string,
{
offset,
pageSize,
}: {
offset: number;
pageSize: number;
},
): IBuilderQuery {
return {
...initialQueryBuilderFormValuesMap.logs,
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: LogsAggregatorOperator.NOOP,
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
aggregations: [],
filter: { expression },
expression,
having: {
expression: '',
},
disabled: false,
stepInterval: 60,
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
{
columnName: 'id',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset,
pageSize,
};
}
const DEFAULT_PAGE_SIZE = 10;
export function getEntityEventsQueryPayload({
start,
end,
expression,
offset = 0,
pageSize = DEFAULT_PAGE_SIZE,
}: EntityEventsQueryParams): {
query: GetQueryResultsProps;
queryData: IBuilderQuery;
} {
const queryData = buildEntityEventsQueryData(expression, { offset, pageSize });
return {
query: {
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
},
queryData,
};
}

View File

@@ -1,109 +0,0 @@
.container {
margin-top: 1rem;
}
.filterContainer {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--spacing-6);
border-radius: var(--radius);
border: 1px solid var(--l1-border);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.filterContainerTime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.filterQuerySearch {
flex: 1;
}
.logs {
border: 1px solid var(--border);
margin-top: 1rem;
:global(.virtuoso-list) {
overflow-y: hidden !important;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--primary);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
:global(.ant-row) {
width: fit-content;
}
}
.skeletonContainer {
height: 100%;
padding: 16px;
}
}
.listContainer {
flex: 1;
height: calc(100vh - 312px) !important;
display: flex;
height: 100%;
:global(.raw-log-content) {
width: 100%;
text-wrap: inherit;
word-wrap: break-word;
}
}
.listCard {
width: 100%;
margin-top: 12px;
:global(.ant-card-body) {
padding: 0;
height: 100%;
width: 100%;
}
}
.logsLoadingSkeleton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
:global(.ant-skeleton-input-sm) {
height: 18px;
}
}

View File

@@ -1,309 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { isKeyNotFoundError } from '../utils';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY, useInfiniteEntityLogs } from './hooks';
import { getEntityLogsQueryPayload } from './utils';
import styles from './EntityLogs.module.scss';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityLogsContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
const initialExpression = useInitialExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
const onAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
handleCloseLogDetail();
const partExpression = generateFilterQuery({
fieldKey,
fieldValue,
type: getOldLogsOperatorFromNew(operator),
});
const currentUser = userExpression;
const newUser = currentUser.trim()
? `${currentUser} AND ${partExpression}`
: partExpression;
querySearchOnRun(newUser);
},
[userExpression, querySearchOnRun, handleCloseLogDetail],
);
const {
logs,
loadMoreLogs,
hasNextPage,
isFetchingNextPage,
isLoading,
isFetching,
isError,
error,
refetch,
cancel,
} = useInfiniteEntityLogs({
queryKey,
timeRange,
expression,
});
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(initialExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
initialExpression
? combineInitialAndUserExpression(initialExpression, newUserExpression)
: newUserExpression || '',
);
if (validation.isValid) {
querySearchOnRun(newUserExpression);
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.LogsView,
});
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
() =>
getEntityLogsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression: userExpression,
}).queryData,
[timeRange.startTime, timeRange.endTime, userExpression],
);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
});
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => {
return (
<div key={logToRender.id}>
<RawLogView
isTextOverflowEllipsisDisabled
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
isActiveLog={activeLog?.id === logToRender.id}
/>
</div>
);
},
[activeLog, handleSetActiveLog, handleCloseLogDetail],
);
const renderFooter = useCallback(
(): JSX.Element | null => (
<>
{isFetchingNextPage ? (
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
) : !hasNextPage && logs.length > 0 ? (
<div className={styles.logsLoadingSkeleton}> *** End *** </div>
) : null}
</>
),
[isFetchingNextPage, hasNextPage, logs.length],
);
const renderContent = useMemo(
() => (
<Card bordered={false} className={styles.listCard}>
<OverlayScrollbar isVirtuoso>
<Virtuoso
key="entity-logs-virtuoso"
ref={virtuosoRef}
data={logs}
endReached={loadMoreLogs}
totalCount={logs.length}
itemContent={getItemContent}
overscan={200}
components={{
Footer: renderFooter,
}}
/>
</OverlayScrollbar>
</Card>
),
[logs, loadMoreLogs, getItemContent, renderFooter],
);
const showInitialLoading = isLoading || (isFetching && logs.length === 0);
const isKeyNotFound = isKeyNotFoundError(error);
const isDataEmpty =
!showInitialLoading && (!isError || isKeyNotFound) && logs.length === 0;
const hasAdditionalFilters = !!userExpression?.trim();
return (
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterQuerySearch}>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.LOGS}
onRun={handleRunQuery}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
<div className={styles.logs}>
{showInitialLoading && <LogsLoading />}
{isDataEmpty && <EntityEmptyState hasFilters={hasAdditionalFilters} />}
{isError && !isKeyNotFound && !showInitialLoading && <EntityError />}
{!showInitialLoading && (!isError || isKeyNotFound) && logs.length > 0 && (
<div className={styles.listContainer} data-log-detail-ignore="true">
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
</div>
);
}
function EntityLogs({ initialExpression, ...rest }: Props): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={K8S_ENTITY_LOGS_EXPRESSION_KEY}
initialExpression={initialExpression}
persistOnUnmount
>
<EntityLogsContent {...rest} />
</QuerySearchV2Provider>
);
}
export default EntityLogs;

View File

@@ -1,185 +0,0 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
act,
fireEvent,
render,
RenderResult,
screen,
waitFor,
} from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityLogs from '../EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../hooks';
function verifyEntityLogsV5Request({
payload,
expectedOffset,
initialTimeRange,
}: {
payload: QueryRangePayloadV5;
expectedOffset: number;
initialTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
expect(spec.offset).toBe(expectedOffset);
if (initialTimeRange) {
expect(payload.start).toBe(initialTimeRange.start);
expect(payload.end).toBe(initialTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
expect(orderKeys).toContain('id');
}
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
() =>
function MockOverlayScrollbar({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div>{children}</div>;
},
);
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
),
}));
describe('EntityLogs', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
const itemHeight = 100;
beforeEach(() => {
capturedQueryRangePayloads = [];
mockQueryRangeV5WithLogsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedQueryRangePayloads.push(body);
return {};
},
});
});
it('should check if k8s logs pagination flows work properly', async () => {
let renderResult: RenderResult;
let scrollableElement: HTMLElement;
act(() => {
renderResult = render(
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_LOGS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
<EntityLogs
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'
/>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,
);
});
await waitFor(() => {
expect(
screen.queryByText('pending_data_placeholder'),
).not.toBeInTheDocument();
});
await waitFor(() => {
expect(capturedQueryRangePayloads).toHaveLength(1);
});
await waitFor(async () => {
scrollableElement = renderResult.container.querySelector(
'[data-test-id="virtuoso-scroller"]',
) as HTMLElement;
expect(scrollableElement).not.toBeNull();
if (scrollableElement) {
scrollableElement.scrollTop = 99 * itemHeight;
act(() => {
fireEvent.scroll(scrollableElement);
});
}
});
await waitFor(() => {
expect(capturedQueryRangePayloads).toHaveLength(2);
});
const firstPayload = capturedQueryRangePayloads[0];
verifyEntityLogsV5Request({
payload: firstPayload,
expectedOffset: 0,
});
const initialTimeRange = {
start: firstPayload.start,
end: firstPayload.end,
};
const secondPayload = capturedQueryRangePayloads[1];
verifyEntityLogsV5Request({
payload: secondPayload,
expectedOffset: 100,
initialTimeRange,
});
await waitFor(async () => {
scrollableElement = renderResult.container.querySelector(
'[data-test-id="virtuoso-scroller"]',
) as HTMLElement;
expect(scrollableElement).not.toBeNull();
if (scrollableElement) {
scrollableElement.scrollTop = 199 * itemHeight;
act(() => {
fireEvent.scroll(scrollableElement);
});
}
});
await waitFor(() => {
expect(capturedQueryRangePayloads.length).toBeGreaterThanOrEqual(3);
});
const thirdPayload = capturedQueryRangePayloads[2];
verifyEntityLogsV5Request({
payload: thirdPayload,
expectedOffset: 200,
initialTimeRange,
});
});
});

View File

@@ -1,186 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import {
mockQueryRangeV5WithError,
mockQueryRangeV5WithLogsResponse,
} from '../../../../../__tests__/query_range_v5.util';
import { useInfiniteEntityLogs } from '../hooks';
const createWrapper = (): React.FC<{ children: React.ReactNode }> => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
describe('useInfiniteEntityLogs', () => {
const defaultParams = {
queryKey: 'entityLogsTest',
timeRange: { startTime: 1708000000, endTime: 1708003600 },
expression: 'k8s.pod.name = "test"',
};
describe('initial state', () => {
it('should return initial loading state', () => {
mockQueryRangeV5WithLogsResponse({
delay: 100,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.logs).toStrictEqual([]);
});
});
describe('successful data fetching', () => {
it('should return logs after successful fetch', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 5,
hasMore: true,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeFalsy();
expect(result.current.logs).toHaveLength(5);
expect(result.current.isError).toBe(false);
});
it('should set hasNextPage based on response size', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 100,
hasMore: true,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasNextPage).toBe(true);
});
it('should not have next page when response is smaller than page size', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 100,
hasMore: false,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasNextPage).toBe(false);
});
});
describe('empty state', () => {
it('should return empty logs array when no data', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 0,
hasMore: false,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.logs).toStrictEqual([]);
expect(result.current.hasNextPage).toBe(false);
});
});
describe('error handling', () => {
it('should set isError on API failure', async () => {
mockQueryRangeV5WithError('Internal Server Error');
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.logs).toStrictEqual([]);
});
});
describe('load more functionality', () => {
it('should fetch next page when loadMoreLogs is called', async () => {
const requestCount = { count: 0 };
mockQueryRangeV5WithLogsResponse({
pageSize: 100,
offset: 0,
hasMore: true,
onReceiveRequest: () => {
requestCount.count += 1;
if (requestCount.count > 1) {
return { offset: 100, pageSize: 100, hasMore: false };
}
return undefined;
},
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.logs).toHaveLength(100);
expect(result.current.hasNextPage).toBe(true);
expect(requestCount.count).toBe(1);
act(() => {
result.current.loadMoreLogs();
});
await waitFor(() => {
expect(result.current.logs).toHaveLength(150);
});
expect(result.current.hasNextPage).toBe(false);
expect(requestCount.count).toBe(2);
});
});
});

View File

@@ -1,126 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useInfiniteQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { getEntityLogsQueryPayload } from './utils';
export const K8S_ENTITY_LOGS_EXPRESSION_KEY = 'k8sEntityLogsExpression';
export function useInfiniteEntityLogs({
queryKey,
timeRange,
expression,
}: {
queryKey: string;
timeRange: { startTime: number; endTime: number };
expression: string;
}): {
logs: ILog[];
isLoading: boolean;
isFetching: boolean;
isFetchingNextPage: boolean;
isError: boolean;
error?: unknown;
hasNextPage: boolean;
loadMoreLogs: () => void;
refetch: () => void;
cancel: () => void;
reactQueryKey: unknown[];
} {
const reactQueryKey = useMemo(
() => [
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
],
[queryKey, timeRange.startTime, timeRange.endTime, expression],
);
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
isError,
error,
hasNextPage,
fetchNextPage,
refetch,
} = useInfiniteQuery({
queryKey: reactQueryKey,
queryFn: async ({ pageParam = 0, signal }) => {
const { query } = getEntityLogsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression,
offset: pageParam as number,
pageSize: DEFAULT_PER_PAGE_VALUE,
});
return GetMetricQueryRange(query, ENTITY_VERSION_V5, undefined, signal);
},
getNextPageParam: (lastPage, allPages) => {
const list = lastPage?.payload?.data?.newResult?.data?.result?.[0]?.list;
if (!list || list.length < DEFAULT_PER_PAGE_VALUE) {
return;
}
return allPages.length * DEFAULT_PER_PAGE_VALUE;
},
enabled: !!expression?.trim(),
});
const logs = useMemo<ILog[]>(() => {
if (!data?.pages) {
return [];
}
return data.pages.flatMap((page) => {
const list = page.payload.data.newResult.data.result?.[0]?.list;
if (!list) {
return [];
}
return list.map(
(item) =>
({
...item.data,
timestamp: item.timestamp,
}) as ILog,
);
});
}, [data?.pages]);
const loadMoreLogs = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const queryClient = useQueryClient();
const cancel = useCallback(() => {
void queryClient.cancelQueries({
queryKey: reactQueryKey,
});
}, [queryClient, reactQueryKey]);
return {
logs,
isLoading,
isFetching,
isFetchingNextPage,
isError,
error,
hasNextPage: !!hasNextPage,
loadMoreLogs,
refetch,
cancel,
reactQueryKey,
};
}

View File

@@ -1,3 +0,0 @@
import EntityLogs from './EntityLogs';
export default EntityLogs;

View File

@@ -1,108 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
LogsAggregatorOperator,
ReduceOperators,
} from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';
export interface EntityLogsQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
function buildEntityLogsQueryData(
expression: string,
{
offset,
pageSize,
}: {
offset: number;
pageSize: number;
},
): IBuilderQuery {
return {
...initialQueryBuilderFormValuesMap.logs,
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: LogsAggregatorOperator.NOOP,
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
aggregations: [],
filter: { expression },
expression,
having: {
expression: '',
},
disabled: false,
stepInterval: 60,
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
{
columnName: 'id',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset,
pageSize,
};
}
export function getEntityLogsQueryPayload({
start,
end,
expression,
offset = 0,
pageSize = DEFAULT_PER_PAGE_VALUE,
}: EntityLogsQueryParams): {
query: GetQueryResultsProps;
queryData: IBuilderQuery;
} {
const queryData = buildEntityLogsQueryData(expression, { offset, pageSize });
return {
query: {
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
},
queryData,
};
}

View File

@@ -1,52 +0,0 @@
.entityMetricsContainer {
display: flex;
flex-wrap: wrap;
margin-top: 1rem;
margin-left: -12px;
margin-right: -12px;
}
.entityMetricsCol {
flex: 0 0 50%;
max-width: 50%;
padding-left: 12px;
padding-right: 12px;
box-sizing: border-box;
}
.entityMetricsTitle {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.metricsHeader {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.entityMetricsCard {
position: relative;
margin: 8px 0 1rem 0;
height: 300px;
padding: 10px;
border: 1px solid var(--l1-border);
border-radius: 3px;
.chartContainer {
width: 100%;
height: 100%;
}
.noDataContainer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

View File

@@ -1,220 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { buildEntityMetricsChartConfig } from './configBuilder';
import { useEntityMetrics } from './hooks';
import { isKeyNotFoundError } from '../utils';
import styles from './EntityMetrics.module.scss';
import { MetricsTable } from './MetricsTable';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
interface EntityMetricsProps<T> {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
entity: T;
entityWidgetInfo: {
title: string;
yAxisUnit: string;
}[];
getEntityQueryPayload: (
node: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
queryKey: string;
category: InfraMonitoringEntity;
}
function EntityMetrics<T>({
selectedInterval,
entity,
timeRange,
handleTimeChange,
isModalTimeSelection,
entityWidgetInfo,
getEntityQueryPayload,
queryKey,
category,
}: EntityMetricsProps<T>): JSX.Element {
const { visibilities, setElement } = useMultiIntersectionObserver(
entityWidgetInfo.length,
{ threshold: 0.1 },
);
const { queries, chartData, tableData, queryPayloads } = useEntityMetrics({
queryKey,
timeRange,
entity,
getEntityQueryPayload,
visibilities,
category,
});
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { currentQuery } = useQueryBuilder();
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
handleTimeChange('custom', [startTimestamp, endTimestamp]);
},
[handleTimeChange],
);
const configs = useMemo(
() =>
queries.map(({ data }, idx) => {
const panelType = queryPayloads[idx]?.graphType;
if (panelType === PANEL_TYPES.TABLE) {
return null;
}
const widgetTitle = entityWidgetInfo[idx].title
.toLowerCase()
.replace(/\s+/g, '-');
return buildEntityMetricsChartConfig({
id: `${category}-${widgetTitle}`,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse: data?.payload,
timezone,
yAxisUnit: entityWidgetInfo[idx].yAxisUnit,
minTimeScale: timeRange.startTime,
maxTimeScale: timeRange.endTime,
});
}),
[
queries,
queryPayloads,
category,
isDarkMode,
currentQuery,
onDragSelect,
timezone,
entityWidgetInfo,
timeRange.startTime,
timeRange.endTime,
],
);
const renderCardContent = (
query: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>,
idx: number,
): JSX.Element => {
if (
(!query.data && query.isLoading) ||
query.isFetching ||
!visibilities[idx]
) {
return <Skeleton />;
}
if (query.error && !isKeyNotFoundError(query.error)) {
const errorMessage =
(query.error as Error)?.message || 'Something went wrong';
return <div>{errorMessage}</div>;
}
const panelType = queryPayloads[idx]?.graphType;
return (
<div
className={cx(styles.chartContainer, {
[styles.noDataContainer]:
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
{panelType === PANEL_TYPES.TABLE ? (
<MetricsTable
rows={tableData[idx]?.[0]?.rows ?? []}
columns={tableData[idx]?.[0]?.columns ?? []}
/>
) : (
configs[idx] &&
chartData[idx] && (
<TimeSeries
config={configs[idx]}
data={chartData[idx]}
legendConfig={{ position: LegendPosition.BOTTOM }}
width={dimensions.width}
height={dimensions.height}
timezone={timezone}
yAxisUnit={entityWidgetInfo[idx].yAxisUnit}
/>
)
)}
</div>
);
};
return (
<>
<div className={styles.metricsHeader}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime={DEFAULT_TIME_RANGE}
isModalTimeSelection={isModalTimeSelection}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
<div className={styles.entityMetricsContainer}>
{queries.map((query, idx) => (
<div
ref={setElement(idx)}
key={entityWidgetInfo[idx].title}
className={styles.entityMetricsCol}
>
<span className={styles.entityMetricsTitle}>
{entityWidgetInfo[idx].title}
</span>
<div className={styles.entityMetricsCard} ref={graphRef}>
{renderCardContent(query, idx)}
</div>
</div>
))}
</div>
</>
);
}
export default EntityMetrics;

View File

@@ -1,7 +0,0 @@
.table {
height: 100%;
}
.paginationContainer {
margin: 0;
}

View File

@@ -1,99 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { SortState } from 'components/TanStackTableView/types';
import styles from './MetricsTable.module.scss';
import { MetricsColumn } from './utils';
interface MetricsTableProps {
rows: Record<string, string>[];
columns: MetricsColumn[];
}
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 7; // the sweetspot for the amount of items without showing the scrollbar
export function MetricsTable({
rows,
columns,
}: MetricsTableProps): JSX.Element {
const [page, setPage] = useState(DEFAULT_PAGE);
const [limit, setLimit] = useState(DEFAULT_LIMIT);
const [orderBy, setOrderBy] = useState<SortState | null>(null);
const sortedRows = useMemo(() => {
if (!orderBy) {
return rows;
}
const { columnName, order } = orderBy;
return [...rows].sort((a, b) => {
const aVal = parseFloat(a[columnName]) || 0;
const bVal = parseFloat(b[columnName]) || 0;
return order === 'asc' ? aVal - bVal : bVal - aVal;
});
}, [rows, orderBy]);
const paginatedRows = useMemo(() => {
const startIndex = (page - 1) * limit;
return sortedRows.slice(startIndex, startIndex + limit);
}, [sortedRows, page, limit]);
const handlePageChange = useCallback((newPage: number) => {
setPage(newPage);
}, []);
const handleLimitChange = useCallback((newLimit: number) => {
setLimit(newLimit);
setPage(DEFAULT_PAGE);
}, []);
const columnDefs = useMemo(
() =>
columns.map(
(col, index) =>
({
id: col.key,
accessorKey: col.key as keyof Record<string, string>,
header: (): JSX.Element => (
<TanStackTable.Text title={col.label}>{col.label}</TanStackTable.Text>
),
cell: ({ value }): JSX.Element => {
const displayValue = String(value ?? '');
return (
<TanStackTable.Text title={displayValue}>
{displayValue}
</TanStackTable.Text>
);
},
enableMove: false,
enableResize: false,
enableRemove: false,
enableSort: col.isValueColumn,
width: {
min: index === 0 ? 220 : Math.max(col.label?.length * 12, 80) || 100,
},
}) satisfies TableColumnDef<Record<string, string>>,
),
[columns],
);
return (
<TanStackTable<Record<string, string>>
className={styles.table}
data={paginatedRows}
columns={columnDefs}
onSort={setOrderBy}
pagination={{
total: rows.length,
defaultPage: page,
defaultLimit: limit,
onPageChange: handlePageChange,
onLimitChange: handleLimitChange,
showPageSize: false,
}}
paginationClassname={styles.paginationContainer}
getRowKey={(row) => row.key}
getItemKey={(row) => row.key}
/>
);
}

View File

@@ -1,456 +0,0 @@
import { render, screen } from '@testing-library/react';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import uPlot from 'uplot';
import EntityMetrics from '../EntityMetrics';
import { useEntityMetrics } from '../hooks';
jest.mock('../hooks', () => ({
useEntityMetrics: jest.fn(),
}));
const mockUseEntityMetrics = useEntityMetrics as jest.MockedFunction<
typeof useEntityMetrics
>;
jest.mock('../configBuilder', () => ({
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
getId: jest.fn().mockReturnValue('mock-id'),
}),
}));
jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
prepareChartData: jest.fn().mockReturnValue([]),
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
() => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="uplot-chart">TimeSeries Chart</div>
),
}),
);
jest.mock('providers/Timezone', () => ({
useTimezone: (): { timezone: { value: string } } => ({
timezone: { value: 'UTC' },
}),
}));
jest.mock('../MetricsTable', () => ({
__esModule: true,
MetricsTable: jest
.fn()
.mockImplementation(
(): JSX.Element => <div data-testid="metrics-table">Metrics Table</div>,
),
}));
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQuery: (config: any): any => mockUseQuery(config),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): { width: number; height: number } => ({
width: 800,
height: 600,
}),
}));
jest.mock('hooks/useMultiIntersectionObserver', () => ({
useMultiIntersectionObserver: (count: number): any => ({
visibilities: new Array(count).fill(true),
setElement: jest.fn(),
}),
}));
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
},
activeLicenseV3: {
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
license: {
license_key: 'test-license-key',
license_type: 'trial',
org_id: 'test-org-id',
plan_id: 'test-plan-id',
plan_name: 'test-plan-name',
plan_type: 'trial',
plan_version: 'test-plan-version',
},
},
featureFlags: [
{
name: 'DOT_METRICS_ENABLED',
active: false,
},
],
} as any);
const mockEntity = {
id: 'test-entity-1',
name: 'test-entity',
type: 'pod',
};
const mockEntityWidgetInfo = [
{
title: 'CPU Usage',
yAxisUnit: 'percentage',
},
{
title: 'Memory Usage',
yAxisUnit: 'bytes',
},
];
const mockGetEntityQueryPayload = jest.fn().mockReturnValue([
{
query: 'cpu_usage',
start: 1705315200,
end: 1705318800,
},
{
query: 'memory_usage',
start: 1705315200,
end: 1705318800,
},
]);
const mockTimeRange = {
startTime: 1705315200,
endTime: 1705318800,
};
const mockHandleTimeChange = jest.fn();
const mockQueries = [
{
data: {
payload: {
data: {
result: [
{
table: {
rows: [
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '42.5' } },
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '43.2' } },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
},
],
},
},
params: {
compositeQuery: {
panelType: 'time_series',
},
},
},
isLoading: false,
isError: false,
error: null,
},
{
data: {
payload: {
data: {
result: [
{
table: {
rows: [
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '1024' } },
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '1028' } },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
},
],
},
},
params: {
compositeQuery: {
panelType: 'table',
},
},
},
isLoading: false,
isError: false,
error: null,
},
];
const mockLoadingQueries = [
{
data: undefined,
isLoading: true,
isError: false,
error: null,
},
{
data: undefined,
isLoading: true,
isError: false,
error: null,
},
];
const mockErrorQueries = [
{
data: undefined,
isLoading: false,
isError: true,
error: new Error('API Error'),
},
{
data: undefined,
isLoading: false,
isError: true,
error: new Error('Network Error'),
},
];
const mockEmptyQueries = [
{
data: {
payload: {
data: {
result: [],
},
},
params: {
compositeQuery: {
panelType: 'time_series',
},
},
},
isLoading: false,
isError: false,
error: null,
},
{
data: {
payload: {
data: {
result: [],
},
},
params: {
compositeQuery: {
panelType: 'table',
},
},
},
isLoading: false,
isError: false,
error: null,
},
];
const renderEntityMetrics = (overrides = {}): any => {
const defaultProps = {
timeRange: mockTimeRange,
isModalTimeSelection: false,
handleTimeChange: mockHandleTimeChange,
selectedInterval: '5m' as Time,
entity: mockEntity,
entityWidgetInfo: mockEntityWidgetInfo,
getEntityQueryPayload: mockGetEntityQueryPayload,
queryKey: 'test-query-key',
category: InfraMonitoringEntity.PODS,
...overrides,
};
return render(
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>,
);
};
const mockChartData: (uPlot.AlignedData | null)[] = [
[
[1705315200, 1705318800],
[42.5, 43.2],
], // time_series chart data (AlignedData)
null, // table uses tableData
];
const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
null, // time_series uses chartData
[
{
rows: [
{ timestamp: '2024-01-15T10:00:00Z', value: '1024' },
{ timestamp: '2024-01-15T10:01:00Z', value: '1028' },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
], // table data
];
const mockQueryPayloads = [
{ graphType: 'graph' }, // time_series
{ graphType: 'table' }, // table
];
describe('EntityMetrics', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseEntityMetrics.mockReturnValue({
queries: mockQueries as any,
chartData: mockChartData,
tableData: mockTableData,
queryPayloads: mockQueryPayloads as any,
});
mockUseQuery.mockReturnValue({
data: {
data: {
data: {
variables: {},
title: 'Test Dashboard',
},
id: 'test-dashboard-id',
},
},
isLoading: false,
isError: false,
refetch: jest.fn(),
});
});
it('should render metrics with data', () => {
renderEntityMetrics();
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
expect(screen.getByText('Memory Usage')).toBeInTheDocument();
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
});
it('renders loading state when fetching metrics', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockLoadingQueries as any,
chartData: [null, null],
tableData: [null, null],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
expect(screen.getAllByText('CPU Usage')).toHaveLength(1);
expect(screen.getAllByText('Memory Usage')).toHaveLength(1);
});
it('renders error state when query fails', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockErrorQueries as any,
chartData: [null, null],
tableData: [null, null],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
expect(screen.getByText('API Error')).toBeInTheDocument();
expect(screen.getByText('Network Error')).toBeInTheDocument();
});
it('renders empty state when no metrics data', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockEmptyQueries as any,
chartData: [[[]], null],
tableData: [
null,
[
{
rows: [],
columns: [],
},
],
],
queryPayloads: mockQueryPayloads as any,
});
renderEntityMetrics();
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
});
it('calls handleTimeChange when datetime selection changes', () => {
renderEntityMetrics();
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
});
it('renders multiple metric widgets', () => {
renderEntityMetrics();
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
expect(screen.getByText('Memory Usage')).toBeInTheDocument();
});
it('handles different panel types correctly', () => {
renderEntityMetrics();
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
});
it('applies intersection observer for visibility', () => {
renderEntityMetrics();
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
expect.objectContaining({
visibilities: [true, true],
}),
);
});
it('passes correct parameters to useEntityMetrics hook', () => {
renderEntityMetrics();
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: 'test-query-key',
timeRange: mockTimeRange,
entity: mockEntity,
category: InfraMonitoringEntity.PODS,
}),
);
});
});

View File

@@ -1,122 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import {
DrawStyle,
FillMode,
LineInterpolation,
LineStyle,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
import { get } from 'lodash-es';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import uPlot from 'uplot';
export interface EntityMetricsChartConfigProps {
id: string;
isDarkMode: boolean;
currentQuery: Query;
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse?: MetricRangePayloadProps;
timezone: Timezone;
yAxisUnit: string;
minTimeScale?: number;
maxTimeScale?: number;
}
export function buildEntityMetricsChartConfig({
id,
isDarkMode,
currentQuery,
onDragSelect,
apiResponse,
timezone,
yAxisUnit,
minTimeScale,
maxTimeScale,
}: EntityMetricsChartConfigProps): UPlotConfigBuilder {
const stepIntervals = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
) as Record<string, number>;
const minStepInterval = Object.keys(stepIntervals).length
? Math.min(...Object.values(stepIntervals))
: undefined;
const tzDate = (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
const builder = new UPlotConfigBuilder({
id,
onDragSelect,
tzDate,
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
stepInterval: minStepInterval,
});
builder.addScale({
scaleKey: 'x',
time: true,
min: minTimeScale,
max: maxTimeScale,
});
builder.addScale({
scaleKey: 'y',
time: false,
});
builder.addAxis({
scaleKey: 'x',
show: true,
side: 2,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
});
builder.addAxis({
scaleKey: 'y',
show: true,
side: 3,
isDarkMode,
yAxisUnit,
panelType: PANEL_TYPES.TIME_SERIES,
});
if (!apiResponse?.data?.result) {
return builder;
}
apiResponse.data.result.forEach((series) => {
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
const baseLabelName = getLabelName(
series.metric,
series.queryName || '',
series.legend || '',
);
const label = getLegend(series, currentQuery, baseLabelName);
builder.addSeries({
scaleKey: 'y',
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label,
colorMapping: {},
spanGaps: true,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: hasSingleValidPoint,
pointSize: 5,
fillMode: FillMode.None,
isDarkMode,
metric: series.metric,
});
});
return builder;
}

View File

@@ -1,121 +0,0 @@
import { useMemo } from 'react';
import { QueryFunctionContext, useQueries, UseQueryResult } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { getMetricsTableData, MetricsTableData } from './utils';
export interface UseEntityMetricsParams<T> {
queryKey: string;
timeRange: { startTime: number; endTime: number };
entity: T;
getEntityQueryPayload: (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
visibilities: boolean[];
category: InfraMonitoringEntity;
}
export interface UseEntityMetricsResult {
queries: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>[];
chartData: (uPlot.AlignedData | null)[];
tableData: (MetricsTableData[] | null)[];
queryPayloads: GetQueryResultsProps[];
}
export function useEntityMetrics<T>({
queryKey,
timeRange,
entity,
getEntityQueryPayload,
visibilities,
category,
}: UseEntityMetricsParams<T>): UseEntityMetricsResult {
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryPayloads = useMemo(
() =>
getEntityQueryPayload(
entity,
timeRange.startTime,
timeRange.endTime,
dotMetricsEnabled,
),
[
getEntityQueryPayload,
entity,
timeRange.startTime,
timeRange.endTime,
dotMetricsEnabled,
],
);
const queries = useQueries(
queryPayloads.map((payload, index) => ({
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
queryKey: [
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
payload,
ENTITY_VERSION_V5,
category,
],
queryFn: ({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> => GetMetricQueryRange(payload, ENTITY_VERSION_V5, undefined, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),
);
const chartData = useMemo(
() =>
queries.map(({ data }, index) => {
const panelType = queryPayloads[index]?.graphType;
if (panelType === PANEL_TYPES.TABLE) {
return null;
}
return data?.payload ? prepareChartData(data.payload) : null;
}),
[queries, queryPayloads],
);
const tableData = useMemo(
() =>
queries.map(({ data }, index) => {
const panelType = queryPayloads[index]?.graphType;
if (panelType !== PANEL_TYPES.TABLE) {
return null;
}
return getMetricsTableData(data);
}),
[queries, queryPayloads],
);
return {
queries,
chartData,
tableData,
queryPayloads,
};
}

View File

@@ -1,3 +0,0 @@
import EntityMetrics from './EntityMetrics';
export default EntityMetrics;

View File

@@ -1,61 +0,0 @@
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export interface MetricsColumn {
key: string;
label: string;
isValueColumn: boolean;
id?: string;
}
export interface MetricsTableData {
rows: Record<string, string>[];
columns: MetricsColumn[];
}
export const getMetricsTableData = (
data: SuccessResponse<MetricRangePayloadProps> | undefined,
): MetricsTableData[] => {
if (data?.payload?.data?.result?.length) {
const rowsData = (data?.payload.data.result[0] as any).table?.rows;
const columnsData = (data?.payload.data.result[0] as any).table?.columns;
if (!rowsData || !columnsData) {
return [{ rows: [], columns: [] }];
}
// V4 uses builderQueries, V5 already includes legend in column name
const builderQueries = (data.params as any)?.compositeQuery?.builderQueries;
const columns = columnsData.map((columnData: any) => {
if (columnData.isValueColumn) {
// V5: column name already includes legend from convertV5ResponseToLegacy
// V4: need to get legend from builderQueries
const label = builderQueries?.[columnData.name]?.legend || columnData.name;
return {
id: columnData.id || columnData.name,
key: columnData.id || columnData.name,
label,
isValueColumn: true,
};
}
return {
key: columnData.id || columnData.name,
label: columnData.name,
isValueColumn: false,
};
});
if (columns.length === 0) {
return [{ rows: [], columns: [] }];
}
const firstColumnId = columns[0].id || columns[0].key;
const rows = rowsData.map((rowData: any) => ({
...rowData.data,
key: rowData[firstColumnId],
}));
return [{ rows, columns }];
}
return [{ rows: [], columns: [] }];
};

View File

@@ -1,49 +0,0 @@
.container {
margin-top: var(--spacing-8);
}
.filterContainer {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--spacing-6);
border-radius: var(--radius);
border: 1px solid var(--l1-border);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
:global([data-slot='badge'] .ant-typography) {
font-size: 12px;
}
}
}
.filterContainerTime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.filterQuerySearch {
flex: 1;
}
.entityTracesTable {
margin-top: var(--spacing-8);
--typography-color: var(--l2-foreground);
}
.controls {
margin-bottom: 1rem;
width: 100%;
display: flex;
justify-content: flex-end;
}

View File

@@ -1,267 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ResizeTable } from 'components/ResizeTable';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { selectedEntityTracesColumns } from '../utils';
import { isKeyNotFoundError } from '../utils';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY, useEntityTraces } from './hooks';
import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityTracesContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
const initialExpression = useInitialExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const [pagination, setPagination] = useQueryState(
'pagination',
parseAsJsonNoValidate<{ offset: number; limit: number }>(),
);
const pageSize = pagination?.limit || PER_PAGE_OPTIONS[0];
const offset = pagination?.offset || 0;
const {
traces,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
} = useEntityTraces({
queryKey,
timeRange,
expression,
offset,
pageSize,
});
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(initialExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
initialExpression
? combineInitialAndUserExpression(initialExpression, newUserExpression)
: newUserExpression || '',
);
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.TracesView,
});
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
() =>
getEntityTracesQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression: userExpression || '',
}).queryData,
[timeRange.startTime, timeRange.endTime, userExpression],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const traceListColumns = getTraceListColumns(
selectedEntityTracesColumns,
formatTimezoneAdjustedTimestamp,
);
const isKeyNotFound = isKeyNotFoundError(error);
const isDataEmpty =
!isLoading &&
!isFetching &&
(!isError || isKeyNotFound) &&
traces.length === 0;
const hasAdditionalFilters = !!userExpression?.trim();
const handleRowClick = useCallback(() => {
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
category,
view: InfraMonitoringEvents.TracesView,
});
}, [category]);
useEffect(() => {
return (): void => {
void setPagination(null);
};
}, [setPagination]);
return (
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterQuerySearch}>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.TRACES}
onRun={handleRunQuery}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
{isLoading && traces.length === 0 && <TracesLoading />}
{isDataEmpty && <EntityEmptyState hasFilters={hasAdditionalFilters} />}
{isError && !isKeyNotFound && !isLoading && <EntityError />}
{(!isError || isKeyNotFound) && traces.length > 0 && (
<div className={styles.entityTracesTable}>
<div className={styles.controls}>
<Controls
totalCount={hasMore ? currentCount + 1 : currentCount}
countPerPage={pageSize}
offset={offset}
perPageOptions={PER_PAGE_OPTIONS}
isLoading={false}
handleNavigatePrevious={(): void => {
void setPagination({
offset: Math.max(0, offset - pageSize),
limit: pageSize,
});
}}
handleNavigateNext={(): void => {
void setPagination({
offset: offset + pageSize,
limit: pageSize,
});
}}
handleCountItemsPerPageChange={(value): void => {
void setPagination({
offset: 0,
limit: value,
});
}}
/>
</div>
<ResizeTable
tableLayout="fixed"
pagination={false}
scroll={{ x: true }}
loading={isFetching && traces.length === 0}
dataSource={traces}
columns={traceListColumns}
onRow={(): Record<string, unknown> => ({
onClick: (): void => handleRowClick(),
})}
/>
</div>
)}
</div>
);
}
function EntityTraces({ initialExpression, ...rest }: Props): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={K8S_ENTITY_TRACES_EXPRESSION_KEY}
initialExpression={initialExpression}
persistOnUnmount
>
<EntityTracesContent {...rest} />
</QuerySearchV2Provider>
);
}
export default EntityTraces;

View File

@@ -1,98 +0,0 @@
import { act, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { renderEntityTraces, verifyQueryPayload } from './testUtils';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
describe('EntityTraces - Default Behavior', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
});
it('should fetch traces using V5 API on initial render', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedOffset: 0,
});
});
it('should pass time range to API (converted to milliseconds)', async () => {
const timeRange = { startTime: 1000, endTime: 2000 };
act(() => {
renderEntityTraces({ timeRange });
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
verifyQueryPayload({
payload: capturedPayloads[0],
expectedTimeRange: {
start: timeRange.startTime * 1000,
end: timeRange.endTime * 1000,
},
});
});
it('should order results by timestamp desc', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
order?: Array<{ key: { name: string }; direction: string }>;
};
const timestampOrder = spec.order?.find((o) => o.key.name === 'timestamp');
expect(timestampOrder?.direction).toBe('desc');
});
it('should use TRACES signal type', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
const query = capturedPayloads[0].compositeQuery.queries[0];
const spec = query?.spec as { signal?: string };
expect(spec?.signal).toBe('traces');
});
it('should use raw request type for traces list view', async () => {
act(() => {
renderEntityTraces();
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
expect(capturedPayloads[0].requestType).toBe('raw');
});
});

View File

@@ -1,38 +0,0 @@
import { act, screen } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import { mockQueryRangeV5WithEmptyTraces } from '__tests__/query_range_v5.util';
describe('EntityTraces - Empty State', () => {
it('should show empty state when no traces returned', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
});
it('should not show table when traces are empty', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('should not show pagination controls when traces are empty', async () => {
mockQueryRangeV5WithEmptyTraces();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
expect(screen.queryByText(/1 - 10/)).not.toBeInTheDocument();
});
});

View File

@@ -1,44 +0,0 @@
import { act, screen } from 'tests/test-utils';
import { renderEntityTraces } from './testUtils';
import {
mockQueryRangeV5WithError,
mockQueryRangeV5WithKeyNotFoundError,
} from '__tests__/query_range_v5.util';
describe('EntityTraces - Error State', () => {
it('should show error state on API error', async () => {
mockQueryRangeV5WithError('Internal server error', 500);
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/Something went wrong/i),
).resolves.toBeInTheDocument();
});
it('should show empty state for key not found error', async () => {
mockQueryRangeV5WithKeyNotFoundError();
act(() => {
renderEntityTraces();
});
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
});
it('should not show table on API error', async () => {
mockQueryRangeV5WithError('Internal server error', 500);
act(() => {
renderEntityTraces();
});
await expect(
screen.findByText(/Something went wrong/i),
).resolves.toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
});

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