Compare commits

..

6 Commits

Author SHA1 Message Date
nikhilmantri0902
dff443f68c chore: added tests 2026-07-21 20:21:55 +05:30
nikhilmantri0902
dadec2acc4 chore: using the type directly instead of a middle variable 2026-07-21 19:49:04 +05:30
nikhilmantri0902
51edea502d chore: strong typing added for meta map 2026-07-21 19:30:59 +05:30
Vinicius Lourenço
e93792d427 fix(overlayscrollbars): leaking memory due to img.load event listener (#12179)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-07-21 12:07:55 +00:00
Vinicius Lourenço
6d0f618a9e feat(infra-monitoring): persist custom time on drawer across categories (#12038)
* feat(globalTime): add method to reset time of child to parent

* refactor(infra-monitoring): extract content from K8sBaseDetails

* feat(entity-details): add support to persist custom time across categories of k8s

* test(entity-details): update existing tests

* test(entity-details): add regression test for time conversion

* fix(k8s-list): ensure default value for list is 30m

* fix(entity-counts-section): address todo around inherit selected time

* fix(pr): missing fix for tab validation, lost of moving/merge
2026-07-21 11:58:26 +00:00
Ashwin Bhatkal
564d479aae test(e2e): fix teardown crashes (network endpoint race + eager keeper lookup) (#12204)
* test(e2e): retry network teardown to survive async endpoint detach

`--teardown` fails on CI with `docker.errors.APIError: 403 ... error while
removing network ... has active endpoints`: containers are torn down before
the network, but Docker detaches endpoints asynchronously, so the network
can still report active endpoints for a moment. Retry the removal,
force-disconnecting any stragglers each round.

* test(e2e): resolve keeper coordinator lazily so --teardown doesn't crash

`create_clickhouse` / `create_clickhouse_cluster` computed
`next(iter(keeper.container_configs.values()))` eagerly. Under `--teardown`
the keeper fixture is empty, so it raised StopIteration during teardown
setup, before any finalizer ran. Move the lookup inside create().
2026-07-21 11:44:30 +00:00
78 changed files with 2965 additions and 1443 deletions

View File

@@ -4247,6 +4247,15 @@ components:
- missingOptionalMetrics
- missingRequiredAttributes
type: object
InframonitoringtypesClusterMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
required:
- k8s.cluster.name
type: object
InframonitoringtypesClusterRecord:
properties:
clusterCPU:
@@ -4292,10 +4301,7 @@ components:
- statefulSets
type: object
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesClusterMeta'
nodeCountsByReadiness:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
@@ -4387,6 +4393,51 @@ components:
- containerCannotRun
- unknown
type: object
InframonitoringtypesContainerMeta:
additionalProperties:
type: string
properties:
container.image.name:
type: string
container.image.tag:
type: string
k8s.cluster.name:
type: string
k8s.container.name:
type: string
k8s.cronjob.name:
type: string
k8s.daemonset.name:
type: string
k8s.deployment.name:
type: string
k8s.job.name:
type: string
k8s.namespace.name:
type: string
k8s.node.name:
type: string
k8s.pod.name:
type: string
k8s.pod.uid:
type: string
k8s.statefulset.name:
type: string
required:
- k8s.pod.uid
- k8s.container.name
- k8s.pod.name
- container.image.name
- container.image.tag
- k8s.namespace.name
- k8s.node.name
- k8s.deployment.name
- k8s.statefulset.name
- k8s.daemonset.name
- k8s.job.name
- k8s.cronjob.name
- k8s.cluster.name
type: object
InframonitoringtypesContainerReady:
enum:
- ready
@@ -4420,10 +4471,7 @@ components:
format: double
type: number
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesContainerMeta'
podUID:
type: string
ready:
@@ -4486,6 +4534,21 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesDaemonSetMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
k8s.daemonset.name:
type: string
k8s.namespace.name:
type: string
required:
- k8s.daemonset.name
- k8s.namespace.name
- k8s.cluster.name
type: object
InframonitoringtypesDaemonSetRecord:
properties:
currentNodes:
@@ -4513,10 +4576,7 @@ components:
desiredNodes:
type: integer
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesDaemonSetMeta'
misscheduledNodes:
type: integer
podCountsByPhase:
@@ -4561,6 +4621,21 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesDeploymentMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
k8s.deployment.name:
type: string
k8s.namespace.name:
type: string
required:
- k8s.deployment.name
- k8s.namespace.name
- k8s.cluster.name
type: object
InframonitoringtypesDeploymentRecord:
properties:
availablePods:
@@ -4588,10 +4663,7 @@ components:
desiredPods:
type: integer
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesDeploymentMeta'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
@@ -4637,6 +4709,18 @@ components:
filterByStatus:
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
type: object
InframonitoringtypesHostMeta:
additionalProperties:
type: string
properties:
host.name:
type: string
os.type:
type: string
required:
- os.type
- host.name
type: object
InframonitoringtypesHostRecord:
properties:
activeHostCount:
@@ -4658,10 +4742,7 @@ components:
format: double
type: number
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesHostMeta'
status:
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
wait:
@@ -4705,6 +4786,21 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesJobMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
k8s.job.name:
type: string
k8s.namespace.name:
type: string
required:
- k8s.job.name
- k8s.namespace.name
- k8s.cluster.name
type: object
InframonitoringtypesJobRecord:
properties:
activePods:
@@ -4734,10 +4830,7 @@ components:
jobName:
type: string
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesJobMeta'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
@@ -4831,6 +4924,18 @@ components:
- message
- documentationLink
type: object
InframonitoringtypesNamespaceMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
k8s.namespace.name:
type: string
required:
- k8s.namespace.name
- k8s.cluster.name
type: object
InframonitoringtypesNamespaceRecord:
properties:
counts:
@@ -4854,10 +4959,7 @@ components:
- statefulSets
type: object
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesNamespaceMeta'
namespaceCPU:
format: double
type: number
@@ -4915,15 +5017,27 @@ components:
- ready
- notReady
type: object
InframonitoringtypesNodeMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
k8s.node.name:
type: string
k8s.node.uid:
type: string
required:
- k8s.node.uid
- k8s.cluster.name
- k8s.node.name
type: object
InframonitoringtypesNodeRecord:
properties:
condition:
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesNodeMeta'
nodeCPU:
format: double
type: number
@@ -5053,6 +5167,45 @@ components:
- shutdown
- unexpectedAdmissionError
type: object
InframonitoringtypesPodMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
k8s.cronjob.name:
type: string
k8s.daemonset.name:
type: string
k8s.deployment.name:
type: string
k8s.job.name:
type: string
k8s.namespace.name:
type: string
k8s.node.name:
type: string
k8s.pod.name:
type: string
k8s.pod.start_time:
type: string
k8s.pod.uid:
type: string
k8s.statefulset.name:
type: string
required:
- k8s.pod.uid
- k8s.pod.name
- k8s.namespace.name
- k8s.node.name
- k8s.deployment.name
- k8s.statefulset.name
- k8s.daemonset.name
- k8s.job.name
- k8s.cronjob.name
- k8s.cluster.name
- k8s.pod.start_time
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -5065,10 +5218,7 @@ components:
InframonitoringtypesPodRecord:
properties:
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesPodMeta'
podAge:
format: int64
type: integer
@@ -5452,6 +5602,21 @@ components:
- list
- grouped_list
type: string
InframonitoringtypesStatefulSetMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
k8s.namespace.name:
type: string
k8s.statefulset.name:
type: string
required:
- k8s.statefulset.name
- k8s.namespace.name
- k8s.cluster.name
type: object
InframonitoringtypesStatefulSetRecord:
properties:
currentPods:
@@ -5459,10 +5624,7 @@ components:
desiredPods:
type: integer
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesStatefulSetMeta'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
@@ -5521,13 +5683,37 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesVolumeMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
k8s.namespace.name:
type: string
k8s.node.name:
type: string
k8s.persistentvolumeclaim.name:
type: string
k8s.pod.name:
type: string
k8s.pod.uid:
type: string
k8s.statefulset.name:
type: string
required:
- k8s.persistentvolumeclaim.name
- k8s.pod.uid
- k8s.pod.name
- k8s.namespace.name
- k8s.node.name
- k8s.statefulset.name
- k8s.cluster.name
type: object
InframonitoringtypesVolumeRecord:
properties:
meta:
additionalProperties:
type: string
nullable: true
type: object
$ref: '#/components/schemas/InframonitoringtypesVolumeMeta'
persistentVolumeClaimName:
type: string
volumeAvailable:

View File

@@ -89,7 +89,7 @@
"lodash-es": "^4.17.21",
"motion": "12.4.13",
"nuqs": "2.8.8",
"overlayscrollbars": "^2.8.1",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
"posthog-js": "1.298.0",
@@ -221,5 +221,18 @@
"*.(scss|css)": [
"stylelint"
]
},
"overrides": {
"@babel/core@<=7.29.0": ">=7.29.6 <8",
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
"cookie@<0.7.0": ">=0.7.1 <1",
"dompurify@<=3.4.10": ">=3.4.11 <4",
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
"js-cookie@<=3.0.5": ">=3.0.7 <4",
"js-yaml@>=4.0.0 <=4.1.1": ">=4.2.0 <5",
"prismjs@<1.30.0": ">=1.30.0 <2",
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
}
}
}

View File

@@ -187,11 +187,11 @@ importers:
specifier: 2.8.8
version: 2.8.8(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.4(react@18.2.0))(react@18.2.0)
overlayscrollbars:
specifier: ^2.8.1
version: 2.9.2
specifier: ^2.16.0
version: 2.16.0
overlayscrollbars-react:
specifier: ^0.5.6
version: 0.5.6(overlayscrollbars@2.9.2)(react@18.2.0)
version: 0.5.6(overlayscrollbars@2.16.0)(react@18.2.0)
papaparse:
specifier: 5.4.1
version: 5.4.1
@@ -6941,8 +6941,8 @@ packages:
overlayscrollbars: ^2.0.0
react: '>=16.8.0'
overlayscrollbars@2.9.2:
resolution: {integrity: sha512-iDT84r39i7oWP72diZN2mbJUsn/taCq568aQaIrc84S87PunBT7qtsVltAF2esk7ORTRjQDnfjVYoqqTzgs8QA==}
overlayscrollbars@2.16.0:
resolution: {integrity: sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==}
oxfmt@0.54.0:
resolution: {integrity: sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==}
@@ -16456,12 +16456,12 @@ snapshots:
outvariant@1.4.0: {}
overlayscrollbars-react@0.5.6(overlayscrollbars@2.9.2)(react@18.2.0):
overlayscrollbars-react@0.5.6(overlayscrollbars@2.16.0)(react@18.2.0):
dependencies:
overlayscrollbars: 2.9.2
overlayscrollbars: 2.16.0
react: 18.2.0
overlayscrollbars@2.9.2: {}
overlayscrollbars@2.16.0: {}
oxfmt@0.54.0:
dependencies:

View File

@@ -0,0 +1,20 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
export function mockFieldsAPIsWithEmptyResponse(): void {
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 } }),
),
),
);
}

View File

