Compare commits

..

8 Commits

Author SHA1 Message Date
Ashwin Bhatkal
1e204fb56f fix(api): guard deprecated generated-API handler against non-envelope bodies
Guard ErrorResponseHandlerForGeneratedAPIs like ErrorResponseHandlerV2: when
response.data has no error object (gateway 5xx with HTML/empty body during a
deploy), synthesize an UPSTREAM_UNAVAILABLE APIError instead of throwing on
response.data.error.code. A present error object (even without a code) is
still read, so real backend messages are preserved.

toAPIError now treats UPSTREAM_UNAVAILABLE (no backend code found) as 'could
not parse' and falls back to the caller's context-specific defaultMessage,
preserving the ServiceAccount/Roles error UX that previously relied on the
handler crashing.

Part of https://github.com/SigNoz/engineering-pod/issues/5761
2026-07-22 15:49:57 +05:30
Ashwin Bhatkal
7228c029a4 revert(api): drop generated-API handler guard from this PR
The deprecated ErrorResponseHandlerForGeneratedAPIs guard broke toAPIError's
defaultMessage fallback (which relied on the handler crashing), regressing the
error UX in ServiceAccount/Roles screens. Moved to a stacked PR + tracked in
engineering-pod#5761. Keeps this PR scoped to ErrorResponseHandlerV2 +
convertToApiError, and the strengthened V2 handler tests remain.
2026-07-22 01:04:14 +05:30
Ashwin Bhatkal
63a17a66c0 fix(api): guard generated-API handler + strengthen V2 handler tests
- Guard the deprecated ErrorResponseHandlerForGeneratedAPIs against a
  non-envelope response body (gateway 5xx with HTML/empty body), mirroring
  ErrorResponseHandlerV2 — falls back to UPSTREAM_UNAVAILABLE instead of
  throwing on response.data.error.code.
- Parametrize the V2 handler tests into an { error, expected } table and
  assert the sub-error 'errors' messages, which several UI surfaces rely on.
2026-07-22 00:38:56 +05:30
Ashwin Bhatkal
b7a9fd17dc test(dashboard-v2): update panelStatus fallback code to UPSTREAM_UNAVAILABLE
convertToApiError now returns UPSTREAM_UNAVAILABLE (not a stringified
status) when the response carries no error code; update the panelStatus
fallback assertion to match.
2026-07-21 23:41:19 +05:30
Ashwin Bhatkal
a3b4a5e7a6 fix(api): use UPSTREAM_UNAVAILABLE fallback code in convertToApiError
When the response body carries no error code, fall back to a stable
UPSTREAM_UNAVAILABLE code instead of a stringified HTTP status, mirroring
the ErrorResponseHandlerV2 fallback.
2026-07-21 19:41:21 +05:30
Ashwin Bhatkal
9b63ab1d34 fix(api): use UPSTREAM_UNAVAILABLE code for non-envelope error bodies
Address review: when the response body isn't a V2 envelope (e.g. a gateway
5xx during a deploy), throw a stable UPSTREAM_UNAVAILABLE code instead of
the stringified HTTP status, and trim the guard comment.
2026-07-21 17:18:56 +05:30
Ashwin Bhatkal
d4564df1d9 refactor(api): keep ErrorV2Resp signature, launder response.data via unknown
Restore the AxiosError<ErrorV2Resp> signature so it documents that this is
the V2 error handler, and confine the runtime uncertainty to the one spot
that matters: launder response.data through a local unknown binding before
narrowing it with the isErrorV2Resp guard.
2026-07-21 17:09:39 +05:30
Ashwin Bhatkal
e2e050f0e0 fix(api): guard ErrorResponseHandlerV2 against non-envelope error bodies
The handler assumed every non-2xx response carried the V2 error envelope
and read response.data.error.code directly. During a deployment the
gateway returns a 5xx with an HTML/empty body, so response.data.error is
undefined and the handler threw its own TypeError, masking the real
failure and crashing the caller.