@@ -2,29 +2,6 @@ import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import APIError from 'types/api/error';
// The wire shape these handlers can actually rely on. The generated
// RenderErrorResponseDTO marks code/message/url/errors as required, but the
// server omits any of them even on valid errors (e.g. a 400 with just a
// message), so a present `error` object is all the guard can guarantee.
type ErrorEnvelope = {
error: {
code?: string;
message?: string;
url?: string;
errors?: { message?: string }[];
};
};
function isErrorEnvelope(data: unknown): data is ErrorEnvelope {
return (
typeof data === 'object' &&
data !== null &&
'error' in data &&
typeof (data as ErrorEnvelope).error === 'object' &&
(data as ErrorEnvelope).error !== null
);
}
// @deprecated Use convertToApiError instead
export function ErrorResponseHandlerForGeneratedAPIs(
error: AxiosError<RenderErrorResponseDTO>,
@@ -33,29 +10,15 @@ export function ErrorResponseHandlerForGeneratedAPIs(
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (response) {
// The body isn't guaranteed to be an error envelope — e.g. a gateway 5xx
// with an HTML/empty body during a deploy. Verify the shape before reading
// it; otherwise synthesize a consistent error from the status.
const data: unknown = response.data;
if (isErrorEnvelope(data)) {
const { code, message, url, errors } = data.error;
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: code ?? '',
message: message ?? '',
url: url ?? '',
errors: (errors ?? []).map((e) => ({ message: e.message ?? '' })),
},
});
}
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: 'UPSTREAM_UNAVAILABLE',
message: error.message || 'Something went wrong',
url: '',
errors: [],
code: response.data.error.code,
message: response.data.error.message,
url: response.data.error.url ?? '',
errors: (response.data.error.errors ?? []).map((e) => ({
message: e.message ?? '',
})),
},
});
}
@@ -99,7 +62,9 @@ export function convertToApiError(
return new APIError({
httpStatusCode: response?.status || error.status || 500,
error: {
code: errorData?.code || 'UPSTREAM_UNAVAILABLE',
code:
errorData?.code ||
String(response?.status || error.code || 'unknown_error'),
message:
errorData?.message ||
response?.statusText ||

View File

@@ -2,38 +2,19 @@ import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
function isErrorV2Resp(data: unknown): data is ErrorV2Resp {
return (
typeof data === 'object' &&
data !== null &&
'error' in data &&
typeof (data as ErrorV2Resp).error?.code === 'string'
);
}
// reference - https://axios-http.com/docs/handling_errors
export function ErrorResponseHandlerV2(error: AxiosError<ErrorV2Resp>): never {
const { response, request } = error;
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (response) {
// response.data isn't guaranteed to be a V2 envelope (e.g. a gateway 5xx
// with an HTML/empty body during a deploy), so verify the shape first.
const data: unknown = response.data;
if (isErrorV2Resp(data)) {
const { code, message, url, errors } = data.error;
throw new APIError({
httpStatusCode: response.status || 500,
error: { code, message, url, errors },
});
}
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: 'UPSTREAM_UNAVAILABLE',
message: error.message || 'Something went wrong',
url: '',
errors: [],
code: response.data.error.code,
message: response.data.error.message,
url: response.data.error.url,
errors: response.data.error.errors,
},
});
}

View File

@@ -1,126 +0,0 @@
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
function asAxiosError(partial: Partial<AxiosError>): AxiosError<ErrorV2Resp> {
return partial as AxiosError<ErrorV2Resp>;
}
// ErrorResponseHandlerV2 always throws — capture the APIError so assertions stay
// unconditional.
function runHandler(error: AxiosError<ErrorV2Resp>): APIError {
try {
ErrorResponseHandlerV2(error);
} catch (thrown) {
return thrown as APIError;
}
throw new Error('expected ErrorResponseHandlerV2 to throw');
}
type ExpectedError = {
httpStatusCode: number;
code: string;
message: string;
errors: { message: string }[];
};
// One row per response shape the handler must normalize. New shapes (with
// different bodies) can be added here without a new test block.
const cases: {
name: string;
error: AxiosError<ErrorV2Resp>;
expected: ExpectedError;
}[] = [
{
name: 'well-formed V2 error envelope',
error: asAxiosError({
message: 'Request failed with status code 400',
response: {
status: 400,
data: {
error: {
code: 'bad_request',
message: 'Invalid dashboard payload',
url: 'https://signoz.io/docs',
errors: [{ message: 'name is required' }, { message: 'name too long' }],
},
},
} as AxiosError['response'],
}),
expected: {
httpStatusCode: 400,
code: 'bad_request',
message: 'Invalid dashboard payload',
errors: [{ message: 'name is required' }, { message: 'name too long' }],
},
},
{
// Regression: during a deployment the gateway returns a 5xx with a
// non-envelope body. Reading response.data.error.code used to throw a
// TypeError from inside the handler itself. See engineering-pod#5760.
name: '5xx with a non-envelope HTML body',
error: asAxiosError({
message: 'Request failed with status code 503',
response: {
status: 503,
data: '<html><body>503 Service Temporarily Unavailable</body></html>',
} as AxiosError['response'],
}),
expected: {
httpStatusCode: 503,
code: 'UPSTREAM_UNAVAILABLE',
message: 'Request failed with status code 503',
errors: [],
},
},
{
name: '5xx with an empty body',
error: asAxiosError({
message: 'Request failed with status code 502',
response: {
status: 502,
data: undefined,
} as AxiosError['response'],
}),
expected: {
httpStatusCode: 502,
code: 'UPSTREAM_UNAVAILABLE',
message: 'Request failed with status code 502',
errors: [],
},
},
{
name: 'no response received (network error)',
error: asAxiosError({
message: 'Network Error',
code: 'ERR_NETWORK',
name: 'AxiosError',
request: {},
}),
expected: {
httpStatusCode: 500,
code: 'ERR_NETWORK',
message: 'Network Error',
errors: [],
},
},
];
describe('ErrorResponseHandlerV2', () => {
it.each(cases)(
'normalizes $name into a consistent APIError',
({ error, expected }) => {
const apiError = runHandler(error);
expect(apiError).toBeInstanceOf(APIError);
expect(apiError.getHttpStatusCode()).toBe(expected.httpStatusCode);
expect(apiError.getErrorCode()).toBe(expected.code);
expect(apiError.getErrorMessage()).toBe(expected.message);
// The sub-error messages feed several parts of the UI, so assert them.
expect(apiError.getErrorDetails().error.errors).toStrictEqual(
expected.errors,
);
},
);
});

View File

@@ -5681,6 +5681,14 @@ export interface InframonitoringtypesChecksDTO {
type: InframonitoringtypesCheckTypeDTO;
}
export interface InframonitoringtypesClusterMetaDTO {
/**
* @type string
*/
'k8s.cluster.name': string;
[key: string]: string;
}
export type InframonitoringtypesClusterRecordDTOCounts = {
/**
* @type integer
@@ -5714,16 +5722,6 @@ export type InframonitoringtypesClusterRecordDTOCounts = {
statefulSets: number;
};
export type InframonitoringtypesClusterRecordDTOMetaAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesClusterRecordDTOMeta =
InframonitoringtypesClusterRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNodeCountsByReadinessDTO {
/**
* @type integer
@@ -5862,10 +5860,7 @@ export interface InframonitoringtypesClusterRecordDTO {
* @type object
*/
counts: InframonitoringtypesClusterRecordDTOCounts;
/**
* @type object,null
*/
meta: InframonitoringtypesClusterRecordDTOMeta;
meta: InframonitoringtypesClusterMetaDTO;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
@@ -5980,21 +5975,67 @@ export interface InframonitoringtypesContainerCountsByStatusDTO {
waiting: number;
}
export interface InframonitoringtypesContainerMetaDTO {
/**
* @type string
*/
'container.image.name': string;
/**
* @type string
*/
'container.image.tag': string;
/**
* @type string
*/
'k8s.cluster.name': string;
/**
* @type string
*/
'k8s.container.name': string;
/**
* @type string
*/
'k8s.cronjob.name': string;
/**
* @type string
*/
'k8s.daemonset.name': string;
/**
* @type string
*/
'k8s.deployment.name': string;
/**
* @type string
*/
'k8s.job.name': string;
/**
* @type string
*/
'k8s.namespace.name': string;
/**
* @type string
*/
'k8s.node.name': string;
/**
* @type string
*/
'k8s.pod.name': string;
/**
* @type string
*/
'k8s.pod.uid': string;
/**
* @type string
*/
'k8s.statefulset.name': string;
[key: string]: string;
}
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',
@@ -6048,10 +6089,7 @@ export interface InframonitoringtypesContainerRecordDTO {
* @format double
*/
memoryRequestUtilization: number;
/**
* @type object,null
*/
meta: InframonitoringtypesContainerRecordDTOMeta;
meta: InframonitoringtypesContainerMetaDTO;
/**
* @type string
*/
@@ -6082,15 +6120,21 @@ export interface InframonitoringtypesContainersDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
export interface InframonitoringtypesDaemonSetMetaDTO {
/**
* @type string
*/
'k8s.cluster.name': string;
/**
* @type string
*/
'k8s.daemonset.name': string;
/**
* @type string
*/
'k8s.namespace.name': string;
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesDaemonSetRecordDTOMeta =
InframonitoringtypesDaemonSetRecordDTOMetaAnyOf | null;
}
export interface InframonitoringtypesDaemonSetRecordDTO {
/**
@@ -6135,10 +6179,7 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
* @type integer
*/
desiredNodes: number;
/**
* @type object,null
*/
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
meta: InframonitoringtypesDaemonSetMetaDTO;
/**
* @type integer
*/
@@ -6168,15 +6209,21 @@ export interface InframonitoringtypesDaemonSetsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesDeploymentRecordDTOMetaAnyOf = {
export interface InframonitoringtypesDeploymentMetaDTO {
/**
* @type string
*/
'k8s.cluster.name': string;
/**
* @type string
*/
'k8s.deployment.name': string;
/**
* @type string
*/
'k8s.namespace.name': string;
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesDeploymentRecordDTOMeta =
InframonitoringtypesDeploymentRecordDTOMetaAnyOf | null;
}
export interface InframonitoringtypesDeploymentRecordDTO {
/**
@@ -6221,10 +6268,7 @@ export interface InframonitoringtypesDeploymentRecordDTO {
* @type integer
*/
desiredPods: number;
/**
* @type object,null
*/
meta: InframonitoringtypesDeploymentRecordDTOMeta;
meta: InframonitoringtypesDeploymentMetaDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
@@ -6259,15 +6303,17 @@ export interface InframonitoringtypesHostFilterDTO {
filterByStatus?: InframonitoringtypesHostStatusDTO;
}
export type InframonitoringtypesHostRecordDTOMetaAnyOf = {
export interface InframonitoringtypesHostMetaDTO {
/**
* @type string
*/
'host.name': string;
/**
* @type string
*/
'os.type': string;
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesHostRecordDTOMeta =
InframonitoringtypesHostRecordDTOMetaAnyOf | null;
}
export interface InframonitoringtypesHostRecordDTO {
/**
@@ -6302,10 +6348,7 @@ export interface InframonitoringtypesHostRecordDTO {
* @format double
*/
memory: number;
/**
* @type object,null
*/
meta: InframonitoringtypesHostRecordDTOMeta;
meta: InframonitoringtypesHostMetaDTO;
status: InframonitoringtypesHostStatusDTO;
/**
* @type number
@@ -6331,15 +6374,21 @@ export interface InframonitoringtypesHostsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesJobRecordDTOMetaAnyOf = {
export interface InframonitoringtypesJobMetaDTO {
/**
* @type string
*/
'k8s.cluster.name': string;
/**
* @type string
*/
'k8s.job.name': string;
/**
* @type string
*/
'k8s.namespace.name': string;
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesJobRecordDTOMeta =
InframonitoringtypesJobRecordDTOMetaAnyOf | null;
}
export interface InframonitoringtypesJobRecordDTO {
/**
@@ -6388,10 +6437,7 @@ export interface InframonitoringtypesJobRecordDTO {
* @type string
*/
jobName: string;
/**
* @type object,null
*/
meta: InframonitoringtypesJobRecordDTOMeta;
meta: InframonitoringtypesJobMetaDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
@@ -6417,6 +6463,18 @@ export interface InframonitoringtypesJobsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesNamespaceMetaDTO {
/**
* @type string
*/
'k8s.cluster.name': string;
/**
* @type string
*/
'k8s.namespace.name': string;
[key: string]: string;
}
export type InframonitoringtypesNamespaceRecordDTOCounts = {
/**
* @type integer
@@ -6440,25 +6498,12 @@ export type InframonitoringtypesNamespaceRecordDTOCounts = {
statefulSets: number;
};
export type InframonitoringtypesNamespaceRecordDTOMetaAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesNamespaceRecordDTOMeta =
InframonitoringtypesNamespaceRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesNamespaceRecordDTO {
/**
* @type object
*/
counts: InframonitoringtypesNamespaceRecordDTOCounts;
/**
* @type object,null
*/
meta: InframonitoringtypesNamespaceRecordDTOMeta;
meta: InframonitoringtypesNamespaceMetaDTO;
/**
* @type number
* @format double
@@ -6499,22 +6544,25 @@ export enum InframonitoringtypesNodeConditionDTO {
not_ready = 'not_ready',
no_data = 'no_data',
}
export type InframonitoringtypesNodeRecordDTOMetaAnyOf = {
export interface InframonitoringtypesNodeMetaDTO {
/**
* @type string
*/
'k8s.cluster.name': string;
/**
* @type string
*/
'k8s.node.name': string;
/**
* @type string
*/
'k8s.node.uid': string;
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesNodeRecordDTOMeta =
InframonitoringtypesNodeRecordDTOMetaAnyOf | null;
}
export interface InframonitoringtypesNodeRecordDTO {
condition: InframonitoringtypesNodeConditionDTO;
/**
* @type object,null
*/
meta: InframonitoringtypesNodeRecordDTOMeta;
meta: InframonitoringtypesNodeMetaDTO;
/**
* @type number
* @format double
@@ -6561,6 +6609,54 @@ export interface InframonitoringtypesNodesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesPodMetaDTO {
/**
* @type string
*/
'k8s.cluster.name': string;
/**
* @type string
*/
'k8s.cronjob.name': string;
/**
* @type string
*/
'k8s.daemonset.name': string;
/**
* @type string
*/
'k8s.deployment.name': string;
/**
* @type string
*/
'k8s.job.name': string;
/**
* @type string
*/
'k8s.namespace.name': string;
/**
* @type string
*/
'k8s.node.name': string;
/**
* @type string
*/
'k8s.pod.name': string;
/**
* @type string
*/
'k8s.pod.start_time': string;
/**
* @type string
*/
'k8s.pod.uid': string;
/**
* @type string
*/
'k8s.statefulset.name': string;
[key: string]: string;
}
export enum InframonitoringtypesPodPhaseDTO {
pending = 'pending',
running = 'running',
@@ -6569,16 +6665,6 @@ export enum InframonitoringtypesPodPhaseDTO {
unknown = 'unknown',
no_data = 'no_data',
}
export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
@@ -6601,10 +6687,7 @@ export enum InframonitoringtypesPodStatusDTO {
no_data = 'no_data',
}
export interface InframonitoringtypesPodRecordDTO {
/**
* @type object,null
*/
meta: InframonitoringtypesPodRecordDTOMeta;
meta: InframonitoringtypesPodMetaDTO;
/**
* @type integer
* @format int64
@@ -6969,15 +7052,21 @@ export interface InframonitoringtypesPostableVolumesDTO {
start: number;
}
export type InframonitoringtypesStatefulSetRecordDTOMetaAnyOf = {
export interface InframonitoringtypesStatefulSetMetaDTO {
/**
* @type string
*/
'k8s.cluster.name': string;
/**
* @type string
*/
'k8s.namespace.name': string;
/**
* @type string
*/
'k8s.statefulset.name': string;
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesStatefulSetRecordDTOMeta =
InframonitoringtypesStatefulSetRecordDTOMetaAnyOf | null;
}
export interface InframonitoringtypesStatefulSetRecordDTO {
/**
@@ -6988,10 +7077,7 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
* @type integer
*/
desiredPods: number;
/**
* @type object,null
*/
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
meta: InframonitoringtypesStatefulSetMetaDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
@@ -7047,21 +7133,40 @@ export interface InframonitoringtypesStatefulSetsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesVolumeRecordDTOMetaAnyOf = {
export interface InframonitoringtypesVolumeMetaDTO {
/**
* @type string
*/
'k8s.cluster.name': string;
/**
* @type string
*/
'k8s.namespace.name': string;
/**
* @type string
*/
'k8s.node.name': string;
/**
* @type string
*/
'k8s.persistentvolumeclaim.name': string;
/**
* @type string
*/
'k8s.pod.name': string;
/**
* @type string
*/
'k8s.pod.uid': string;
/**
* @type string
*/
'k8s.statefulset.name': string;
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesVolumeRecordDTOMeta =
InframonitoringtypesVolumeRecordDTOMetaAnyOf | null;
}
export interface InframonitoringtypesVolumeRecordDTO {
/**
* @type object,null
*/
meta: InframonitoringtypesVolumeRecordDTOMeta;
meta: InframonitoringtypesVolumeMetaDTO;
/**
* @type string
*/

View File

@@ -1,148 +1,39 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { X } from '@signozhq/icons';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { Drawer } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import APIError from 'types/api/error';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/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 {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
GlobalTimeProvider,
NANO_SECOND_MULTIPLIER,
useGlobalTimeStore,
} from 'store/globalTime';
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 {
SelectedItemParams,
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
import { useInfraMonitoringSelectedItemParams } from '../hooks';
import LoadingContainer from '../LoadingContainer';
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
import { K8sBaseDetailsProps } from './types';
import '../EntityDetailsUtils/entityDetails.styles.scss';
import { parseAsString, useQueryState } from 'nuqs';
import {
EntityCountConfig,
EntityCountsSection,
} from './components/EntityCountsSection/EntityCountsSection';
const TimeRangeOffset = 1000000000;
export type {
CustomTab,
CustomTabRenderProps,
K8sBaseDetailsProps,
K8sDetailsCountConfig,
K8sDetailsFilters,
K8sDetailsMetadataConfig,
} from './types';
export interface K8sDetailsMetadataConfig<T> {
label: string;
getValue: (entity: T) => string | number;
render?: (value: string | number, entity: T) => React.ReactNode;
}
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
export interface K8sDetailsFilters {
filter: { expression: string };
start: number;
end: number;
}
export interface CustomTabRenderProps<T> {
entity: T;
timeRange: { startTime: number; endTime: number };
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}
export interface CustomTab<T> {
key: string;
label: string;
icon: React.ReactNode;
render: (props: CustomTabRenderProps<T>) => React.ReactNode;
}
export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: string;
// Data fetching configuration
getSelectedItemExpression: (params: SelectedItemParams) => string;
fetchEntityData: (
filters: K8sDetailsFilters,
signal?: AbortSignal,
) => Promise<{ data: T | null; error?: APIError | null }>;
// Entity configuration
getEntityName: (entity: T) => string;
getInitialLogTracesExpression: (entity: T) => string;
getInitialEventsExpression: (entity: T) => string;
metadataConfig: K8sDetailsMetadataConfig<T>[];
countsConfig?: K8sDetailsCountConfig<T>[];
getCountsFilterExpression?: (entity: T) => string;
entityWidgetInfo: {
title: string;
yAxisUnit: string;
docPath?: 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<CustomTab<T>>;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetails<T>({
category,
eventCategory,
@@ -163,7 +54,6 @@ export default function K8sBaseDetails<T>({
}: 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,
);
@@ -237,86 +127,9 @@ export default function K8sBaseDetails<T>({
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 validTabs = useMemo(() => {
const tabs: string[] = [];
if (tabVisibility.showMetrics) {
tabs.push(VIEW_TYPES.METRICS);
}
if (tabVisibility.showLogs) {
tabs.push(VIEW_TYPES.LOGS);
}
if (tabVisibility.showTraces) {
tabs.push(VIEW_TYPES.TRACES);
}
if (tabVisibility.showEvents) {
tabs.push(VIEW_TYPES.EVENTS);
}
if (customTabs) {
tabs.push(...customTabs.map((t) => t.key));
}
return tabs;
}, [tabVisibility, customTabs]);
useEffect(() => {
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
void setSelectedView(firstValid);
}
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
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, {
void logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
@@ -324,136 +137,6 @@ export default function K8sBaseDetails<T>({
}
}, [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 | null): void => {
if (!value) {
return;
}
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%"
@@ -498,207 +181,33 @@ export default function K8sBaseDetails<T>({
</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>
{countsConfig &&
countsConfig.length > 0 &&
selectedItem &&
getCountsFilterExpression && (
<EntityCountsSection
entity={entity}
countsConfig={countsConfig}
selectedItem={selectedItem}
filterExpression={getCountsFilterExpression(entity)}
closeDrawer={handleClose}
/>
)}
</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,
)}
</>
<GlobalTimeProvider
inheritGlobalTime
enableUrlParams={{
relativeTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
startTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
endTimeKey: INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
}}
>
<K8sBaseDetailsContent<T>
entity={entity}
category={category}
eventCategory={eventCategory}
metadataConfig={metadataConfig}
countsConfig={countsConfig}
getCountsFilterExpression={getCountsFilterExpression}
selectedItem={selectedItem}
handleClose={handleClose}
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
queryKeyPrefix={queryKeyPrefix}
hideDetailViewTabs={hideDetailViewTabs}
tabsConfig={tabsConfig}
customTabs={customTabs}
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
eventsInitialExpression={eventsInitialExpression}
/>
</GlobalTimeProvider>
)}
</Drawer>
);

View File

@@ -0,0 +1,401 @@
import { Fragment, useEffect, useMemo } from 'react';
import {
BarChart,
ChevronsLeftRight,
Compass,
DraftingCompass,
ScrollText,
} from '@signozhq/icons';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import { Button, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { parseAsString, useQueryState } from 'nuqs';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { openInNewTab } from 'utils/navigation';
import { VIEW_TYPES } from '../constants';
import { useEntityDetailsTime } from '../EntityDetailsUtils/EntityDateTimeSelector/useEntityDetailsTime';
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,
useInfraMonitoringTracesFilters,
useInfraMonitoringView,
} from '../hooks';
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
import { K8sBaseDetailsContentProps } from './types';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetailsContent<T>({
entity,
category,
eventCategory,
metadataConfig,
countsConfig,
getCountsFilterExpression,
selectedItem,
handleClose,
entityWidgetInfo,
getEntityQueryPayload,
queryKeyPrefix,
hideDetailViewTabs,
tabsConfig,
customTabs,
logsAndTracesInitialExpression,
eventsInitialExpression,
}: K8sBaseDetailsContentProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
const tabVisibility = useMemo(
() => ({
showMetrics: true,
showLogs: true,
showTraces: true,
showEvents: true,
...tabsConfig,
}),
[tabsConfig],
);
const [selectedView, setSelectedView] = useInfraMonitoringView();
const validTabs = useMemo(() => {
const tabs: string[] = [];
if (tabVisibility.showMetrics) {
tabs.push(VIEW_TYPES.METRICS);
}
if (tabVisibility.showLogs) {
tabs.push(VIEW_TYPES.LOGS);
}
if (tabVisibility.showTraces) {
tabs.push(VIEW_TYPES.TRACES);
}
if (tabVisibility.showEvents) {
tabs.push(VIEW_TYPES.EVENTS);
}
if (customTabs) {
tabs.push(...customTabs.map((t) => t.key));
}
return tabs;
}, [tabVisibility, customTabs]);
useEffect(() => {
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
void setSelectedView(firstValid);
}
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
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,
);
const handleTabChange = (value: string | null): void => {
if (!value) {
return;
}
void setSelectedView(value);
void setLogFiltersParam(null);
void setTracesFiltersParam(null);
void setEventsFiltersParam(null);
void logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: value,
});
};
const handleExplorePagesRedirect = (): void => {
const urlQuery = new URLSearchParams();
if (selectedInterval !== 'custom') {
urlQuery.set(QueryParams.relativeTime, selectedInterval);
} else {
urlQuery.delete(QueryParams.relativeTime);
// Explorer URL params are in milliseconds, timeRange is in seconds
urlQuery.set(QueryParams.startTime, (timeRange.startTime * 1000).toString());
urlQuery.set(QueryParams.endTime, (timeRange.endTime * 1000).toString());
}
void 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 (
<>
<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>
{countsConfig &&
countsConfig.length > 0 &&
selectedItem &&
getCountsFilterExpression && (
<EntityCountsSection
entity={entity}
countsConfig={countsConfig}
selectedItem={selectedItem}
filterExpression={getCountsFilterExpression(entity)}
closeDrawer={handleClose}
/>
)}
</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}
eventEntity={eventCategory}
entityWidgetInfo={entityWidgetInfo}
getEntityQueryPayload={getEntityQueryPayload}
category={category}
queryKey={`${queryKeyPrefix}Metrics`}
/>
)}
{effectiveView === VIEW_TYPES.LOGS && (
<EntityLogs
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.TRACES && (
<EntityTraces
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Traces`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
<EntityEvents
eventEntity={eventCategory}
queryKey={`${queryKeyPrefix}Events`}
initialExpression={eventsInitialExpression}
category={category}
/>
)}
{customTabs?.map((tab) =>
selectedView === tab.key ? (
<Fragment key={tab.key}>
{tab.render({
entity,
timeRange,
selectedInterval,
handleTimeChange,
})}
</Fragment>
) : null,
)}
</>
);
}

View File

@@ -245,6 +245,7 @@ function K8sHeader<TData>({
showAutoRefresh={showAutoRefresh}
showRefreshText={false}
hideShareModal
defaultRelativeTime="30m"
/>
</div>

View File

@@ -52,7 +52,6 @@ export function EntityCountsSection<T>({
},
};
// TODO(H4ad): After https://github.com/SigNoz/signoz/pull/12038, inherit custom time of drawer to list
const urlParams = new URLSearchParams();
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
urlParams.set(
@@ -60,6 +59,44 @@ export function EntityCountsSection<T>({
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const currentSearchParams = new URLSearchParams(window.location.search);
const detailRelativeTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
);
const detailStartTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
);
const detailEndTime = currentSearchParams.get(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
);
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
const listStartTime = currentSearchParams.get(QueryParams.startTime);
const listEndTime = currentSearchParams.get(QueryParams.endTime);
if (listRelativeTime) {
urlParams.set(QueryParams.relativeTime, listRelativeTime);
} else if (listStartTime && listEndTime) {
urlParams.set(QueryParams.startTime, listStartTime);
urlParams.set(QueryParams.endTime, listEndTime);
}
if (detailRelativeTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
detailRelativeTime,
);
} else if (detailStartTime && detailEndTime) {
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
detailStartTime,
);
urlParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
detailEndTime,
);
}
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
};

View File

@@ -1,3 +1,17 @@
import { ReactNode } from 'react';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import APIError from 'types/api/error';
import { EntityCountConfig } from './components/EntityCountsSection/EntityCountsSection';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
export type K8sBaseFilters = {
filter: {
expression: string;
@@ -33,3 +47,100 @@ export type K8sTableRowData<T> = T & {
/** Metadata about which attributes were used for grouping */
groupedByMeta?: Record<string, string>;
};
export interface K8sDetailsMetadataConfig<T> {
label: string;
getValue: (entity: T) => string | number;
render?: (value: string | number, entity: T) => ReactNode;
}
export interface K8sDetailsFilters {
filter: { expression: string };
start: number;
end: number;
}
export interface K8sDetailsWidgetInfo {
title: string;
yAxisUnit: string;
}
export type GetEntityQueryPayload<T> = (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
export interface K8sDetailsTabsConfig {
showMetrics?: boolean;
showLogs?: boolean;
showTraces?: boolean;
showEvents?: boolean;
}
export interface K8sDetailsCustomTabRenderProps<T> {
entity: T;
/** Time range in seconds — see useEntityDetailsTime */
timeRange: { startTime: number; endTime: number };
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}
export interface K8sDetailsCustomTab<T> {
key: string;
label: string;
icon: ReactNode;
render: (props: K8sDetailsCustomTabRenderProps<T>) => ReactNode;
}
export interface K8sBaseDetailsProps<T> {
category: InfraMonitoringEntity;
eventCategory: string;
// Data fetching configuration
getSelectedItemExpression: (params: SelectedItemParams) => string;
fetchEntityData: (
filters: K8sDetailsFilters,
signal?: AbortSignal,
) => Promise<{ data: T | null; error?: APIError | null }>;
// Entity configuration
getEntityName: (entity: T) => string;
getInitialLogTracesExpression: (entity: T) => string;
getInitialEventsExpression: (entity: T) => string;
metadataConfig: K8sDetailsMetadataConfig<T>[];
countsConfig?: K8sDetailsCountConfig<T>[];
getCountsFilterExpression?: (entity: T) => string;
entityWidgetInfo: K8sDetailsWidgetInfo[];
getEntityQueryPayload: GetEntityQueryPayload<T>;
queryKeyPrefix: string;
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
hideDetailViewTabs?: boolean;
tabsConfig?: K8sDetailsTabsConfig;
customTabs?: K8sDetailsCustomTab<T>[];
}
export interface K8sBaseDetailsContentProps<T> {
entity: T;
category: InfraMonitoringEntity;
eventCategory: string;
metadataConfig: K8sDetailsMetadataConfig<T>[];
countsConfig?: K8sDetailsCountConfig<T>[];
getCountsFilterExpression?: (entity: T) => string;
selectedItem: string | null;
handleClose: () => void;
entityWidgetInfo: K8sDetailsWidgetInfo[];
getEntityQueryPayload: GetEntityQueryPayload<T>;
queryKeyPrefix: string;
hideDetailViewTabs: boolean;
tabsConfig?: K8sDetailsTabsConfig;
customTabs?: K8sDetailsCustomTab<T>[];
logsAndTracesInitialExpression: string;
eventsInitialExpression: string;
}
// Aliases for backward compatibility
export type CustomTab<T> = K8sDetailsCustomTab<T>;
export type CustomTabRenderProps<T> = K8sDetailsCustomTabRenderProps<T>;

View File

@@ -0,0 +1,78 @@
import { useCallback } from 'react';
import { Undo } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useEntityDetailsTime } from './useEntityDetailsTime';
interface EntityDateTimeSelectorProps {
eventEntity: string;
category: InfraMonitoringEntity;
view: string;
}
function EntityDateTimeSelector({
eventEntity,
category,
view,
}: EntityDateTimeSelectorProps): JSX.Element {
const {
timeRange,
selectedInterval,
handleTimeChange,
handleResetToParentTime,
hasTimeChanged,
} = useEntityDetailsTime();
const onTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
void logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: eventEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view,
interval,
});
handleTimeChange(interval, dateTimeRange);
},
[category, view, eventEntity, handleTimeChange],
);
return (
<div className="entity-date-time-selector">
{hasTimeChanged && (
<TooltipSimple title="Reset to list time" side="bottom">
<Button
variant="outlined"
color="secondary"
onClick={handleResetToParentTime}
data-testid="reset-to-list-time-button"
prefix={<Undo size={14} />}
>
Reset
</Button>
</TooltipSimple>
)}
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection
onTimeChange={onTimeChange}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
);
}
export default EntityDateTimeSelector;

View File

@@ -0,0 +1,95 @@
import { useCallback, useMemo } from 'react';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import {
createCustomTimeRange,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
useGlobalTime,
} from 'store/globalTime';
export interface EntityDetailsTimeRange {
/** Unix timestamp in seconds — the unit API payload builders expect. Multiply by 1000 for ms-based UI (modals, URL params). */
startTime: number;
/** Unix timestamp in seconds — the unit API payload builders expect. Multiply by 1000 for ms-based UI (modals, URL params). */
endTime: number;
}
export interface UseEntityDetailsTimeResult {
/** Time range in seconds */
timeRange: EntityDetailsTimeRange;
selectedInterval: Time;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
handleResetToParentTime: () => boolean;
canResetToParent: boolean;
hasTimeChanged: boolean;
}
export function useEntityDetailsTime(): UseEntityDetailsTimeResult {
const selectedTime = useGlobalTime((s) => s.selectedTime);
const lastComputedMinMax = useGlobalTime((s) => s.lastComputedMinMax);
const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
const handleResetToParentTime = useGlobalTime((s) => s.resetToParentTime);
const parentStore = useGlobalTime((s) => s.parentStore);
const canResetToParent = !!parentStore;
const hasTimeChanged = useMemo(() => {
if (!parentStore) {
return false;
}
return selectedTime !== parentStore.getState().selectedTime;
}, [parentStore, selectedTime]);
const timeRange = useMemo<EntityDetailsTimeRange>(
() => ({
startTime: Math.floor(
lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER / 1000,
),
endTime: Math.floor(
lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER / 1000,
),
}),
[lastComputedMinMax],
);
const selectedInterval = useMemo<Time>(
() =>
isCustomTimeRange(selectedTime)
? ('custom' as Time)
: (selectedTime as Time),
[selectedTime],
);
const handleTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
if (interval === 'custom' && dateTimeRange) {
// DateTimeSelector delivers milliseconds; the store keys custom
// ranges by nanoseconds.
setSelectedTime(
createCustomTimeRange(
dateTimeRange[0] * NANO_SECOND_MULTIPLIER,
dateTimeRange[1] * NANO_SECOND_MULTIPLIER,
),
);
} else {
setSelectedTime(interval as Time);
}
},
[setSelectedTime],
);
return {
timeRange,
selectedInterval,
handleTimeChange,
handleResetToParentTime,
canResetToParent,
hasTimeChanged,
};
}

View File

@@ -21,17 +21,14 @@ 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 EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { EventContents } from './EventsContent';
@@ -53,16 +50,7 @@ interface EventDataType {
}
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
eventEntity: string;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
@@ -77,13 +65,11 @@ const handleExpandRow = (record: EventDataType): JSX.Element => (
);
function EntityEventsContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
eventEntity,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
@@ -131,8 +117,8 @@ function EntityEventsContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: eventEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.EventsView,
@@ -141,7 +127,14 @@ function EntityEventsContent({
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
[
inputExpression,
initialExpression,
refetch,
querySearchOnRun,
category,
eventEntity,
],
);
const queryData = useMemo(
@@ -229,16 +222,10 @@ function EntityEventsContent({
<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}
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.EventsView}
/>
<RunQueryBtn

View File

@@ -29,25 +29,25 @@ function verifyEntityEventsV5Request({
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__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>
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
useEntityDetailsTime: (): {
timeRange: { startTime: number; endTime: number };
selectedInterval: string;
handleTimeChange: jest.Mock;
} => ({
timeRange: { startTime: 1, endTime: 2 },
selectedInterval: '5m',
handleTimeChange: jest.fn(),
}),
}));
describe('EntityEvents', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
@@ -69,10 +69,7 @@ describe('EntityEvents', () => {
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<EntityEvents
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'

View File

@@ -0,0 +1,81 @@
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
import { mockQueryRangeV5WithEventsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
createCustomTimeRange,
createGlobalTimeStore,
GlobalTimeContext,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime';
import { act, render, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityEvents from '../EntityEvents';
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY } from '../hooks';
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
describe('EntityEvents time range wiring', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithEventsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
mockFieldsAPIsWithEmptyResponse();
});
it('should query the V5 API with the global time range converted to milliseconds', async () => {
const store = createGlobalTimeStore();
store
.getState()
.setSelectedTime(
createCustomTimeRange(
START_MS * NANO_SECOND_MULTIPLIER,
END_MS * NANO_SECOND_MULTIPLIER,
),
);
const expression = 'k8s.pod.name = "test-pod"';
act(() => {
render(
<GlobalTimeContext.Provider value={store}>
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=${encodeURIComponent(
expression,
)}`}
>
<EntityEvents
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression={expression}
/>
</NuqsTestingAdapter>
</GlobalTimeContext.Provider>,
);
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
expect(capturedPayloads[0].start).toBe(START_MS);
expect(capturedPayloads[0].end).toBe(END_MS);
});
});

View File

@@ -25,11 +25,6 @@ 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';
@@ -38,6 +33,8 @@ import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { isKeyNotFoundError } from '../utils';
@@ -47,29 +44,18 @@ 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;
eventEntity: string;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityLogsContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
eventEntity,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const expression = useExpression();
@@ -134,7 +120,7 @@ function EntityLogsContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression);
logEvent(InfraMonitoringEvents.FilterApplied, {
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
@@ -239,16 +225,10 @@ function EntityLogsContent({
<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}
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.LogsView}
/>
<RunQueryBtn

View File

@@ -50,25 +50,25 @@ jest.mock(
},
);
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__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>
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
useEntityDetailsTime: (): {
timeRange: { startTime: number; endTime: number };
selectedInterval: string;
handleTimeChange: jest.Mock;
} => ({
timeRange: { startTime: 1, endTime: 2 },
selectedInterval: '5m',
handleTimeChange: jest.fn(),
}),
}));
describe('EntityLogs', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
const itemHeight = 100;
@@ -94,10 +94,7 @@ describe('EntityLogs', () => {
>
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
<EntityLogs
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'

View File

@@ -0,0 +1,93 @@
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
createCustomTimeRange,
createGlobalTimeStore,
GlobalTimeContext,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime';
import { act, render, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityLogs from '../EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../hooks';
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
() =>
function MockOverlayScrollbar({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div>{children}</div>;
},
);
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
describe('EntityLogs time range wiring', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithLogsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
mockFieldsAPIsWithEmptyResponse();
});
it('should query the V5 API with the global time range converted to milliseconds', async () => {
const store = createGlobalTimeStore();
store
.getState()
.setSelectedTime(
createCustomTimeRange(
START_MS * NANO_SECOND_MULTIPLIER,
END_MS * NANO_SECOND_MULTIPLIER,
),
);
const expression = 'k8s.pod.name = "test-pod"';
act(() => {
render(
<GlobalTimeContext.Provider value={store}>
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_LOGS_EXPRESSION_KEY}=${encodeURIComponent(
expression,
)}`}
>
<EntityLogs
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression={expression}
/>
</NuqsTestingAdapter>
</GlobalTimeContext.Provider>,
);
});
await waitFor(() => {
expect(capturedPayloads.length).toBeGreaterThanOrEqual(1);
});
expect(capturedPayloads[0].start).toBe(START_MS);
expect(capturedPayloads[0].end).toBe(END_MS);
});
});

View File

@@ -2,15 +2,11 @@ import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { InfraMonitoringEvents } from 'constants/events';
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';
@@ -24,25 +20,17 @@ import { getMetricsExplorerUrl } from 'utils/explorerUtils';
import { buildEntityMetricsChartConfig } from './configBuilder';
import ChartHeader from './ChartHeader';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
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;
eventEntity: string;
entityWidgetInfo: {
title: string;
yAxisUnit: string;
@@ -59,16 +47,16 @@ interface EntityMetricsProps<T> {
}
function EntityMetrics<T>({
selectedInterval,
entity,
timeRange,
handleTimeChange,
isModalTimeSelection,
eventEntity,
entityWidgetInfo,
getEntityQueryPayload,
queryKey,
category,
}: EntityMetricsProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
const { visibilities, setElement } = useMultiIntersectionObserver(
entityWidgetInfo.length,
{ threshold: 0.1 },
@@ -91,10 +79,8 @@ function EntityMetrics<T>({
const onDragSelect = useCallback(
(start: number, end: number): void => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
handleTimeChange('custom', [startTimestamp, endTimestamp]);
// UPlotConfigBuilder's setSelect hook already delivers milliseconds
handleTimeChange('custom', [Math.trunc(start), Math.trunc(end)]);
},
[handleTimeChange],
);
@@ -188,16 +174,10 @@ function EntityMetrics<T>({
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}
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.MetricsView}
/>
</div>
<div className={styles.entityMetricsContainer}>

View File

@@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
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';
@@ -28,13 +27,28 @@ jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
const mockTimeRange = { startTime: 1705315200, endTime: 1705318800 };
let mockSelectedInterval = '5m';
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
useEntityDetailsTime: (): {
timeRange: { startTime: number; endTime: number };
selectedInterval: string;
handleTimeChange: jest.Mock;
} => ({
timeRange: mockTimeRange,
selectedInterval: mockSelectedInterval,
handleTimeChange: jest.fn(),
}),
}));
jest.mock(
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
() => ({
@@ -144,13 +158,6 @@ const mockGetEntityQueryPayload = jest.fn().mockReturnValue([
},
]);
const mockTimeRange = {
startTime: 1705315200,
endTime: 1705318800,
};
const mockHandleTimeChange = jest.fn();
const mockQueries = [
{
data: {
@@ -283,10 +290,6 @@ const mockEmptyQueries = [
const renderEntityMetrics = (overrides = {}): any => {
const defaultProps = {
timeRange: mockTimeRange,
isModalTimeSelection: false,
handleTimeChange: mockHandleTimeChange,
selectedInterval: '5m' as Time,
entity: mockEntity,
entityWidgetInfo: mockEntityWidgetInfo,
getEntityQueryPayload: mockGetEntityQueryPayload,
@@ -298,11 +301,8 @@ const renderEntityMetrics = (overrides = {}): any => {
return render(
<MemoryRouter>
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
eventEntity="test"
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
@@ -344,6 +344,7 @@ const mockQueryPayloads = [
describe('EntityMetrics', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSelectedInterval = '5m';
mockUseEntityMetrics.mockReturnValue({
queries: mockQueries as any,
chartData: mockChartData,
@@ -454,7 +455,8 @@ describe('EntityMetrics', () => {
});
it('builds metrics explorer link with relativeTime when a relative interval is selected', () => {
renderEntityMetrics({ selectedInterval: '5m' as Time });
mockSelectedInterval = '5m';
renderEntityMetrics();
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
@@ -464,7 +466,8 @@ describe('EntityMetrics', () => {
});
it('builds metrics explorer link with absolute time range in milliseconds for custom interval', () => {
renderEntityMetrics({ selectedInterval: 'custom' as Time });
mockSelectedInterval = 'custom';
renderEntityMetrics();
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
@@ -478,7 +481,6 @@ describe('EntityMetrics', () => {
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: 'test-query-key',
timeRange: mockTimeRange,
entity: mockEntity,
category: InfraMonitoringEntity.PODS,
}),

View File

@@ -0,0 +1,119 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import {
createCustomTimeRange,
createGlobalTimeStore,
GlobalTimeContext,
GlobalTimeStoreApi,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime';
import { act, render } from 'tests/test-utils';
import { buildEntityMetricsChartConfig } from '../configBuilder';
import EntityMetrics from '../EntityMetrics';
jest.mock('../configBuilder', () => ({
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
getId: jest.fn().mockReturnValue('mock-id'),
}),
}));
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
const mockBuildChartConfig = buildEntityMetricsChartConfig as jest.Mock;
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
const START_SECONDS = 1705315200;
const END_SECONDS = 1705318800;
const entity = { id: 'test-entity-1' };
// The jsdom IntersectionObserver polyfill never reports visibility, so the
// queries stay disabled and no network request is issued.
const queryPayload = {
graphType: PANEL_TYPES.TIME_SERIES,
} as unknown as GetQueryResultsProps;
function createStoreWithCustomRange(): GlobalTimeStoreApi {
const store = createGlobalTimeStore();
store
.getState()
.setSelectedTime(
createCustomTimeRange(
START_MS * NANO_SECOND_MULTIPLIER,
END_MS * NANO_SECOND_MULTIPLIER,
),
);
return store;
}
function renderEntityMetrics(store: GlobalTimeStoreApi): jest.Mock {
const getEntityQueryPayload = jest.fn().mockReturnValue([queryPayload]);
render(
<GlobalTimeContext.Provider value={store}>
<EntityMetrics
entity={entity}
eventEntity="test"
entityWidgetInfo={[{ title: 'CPU Usage', yAxisUnit: 'percentage' }]}
getEntityQueryPayload={getEntityQueryPayload}
queryKey="test-query-key"
category={InfraMonitoringEntity.PODS}
/>
</GlobalTimeContext.Provider>,
);
return getEntityQueryPayload;
}
describe('EntityMetrics time range wiring', () => {
beforeEach(() => {
mockBuildChartConfig.mockClear();
});
it('should build the metrics query payloads and chart config from the global time range in seconds', () => {
const store = createStoreWithCustomRange();
const getEntityQueryPayload = renderEntityMetrics(store);
expect(getEntityQueryPayload).toHaveBeenCalledWith(
entity,
START_SECONDS,
END_SECONDS,
false,
);
expect(mockBuildChartConfig).toHaveBeenCalledWith(
expect.objectContaining({
minTimeScale: START_SECONDS,
maxTimeScale: END_SECONDS,
}),
);
});
it('should store a drag selection (received in milliseconds) as the equivalent custom range', () => {
const store = createStoreWithCustomRange();
renderEntityMetrics(store);
const { onDragSelect } = mockBuildChartConfig.mock.calls[0][0];
// UPlotConfigBuilder's setSelect hook calls onDragSelect with milliseconds
const dragStartMs = 1705316000000;
const dragEndMs = 1705317000000;
act(() => {
onDragSelect(dragStartMs, dragEndMs);
});
expect(store.getState().selectedTime).toBe(
createCustomTimeRange(
dragStartMs * NANO_SECOND_MULTIPLIER,
dragEndMs * NANO_SECOND_MULTIPLIER,
),
);
});
});

View File

@@ -20,11 +20,6 @@ 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';
@@ -32,10 +27,11 @@ import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { selectedEntityTracesColumns } from '../utils';
import { isKeyNotFoundError } from '../utils';
import { isKeyNotFoundError, selectedEntityTracesColumns } from '../utils';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY, useEntityTraces } from './hooks';
import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
@@ -44,29 +40,18 @@ 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;
eventEntity: string;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityTracesContent({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
eventEntity,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
@@ -114,8 +99,8 @@ function EntityTracesContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: eventEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.TracesView,
@@ -124,7 +109,14 @@ function EntityTracesContent({
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
[
inputExpression,
initialExpression,
refetch,
querySearchOnRun,
category,
eventEntity,
],
);
const queryData = useMemo(
@@ -152,7 +144,7 @@ function EntityTracesContent({
const hasAdditionalFilters = !!userExpression?.trim();
const handleRowClick = useCallback(() => {
logEvent(InfraMonitoringEvents.ItemClicked, {
void logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
category,
view: InfraMonitoringEvents.TracesView,
@@ -169,16 +161,10 @@ function EntityTracesContent({
<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}
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.TracesView}
/>
<RunQueryBtn

View File

@@ -34,10 +34,8 @@ describe('EntityTraces - Default Behavior', () => {
});
it('should pass time range to API (converted to milliseconds)', async () => {
const timeRange = { startTime: 1000, endTime: 2000 };
act(() => {
renderEntityTraces({ timeRange });
renderEntityTraces();
});
await waitFor(() => {
@@ -47,8 +45,8 @@ describe('EntityTraces - Default Behavior', () => {
verifyQueryPayload({
payload: capturedPayloads[0],
expectedTimeRange: {
start: timeRange.startTime * 1000,
end: timeRange.endTime * 1000,
start: 1 * 1000,
end: 2 * 1000,
},
});
});

View File

@@ -0,0 +1,81 @@
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
createCustomTimeRange,
createGlobalTimeStore,
GlobalTimeContext,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime';
import { act, render, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import EntityTraces from '../EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
const START_MS = 1705315200000; // 2024-01-15T10:40:00Z
const END_MS = 1705318800000; // 2024-01-15T11:40:00Z
describe('EntityTraces time range wiring', () => {
let capturedPayloads: QueryRangePayloadV5[] = [];
beforeEach(() => {
capturedPayloads = [];
mockQueryRangeV5WithTracesResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedPayloads.push(body);
return {};
},
});
mockFieldsAPIsWithEmptyResponse();
});
it('should query the V5 API with the global time range converted to milliseconds', async () => {
const store = createGlobalTimeStore();
store
.getState()
.setSelectedTime(
createCustomTimeRange(
START_MS * NANO_SECOND_MULTIPLIER,
END_MS * NANO_SECOND_MULTIPLIER,
),
);
const expression = 'k8s.pod.name = "test-pod"';
act(() => {
render(
<GlobalTimeContext.Provider value={store}>
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_TRACES_EXPRESSION_KEY}=${encodeURIComponent(
expression,
)}`}
>
<EntityTraces
eventEntity="test"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression={expression}
/>
</NuqsTestingAdapter>
</GlobalTimeContext.Provider>,
);
});
await waitFor(() => {
expect(capturedPayloads).toHaveLength(1);
});
expect(capturedPayloads[0].start).toBe(START_MS);
expect(capturedPayloads[0].end).toBe(END_MS);
});
});

View File

@@ -1,7 +1,5 @@
import { ENVIRONMENT } from 'constants/env';
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/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';
@@ -9,39 +7,19 @@ 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 } }),
),
),
);
mockFieldsAPIsWithEmptyResponse();
});
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);
@@ -55,10 +33,7 @@ export function renderEntityTraces({
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<EntityTraces
timeRange={timeRange}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval={selectedInterval}
eventEntity="test"
queryKey="test"
category={category}
initialExpression={expression}
@@ -101,21 +76,21 @@ export function verifyQueryPayload({
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
__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>
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
),
}));
jest.mock('../../EntityDateTimeSelector/useEntityDetailsTime', () => ({
useEntityDetailsTime: (): {
timeRange: { startTime: number; endTime: number };
selectedInterval: string;
handleTimeChange: jest.Mock;
} => ({
timeRange: { startTime: 1, endTime: 2 },
selectedInterval: '5m',
handleTimeChange: jest.fn(),
}),
}));

View File

@@ -1,4 +1,5 @@
import { Container } from '@signozhq/icons';
import { InfraMonitoringEvents } from 'constants/events';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { CustomTab } from '../Base/K8sBaseDetails';
@@ -10,6 +11,20 @@ import {
import EntityMetrics from './EntityMetrics';
const categoryToEventEntity: Record<InfraMonitoringEntity, string> = {
[InfraMonitoringEntity.DAEMONSETS]: InfraMonitoringEvents.DaemonSet,
[InfraMonitoringEntity.DEPLOYMENTS]: InfraMonitoringEvents.Deployment,
[InfraMonitoringEntity.JOBS]: InfraMonitoringEvents.Job,
[InfraMonitoringEntity.NAMESPACES]: InfraMonitoringEvents.Namespace,
[InfraMonitoringEntity.STATEFULSETS]: InfraMonitoringEvents.StatefulSet,
[InfraMonitoringEntity.PODS]: InfraMonitoringEvents.Pod,
[InfraMonitoringEntity.NODES]: InfraMonitoringEvents.Node,
[InfraMonitoringEntity.CLUSTERS]: InfraMonitoringEvents.Cluster,
[InfraMonitoringEntity.VOLUMES]: InfraMonitoringEvents.Volume,
[InfraMonitoringEntity.HOSTS]: InfraMonitoringEvents.HostEntity,
[InfraMonitoringEntity.CONTAINERS]: 'container',
};
interface CreatePodMetricsTabParams<T> {
getQueryPayload: (
entity: T,
@@ -17,30 +32,29 @@ interface CreatePodMetricsTabParams<T> {
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
category: InfraMonitoringEntity;
queryKey: string;
category: InfraMonitoringEntity;
}
export function createPodMetricsTab<T>({
getQueryPayload,
category,
queryKey,
category,
}: CreatePodMetricsTabParams<T>): CustomTab<T> {
const eventEntity = categoryToEventEntity[category];
return {
key: VIEW_TYPES.POD_METRICS,
label: 'Pod Metrics',
icon: <Container size={14} />,
render: ({ entity, timeRange, selectedInterval, handleTimeChange }) => (
render: ({ entity }) => (
<EntityMetrics
entity={entity}
selectedInterval={selectedInterval}
timeRange={timeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection
eventEntity={eventEntity}
entityWidgetInfo={podUtilizationByPodWidgetInfo}
getEntityQueryPayload={getQueryPayload}
category={category}
queryKey={queryKey}
category={category}
/>
),
};

View File

@@ -454,3 +454,9 @@
gap: 16px;
}
}
.entity-date-time-selector {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -905,6 +905,9 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
DETAIL_START_TIME: 'detailStartTime',
DETAIL_END_TIME: 'detailEndTime',
};
/** Metric namespace prefixes for /fields/keys and /fields/values APIs */

View File

@@ -7,6 +7,7 @@ import AppRoutes from 'AppRoutes';
import { AxiosError } from 'axios';
import { GlobalTimeStoreAdapter } from 'components/GlobalTimeStoreAdapter/GlobalTimeStoreAdapter';
import { ThemeProvider } from 'hooks/useDarkMode';
import { configureOverlayScrollbars } from 'lib/configureOverlayScrollbars';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppProvider } from 'providers/App/App';
import TimezoneProvider from 'providers/Timezone';
@@ -17,6 +18,8 @@ import './ReactI18';
import 'styles.scss';
configureOverlayScrollbars();
const queryClient = new QueryClient({
defaultOptions: {
queries: {

View File

@@ -0,0 +1,22 @@
import { OverlayScrollbars } from 'overlayscrollbars';
/**
* Disables the content `elementEvents` option (default: `[['img', 'load']]`)
* for every OverlayScrollbars instance.
*
* The per-element event cleanups OverlayScrollbars stores in its internal
* WeakMap close over the MutationObserver callback scope, which holds arrays
* with every node added/removed in that mutation batch. A single long-lived
* reference to one of those elements (e.g. CodeMirror's EditContext or its
* module-level scratch Range pinning a detached editor) then retains entire
* unmounted subtrees as detached DOM — ~1.3k nodes per InfraMonitoring
* category switch.
*
* Content size changes from loading images are still handled by the
* scrollbars' size observer, so scrollbar geometry stays correct.
*/
export function configureOverlayScrollbars(): void {
OverlayScrollbars.env().setDefaultOptions({
update: { elementEvents: null },
});
}

View File

@@ -1,8 +1,6 @@
import type {
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
import { StatusCodes } from 'http-status-codes';
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
@@ -46,7 +44,7 @@ describe('panelStatusFromError', () => {
it('falls back to the error message when there is no structured body', () => {
expect(panelStatusFromError(new Error('boom'))).toStrictEqual({
code: 'UPSTREAM_UNAVAILABLE',
code: 'unknown_error',
message: 'boom',
docsUrl: undefined,
messages: [],

View File

@@ -55,6 +55,7 @@ export function GlobalTimeProvider({
name,
selectedTime: resolveInitialTime(),
refreshInterval: initialRefreshInterval ?? 0,
parentStore: inheritGlobalTime ? globalStore : undefined,
}),
);

View File

@@ -38,6 +38,36 @@ const createWrapper = (
};
};
const createNestedWrapper = (
parentProps: GlobalTimeProviderOptions,
childProps: GlobalTimeProviderOptions,
nuqsProps?: {
searchParams?: string;
onUrlUpdate?: (event: { queryString: string }) => void;
},
) => {
const queryClient = createTestQueryClient();
return function NestedWrapper({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={nuqsProps?.searchParams}
onUrlUpdate={nuqsProps?.onUrlUpdate}
>
<GlobalTimeProvider {...parentProps}>
<GlobalTimeProvider {...childProps}>{children}</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('GlobalTimeProvider', () => {
describe('name prop', () => {
it('should pass name to store when provided', () => {
@@ -82,138 +112,73 @@ describe('GlobalTimeProvider', () => {
describe('inheritGlobalTime', () => {
it('should inherit time from parent store when inheritGlobalTime is true', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime>{children}</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
wrapper,
});
// Should inherit '6h' from parent provider
expect(result.current).toBe('6h');
});
it('should use initialTime when inheritGlobalTime is false', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: false, initialTime: '15m' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
wrapper,
});
// Should use its own initialTime, not parent's
expect(result.current).toBe('15m');
});
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="?relativeTime=1h">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true, enableUrlParams: true },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
wrapper,
});
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should use inherited time when URL params are empty', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true, enableUrlParams: true },
{ searchParams: '' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
wrapper,
});
// No URL params, should keep inherited value
expect(result.current).toBe('6h');
});
it('should prefer custom time URL params over inheritGlobalTime', async () => {
const queryClient = createTestQueryClient();
const startTime = 1700000000000;
const endTime = 1700003600000;
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true, enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), {
wrapper: NestedWrapper,
wrapper,
});
// URL custom time params should override inherited time
@@ -690,4 +655,151 @@ describe('GlobalTimeProvider', () => {
expect(result.current.isRefreshEnabled).toBe(true);
});
});
describe('resetToParentTime', () => {
it('should return false when no parent store exists', () => {
const wrapper = createWrapper({ initialTime: '1h' });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.resetToParentTime()).toBe(false);
expect(result.current.parentStore).toBeUndefined();
});
it('should have parentStore when inheritGlobalTime is true', () => {
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.parentStore).toBeDefined();
});
it('should reset to parent time when called', () => {
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.selectedTime).toBe('6h');
const initialMinMax = result.current.lastComputedMinMax;
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
const changedMinMax = result.current.lastComputedMinMax;
expect(changedMinMax.maxTime - changedMinMax.minTime).toBeLessThan(
initialMinMax.maxTime - initialMinMax.minTime,
);
act(() => {
const success = result.current.resetToParentTime();
expect(success).toBe(true);
});
expect(result.current.selectedTime).toBe('6h');
const resetMinMax = result.current.lastComputedMinMax;
expect(resetMinMax.maxTime - resetMinMax.minTime).toBeGreaterThan(
changedMinMax.maxTime - changedMinMax.minTime,
);
});
it('should set shouldClearUrlParams flag when reset', () => {
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.setSelectedTime('15m');
});
act(() => {
result.current.resetToParentTime();
});
expect(result.current.shouldClearUrlParams).toBe(true);
act(() => {
result.current.clearUrlParamsFlag();
});
expect(result.current.shouldClearUrlParams).toBe(false);
});
it('should clear URL params when resetToParentTime is called with enableUrlParams', async () => {
let currentQueryString = 'relativeTime=15m';
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true, enableUrlParams: true },
{
searchParams: currentQueryString,
onUrlUpdate: (event): void => {
currentQueryString = event.queryString;
},
},
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
expect(result.current.selectedTime).toBe('15m');
});
act(() => {
result.current.resetToParentTime();
});
await waitFor(() => {
expect(currentQueryString).not.toContain('relativeTime');
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
expect(result.current.selectedTime).toBe('6h');
});
it('should clear custom time URL params when resetToParentTime is called', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true, enableUrlParams: true },
{
searchParams: currentQueryString,
onUrlUpdate: (event): void => {
currentQueryString = event.queryString;
},
},
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
expect(result.current.selectedTime).toContain('||_||');
});
act(() => {
result.current.resetToParentTime();
});
await waitFor(() => {
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
expect(result.current.selectedTime).toBe('6h');
});
});
});

View File

@@ -5,6 +5,7 @@ import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constan
import {
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStoreApiRef,
GlobalTimeStore,
ParsedTimeRange,
} from './types';
@@ -18,6 +19,10 @@ import {
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
export type IGlobalTimeStore = GlobalTimeStore;
export interface CreateGlobalTimeStoreOptions extends Partial<GlobalTimeState> {
parentStore?: GlobalTimeStoreApiRef;
}
function computeIsRefreshEnabled(
selectedTime: GlobalTimeSelectedTime,
refreshInterval: number,
@@ -29,11 +34,12 @@ function computeIsRefreshEnabled(
}
export function createGlobalTimeStore(
initialState?: Partial<GlobalTimeState>,
options?: CreateGlobalTimeStoreOptions,
): GlobalTimeStoreApi {
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
const refreshInterval = initialState?.refreshInterval ?? 0;
const name = initialState?.name;
const selectedTime = options?.selectedTime ?? DEFAULT_TIME_RANGE;
const refreshInterval = options?.refreshInterval ?? 0;
const name = options?.name;
const parentStore = options?.parentStore;
return createStore<GlobalTimeStore>((set, get) => ({
name,
@@ -42,6 +48,8 @@ export function createGlobalTimeStore(
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
lastRefreshTimestamp: 0,
lastComputedMinMax: safeParseSelectedTime(selectedTime),
parentStore,
shouldClearUrlParams: false,
setSelectedTime: (
time: GlobalTimeSelectedTime,
@@ -130,6 +138,29 @@ export function createGlobalTimeStore(
}
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
},
resetToParentTime: (): boolean => {
const state = get();
if (!state.parentStore) {
return false;
}
const parentSelectedTime = state.parentStore.getState().selectedTime;
const computedMinMax = parseSelectedTime(parentSelectedTime);
set({
selectedTime: parentSelectedTime,
lastComputedMinMax: computedMinMax,
lastRefreshTimestamp: Date.now(),
shouldClearUrlParams: true,
});
return true;
},
clearUrlParamsFlag: (): void => {
set({ shouldClearUrlParams: false });
},
}));
}

View File

@@ -206,6 +206,7 @@
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
* | `getAutoRefreshQueryKey(time, ...parts)` | Build scoped query key for this store instance |
* | `resetToParentTime()` | Reset to parent store's time (only if `inheritGlobalTime` was used). Clears URL params. Returns `true` on success |
*
* ### Utilities
*
@@ -235,7 +236,7 @@
* | Option | Type | Description |
* |--------|------|-------------|
* | `name` | `string` | Scope query keys to this store (enables isolated invalidation) |
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value. Enables `resetToParentTime()` |
* | `initialTime` | `string` | Initial time if not inheriting |
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
@@ -341,7 +342,39 @@
* }
* ```
*
* ### Example 3: Nested Contexts
* ### Example 3: Reset to Parent Time
*
* When using `inheritGlobalTime`, you can reset the child store back to the parent's time:
*
* ```tsx
* function DrawerHeader({ onClose }) {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const resetToParentTime = useGlobalTime((s) => s.resetToParentTime);
* const parentStore = useGlobalTime((s) => s.parentStore);
*
* const handleReset = () => {
* // Returns true if reset succeeded, false if no parent store
* const success = resetToParentTime();
* if (success) {
* // URL params are automatically cleared
* console.log('Reset to parent time');
* }
* };
*
* return (
* <div>
* <DateTimeSelectionV3 />
* {parentStore && (
* <Button onClick={handleReset}>
* Reset to Global Time
* </Button>
* )}
* </div>
* );
* }
* ```
*
* ### Example 4: Nested Contexts
*
* Contexts can be nested - each level creates isolation:
*
@@ -379,9 +412,10 @@
* }
* ```
*
* ### Example 4: URL Sync for Shareable Links
* ### Example 5: URL Sync for Shareable Links
*
* Persist time selection to URL for shareable links:
* Persist time selection to URL for shareable links. When using `resetToParentTime()`,
* URL params are automatically cleared:
*
* ```tsx
* function TracesExplorer() {
@@ -400,7 +434,7 @@
* }
* ```
*
* ### Example 5: localStorage Persistence
* ### Example 6: localStorage Persistence
*
* Remember user's last selected time across sessions:
*

View File

@@ -1,9 +1,13 @@
import { StoreApi } from 'zustand';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
export type CustomTimeRangeSeparator = '||_||';
export type CustomTimeRange = `${number}${CustomTimeRangeSeparator}${number}`;
export type GlobalTimeSelectedTime = Time | CustomTimeRange;
// Forward declaration to avoid circular dependency
export type GlobalTimeStoreApiRef = StoreApi<GlobalTimeStore>;
export interface IGlobalTimeStoreState {
/**
* The selected time range, can be:
@@ -88,6 +92,16 @@ export interface GlobalTimeState {
isRefreshEnabled: boolean;
lastRefreshTimestamp: number;
lastComputedMinMax: ParsedTimeRange;
/**
* Reference to parent store when inheritGlobalTime was used.
* Enables resetToParentTime() functionality.
*/
parentStore?: GlobalTimeStoreApiRef;
/**
* Flag indicating URL params should be cleared (not synced).
* Set by resetToParentTime(), consumed by useUrlSync.
*/
shouldClearUrlParams: boolean;
}
export interface GlobalTimeActions {
@@ -118,6 +132,16 @@ export interface GlobalTimeActions {
selectedTime: GlobalTimeSelectedTime,
...queryParts: unknown[]
) => unknown[];
/**
* Reset to parent store's time. Only works if inheritGlobalTime was used.
* Clears URL params and sets selectedTime to parent's value.
* @returns true if reset succeeded, false if no parent store
*/
resetToParentTime: () => boolean;
/**
* Clear the shouldClearUrlParams flag after URL sync has processed it.
*/
clearUrlParamsFlag: () => void;
}
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;

View File

@@ -91,6 +91,17 @@ export function useUrlSync(
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
if (state.shouldClearUrlParams) {
previousSelectedTime = state.selectedTime;
void setUrlState({
[keys.relativeTimeKey]: null,
[keys.startTimeKey]: null,
[keys.endTimeKey]: null,
});
store.getState().clearUrlParamsFlag();
return;
}
if (state.selectedTime === previousSelectedTime) {
return;
}

View File

@@ -42,12 +42,7 @@ export function toAPIError(
try {
ErrorResponseHandlerForGeneratedAPIs(error);
} catch (apiError) {
// UPSTREAM_UNAVAILABLE means the handler couldn't find a backend error code
// (non-envelope body); prefer the caller's context-specific defaultMessage.
if (
apiError instanceof APIError &&
apiError.getErrorCode() !== 'UPSTREAM_UNAVAILABLE'
) {
if (apiError instanceof APIError) {
return apiError;
}
}

View File

@@ -36,7 +36,7 @@ func buildClusterRecords(
ClusterCPUAllocatable: -1,
ClusterMemory: -1,
ClusterMemoryAllocatable: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewClusterMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -85,9 +85,7 @@ func buildClusterRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewClusterMeta(attrs)
}
records = append(records, record)
@@ -149,7 +147,7 @@ func (m *module) getTopClusterGroups(
func (m *module) getClustersTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range clusterAttrKeysForMetadata {
for _, key := range inframonitoringtypes.ClusterMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -31,10 +31,6 @@ var clustersTableMetricNamesList = []string{
"k8s.container.status.reason",
}
var clusterAttrKeysForMetadata = []string{
"k8s.cluster.name",
}
// clusterCountAttrKeys are the resource attributes whose distinct values are
// counted per cluster. Node name is read from the node metric universe, while
// namespace + workload names come from the pod metric universe — both unioned

View File

@@ -48,7 +48,7 @@ func buildContainerRecords(
Memory: -1,
MemoryRequestUtilization: -1,
MemoryLimitUtilization: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewContainerMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -128,9 +128,7 @@ func buildContainerRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewContainerMeta(attrs)
}
records = append(records, record)
@@ -192,7 +190,7 @@ func (m *module) getTopContainerGroups(
func (m *module) getContainersTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableContainers) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range containerAttrKeysForMetadata {
for _, key := range inframonitoringtypes.ContainerMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -58,22 +58,6 @@ var containersTableMetricNamesList = []string{
"k8s.container.status.state",
}
var containerAttrKeysForMetadata = []string{
"k8s.pod.uid",
"k8s.container.name",
"k8s.pod.name",
"container.image.name",
"container.image.tag",
"k8s.namespace.name",
"k8s.node.name",
"k8s.deployment.name",
"k8s.statefulset.name",
"k8s.daemonset.name",
"k8s.job.name",
"k8s.cronjob.name",
"k8s.cluster.name",
}
var orderByToContainersQueryNames = map[string][]string{
inframonitoringtypes.ContainersOrderByCPU: {"A"},
inframonitoringtypes.ContainersOrderByCPURequest: {"B"},

View File

@@ -40,7 +40,7 @@ func buildDaemonSetRecords(
CurrentNodes: -1,
ReadyNodes: -1,
MisscheduledNodes: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewDaemonSetMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -91,9 +91,7 @@ func buildDaemonSetRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewDaemonSetMeta(attrs)
}
records = append(records, record)
@@ -155,7 +153,7 @@ func (m *module) getTopDaemonSetGroups(
func (m *module) getDaemonSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDaemonSets) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range daemonSetAttrKeysForMetadata {
for _, key := range inframonitoringtypes.DaemonSetMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -39,11 +39,6 @@ var daemonSetsTableMetricNamesList = []string{
// Carried forward from v1 daemonSetAttrsToEnrich
// (pkg/query-service/app/inframetrics/daemonsets.go:29-33).
var daemonSetAttrKeysForMetadata = []string{
"k8s.daemonset.name",
"k8s.namespace.name",
"k8s.cluster.name",
}
// orderByToDaemonSetsQueryNames maps the orderBy column to the query name
// used for ranking daemonset groups. v2 B/C/E/F are direct metrics, no

View File

@@ -38,7 +38,7 @@ func buildDeploymentRecords(
DeploymentMemoryLimit: -1,
DesiredPods: -1,
AvailablePods: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewDeploymentMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -83,9 +83,7 @@ func buildDeploymentRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewDeploymentMeta(attrs)
}
records = append(records, record)
@@ -147,7 +145,7 @@ func (m *module) getTopDeploymentGroups(
func (m *module) getDeploymentsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDeployments) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range deploymentAttrKeysForMetadata {
for _, key := range inframonitoringtypes.DeploymentMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -37,11 +37,6 @@ var deploymentsTableMetricNamesList = []string{
// Carried forward from v1 deploymentAttrsToEnrich
// (pkg/query-service/app/inframetrics/deployments.go:29-33).
var deploymentAttrKeysForMetadata = []string{
"k8s.deployment.name",
"k8s.namespace.name",
"k8s.cluster.name",
}
// orderByToDeploymentsQueryNames maps the orderBy column to the query name
// used for ranking deployment groups. v2 B/C/E/F are direct metrics, no

View File

@@ -216,7 +216,7 @@ func buildHostRecords(
Wait: -1,
Load15: -1,
DiskUsage: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewHostMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -238,9 +238,7 @@ func buildHostRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewHostMeta(attrs)
}
records = append(records, record)
@@ -333,7 +331,7 @@ func (m *module) applyHostsActiveStatusFilter(req *inframonitoringtypes.Postable
func (m *module) getHostsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range hostAttrKeysForMetadata {
for _, key := range inframonitoringtypes.HostMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -23,10 +23,6 @@ var hostsTableMetricNamesList = []string{
"system.filesystem.usage",
}
var hostAttrKeysForMetadata = []string{
"os.type",
}
// orderByToHostsQueryNames maps the orderBy column to the query/formula names
// from HostsTableListQuery used for ranking host groups.
var orderByToHostsQueryNames = map[string][]string{

View File

@@ -40,7 +40,7 @@ func buildJobRecords(
ActivePods: -1,
FailedPods: -1,
SuccessfulPods: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewJobMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -91,9 +91,7 @@ func buildJobRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewJobMeta(attrs)
}
records = append(records, record)
@@ -155,7 +153,7 @@ func (m *module) getTopJobGroups(
func (m *module) getJobsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range jobAttrKeysForMetadata {
for _, key := range inframonitoringtypes.JobMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -39,11 +39,6 @@ var jobsTableMetricNamesList = []string{
// Carried forward from v1 jobAttrsToEnrich
// (pkg/query-service/app/inframetrics/jobs.go:31-35).
var jobAttrKeysForMetadata = []string{
"k8s.job.name",
"k8s.namespace.name",
"k8s.cluster.name",
}
// orderByToJobsQueryNames maps the orderBy column to the query name
// used for ranking job groups. v2 B/C/E/F are direct metrics, no

View File

@@ -32,7 +32,7 @@ func buildNamespaceRecords(
NamespaceName: namespaceName,
NamespaceCPU: -1,
NamespaceMemory: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewNamespaceMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -66,9 +66,7 @@ func buildNamespaceRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewNamespaceMeta(attrs)
}
records = append(records, record)
@@ -130,7 +128,7 @@ func (m *module) getTopNamespaceGroups(
func (m *module) getNamespacesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range namespaceAttrKeysForMetadata {
for _, key := range inframonitoringtypes.NamespaceMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -59,11 +59,6 @@ var namespacesMetricNamesListForCounts = []string{
"k8s.pod.status_reason",
}
var namespaceAttrKeysForMetadata = []string{
"k8s.namespace.name",
"k8s.cluster.name",
}
// namespaceCountAttrKeys are the workload resource attributes whose distinct
// values are counted per namespace. They are read from the pod metric universe,
// which carries the owner workload names for each pod series.

View File

@@ -42,7 +42,7 @@ func buildNodeRecords(
NodeCPUAllocatable: -1,
NodeMemory: -1,
NodeMemoryAllocatable: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewNodeMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -92,9 +92,7 @@ func buildNodeRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewNodeMeta(attrs)
}
records = append(records, record)
@@ -156,7 +154,7 @@ func (m *module) getTopNodeGroups(
func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range nodeAttrKeysForMetadata {
for _, key := range inframonitoringtypes.NodeMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -30,11 +30,6 @@ var nodesTableMetricNamesList = []string{
"k8s.container.status.reason",
}
var nodeAttrKeysForMetadata = []string{
"k8s.node.uid",
"k8s.cluster.name",
}
var orderByToNodesQueryNames = map[string][]string{
inframonitoringtypes.NodesOrderByCPU: {"A"},
inframonitoringtypes.NodesOrderByCPUAllocatable: {"B"},

View File

@@ -49,7 +49,7 @@ func buildPodRecords(
PodMemoryRequest: -1,
PodMemoryLimit: -1,
PodAge: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewPodMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -163,9 +163,7 @@ func buildPodRecords(
}
}
}
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewPodMeta(attrs)
}
records = append(records, record)
@@ -227,7 +225,7 @@ func (m *module) getTopPodGroups(
func (m *module) getPodsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range podAttrKeysForMetadata {
for _, key := range inframonitoringtypes.PodMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -45,20 +45,6 @@ var podsTableMetricNamesList = []string{
// "k8s.container.restarts",
}
var podAttrKeysForMetadata = []string{
"k8s.pod.uid",
"k8s.pod.name",
"k8s.namespace.name",
"k8s.node.name",
"k8s.deployment.name",
"k8s.statefulset.name",
"k8s.daemonset.name",
"k8s.job.name",
"k8s.cronjob.name",
"k8s.cluster.name",
"k8s.pod.start_time",
}
var orderByToPodsQueryNames = map[string][]string{
inframonitoringtypes.PodsOrderByCPU: {"A"},
inframonitoringtypes.PodsOrderByCPURequest: {"B"},

View File

@@ -38,7 +38,7 @@ func buildStatefulSetRecords(
StatefulSetMemoryLimit: -1,
DesiredPods: -1,
CurrentPods: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewStatefulSetMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -83,9 +83,7 @@ func buildStatefulSetRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewStatefulSetMeta(attrs)
}
records = append(records, record)
@@ -147,7 +145,7 @@ func (m *module) getTopStatefulSetGroups(
func (m *module) getStatefulSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableStatefulSets) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range statefulSetAttrKeysForMetadata {
for _, key := range inframonitoringtypes.StatefulSetMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -37,11 +37,6 @@ var statefulSetsTableMetricNamesList = []string{
// Carried forward from v1 statefulSetAttrsToEnrich
// (pkg/query-service/app/inframetrics/statefulsets.go:29-33).
var statefulSetAttrKeysForMetadata = []string{
"k8s.statefulset.name",
"k8s.namespace.name",
"k8s.cluster.name",
}
// orderByToStatefulSetsQueryNames maps the orderBy column to the query name
// used for ranking statefulset groups. v2 B/C/E/F are direct metrics, no

View File

@@ -33,7 +33,7 @@ func buildVolumeRecords(
VolumeInodes: -1,
VolumeInodesFree: -1,
VolumeInodesUsed: -1,
Meta: map[string]string{},
Meta: inframonitoringtypes.NewVolumeMeta(nil),
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -58,9 +58,7 @@ func buildVolumeRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
record.Meta = inframonitoringtypes.NewVolumeMeta(attrs)
}
records = append(records, record)
@@ -122,7 +120,7 @@ func (m *module) getTopVolumeGroups(
func (m *module) getVolumesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableVolumes) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range volumeAttrKeysForMetadata {
for _, key := range inframonitoringtypes.VolumeMetaKeys {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -28,15 +28,6 @@ var volumesTableMetricNamesList = []string{
// Carried forward from v1 volumeAttrsToEnrich
// (pkg/query-service/app/inframetrics/pvcs.go:23-31).
var volumeAttrKeysForMetadata = []string{
"k8s.persistentvolumeclaim.name",
"k8s.pod.uid",
"k8s.pod.name",
"k8s.namespace.name",
"k8s.node.name",
"k8s.statefulset.name",
"k8s.cluster.name",
}
// orderByToVolumesQueryNames maps the orderBy column to the query/formula names
// from newVolumesTableListQuery used for ranking volume groups. For "usage",

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Clusters struct {
@@ -36,7 +38,7 @@ type ClusterRecord struct {
Jobs int64 `json:"jobs" required:"true"`
StatefulSets int64 `json:"statefulSets" required:"true"`
} `json:"counts" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Meta ClusterMeta `json:"meta" required:"true"`
}
// PostableClusters is the request body for the v2 clusters list API.
@@ -114,3 +116,36 @@ func (req *PostableClusters) UnmarshalJSON(data []byte) error {
*req = PostableClusters(decoded)
return req.Validate()
}
// ClusterMeta carries the guaranteed cluster metadata keys as typed fields plus
// any dynamic group-by keys in Extra.
type ClusterMeta struct {
ClusterName string `json:"k8s.cluster.name" required:"true"`
Extra map[string]string `json:"-"`
}
var ClusterMetaKeys = getMetaKeys(&ClusterMeta{})
func NewClusterMeta(attrs map[string]string) ClusterMeta {
var m ClusterMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m ClusterMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *ClusterMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (ClusterMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Containers struct {
@@ -68,7 +70,7 @@ type ContainerRecord struct {
MemoryRequestUtilization float64 `json:"memoryRequestUtilization" required:"true"` // k8s.container.memory_request_utilization
MemoryLimitUtilization float64 `json:"memoryLimitUtilization" required:"true"` // k8s.container.memory_limit_utilization
Meta map[string]string `json:"meta" required:"true"`
Meta ContainerMeta `json:"meta" required:"true"`
}
// PostableContainers is the request body for the v2 containers list API.
@@ -146,3 +148,48 @@ func (req *PostableContainers) UnmarshalJSON(data []byte) error {
*req = PostableContainers(decoded)
return req.Validate()
}
// ContainerMeta carries the guaranteed container metadata keys as typed fields
// plus any dynamic group-by keys in Extra.
type ContainerMeta struct {
PodUID string `json:"k8s.pod.uid" required:"true"`
ContainerName string `json:"k8s.container.name" required:"true"`
PodName string `json:"k8s.pod.name" required:"true"`
ImageName string `json:"container.image.name" required:"true"`
ImageTag string `json:"container.image.tag" required:"true"`
NamespaceName string `json:"k8s.namespace.name" required:"true"`
NodeName string `json:"k8s.node.name" required:"true"`
DeploymentName string `json:"k8s.deployment.name" required:"true"`
StatefulSetName string `json:"k8s.statefulset.name" required:"true"`
DaemonSetName string `json:"k8s.daemonset.name" required:"true"`
JobName string `json:"k8s.job.name" required:"true"`
CronJobName string `json:"k8s.cronjob.name" required:"true"`
ClusterName string `json:"k8s.cluster.name" required:"true"`
Extra map[string]string `json:"-"`
}
var ContainerMetaKeys = getMetaKeys(&ContainerMeta{})
func NewContainerMeta(attrs map[string]string) ContainerMeta {
var m ContainerMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m ContainerMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *ContainerMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (ContainerMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type DaemonSets struct {
@@ -30,7 +32,7 @@ type DaemonSetRecord struct {
MisscheduledNodes int `json:"misscheduledNodes" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Meta DaemonSetMeta `json:"meta" required:"true"`
}
// PostableDaemonSets is the request body for the v2 daemonsets list API.
@@ -108,3 +110,38 @@ func (req *PostableDaemonSets) UnmarshalJSON(data []byte) error {
*req = PostableDaemonSets(decoded)
return req.Validate()
}
// DaemonSetMeta carries the guaranteed daemonset metadata keys as typed fields
// plus any dynamic group-by keys in Extra.
type DaemonSetMeta struct {
DaemonSetName string `json:"k8s.daemonset.name" required:"true"`
NamespaceName string `json:"k8s.namespace.name" required:"true"`
ClusterName string `json:"k8s.cluster.name" required:"true"`
Extra map[string]string `json:"-"`
}
var DaemonSetMetaKeys = getMetaKeys(&DaemonSetMeta{})
func NewDaemonSetMeta(attrs map[string]string) DaemonSetMeta {
var m DaemonSetMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m DaemonSetMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *DaemonSetMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (DaemonSetMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Deployments struct {
@@ -28,7 +30,7 @@ type DeploymentRecord struct {
AvailablePods int `json:"availablePods" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Meta DeploymentMeta `json:"meta" required:"true"`
}
// PostableDeployments is the request body for the v2 deployments list API.
@@ -106,3 +108,38 @@ func (req *PostableDeployments) UnmarshalJSON(data []byte) error {
*req = PostableDeployments(decoded)
return req.Validate()
}
// DeploymentMeta carries the guaranteed deployment metadata keys as typed fields
// plus any dynamic group-by keys in Extra.
type DeploymentMeta struct {
DeploymentName string `json:"k8s.deployment.name" required:"true"`
NamespaceName string `json:"k8s.namespace.name" required:"true"`
ClusterName string `json:"k8s.cluster.name" required:"true"`
Extra map[string]string `json:"-"`
}
var DeploymentMetaKeys = getMetaKeys(&DeploymentMeta{})
func NewDeploymentMeta(attrs map[string]string) DeploymentMeta {
var m DeploymentMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m DeploymentMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *DeploymentMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (DeploymentMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Hosts struct {
@@ -17,16 +19,16 @@ type Hosts struct {
}
type HostRecord struct {
HostName string `json:"hostName" required:"true"`
Status HostStatus `json:"status" required:"true"`
ActiveHostCount int `json:"activeHostCount" required:"true"`
InactiveHostCount int `json:"inactiveHostCount" required:"true"`
CPU float64 `json:"cpu" required:"true"`
Memory float64 `json:"memory" required:"true"`
Wait float64 `json:"wait" required:"true"`
Load15 float64 `json:"load15" required:"true"`
DiskUsage float64 `json:"diskUsage" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
HostName string `json:"hostName" required:"true"`
Status HostStatus `json:"status" required:"true"`
ActiveHostCount int `json:"activeHostCount" required:"true"`
InactiveHostCount int `json:"inactiveHostCount" required:"true"`
CPU float64 `json:"cpu" required:"true"`
Memory float64 `json:"memory" required:"true"`
Wait float64 `json:"wait" required:"true"`
Load15 float64 `json:"load15" required:"true"`
DiskUsage float64 `json:"diskUsage" required:"true"`
Meta HostMeta `json:"meta" required:"true"`
}
type PostableHosts struct {
@@ -113,3 +115,37 @@ func (req *PostableHosts) UnmarshalJSON(data []byte) error {
*req = PostableHosts(decoded)
return req.Validate()
}
// HostMeta carries the guaranteed host metadata keys as typed fields plus any
// dynamic group-by keys in Extra.
type HostMeta struct {
OSType string `json:"os.type" required:"true"`
HostName string `json:"host.name" required:"true"`
Extra map[string]string `json:"-"`
}
var HostMetaKeys = getMetaKeys(&HostMeta{})
func NewHostMeta(attrs map[string]string) HostMeta {
var m HostMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m HostMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *HostMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (HostMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Jobs struct {
@@ -30,7 +32,7 @@ type JobRecord struct {
SuccessfulPods int `json:"successfulPods" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Meta JobMeta `json:"meta" required:"true"`
}
// PostableJobs is the request body for the v2 jobs list API.
@@ -108,3 +110,38 @@ func (req *PostableJobs) UnmarshalJSON(data []byte) error {
*req = PostableJobs(decoded)
return req.Validate()
}
// JobMeta carries the guaranteed job metadata keys as typed fields plus any
// dynamic group-by keys in Extra.
type JobMeta struct {
JobName string `json:"k8s.job.name" required:"true"`
NamespaceName string `json:"k8s.namespace.name" required:"true"`
ClusterName string `json:"k8s.cluster.name" required:"true"`
Extra map[string]string `json:"-"`
}
var JobMetaKeys = getMetaKeys(&JobMeta{})
func NewJobMeta(attrs map[string]string) JobMeta {
var m JobMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m JobMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *JobMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (JobMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -0,0 +1,112 @@
package inframonitoringtypes
import (
"encoding/json"
"maps"
"reflect"
"strings"
"github.com/swaggest/jsonschema-go"
)
// The meta field on every infra-monitoring v2 record carries a fixed set of
// guaranteed keys (surfaced as strongly-typed struct fields) plus any dynamic
// keys the caller introduces via group-by (kept in Extra). Each entity defines
// its own XMeta type; the helpers below give them a shared, wire-stable
// marshaling and schema contract:
//
// - MarshalJSON -> flattenMeta: known + Extra collapse into one flat object.
// - UnmarshalJSON -> splitMeta: decode the flat object, route known keys to
// fields (via the entity's NewXMeta), the rest to Extra.
// - PrepareJSONSchema -> addStringAdditionalProps: keep the reflected known
// properties and mark the object as an open map of string values.
//
// flattenMeta merges the guaranteed known keys and the open Extra map into a
// single flat JSON object, preserving the historical wire shape. known wins on
// collision. The output map is always non-nil, so an empty meta serializes as
// {} rather than null.
func flattenMeta(extra map[string]string, known map[string]string) ([]byte, error) {
out := make(map[string]string, len(extra)+len(known))
maps.Copy(out, extra)
maps.Copy(out, known)
return json.Marshal(out)
}
// splitMeta decodes a flat meta object into its raw string map. Callers pull
// their known keys out of the returned map; whatever remains becomes Extra.
func splitMeta(data []byte) (map[string]string, error) {
raw := map[string]string{}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
return raw, nil
}
// metaField is one guaranteed meta key discovered from an XMeta struct's json
// tags (json:"-" fields like Extra are skipped).
type metaField struct {
index int
key string
}
// metaFieldsOf reflects the string fields of an XMeta struct (skipping json:"-"
// fields like Extra).
func metaFieldsOf(t reflect.Type) []metaField {
fields := make([]metaField, 0, t.NumField())
for i := range t.NumField() {
f := t.Field(i)
key, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if key == "" || key == "-" {
continue
}
fields = append(fields, metaField{index: i, key: key})
}
return fields
}
// populateMeta routes src into the guaranteed fields of the XMeta pointed to by
// m (empty string when a key is absent) and returns the leftover keys (dynamic
// group-by keys) as Extra.
func populateMeta(m any, src map[string]string) map[string]string {
extra := map[string]string{}
maps.Copy(extra, src)
rv := reflect.ValueOf(m).Elem()
for _, f := range metaFieldsOf(rv.Type()) {
rv.Field(f.index).SetString(src[f.key])
delete(extra, f.key)
}
return extra
}
// marshalMeta flattens the guaranteed fields of the XMeta pointed to by m plus
// its Extra map into a single flat JSON object.
func marshalMeta(m any, extra map[string]string) ([]byte, error) {
rv := reflect.ValueOf(m).Elem()
known := make(map[string]string)
for _, f := range metaFieldsOf(rv.Type()) {
known[f.key] = rv.Field(f.index).String()
}
return flattenMeta(extra, known)
}
// getMetaKeys returns the guaranteed keys of an XMeta, in struct-field order. Used
// by implinframonitoring as the metadata fetch column list, keeping the key set
// defined once (on the struct).
func getMetaKeys(m any) []string {
rv := reflect.ValueOf(m).Elem()
fields := metaFieldsOf(rv.Type())
keys := make([]string, 0, len(fields))
for _, f := range fields {
keys = append(keys, f.key)
}
return keys
}
// addStringAdditionalProps marks an already-reflected meta object schema as an
// open map of string values. The known properties and their required list come
// from struct-tag reflection (this runs after, via the Preparer hook); this
// only attaches additionalProperties:{type:string} so callers may add arbitrary
// group-by keys.
func addStringAdditionalProps(s *jsonschema.Schema) {
s.WithAdditionalProperties(jsonschema.String.ToSchemaOrBool())
}

View File

@@ -0,0 +1,150 @@
package inframonitoringtypes
import (
"encoding/json"
"reflect"
"testing"
"github.com/stretchr/testify/require"
"github.com/swaggest/jsonschema-go"
)
// fullPodAttrs is a metadata map with every guaranteed pod key plus a dynamic
// group-by key, used across the round-trip cases.
func fullPodAttrs() map[string]string {
return map[string]string{
"k8s.pod.uid": "uid-1",
"k8s.pod.name": "pod-1",
"k8s.namespace.name": "ns-1",
"k8s.node.name": "node-1",
"k8s.deployment.name": "dep-1",
"k8s.statefulset.name": "",
"k8s.daemonset.name": "",
"k8s.job.name": "",
"k8s.cronjob.name": "",
"k8s.cluster.name": "cluster-1",
"k8s.pod.start_time": "2024-01-01T00:00:00Z",
"custom.groupby": "gv",
}
}
func TestPodMeta_RoundTripAndRouting(t *testing.T) {
attrs := fullPodAttrs()
m := NewPodMeta(attrs)
// known keys route to typed fields
require.Equal(t, "uid-1", m.PodUID)
require.Equal(t, "cluster-1", m.ClusterName)
require.Equal(t, "2024-01-01T00:00:00Z", m.PodStartTime)
// dynamic key routes to Extra, guaranteed keys do not
require.Equal(t, map[string]string{"custom.groupby": "gv"}, m.Extra)
// marshals to a single flat object equal to the input map
b, err := json.Marshal(m)
require.NoError(t, err)
var flat map[string]string
require.NoError(t, json.Unmarshal(b, &flat))
require.Equal(t, attrs, flat)
require.NotContains(t, string(b), "Extra")
// round-trip back into the struct is stable
var back PodMeta
require.NoError(t, json.Unmarshal(b, &back))
require.Equal(t, m, back)
}
func TestMeta_EmptyMarshalsToObjectNotNull(t *testing.T) {
b, err := json.Marshal(NewClusterMeta(nil))
require.NoError(t, err)
require.JSONEq(t, `{"k8s.cluster.name":""}`, string(b))
// a nil-Extra zero value still marshals its guaranteed keys, never null
b, err = json.Marshal(ClusterMeta{})
require.NoError(t, err)
require.JSONEq(t, `{"k8s.cluster.name":""}`, string(b))
}
func TestMeta_AbsentGuaranteedKeyEmitsEmptyString(t *testing.T) {
// os.type absent from attrs -> still present as "" (accepted behavior)
m := NewHostMeta(map[string]string{"host.name": "h1"})
require.Equal(t, "", m.OSType)
b, err := json.Marshal(m)
require.NoError(t, err)
require.JSONEq(t, `{"host.name":"h1","os.type":""}`, string(b))
}
func TestMeta_DynamicKeyDoesNotDuplicateKnownKey(t *testing.T) {
// a dynamic key colliding with a known key must not double-emit; the typed
// field wins and there is exactly one occurrence on the wire.
m := NewClusterMeta(map[string]string{"k8s.cluster.name": "c1"})
require.Empty(t, m.Extra)
require.Equal(t, "c1", m.ClusterName)
b, err := json.Marshal(m)
require.NoError(t, err)
require.JSONEq(t, `{"k8s.cluster.name":"c1"}`, string(b))
}
func TestMetaKeys_ExpectedSetAndOrder(t *testing.T) {
require.Equal(t, []string{"os.type", "host.name"}, HostMetaKeys)
require.Equal(t, []string{"k8s.node.uid", "k8s.cluster.name", "k8s.node.name"}, NodeMetaKeys)
require.Equal(t, []string{
"k8s.pod.uid", "k8s.pod.name", "k8s.namespace.name", "k8s.node.name",
"k8s.deployment.name", "k8s.statefulset.name", "k8s.daemonset.name",
"k8s.job.name", "k8s.cronjob.name", "k8s.cluster.name", "k8s.pod.start_time",
}, PodMetaKeys)
}
// TestMetaKeys_MatchStructTags guards against a field rename/reorder silently
// changing the fetch column list: every key must correspond to a json tag on
// the struct, and the two must be in the same order.
func TestMetaKeys_MatchStructTags(t *testing.T) {
cases := []struct {
keys []string
typ reflect.Type
}{
{HostMetaKeys, reflect.TypeOf(HostMeta{})},
{PodMetaKeys, reflect.TypeOf(PodMeta{})},
{ContainerMetaKeys, reflect.TypeOf(ContainerMeta{})},
{NodeMetaKeys, reflect.TypeOf(NodeMeta{})},
{NamespaceMetaKeys, reflect.TypeOf(NamespaceMeta{})},
{ClusterMetaKeys, reflect.TypeOf(ClusterMeta{})},
{DeploymentMetaKeys, reflect.TypeOf(DeploymentMeta{})},
{DaemonSetMetaKeys, reflect.TypeOf(DaemonSetMeta{})},
{StatefulSetMetaKeys, reflect.TypeOf(StatefulSetMeta{})},
{JobMetaKeys, reflect.TypeOf(JobMeta{})},
{VolumeMetaKeys, reflect.TypeOf(VolumeMeta{})},
}
for _, c := range cases {
t.Run(c.typ.Name(), func(t *testing.T) {
var tagKeys []string
for i := range c.typ.NumField() {
tag := c.typ.Field(i).Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
tagKeys = append(tagKeys, tag)
}
require.Equal(t, tagKeys, c.keys)
})
}
}
// TestMeta_SchemaShape locks the OpenAPI contract the frontend depends on: known
// keys as properties + all required + additionalProperties:{type:string}.
func TestMeta_SchemaShape(t *testing.T) {
r := jsonschema.Reflector{}
s, err := r.Reflect(PodMeta{})
require.NoError(t, err)
require.Equal(t, PodMetaKeys, s.Required)
require.Len(t, s.Properties, len(PodMetaKeys))
for _, k := range PodMetaKeys {
prop, ok := s.Properties[k]
require.True(t, ok, "missing property %s", k)
require.NotNil(t, prop.TypeObject)
require.Equal(t, jsonschema.String.Type(), *prop.TypeObject.Type)
}
require.NotNil(t, s.AdditionalProperties)
require.NotNil(t, s.AdditionalProperties.TypeObject)
require.Equal(t, jsonschema.String.Type(), *s.AdditionalProperties.TypeObject.Type)
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Namespaces struct {
@@ -28,7 +30,7 @@ type NamespaceRecord struct {
Jobs int64 `json:"jobs" required:"true"`
StatefulSets int64 `json:"statefulSets" required:"true"`
} `json:"counts" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Meta NamespaceMeta `json:"meta" required:"true"`
}
// PostableNamespaces is the request body for the v2 namespaces list API.
@@ -106,3 +108,37 @@ func (req *PostableNamespaces) UnmarshalJSON(data []byte) error {
*req = PostableNamespaces(decoded)
return req.Validate()
}
// NamespaceMeta carries the guaranteed namespace metadata keys as typed fields
// plus any dynamic group-by keys in Extra.
type NamespaceMeta struct {
NamespaceName string `json:"k8s.namespace.name" required:"true"`
ClusterName string `json:"k8s.cluster.name" required:"true"`
Extra map[string]string `json:"-"`
}
var NamespaceMetaKeys = getMetaKeys(&NamespaceMeta{})
func NewNamespaceMeta(attrs map[string]string) NamespaceMeta {
var m NamespaceMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m NamespaceMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *NamespaceMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (NamespaceMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Nodes struct {
@@ -33,7 +35,7 @@ type NodeRecord struct {
NodeCPUAllocatable float64 `json:"nodeCPUAllocatable" required:"true"`
NodeMemory float64 `json:"nodeMemory" required:"true"`
NodeMemoryAllocatable float64 `json:"nodeMemoryAllocatable" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Meta NodeMeta `json:"meta" required:"true"`
}
// PostableNodes is the request body for the v2 nodes list API.
@@ -111,3 +113,38 @@ func (req *PostableNodes) UnmarshalJSON(data []byte) error {
*req = PostableNodes(decoded)
return req.Validate()
}
// NodeMeta carries the guaranteed node metadata keys as typed fields plus any
// dynamic group-by keys in Extra.
type NodeMeta struct {
NodeUID string `json:"k8s.node.uid" required:"true"`
ClusterName string `json:"k8s.cluster.name" required:"true"`
NodeName string `json:"k8s.node.name" required:"true"`
Extra map[string]string `json:"-"`
}
var NodeMetaKeys = getMetaKeys(&NodeMeta{})
func NewNodeMeta(attrs map[string]string) NodeMeta {
var m NodeMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m NodeMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *NodeMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (NodeMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Pods struct {
@@ -68,7 +70,7 @@ type PodRecord struct {
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
PodRestarts int64 `json:"podRestarts" required:"true"`
PodAge int64 `json:"podAge" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Meta PodMeta `json:"meta" required:"true"`
}
// PostablePods is the request body for the v2 pods list API.
@@ -146,3 +148,46 @@ func (req *PostablePods) UnmarshalJSON(data []byte) error {
*req = PostablePods(decoded)
return req.Validate()
}
// PodMeta carries the guaranteed pod metadata keys as typed fields plus any
// dynamic group-by keys in Extra.
type PodMeta struct {
PodUID string `json:"k8s.pod.uid" required:"true"`
PodName string `json:"k8s.pod.name" required:"true"`
NamespaceName string `json:"k8s.namespace.name" required:"true"`
NodeName string `json:"k8s.node.name" required:"true"`
DeploymentName string `json:"k8s.deployment.name" required:"true"`
StatefulSetName string `json:"k8s.statefulset.name" required:"true"`
DaemonSetName string `json:"k8s.daemonset.name" required:"true"`
JobName string `json:"k8s.job.name" required:"true"`
CronJobName string `json:"k8s.cronjob.name" required:"true"`
ClusterName string `json:"k8s.cluster.name" required:"true"`
PodStartTime string `json:"k8s.pod.start_time" required:"true"`
Extra map[string]string `json:"-"`
}
var PodMetaKeys = getMetaKeys(&PodMeta{})
func NewPodMeta(attrs map[string]string) PodMeta {
var m PodMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m PodMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *PodMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (PodMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type StatefulSets struct {
@@ -28,7 +30,7 @@ type StatefulSetRecord struct {
CurrentPods int `json:"currentPods" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
Meta StatefulSetMeta `json:"meta" required:"true"`
}
// PostableStatefulSets is the request body for the v2 statefulsets list API.
@@ -106,3 +108,38 @@ func (req *PostableStatefulSets) UnmarshalJSON(data []byte) error {
*req = PostableStatefulSets(decoded)
return req.Validate()
}
// StatefulSetMeta carries the guaranteed statefulset metadata keys as typed
// fields plus any dynamic group-by keys in Extra.
type StatefulSetMeta struct {
StatefulSetName string `json:"k8s.statefulset.name" required:"true"`
NamespaceName string `json:"k8s.namespace.name" required:"true"`
ClusterName string `json:"k8s.cluster.name" required:"true"`
Extra map[string]string `json:"-"`
}
var StatefulSetMetaKeys = getMetaKeys(&StatefulSetMeta{})
func NewStatefulSetMeta(attrs map[string]string) StatefulSetMeta {
var m StatefulSetMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m StatefulSetMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *StatefulSetMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (StatefulSetMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Volumes struct {
@@ -17,14 +19,14 @@ type Volumes struct {
}
type VolumeRecord struct {
PersistentVolumeClaimName string `json:"persistentVolumeClaimName" required:"true"`
VolumeAvailable float64 `json:"volumeAvailable" required:"true"`
VolumeCapacity float64 `json:"volumeCapacity" required:"true"`
VolumeUsage float64 `json:"volumeUsage" required:"true"`
VolumeInodes float64 `json:"volumeInodes" required:"true"`
VolumeInodesFree float64 `json:"volumeInodesFree" required:"true"`
VolumeInodesUsed float64 `json:"volumeInodesUsed" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
PersistentVolumeClaimName string `json:"persistentVolumeClaimName" required:"true"`
VolumeAvailable float64 `json:"volumeAvailable" required:"true"`
VolumeCapacity float64 `json:"volumeCapacity" required:"true"`
VolumeUsage float64 `json:"volumeUsage" required:"true"`
VolumeInodes float64 `json:"volumeInodes" required:"true"`
VolumeInodesFree float64 `json:"volumeInodesFree" required:"true"`
VolumeInodesUsed float64 `json:"volumeInodesUsed" required:"true"`
Meta VolumeMeta `json:"meta" required:"true"`
}
// PostableVolumes is the request body for the v2 volumes (PVCs) list API.
@@ -102,3 +104,42 @@ func (req *PostableVolumes) UnmarshalJSON(data []byte) error {
*req = PostableVolumes(decoded)
return req.Validate()
}
// VolumeMeta carries the guaranteed volume (PVC) metadata keys as typed fields
// plus any dynamic group-by keys in Extra.
type VolumeMeta struct {
PVCName string `json:"k8s.persistentvolumeclaim.name" required:"true"`
PodUID string `json:"k8s.pod.uid" required:"true"`
PodName string `json:"k8s.pod.name" required:"true"`
NamespaceName string `json:"k8s.namespace.name" required:"true"`
NodeName string `json:"k8s.node.name" required:"true"`
StatefulSetName string `json:"k8s.statefulset.name" required:"true"`
ClusterName string `json:"k8s.cluster.name" required:"true"`
Extra map[string]string `json:"-"`
}
var VolumeMetaKeys = getMetaKeys(&VolumeMeta{})
func NewVolumeMeta(attrs map[string]string) VolumeMeta {
var m VolumeMeta
m.Extra = populateMeta(&m, attrs)
return m
}
func (m VolumeMeta) MarshalJSON() ([]byte, error) {
return marshalMeta(&m, m.Extra)
}
func (m *VolumeMeta) UnmarshalJSON(data []byte) error {
raw, err := splitMeta(data)
if err != nil {
return err
}
m.Extra = populateMeta(m, raw)
return nil
}
func (VolumeMeta) PrepareJSONSchema(s *jsonschema.Schema) error {
addStringAdditionalProps(s)
return nil
}

View File

@@ -203,9 +203,9 @@ def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional
pytestconfig: pytest.Config,
cache_key: str = "clickhouse",
) -> types.TestContainerClickhouse:
coordinator = next(iter(keeper.container_configs.values()))
def create() -> types.TestContainerClickhouse:
# Lazy: the keeper fixture is empty under --teardown (never created).
coordinator = next(iter(keeper.container_configs.values()))
clickhouse_version = request.config.getoption("--clickhouse-version")
container = ClickHouseContainer(
@@ -381,9 +381,10 @@ def create_clickhouse_cluster( # pylint: disable=too-many-arguments,too-many-po
migration goes through. Per-node containers are exposed via `nodes` so
tests can assert shard-local state.
"""
coordinator = next(iter(keeper.container_configs.values()))
def create() -> types.TestContainerClickhouse:
# Lazy: the keeper fixture is empty under --teardown (never created).
coordinator = next(iter(keeper.container_configs.values()))
clickhouse_version = request.config.getoption("--clickhouse-version")
# Unique aliases per creation: docker allows duplicate network aliases

View File

@@ -1,3 +1,5 @@
import time
import docker
import docker.errors
import pytest
@@ -23,12 +25,37 @@ def network(request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> type
def delete(nw: types.Network):
client = docker.from_env()
try:
client.networks.get(network_id=nw.id).remove()
network = client.networks.get(network_id=nw.id)
except docker.errors.NotFound:
logger.info(
"Skipping removal of Network, Network(%s) not found. Maybe it was manually removed?",
{"name": nw.name, "id": nw.id},
)
return
# Docker detaches endpoints asynchronously, so the network can briefly
# report "has active endpoints" after its containers are gone. Retry,
# force-disconnecting any stragglers.
last_err: docker.errors.APIError | None = None
for _ in range(10):
try:
network.remove()
return
except docker.errors.NotFound:
return
except docker.errors.APIError as err:
if "has active endpoints" not in str(err):
raise
last_err = err
network.reload()
for container_id in network.attrs.get("Containers") or {}:
try:
network.disconnect(container_id, force=True)
except docker.errors.APIError:
pass
time.sleep(1)
raise last_err
def restore(existing: dict) -> types.Network:
client = docker.from_env()