Type the inbound body as unknown and narrow it with an isErrorV2Resp type
guard; when the body isn't a V2 envelope, synthesize an APIError from the
HTTP status instead of throwing. Callers already pass
AxiosError<ErrorV2Resp>, which stays assignable to AxiosError<unknown>.
2026-07-21 16:36:03 +05:30
78 changed files with 1444 additions and 2966 deletions

View File

@@ -4247,15 +4247,6 @@ components:
- missingOptionalMetrics
- missingRequiredAttributes
type: object
InframonitoringtypesClusterMeta:
additionalProperties:
type: string
properties:
k8s.cluster.name:
type: string
required:
- k8s.cluster.name
type: object
InframonitoringtypesClusterRecord:
properties:
clusterCPU:
@@ -4301,7 +4292,10 @@ components:
- statefulSets
type: object
meta:
$ref: '#/components/schemas/InframonitoringtypesClusterMeta'
additionalProperties:
type: string
nullable: true
type: object
nodeCountsByReadiness:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
@@ -4393,51 +4387,6 @@ 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
@@ -4471,7 +4420,10 @@ components:
format: double
type: number
meta:
$ref: '#/components/schemas/InframonitoringtypesContainerMeta'
additionalProperties:
type: string
nullable: true
type: object
podUID:
type: string
ready:
@@ -4534,21 +4486,6 @@ 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:
@@ -4576,7 +4513,10 @@ components:
desiredNodes:
type: integer
meta:
$ref: '#/components/schemas/InframonitoringtypesDaemonSetMeta'
additionalProperties:
type: string
nullable: true
type: object
misscheduledNodes:
type: integer
podCountsByPhase:
@@ -4621,21 +4561,6 @@ 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:
@@ -4663,7 +4588,10 @@ components:
desiredPods:
type: integer
meta:
$ref: '#/components/schemas/InframonitoringtypesDeploymentMeta'
additionalProperties:
type: string
nullable: true
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
@@ -4709,18 +4637,6 @@ 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:
@@ -4742,7 +4658,10 @@ components:
format: double
type: number
meta:
$ref: '#/components/schemas/InframonitoringtypesHostMeta'
additionalProperties:
type: string
nullable: true
type: object
status:
$ref: '#/components/schemas/InframonitoringtypesHostStatus'
wait:
@@ -4786,21 +4705,6 @@ 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:
@@ -4830,7 +4734,10 @@ components:
jobName:
type: string
meta:
$ref: '#/components/schemas/InframonitoringtypesJobMeta'
additionalProperties:
type: string
nullable: true
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
@@ -4924,18 +4831,6 @@ 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:
@@ -4959,7 +4854,10 @@ components:
- statefulSets
type: object
meta:
$ref: '#/components/schemas/InframonitoringtypesNamespaceMeta'
additionalProperties:
type: string
nullable: true
type: object
namespaceCPU:
format: double
type: number
@@ -5017,27 +4915,15 @@ 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:
$ref: '#/components/schemas/InframonitoringtypesNodeMeta'
additionalProperties:
type: string
nullable: true
type: object
nodeCPU:
format: double
type: number
@@ -5167,45 +5053,6 @@ 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
@@ -5218,7 +5065,10 @@ components:
InframonitoringtypesPodRecord:
properties:
meta:
$ref: '#/components/schemas/InframonitoringtypesPodMeta'
additionalProperties:
type: string
nullable: true
type: object
podAge:
format: int64
type: integer
@@ -5602,21 +5452,6 @@ 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:
@@ -5624,7 +5459,10 @@ components:
desiredPods:
type: integer
meta:
$ref: '#/components/schemas/InframonitoringtypesStatefulSetMeta'
additionalProperties:
type: string
nullable: true
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
@@ -5683,37 +5521,13 @@ 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:
$ref: '#/components/schemas/InframonitoringtypesVolumeMeta'
additionalProperties:
type: string
nullable: true
type: object
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.16.0",
"overlayscrollbars": "^2.8.1",
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
"posthog-js": "1.298.0",
@@ -221,18 +221,5 @@
"*.(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.16.0
version: 2.16.0
specifier: ^2.8.1
version: 2.9.2
overlayscrollbars-react:
specifier: ^0.5.6
version: 0.5.6(overlayscrollbars@2.16.0)(react@18.2.0)
version: 0.5.6(overlayscrollbars@2.9.2)(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.16.0:
resolution: {integrity: sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==}
overlayscrollbars@2.9.2:
resolution: {integrity: sha512-iDT84r39i7oWP72diZN2mbJUsn/taCq568aQaIrc84S87PunBT7qtsVltAF2esk7ORTRjQDnfjVYoqqTzgs8QA==}
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.16.0)(react@18.2.0):
overlayscrollbars-react@0.5.6(overlayscrollbars@2.9.2)(react@18.2.0):
dependencies:
overlayscrollbars: 2.16.0
overlayscrollbars: 2.9.2
react: 18.2.0
overlayscrollbars@2.16.0: {}
overlayscrollbars@2.9.2: {}
oxfmt@0.54.0:
dependencies:

View File

@@ -1,20 +0,0 @@
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,6 +2,29 @@ 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>,
@@ -10,15 +33,29 @@ 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: response.data.error.code,
message: response.data.error.message,
url: response.data.error.url ?? '',
errors: (response.data.error.errors ?? []).map((e) => ({
message: e.message ?? '',
})),
code: 'UPSTREAM_UNAVAILABLE',
message: error.message || 'Something went wrong',
url: '',
errors: [],
},
});
}
@@ -62,9 +99,7 @@ export function convertToApiError(
return new APIError({
httpStatusCode: response?.status || error.status || 500,
error: {
code:
errorData?.code ||
String(response?.status || error.code || 'unknown_error'),
code: errorData?.code || 'UPSTREAM_UNAVAILABLE',
message:
errorData?.message ||
response?.statusText ||

View File

@@ -2,19 +2,38 @@ 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: response.data.error.code,
message: response.data.error.message,
url: response.data.error.url,
errors: response.data.error.errors,
code: 'UPSTREAM_UNAVAILABLE',
message: error.message || 'Something went wrong',
url: '',
errors: [],
},
});
}

View File

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

View File

@@ -1,39 +1,148 @@
import { useCallback, useEffect, useMemo } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useQuery } from 'react-query';
import { Color, Spacing } from '@signozhq/design-tokens';
import { X } from '@signozhq/icons';
import { Button, Drawer, Tooltip } from 'antd';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import { 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 { useIsDarkMode } from 'hooks/useDarkMode';
import { QueryParams } from 'constants/query';
import {
GlobalTimeProvider,
NANO_SECOND_MULTIPLIER,
useGlobalTimeStore,
} from 'store/globalTime';
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';
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
import { useInfraMonitoringSelectedItemParams } from '../hooks';
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 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';
export type {
CustomTab,
CustomTabRenderProps,
K8sBaseDetailsProps,
K8sDetailsCountConfig,
K8sDetailsFilters,
K8sDetailsMetadataConfig,
} from './types';
const TimeRangeOffset = 1000000000;
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,
@@ -54,6 +163,7 @@ 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,
);
@@ -127,9 +237,86 @@ 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) {
void logEvent(InfraMonitoringEvents.PageVisited, {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
@@ -137,6 +324,136 @@ 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%"
@@ -181,33 +498,207 @@ export default function K8sBaseDetails<T>({
</div>
)}
{entity && !isEntityLoading && !hasResponseError && (
<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>
<>
<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,
)}
</>
)}
</Drawer>
);

View File

@@ -1,401 +0,0 @@
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,7 +245,6 @@ function K8sHeader<TData>({
showAutoRefresh={showAutoRefresh}
showRefreshText={false}
hideShareModal
defaultRelativeTime="30m"
/>
</div>

View File

@@ -52,6 +52,7 @@ 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(
@@ -59,44 +60,6 @@ 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,17 +1,3 @@
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;
@@ -47,100 +33,3 @@ 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

@@ -1,78 +0,0 @@
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

@@ -1,95 +0,0 @@
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,14 +21,17 @@ 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';
@@ -50,7 +53,16 @@ interface EventDataType {
}
interface Props {
eventEntity: string;
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
@@ -65,11 +77,13 @@ const handleExpandRow = (record: EventDataType): JSX.Element => (
);
function EntityEventsContent({
eventEntity,
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
@@ -117,8 +131,8 @@ function EntityEventsContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: eventEntity,
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.EventsView,
@@ -127,14 +141,7 @@ function EntityEventsContent({
refetch();
}
},
[
inputExpression,
initialExpression,
refetch,
querySearchOnRun,
category,
eventEntity,
],
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
@@ -222,10 +229,16 @@ function EntityEventsContent({
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.EventsView}
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn

View File

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

View File

@@ -1,81 +0,0 @@
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,6 +25,11 @@ 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';
@@ -33,8 +38,6 @@ 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';
@@ -44,18 +47,29 @@ import { getEntityLogsQueryPayload } from './utils';
import styles from './EntityLogs.module.scss';
interface Props {
eventEntity: string;
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityLogsContent({
eventEntity,
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const expression = useExpression();
@@ -120,7 +134,7 @@ function EntityLogsContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression);
void logEvent(InfraMonitoringEvents.FilterApplied, {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
@@ -225,10 +239,16 @@ function EntityLogsContent({
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.LogsView}
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn

View File

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

View File

@@ -1,93 +0,0 @@
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,11 +2,15 @@ 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';
@@ -20,17 +24,25 @@ 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;
@@ -47,16 +59,16 @@ interface EntityMetricsProps<T> {
}
function EntityMetrics<T>({
selectedInterval,
entity,
eventEntity,
timeRange,
handleTimeChange,
isModalTimeSelection,
entityWidgetInfo,
getEntityQueryPayload,
queryKey,
category,
}: EntityMetricsProps<T>): JSX.Element {
const { timeRange, selectedInterval, handleTimeChange } =
useEntityDetailsTime();
const { visibilities, setElement } = useMultiIntersectionObserver(
entityWidgetInfo.length,
{ threshold: 0.1 },
@@ -79,8 +91,10 @@ function EntityMetrics<T>({
const onDragSelect = useCallback(
(start: number, end: number): void => {
// UPlotConfigBuilder's setSelect hook already delivers milliseconds
handleTimeChange('custom', [Math.trunc(start), Math.trunc(end)]);
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
handleTimeChange('custom', [startTimestamp, endTimestamp]);
},
[handleTimeChange],
);
@@ -174,10 +188,16 @@ function EntityMetrics<T>({
return (
<>
<div className={styles.metricsHeader}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.MetricsView}
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime={DEFAULT_TIME_RANGE}
isModalTimeSelection={isModalTimeSelection}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
<div className={styles.entityMetricsContainer}>

View File

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

View File

@@ -1,119 +0,0 @@
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,6 +20,11 @@ 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';
@@ -27,11 +32,10 @@ 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 { isKeyNotFoundError, selectedEntityTracesColumns } from '../utils';
import { selectedEntityTracesColumns } from '../utils';
import { isKeyNotFoundError } from '../utils';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY, useEntityTraces } from './hooks';
import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
@@ -40,18 +44,29 @@ import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
interface Props {
eventEntity: string;
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
}
function EntityTracesContent({
eventEntity,
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const { timeRange } = useEntityDetailsTime();
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
@@ -99,8 +114,8 @@ function EntityTracesContent({
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
void logEvent(InfraMonitoringEvents.FilterApplied, {
entity: eventEntity,
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.TracesView,
@@ -109,14 +124,7 @@ function EntityTracesContent({
refetch();
}
},
[
inputExpression,
initialExpression,
refetch,
querySearchOnRun,
category,
eventEntity,
],
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
@@ -144,7 +152,7 @@ function EntityTracesContent({
const hasAdditionalFilters = !!userExpression?.trim();
const handleRowClick = useCallback(() => {
void logEvent(InfraMonitoringEvents.ItemClicked, {
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
category,
view: InfraMonitoringEvents.TracesView,
@@ -161,10 +169,16 @@ function EntityTracesContent({
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<EntityDateTimeSelector
eventEntity={eventEntity}
category={category}
view={InfraMonitoringEvents.TracesView}
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn

View File

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

View File

@@ -1,81 +0,0 @@
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,5 +1,7 @@
import { mockFieldsAPIsWithEmptyResponse } from '__tests__/fields_api.util';
import { ENVIRONMENT } from 'constants/env';
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';
@@ -7,19 +9,39 @@ 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(() => {
mockFieldsAPIsWithEmptyResponse();
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
),
),
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
),
),
);
});
export interface RenderEntityTracesOptions {
expression?: string;
timeRange?: { startTime: number; endTime: number };
category?: InfraMonitoringEntity;
selectedInterval?: '5m' | '15m' | '30m' | '1h';
pagination?: { offset: number; limit: number };
}
export function renderEntityTraces({
expression = 'k8s.pod.name = "test-pod"',
timeRange = { startTime: 1, endTime: 2 },
category = InfraMonitoringEntity.PODS,
selectedInterval = '5m',
pagination,
}: RenderEntityTracesOptions = {}): RenderResult {
const encodedExpression = encodeURIComponent(expression);
@@ -33,7 +55,10 @@ export function renderEntityTraces({
return render(
<NuqsTestingAdapter searchParams={searchParams}>
<EntityTraces
eventEntity="test"
timeRange={timeRange}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval={selectedInterval}
queryKey="test"
category={category}
initialExpression={expression}
@@ -76,21 +101,21 @@ export function verifyQueryPayload({
expect(orderKeys).toContain('timestamp');
}
jest.mock('../../EntityDateTimeSelector/EntityDateTimeSelector', () => ({
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="mock-datetime-selection">Date Time</div>
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>
),
}));
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,5 +1,4 @@
import { Container } from '@signozhq/icons';
import { InfraMonitoringEvents } from 'constants/events';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { CustomTab } from '../Base/K8sBaseDetails';
@@ -11,20 +10,6 @@ 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,
@@ -32,29 +17,30 @@ interface CreatePodMetricsTabParams<T> {
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
queryKey: string;
category: InfraMonitoringEntity;
queryKey: string;
}
export function createPodMetricsTab<T>({
getQueryPayload,
queryKey,
category,
queryKey,
}: CreatePodMetricsTabParams<T>): CustomTab<T> {
const eventEntity = categoryToEventEntity[category];
return {
key: VIEW_TYPES.POD_METRICS,
label: 'Pod Metrics',
icon: <Container size={14} />,
render: ({ entity }) => (
render: ({ entity, timeRange, selectedInterval, handleTimeChange }) => (
<EntityMetrics
entity={entity}
eventEntity={eventEntity}
selectedInterval={selectedInterval}
timeRange={timeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection
entityWidgetInfo={podUtilizationByPodWidgetInfo}
getEntityQueryPayload={getQueryPayload}
queryKey={queryKey}
category={category}
queryKey={queryKey}
/>
),
};

View File

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

View File

@@ -905,9 +905,6 @@ 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,7 +7,6 @@ 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';
@@ -18,8 +17,6 @@ import './ReactI18';
import 'styles.scss';
configureOverlayScrollbars();
const queryClient = new QueryClient({
defaultOptions: {
queries: {

View File

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

View File

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

View File

@@ -38,36 +38,6 @@ 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', () => {
@@ -112,73 +82,138 @@ describe('GlobalTimeProvider', () => {
describe('inheritGlobalTime', () => {
it('should inherit time from parent store when inheritGlobalTime is true', () => {
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true },
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime>{children}</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
wrapper: NestedWrapper,
});
// Should inherit '6h' from parent provider
expect(result.current).toBe('6h');
});
it('should use initialTime when inheritGlobalTime is false', () => {
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: false, initialTime: '15m' },
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
wrapper: NestedWrapper,
});
// Should use its own initialTime, not parent's
expect(result.current).toBe('15m');
});
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true, enableUrlParams: true },
{ searchParams: '?relativeTime=1h' },
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="?relativeTime=1h">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
wrapper: NestedWrapper,
});
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should use inherited time when URL params are empty', async () => {
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true, enableUrlParams: true },
{ searchParams: '' },
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
wrapper: NestedWrapper,
});
// No URL params, should keep inherited value
expect(result.current).toBe('6h');
});
it('should prefer custom time URL params over inheritGlobalTime', async () => {
const queryClient = createTestQueryClient();
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createNestedWrapper(
{ initialTime: '6h' },
{ inheritGlobalTime: true, enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime(), {
wrapper,
wrapper: NestedWrapper,
});
// URL custom time params should override inherited time
@@ -655,151 +690,4 @@ 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,7 +5,6 @@ import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constan
import {
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStoreApiRef,
GlobalTimeStore,
ParsedTimeRange,
} from './types';
@@ -19,10 +18,6 @@ import {
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
export type IGlobalTimeStore = GlobalTimeStore;
export interface CreateGlobalTimeStoreOptions extends Partial<GlobalTimeState> {
parentStore?: GlobalTimeStoreApiRef;
}
function computeIsRefreshEnabled(
selectedTime: GlobalTimeSelectedTime,
refreshInterval: number,
@@ -34,12 +29,11 @@ function computeIsRefreshEnabled(
}
export function createGlobalTimeStore(
options?: CreateGlobalTimeStoreOptions,
initialState?: Partial<GlobalTimeState>,
): GlobalTimeStoreApi {
const selectedTime = options?.selectedTime ?? DEFAULT_TIME_RANGE;
const refreshInterval = options?.refreshInterval ?? 0;
const name = options?.name;
const parentStore = options?.parentStore;
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
const refreshInterval = initialState?.refreshInterval ?? 0;
const name = initialState?.name;
return createStore<GlobalTimeStore>((set, get) => ({
name,
@@ -48,8 +42,6 @@ export function createGlobalTimeStore(
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
lastRefreshTimestamp: 0,
lastComputedMinMax: safeParseSelectedTime(selectedTime),
parentStore,
shouldClearUrlParams: false,
setSelectedTime: (
time: GlobalTimeSelectedTime,
@@ -138,29 +130,6 @@ 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,7 +206,6 @@
* | `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
*
@@ -236,7 +235,7 @@
* | Option | Type | Description |
* |--------|------|-------------|
* | `name` | `string` | Scope query keys to this store (enables isolated invalidation) |
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value. Enables `resetToParentTime()` |
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
* | `initialTime` | `string` | Initial time if not inheriting |
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
@@ -342,39 +341,7 @@
* }
* ```
*
* ### 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
* ### Example 3: Nested Contexts
*
* Contexts can be nested - each level creates isolation:
*
@@ -412,10 +379,9 @@
* }
* ```
*
* ### Example 5: URL Sync for Shareable Links
* ### Example 4: URL Sync for Shareable Links
*
* Persist time selection to URL for shareable links. When using `resetToParentTime()`,
* URL params are automatically cleared:
* Persist time selection to URL for shareable links:
*
* ```tsx
* function TracesExplorer() {
@@ -434,7 +400,7 @@
* }
* ```
*
* ### Example 6: localStorage Persistence
* ### Example 5: localStorage Persistence
*
* Remember user's last selected time across sessions:
*

View File

@@ -1,13 +1,9 @@
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:
@@ -92,16 +88,6 @@ 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 {
@@ -132,16 +118,6 @@ 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,17 +91,6 @@ 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,7 +42,12 @@ export function toAPIError(
try {
ErrorResponseHandlerForGeneratedAPIs(error);
} catch (apiError) {
if (apiError instanceof 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'
) {
return apiError;
}
}

View File

@@ -36,7 +36,7 @@ func buildClusterRecords(
ClusterCPUAllocatable: -1,
ClusterMemory: -1,
ClusterMemoryAllocatable: -1,
Meta: inframonitoringtypes.NewClusterMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -85,7 +85,9 @@ func buildClusterRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewClusterMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -147,7 +149,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 inframonitoringtypes.ClusterMetaKeys {
for _, key := range clusterAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -31,6 +31,10 @@ 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: inframonitoringtypes.NewContainerMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -128,7 +128,9 @@ func buildContainerRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewContainerMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -190,7 +192,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 inframonitoringtypes.ContainerMetaKeys {
for _, key := range containerAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -58,6 +58,22 @@ 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: inframonitoringtypes.NewDaemonSetMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -91,7 +91,9 @@ func buildDaemonSetRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewDaemonSetMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -153,7 +155,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 inframonitoringtypes.DaemonSetMetaKeys {
for _, key := range daemonSetAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -39,6 +39,11 @@ 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: inframonitoringtypes.NewDeploymentMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -83,7 +83,9 @@ func buildDeploymentRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewDeploymentMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -145,7 +147,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 inframonitoringtypes.DeploymentMetaKeys {
for _, key := range deploymentAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -37,6 +37,11 @@ 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: inframonitoringtypes.NewHostMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -238,7 +238,9 @@ func buildHostRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewHostMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -331,7 +333,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 inframonitoringtypes.HostMetaKeys {
for _, key := range hostAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -23,6 +23,10 @@ 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: inframonitoringtypes.NewJobMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -91,7 +91,9 @@ func buildJobRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewJobMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -153,7 +155,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 inframonitoringtypes.JobMetaKeys {
for _, key := range jobAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -39,6 +39,11 @@ 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: inframonitoringtypes.NewNamespaceMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -66,7 +66,9 @@ func buildNamespaceRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewNamespaceMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -128,7 +130,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 inframonitoringtypes.NamespaceMetaKeys {
for _, key := range namespaceAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -59,6 +59,11 @@ 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: inframonitoringtypes.NewNodeMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -92,7 +92,9 @@ func buildNodeRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewNodeMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -154,7 +156,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 inframonitoringtypes.NodeMetaKeys {
for _, key := range nodeAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -30,6 +30,11 @@ 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: inframonitoringtypes.NewPodMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -163,7 +163,9 @@ func buildPodRecords(
}
}
}
record.Meta = inframonitoringtypes.NewPodMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -225,7 +227,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 inframonitoringtypes.PodMetaKeys {
for _, key := range podAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -45,6 +45,20 @@ 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: inframonitoringtypes.NewStatefulSetMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -83,7 +83,9 @@ func buildStatefulSetRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewStatefulSetMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -145,7 +147,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 inframonitoringtypes.StatefulSetMetaKeys {
for _, key := range statefulSetAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -37,6 +37,11 @@ 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: inframonitoringtypes.NewVolumeMeta(nil),
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -58,7 +58,9 @@ func buildVolumeRecords(
}
if attrs, ok := metadataMap[compositeKey]; ok {
record.Meta = inframonitoringtypes.NewVolumeMeta(attrs)
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
@@ -120,7 +122,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 inframonitoringtypes.VolumeMetaKeys {
for _, key := range volumeAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}

View File

@@ -28,6 +28,15 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Clusters struct {
@@ -38,7 +36,7 @@ type ClusterRecord struct {
Jobs int64 `json:"jobs" required:"true"`
StatefulSets int64 `json:"statefulSets" required:"true"`
} `json:"counts" required:"true"`
Meta ClusterMeta `json:"meta" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableClusters is the request body for the v2 clusters list API.
@@ -116,36 +114,3 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Containers struct {
@@ -70,7 +68,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 ContainerMeta `json:"meta" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableContainers is the request body for the v2 containers list API.
@@ -148,48 +146,3 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type DaemonSets struct {
@@ -32,7 +30,7 @@ type DaemonSetRecord struct {
MisscheduledNodes int `json:"misscheduledNodes" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta DaemonSetMeta `json:"meta" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableDaemonSets is the request body for the v2 daemonsets list API.
@@ -110,38 +108,3 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Deployments struct {
@@ -30,7 +28,7 @@ type DeploymentRecord struct {
AvailablePods int `json:"availablePods" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta DeploymentMeta `json:"meta" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableDeployments is the request body for the v2 deployments list API.
@@ -108,38 +106,3 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Hosts struct {
@@ -19,16 +17,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 HostMeta `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 map[string]string `json:"meta" required:"true"`
}
type PostableHosts struct {
@@ -115,37 +113,3 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Jobs struct {
@@ -32,7 +30,7 @@ type JobRecord struct {
SuccessfulPods int `json:"successfulPods" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta JobMeta `json:"meta" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableJobs is the request body for the v2 jobs list API.
@@ -110,38 +108,3 @@ 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

@@ -1,112 +0,0 @@
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

@@ -1,150 +0,0 @@
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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Namespaces struct {
@@ -30,7 +28,7 @@ type NamespaceRecord struct {
Jobs int64 `json:"jobs" required:"true"`
StatefulSets int64 `json:"statefulSets" required:"true"`
} `json:"counts" required:"true"`
Meta NamespaceMeta `json:"meta" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableNamespaces is the request body for the v2 namespaces list API.
@@ -108,37 +106,3 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Nodes struct {
@@ -35,7 +33,7 @@ type NodeRecord struct {
NodeCPUAllocatable float64 `json:"nodeCPUAllocatable" required:"true"`
NodeMemory float64 `json:"nodeMemory" required:"true"`
NodeMemoryAllocatable float64 `json:"nodeMemoryAllocatable" required:"true"`
Meta NodeMeta `json:"meta" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableNodes is the request body for the v2 nodes list API.
@@ -113,38 +111,3 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Pods struct {
@@ -70,7 +68,7 @@ type PodRecord struct {
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
PodRestarts int64 `json:"podRestarts" required:"true"`
PodAge int64 `json:"podAge" required:"true"`
Meta PodMeta `json:"meta" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostablePods is the request body for the v2 pods list API.
@@ -148,46 +146,3 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type StatefulSets struct {
@@ -30,7 +28,7 @@ type StatefulSetRecord struct {
CurrentPods int `json:"currentPods" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodCountsByStatus PodCountsByStatus `json:"podCountsByStatus" required:"true"`
Meta StatefulSetMeta `json:"meta" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableStatefulSets is the request body for the v2 statefulsets list API.
@@ -108,38 +106,3 @@ 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,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/swaggest/jsonschema-go"
)
type Volumes struct {
@@ -19,14 +17,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 VolumeMeta `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 map[string]string `json:"meta" required:"true"`
}
// PostableVolumes is the request body for the v2 volumes (PVCs) list API.
@@ -104,42 +102,3 @@ 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,10 +381,9 @@ 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,5 +1,3 @@
import time
import docker
import docker.errors
import pytest
@@ -25,37 +23,12 @@ def network(request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> type
def delete(nw: types.Network):
client = docker.from_env()
try:
network = client.networks.get(network_id=nw.id)
client.networks.get(network_id=nw.id).remove()
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()