Compare commits

..

1 Commits

Author SHA1 Message Date
Ankit Nayan
27a43dc6d2 enable recommendedsecondary_indices_enable_bulk_filtering 2026-05-21 14:49:00 +05:30
101 changed files with 279 additions and 2073 deletions

View File

@@ -2689,6 +2689,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesClusterRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -2758,6 +2759,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesDaemonSetRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -2827,6 +2829,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesDeploymentRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -2905,6 +2908,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesHostRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -2980,6 +2984,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesJobRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3027,6 +3032,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesNamespaceRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3104,6 +3110,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3202,6 +3209,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesPodRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3546,6 +3554,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesStatefulSetRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
@@ -3606,6 +3615,7 @@ components:
records:
items:
$ref: '#/components/schemas/InframonitoringtypesVolumeRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'

View File

@@ -49,7 +49,7 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.21",
"@signozhq/ui": "0.0.19",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",

View File

@@ -77,8 +77,8 @@ importers:
specifier: 0.0.2
version: 0.0.2(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@signozhq/ui':
specifier: 0.0.21
version: 0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
specifier: 0.0.19
version: 0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -3269,8 +3269,8 @@ packages:
peerDependencies:
react: ^18.2.0
'@signozhq/ui@0.0.21':
resolution: {integrity: sha512-uLM3Vqwxlk2USXbwtb3qRLpjZR9b9QSHFQq/jtcfYNMDmIE/sNjSj0nRkEhX4RqqRgsLRt2PVA33aeWxDOLO3g==}
'@signozhq/ui@0.0.19':
resolution: {integrity: sha512-2q6aRxN/PR4PlR2xJZAREEuvLPiDFggfFKzCW2Z5vHVVbrgnvZHWD1jPUuwszfEg0ceH3UvkwqceO7wN4uRJAA==}
peerDependencies:
'@signozhq/icons': 0.3.0
react: ^18.2.0
@@ -3851,6 +3851,27 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
'@webassemblyjs/floating-point-hex-parser@1.13.2':
resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==}
'@webassemblyjs/helper-api-error@1.13.2':
resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==}
'@webassemblyjs/helper-buffer@1.14.1':
resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==}
'@webassemblyjs/helper-numbers@1.13.2':
resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==}
'@webassemblyjs/helper-wasm-bytecode@1.13.2':
resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==}
'@webassemblyjs/helper-wasm-section@1.14.1':
resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==}
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
@@ -12013,7 +12034,7 @@ snapshots:
- react-dom
- tailwindcss
'@signozhq/ui@0.0.21(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
'@signozhq/ui@0.0.19(@emotion/is-prop-valid@1.2.0)(@signozhq/icons@0.4.0)(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react-router-dom@5.3.4(react@18.2.0))(react-router@6.30.3(react@18.2.0))(react@18.2.0)':
dependencies:
'@chenglou/pretext': 0.0.5
'@radix-ui/react-checkbox': 1.3.3(@types/react-dom@18.0.10)(@types/react@18.0.26)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)

View File

@@ -3488,9 +3488,9 @@ export interface InframonitoringtypesClustersDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesClusterRecordDTO[];
records: InframonitoringtypesClusterRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3566,9 +3566,9 @@ export interface InframonitoringtypesDaemonSetsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesDaemonSetRecordDTO[];
records: InframonitoringtypesDaemonSetRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3644,9 +3644,9 @@ export interface InframonitoringtypesDeploymentsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesDeploymentRecordDTO[];
records: InframonitoringtypesDeploymentRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3730,9 +3730,9 @@ export interface InframonitoringtypesHostsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesHostRecordDTO[];
records: InframonitoringtypesHostRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3816,9 +3816,9 @@ export interface InframonitoringtypesJobsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesJobRecordDTO[];
records: InframonitoringtypesJobRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3866,9 +3866,9 @@ export interface InframonitoringtypesNamespacesDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesNamespaceRecordDTO[];
records: InframonitoringtypesNamespaceRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -3933,9 +3933,9 @@ export interface InframonitoringtypesNodesDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesNodeRecordDTO[];
records: InframonitoringtypesNodeRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -4017,9 +4017,9 @@ export interface InframonitoringtypesPodsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesPodRecordDTO[];
records: InframonitoringtypesPodRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -4437,9 +4437,9 @@ export interface InframonitoringtypesStatefulSetsDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesStatefulSetRecordDTO[];
records: InframonitoringtypesStatefulSetRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
@@ -4506,9 +4506,9 @@ export interface InframonitoringtypesVolumesDTO {
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @type array,null
*/
records: InframonitoringtypesVolumeRecordDTO[];
records: InframonitoringtypesVolumeRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer

View File

@@ -137,6 +137,7 @@ function CreateServiceAccountModal(): JSX.Element {
<AuthZTooltip checks={[SACreatePermission]}>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form="create-sa-form"
variant="solid"
color="primary"

View File

@@ -11,6 +11,9 @@ import { GuardAuthZ } from './GuardAuthZ';
describe('GuardAuthZ', () => {
const TestChild = (): ReactElement => <div>Protected Content</div>;
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
const ErrorFallback = (error: Error): ReactElement => (
<div>Error occurred: {error.message}</div>
);
const NoPermissionFallback = (_response: {
requiredPermissionName: BrandedPermission;
}): ReactElement => <div>Access denied</div>;
@@ -87,28 +90,40 @@ describe('GuardAuthZ', () => {
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render children when API error occurs and no fallbackOnError provided (fail open)', async () => {
it('should render fallbackOnError when API error occurs', async () => {
const errorMessage = 'Internal Server Error';
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
return res(ctx.status(500), ctx.json({ error: errorMessage }));
}),
);
render(
<GuardAuthZ relation="read" object="role:*">
<GuardAuthZ relation="read" object="role:*" fallbackOnError={ErrorFallback}>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Protected Content')).toBeInTheDocument();
expect(screen.getByText(/Error occurred:/)).toBeInTheDocument();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
});
it('should render fallbackOnError when API error occurs and fallbackOnError is provided', async () => {
it('should pass error object to fallbackOnError function', async () => {
const errorMessage = 'Network request failed';
let receivedError: Error | null = null;
const errorFallbackWithCapture = (error: Error): ReactElement => {
receivedError = error;
return <div>Captured error: {error.message}</div>;
};
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
return res(ctx.status(500), ctx.json({ error: errorMessage }));
}),
);
@@ -116,14 +131,35 @@ describe('GuardAuthZ', () => {
<GuardAuthZ
relation="read"
object="role:*"
fallbackOnError={<div>Custom error fallback</div>}
fallbackOnError={errorFallbackWithCapture}
>
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(screen.getByText('Custom error fallback')).toBeInTheDocument();
expect(receivedError).not.toBeNull();
});
expect(receivedError).toBeInstanceOf(Error);
expect(screen.getByText(/Captured error:/)).toBeInTheDocument();
});
it('should render null when error occurs and no fallbackOnError provided', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
}),
);
const { container } = render(
<GuardAuthZ relation="read" object="role:*">
<TestChild />
</GuardAuthZ>,
);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();

View File

@@ -12,7 +12,7 @@ export type GuardAuthZProps<R extends AuthZRelation> = {
relation: R;
object: AuthZObject<R>;
fallbackOnLoading?: JSX.Element;
fallbackOnError?: JSX.Element;
fallbackOnError?: (error: Error) => JSX.Element;
fallbackOnNoPermissions?: (response: {
requiredPermissionName: BrandedPermission;
}) => JSX.Element;
@@ -35,7 +35,7 @@ export function GuardAuthZ<R extends AuthZRelation>({
}
if (error) {
return fallbackOnError ?? children;
return fallbackOnError?.(error) ?? null;
}
if (!permissions?.[permission]?.isGranted) {

View File

@@ -13,7 +13,7 @@ export function NoAuthBanner(): JSX.Element {
Impersonation mode: authentication is disabled. Anyone with access to this
instance has admin privileges.{' '}
<a
href="https://signoz.io/docs/manage/administrator-guide/configuration/impersonation-mode/"
href="https://signoz.io/docs/manage/administrator-guide/configuration/no-auth-mode/"
target="_blank"
rel="noreferrer"
>

View File

@@ -127,6 +127,7 @@ function KeyFormPhase({
>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"

View File

@@ -190,6 +190,7 @@ function EditKeyForm({
>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"

View File

@@ -204,7 +204,7 @@ describe('createGuardedRoute', () => {
).not.toBeInTheDocument();
});
it('should render the component when API error occurs (fail open)', async () => {
it('should render error fallback when API error occurs', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
@@ -230,8 +230,12 @@ describe('createGuardedRoute', () => {
render(<GuardedComponent {...props} />);
await waitFor(() => {
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
});
expect(
screen.queryByText('Test Component: test-value'),
).not.toBeInTheDocument();
});
it('should render no permissions fallback when permission is denied', async () => {

View File

@@ -9,11 +9,14 @@ import { parsePermission } from 'hooks/useAuthZ/utils';
import noDataUrl from '@/assets/Icons/no-data.svg';
import ErrorBoundaryFallback from '../../pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import AppLoading from '../AppLoading/AppLoading';
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
import './createGuardedRoute.styles.scss';
const onErrorFallback = (): JSX.Element => <ErrorBoundaryFallback />;
function OnNoPermissionsFallback(response: {
requiredPermissionName: BrandedPermission;
}): ReactElement {
@@ -60,6 +63,7 @@ export function createGuardedRoute<P extends object, R extends AuthZRelation>(
relation={relation}
object={resolvedObject}
fallbackOnLoading={<AppLoading />}
fallbackOnError={onErrorFallback}
fallbackOnNoPermissions={(response): ReactElement => (
<OnNoPermissionsFallback {...response} />
)}

View File

@@ -1,147 +0,0 @@
import { renderHook } from '@testing-library/react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { usePanelContextMenu } from '../usePanelContextMenu';
// The hook composes `useCoordinates` (popover state) and `useGraphContextMenu`
// (menu items). We mock both so the test focuses on the `enableDrillDown` gate
// rather than the implementation of the menu wiring itself.
const onClickMock = jest.fn();
jest.mock('periscope/components/ContextMenu', () => ({
useCoordinates: (): unknown => ({
coordinates: null,
popoverPosition: null,
clickedData: null,
onClose: jest.fn(),
subMenu: null,
onClick: onClickMock,
setSubMenu: jest.fn(),
}),
}));
jest.mock('container/QueryTable/Drilldown/useGraphContextMenu', () => ({
__esModule: true,
default: (): { menuItemsConfig: { header: string; items: string } } => ({
menuItemsConfig: { header: 'menu-header', items: 'menu-items' },
}),
}));
jest.mock('container/QueryTable/Drilldown/drilldownUtils', () => ({
getUplotClickData: jest.fn(() => ({
coord: { x: 1, y: 2 },
record: { queryName: 'A', filters: [] },
label: 'lbl',
seriesColor: '#abc',
})),
}));
jest.mock('container/PanelWrapper/utils', () => ({
isApmMetric: jest.fn(() => false),
getTimeRangeFromStepInterval: jest.fn(() => ({ start: 0, end: 0 })),
}));
const mockWidget = { id: 'w-1', query: {} } as unknown as Widgets;
const mockQueryResponse = {
data: undefined,
isLoading: false,
} as unknown as UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
describe('usePanelContextMenu', () => {
beforeEach(() => {
onClickMock.mockClear();
});
it('returns empty menuItemsConfig when enableDrillDown is false', () => {
const { result } = renderHook(() =>
usePanelContextMenu({
widget: mockWidget,
queryResponse: mockQueryResponse,
enableDrillDown: false,
}),
);
expect(result.current.menuItemsConfig).toStrictEqual({});
});
it('returns wired menuItemsConfig when enableDrillDown is true', () => {
const { result } = renderHook(() =>
usePanelContextMenu({
widget: mockWidget,
queryResponse: mockQueryResponse,
enableDrillDown: true,
}),
);
expect(result.current.menuItemsConfig).toStrictEqual({
header: 'menu-header',
items: 'menu-items',
});
});
it('clickHandlerWithContextMenu is a no-op when enableDrillDown is false', () => {
const { result } = renderHook(() =>
usePanelContextMenu({
widget: mockWidget,
queryResponse: mockQueryResponse,
enableDrillDown: false,
}),
);
result.current.clickHandlerWithContextMenu(
100, // xValue
200, // yValue
0, // mouseX
0, // mouseY
{ serviceName: 'svc' }, // metric
{ queryName: 'A', inFocusOrNot: true }, // queryData
10, // absoluteMouseX
20, // absoluteMouseY
{}, // axesData
{ seriesIndex: 0, seriesName: 'A', value: 1, color: '#abc' }, // focusedSeries
);
expect(onClickMock).not.toHaveBeenCalled();
});
it('clickHandlerWithContextMenu opens popover when enableDrillDown is true', () => {
const { result } = renderHook(() =>
usePanelContextMenu({
widget: mockWidget,
queryResponse: mockQueryResponse,
enableDrillDown: true,
}),
);
result.current.clickHandlerWithContextMenu(
100,
200,
0,
0,
{ serviceName: 'svc' },
{ queryName: 'A', inFocusOrNot: true },
10,
20,
{},
{ seriesIndex: 0, seriesName: 'A', value: 1, color: '#abc' },
);
expect(onClickMock).toHaveBeenCalledTimes(1);
});
it('defaults to disabled when enableDrillDown is not provided', () => {
const { result } = renderHook(() =>
usePanelContextMenu({
widget: mockWidget,
queryResponse: mockQueryResponse,
}),
);
expect(result.current.menuItemsConfig).toStrictEqual({});
});
});

View File

@@ -21,13 +21,11 @@ interface UseTimeSeriesContextMenuParams {
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
enableDrillDown?: boolean;
}
export const usePanelContextMenu = ({
widget,
queryResponse,
enableDrillDown = false,
}: UseTimeSeriesContextMenuParams): {
coordinates: { x: number; y: number } | null;
popoverPosition: PopoverPosition | null;
@@ -63,9 +61,6 @@ export const usePanelContextMenu = ({
const clickHandlerWithContextMenu = useCallback(
(...args: any[]) => {
if (!enableDrillDown) {
return;
}
const [
xValue,
_yvalue,
@@ -117,14 +112,14 @@ export const usePanelContextMenu = ({
});
}
},
[enableDrillDown, onClick, queryResponse],
[onClick, queryResponse],
);
return {
coordinates,
popoverPosition,
onClose,
menuItemsConfig: enableDrillDown ? menuItemsConfig : {},
menuItemsConfig,
clickHandlerWithContextMenu,
};
};

View File

@@ -31,7 +31,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
enableDrillDown = false,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const graphRef = useRef<HTMLDivElement>(null);
@@ -62,7 +61,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
} = usePanelContextMenu({
widget,
queryResponse,
enableDrillDown,
});
const config = useMemo(() => {

View File

@@ -31,7 +31,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
enableDrillDown = false,
} = props;
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
@@ -61,7 +60,6 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
} = usePanelContextMenu({
widget,
queryResponse,
enableDrillDown,
});
const chartData = useMemo(() => {

View File

@@ -292,8 +292,6 @@ function FullView({
return <Spinner height="100%" size="large" tip="Loading..." />;
}
const showEditBtn = editWidget && dashboardEditView;
return (
<div className="full-view-container">
<OverlayScrollbar>
@@ -308,7 +306,7 @@ function FullView({
Reset Query
</Button>
)}
{showEditBtn && (
{editWidget && (
<Button
className="switch-edit-btn"
disabled={response.isFetching || response.isLoading}

View File

@@ -30,12 +30,7 @@ import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import {
GraphTitle,
MENU_ITEMS,
SERVICE_CHART_ID,
SERVICE_DETAIL_DRILLDOWN_ENABLED,
} from '../constant';
import { GraphTitle, MENU_ITEMS, SERVICE_CHART_ID } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
import { Button } from './styles';
@@ -211,7 +206,6 @@ function DBCall(): JSX.Element {
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
@@ -250,7 +244,6 @@ function DBCall(): JSX.Element {
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>

View File

@@ -32,12 +32,7 @@ import { v4 as uuid } from 'uuid';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import {
GraphTitle,
legend,
MENU_ITEMS,
SERVICE_DETAIL_DRILLDOWN_ENABLED,
} from '../constant';
import { GraphTitle, legend, MENU_ITEMS } from '../constant';
import { getWidgetQueryBuilder } from '../MetricsApplication.factory';
import { Card, GraphContainer, Row } from '../styles';
import GraphControlsPanel from './Overview/GraphControlsPanel/GraphControlsPanel';
@@ -284,7 +279,6 @@ function External(): JSX.Element {
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
@@ -328,7 +322,6 @@ function External(): JSX.Element {
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
@@ -373,7 +366,6 @@ function External(): JSX.Element {
}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
@@ -417,7 +409,6 @@ function External(): JSX.Element {
}}
onDragSelect={onDragSelect}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>

View File

@@ -15,7 +15,6 @@ import DisplayThreshold from 'container/GridCardLayout/WidgetHeader/DisplayThres
import {
GraphTitle,
SERVICE_CHART_ID,
SERVICE_DETAIL_DRILLDOWN_ENABLED,
} from 'container/MetricsApplication/constant';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { apDexMetricsQueryBuilderQueries } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
@@ -106,7 +105,6 @@ function ApDexMetrics({
threshold={threshold}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
);
}

View File

@@ -8,7 +8,6 @@ import Graph from 'container/GridCardLayout/GridCard';
import {
GraphTitle,
SERVICE_CHART_ID,
SERVICE_DETAIL_DRILLDOWN_ENABLED,
} from 'container/MetricsApplication/constant';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { latency } from 'container/MetricsApplication/MetricsPageQueries/OverviewQueries';
@@ -139,7 +138,6 @@ function ServiceOverview({
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
</GraphContainer>

View File

@@ -4,7 +4,6 @@ import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { ENTITY_VERSION_V4 } from 'constants/app';
import Graph from 'container/GridCardLayout/GridCard';
import { SERVICE_DETAIL_DRILLDOWN_ENABLED } from 'container/MetricsApplication/constant';
import { Card, GraphContainer } from 'container/MetricsApplication/styles';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { Widgets } from 'types/api/dashboard/getAll';
@@ -44,7 +43,6 @@ function TopLevelOperation({
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
</GraphContainer>

View File

@@ -25,8 +25,6 @@ export const OPERATION_LEGENDS = ['Operations'];
export const MENU_ITEMS = [MenuItemKeys.View, MenuItemKeys.CreateAlerts];
export const SERVICE_DETAIL_DRILLDOWN_ENABLED = true;
export enum FORMULA {
ERROR_PERCENTAGE = 'A*100/B',
DATABASE_CALLS_AVG_DURATION = 'A/B',

View File

@@ -127,12 +127,6 @@
flex-direction: column;
gap: 8px;
padding-bottom: 16px;
.password-error-text {
font-size: var(--font-size-xs);
color: var(--bg-cherry-400);
margin-top: 2px;
}
}
.ant-color-picker-trigger {

View File

@@ -1,27 +1,25 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import {
updateMyPassword,
useUpdateMyUserV2,
} from 'api/generated/services/users';
import { toast } from '@signozhq/ui/sonner';
import { useNotifications } from 'hooks/useNotifications';
import { Check, FileTerminal, Mail, User } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ErrorV2Resp } from 'types/api';
import { AxiosError } from 'axios';
import '../MySettings.styles.scss';
import './UserInfo.styles.scss';
function UserInfo(): JSX.Element {
const { user, org, updateUser } = useAppContext();
const { t } = useTranslation(['routes', 'settings', 'common']);
const { showErrorModal } = useErrorModal();
const { notifications } = useNotifications();
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const [currentPassword, setCurrentPassword] = useState<string>('');
@@ -49,8 +47,6 @@ function UserInfo(): JSX.Element {
const hideResetPasswordModal = (): void => {
setIsResetPasswordModalOpen(false);
setCurrentPassword('');
setUpdatePassword('');
};
const onChangePasswordClickHandler = async (): Promise<void> => {
@@ -61,29 +57,27 @@ function UserInfo(): JSX.Element {
newPassword: updatePassword,
oldPassword: currentPassword,
});
toast.success('Password updated successfully');
notifications.success({
message: t('success', {
ns: 'common',
}),
});
hideResetPasswordModal();
setIsLoading(false);
} catch (error) {
setIsLoading(false);
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
notifications.error({
message: (error as APIError).error.error.code,
description: (error as APIError).error.error.message,
});
}
};
const passwordsMatch =
currentPassword.length > 0 &&
updatePassword.length > 0 &&
currentPassword === updatePassword;
const isResetPasswordDisabled =
isLoading ||
currentPassword.length === 0 ||
updatePassword.length === 0 ||
passwordsMatch;
currentPassword === updatePassword;
const onSaveHandler = async (): Promise<void> => {
void logEvent('Account Settings: Name Updated', {
@@ -100,7 +94,11 @@ function UserInfo(): JSX.Element {
setIsLoading(true);
await updateMyUser({ data: { displayName: changedName } });
toast.success('Name updated successfully');
notifications.success({
message: t('success', {
ns: 'common',
}),
});
updateUser({
...user,
displayName: changedName,
@@ -108,11 +106,10 @@ function UserInfo(): JSX.Element {
setIsLoading(false);
hideUpdateNameModal();
} catch (error) {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
setIsLoading(false);
};
@@ -169,7 +166,7 @@ function UserInfo(): JSX.Element {
type="primary"
icon={<Check size={16} />}
onClick={onSaveHandler}
loading={isLoading}
disabled={isLoading}
data-testid="update-name-btn"
>
Update name
@@ -181,11 +178,7 @@ function UserInfo(): JSX.Element {
<Input
placeholder="e.g. John Doe"
value={changedName}
disabled={isLoading}
onChange={(e): void => setChangedName(e.target.value)}
onPressEnter={(): void => {
void onSaveHandler();
}}
/>
</div>
</Modal>
@@ -195,7 +188,6 @@ function UserInfo(): JSX.Element {
title={<span className="title">Reset password</span>}
open={isResetPasswordModalOpen}
closable
destroyOnClose
onCancel={hideResetPasswordModal}
footer={[
<Button
@@ -205,8 +197,7 @@ function UserInfo(): JSX.Element {
}`}
icon={<Check size={16} />}
onClick={onChangePasswordClickHandler}
loading={isLoading}
disabled={isResetPasswordDisabled}
disabled={isLoading || isResetPasswordDisabled}
data-testid="reset-password-btn"
>
Reset password
@@ -227,11 +218,6 @@ function UserInfo(): JSX.Element {
type="password"
autoComplete="off"
visibilityToggle
onPressEnter={(): void => {
if (!isResetPasswordDisabled) {
void onChangePasswordClickHandler();
}
}}
/>
</div>
@@ -249,18 +235,7 @@ function UserInfo(): JSX.Element {
type="password"
autoComplete="off"
visibilityToggle={false}
status={passwordsMatch ? 'error' : ''}
onPressEnter={(): void => {
if (!isResetPasswordDisabled) {
void onChangePasswordClickHandler();
}
}}
/>
{passwordsMatch && (
<span className="password-error-text">
New password must be different from current password
</span>
)}
</div>
</div>
</Modal>

View File

@@ -8,23 +8,11 @@ import {
waitFor,
within,
} from 'tests/test-utils';
import APIError from 'types/api/error';
import { toast } from '@signozhq/ui/sonner';
const toggleThemeFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
const updateMyPasswordFn = jest.fn();
const showErrorModalFn = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('react-use', () => ({
__esModule: true,
@@ -36,21 +24,12 @@ jest.mock('react-use', () => ({
jest.mock('api/generated/services/users', () => ({
...jest.requireActual('api/generated/services/users'),
updateMyPassword: (...args: unknown[]): Promise<unknown> =>
updateMyPasswordFn(...args),
useUpdateMyUserV2: jest.fn(() => ({
mutateAsync: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
isLoading: false,
})),
}));
jest.mock('providers/ErrorModalProvider', () => ({
...jest.requireActual('providers/ErrorModalProvider'),
useErrorModal: jest.fn(() => ({
showErrorModal: showErrorModalFn,
})),
}));
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
useIsDarkMode: jest.fn(() => true),
@@ -86,12 +65,12 @@ const NEW_PASSWORD_TEST_ID = 'new-password-textbox';
const UPDATE_NAME_BUTTON_TEST_ID = 'update-name-btn';
const RESET_PASSWORD_BUTTON_TEST_ID = 'reset-password-btn';
const UPDATE_NAME_BUTTON_TEXT = 'Update name';
const PASSWORD_VALIDATION_MESSAGE_TEST_ID = 'password-validation-message';
describe('MySettings Flows', () => {
beforeEach(() => {
jest.clearAllMocks();
editUserFn.mockResolvedValue({});
updateMyPasswordFn.mockResolvedValue({});
render(<MySettingsContainer />);
});
@@ -173,7 +152,9 @@ describe('MySettings Flows', () => {
fireEvent.click(modalUpdateNameButton);
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith('Name updated successfully'),
expect(successNotification).toHaveBeenCalledWith({
message: 'success',
}),
);
});
});
@@ -200,131 +181,22 @@ describe('MySettings Flows', () => {
expect(screen.getByTestId(NEW_PASSWORD_TEST_ID)).toBeInTheDocument();
});
it('Should show inline error when new password matches current password', async () => {
it('Should display validation error if password is less than 8 characters', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
const currentPasswordTextbox = screen.getByTestId(CURRENT_PASSWORD_TEST_ID);
act(() => {
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'samePassword1' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'samePassword1' },
});
fireEvent.change(currentPasswordTextbox, { target: { value: '123' } });
});
expect(
screen.getByText('New password must be different from current password'),
).toBeInTheDocument();
expect(screen.getByTestId(RESET_PASSWORD_BUTTON_TEST_ID)).toBeDisabled();
});
it('Should hide inline error when passwords are changed to be different', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
act(() => {
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'samePassword1' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'samePassword1' },
});
});
act(() => {
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'differentPassword1' },
});
});
expect(
screen.queryByText('New password must be different from current password'),
).not.toBeInTheDocument();
});
it('Should show error modal when password reset API returns an error', async () => {
updateMyPasswordFn.mockRejectedValue(
new Error('Current password is incorrect'),
);
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
act(() => {
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'oldPassword1' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'newPassword1' },
});
});
fireEvent.click(screen.getByTestId(RESET_PASSWORD_BUTTON_TEST_ID));
await waitFor(() => {
expect(showErrorModalFn).toHaveBeenCalledWith(expect.any(APIError));
});
});
it('Should show success toast and close modal on successful password reset', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
act(() => {
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'oldPassword1' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'newPassword1' },
});
});
fireEvent.click(screen.getByTestId(RESET_PASSWORD_BUTTON_TEST_ID));
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Password updated successfully');
expect(
screen.queryByTestId(CURRENT_PASSWORD_TEST_ID),
).not.toBeInTheDocument();
});
});
it('Should clear password fields when modal is cancelled', async () => {
const resetPasswordButtons = screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT);
fireEvent.click(resetPasswordButtons[0]);
act(() => {
fireEvent.change(screen.getByTestId(CURRENT_PASSWORD_TEST_ID), {
target: { value: 'somePassword' },
});
fireEvent.change(screen.getByTestId(NEW_PASSWORD_TEST_ID), {
target: { value: 'otherPassword' },
});
});
expect(screen.getByTestId(CURRENT_PASSWORD_TEST_ID)).toHaveValue(
'somePassword',
);
// Close the modal
const closeButton = document.querySelector(
'.reset-password-modal .ant-modal-close',
) as HTMLElement;
fireEvent.click(closeButton);
// Reopen the modal
await waitFor(() => {
expect(
screen.queryByTestId(CURRENT_PASSWORD_TEST_ID),
).not.toBeInTheDocument();
});
fireEvent.click(screen.getAllByText(RESET_PASSWORD_BUTTON_TEXT)[0]);
await waitFor(() => {
expect(screen.getByTestId(CURRENT_PASSWORD_TEST_ID)).toHaveValue('');
expect(screen.getByTestId(NEW_PASSWORD_TEST_ID)).toHaveValue('');
// Use getByTestId for the validation message (if present in your modal/component)
if (screen.queryByTestId(PASSWORD_VALIDATION_MESSAGE_TEST_ID)) {
expect(
screen.getByTestId(PASSWORD_VALIDATION_MESSAGE_TEST_ID),
).toBeInTheDocument();
}
});
});

View File

@@ -169,10 +169,9 @@ describe('drilldownUtils', () => {
// Verify transformations were applied
if (filterExpression) {
// `operation` rewrites to `name` via source-side pass, then `name`
// is dropped by the logs target-side pass (logs has no span-name).
// Rule 2: operationname
expect(filterExpression).toContain(`name = 'GET'`);
expect(filterExpression).not.toContain(`operation = 'GET'`);
expect(filterExpression).not.toContain(`name = 'GET'`);
// Rule 3: span.kind → kind
expect(filterExpression).toContain(`${spanKindKey} = '${spanKindServer}'`);
@@ -263,9 +262,8 @@ describe('drilldownUtils', () => {
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
if (filterExpression) {
// `operation` rewrites to `name` then drops for logs target.
expect(filterExpression).not.toContain(`operation = 'POST'`);
expect(filterExpression).not.toContain(`name = 'POST'`);
// All transformations should be applied
expect(filterExpression).toContain(`name = 'POST'`);
expect(filterExpression).toContain(`${spanKindKey} = '${spanKindClient}'`);
expect(filterExpression).toContain(`status_code_string = 'Error'`);
expect(filterExpression).toContain(`http.status_code = 500`);
@@ -412,9 +410,8 @@ describe('drilldownUtils', () => {
const filterExpression = result?.builder.queryData[0]?.filter?.expression;
if (filterExpression) {
// `operation` rewrites to `name` then drops for logs target.
expect(filterExpression).not.toContain(`operation = 'GET'`);
expect(filterExpression).not.toContain(`name = 'GET'`);
// Transformed attributes
expect(filterExpression).toContain(`name = 'GET'`);
expect(filterExpression).toContain(`${spanKindKey} = '${spanKindServer}'`);
// Preserved non-metric attributes
@@ -502,189 +499,4 @@ describe('drilldownUtils', () => {
});
});
});
describe('getViewQuery target-aware sanitisation (serviceName / name)', () => {
const makeQuery = (
expression: string,
dataSource: 'traces' | 'logs' | 'metrics' = 'traces',
): Query => ({
id: 'src-query',
queryType: 'builder' as any,
builder: {
queryData: [
{
queryName: 'src',
dataSource: dataSource as any,
aggregations: [{ metricName: 'non_apm_metric' }] as any,
groupBy: [],
expression: '',
disabled: false,
functions: [],
legend: '',
having: [],
limit: null,
stepInterval: undefined,
orderBy: [],
filter: { expression },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
});
it('rewrites serviceName -> service.name when drilling to logs', () => {
const result = getViewQuery(
makeQuery(`serviceName = 'svc'`),
[],
'view_logs',
'src',
);
const expr = result?.builder.queryData[0]?.filter?.expression || '';
expect(expr).toContain(`service.name = 'svc'`);
expect(expr).not.toContain('serviceName');
});
it('rewrites serviceName -> service.name when drilling to traces', () => {
const result = getViewQuery(
makeQuery(`serviceName = 'svc'`),
[],
'view_traces',
'src',
);
const expr = result?.builder.queryData[0]?.filter?.expression || '';
expect(expr).toContain(`service.name = 'svc'`);
expect(expr).not.toContain('serviceName');
});
it('drops `name` clause when drilling to logs', () => {
const result = getViewQuery(
makeQuery(`name = 'GET /api'`),
[],
'view_logs',
'src',
);
const expr = result?.builder.queryData[0]?.filter?.expression || '';
expect(expr).not.toContain(`name = 'GET /api'`);
});
it('keeps `name` clause when drilling to traces', () => {
const result = getViewQuery(
makeQuery(`name = 'GET /api'`),
[],
'view_traces',
'src',
);
const expr = result?.builder.queryData[0]?.filter?.expression || '';
expect(expr).toContain(`name = 'GET /api'`);
});
it('combined: drilling to logs rewrites serviceName and drops name', () => {
const result = getViewQuery(
makeQuery(`serviceName = 'svc' AND name = 'GET /api'`),
[],
'view_logs',
'src',
);
const expr = result?.builder.queryData[0]?.filter?.expression || '';
expect(expr).toContain(`service.name = 'svc'`);
expect(expr).not.toContain('serviceName');
expect(expr).not.toContain(`name = 'GET /api'`);
});
it('combined: drilling to traces rewrites serviceName and keeps name', () => {
const result = getViewQuery(
makeQuery(`serviceName = 'svc' AND name = 'GET /api'`),
[],
'view_traces',
'src',
);
const expr = result?.builder.queryData[0]?.filter?.expression || '';
expect(expr).toContain(`service.name = 'svc'`);
expect(expr).toContain(`name = 'GET /api'`);
expect(expr).not.toContain('serviceName');
});
it('metric-APM source -> traces target preserves existing operation -> name rewrite', () => {
const metricsQuery: Query = {
id: 'apm-metrics',
queryType: 'builder' as any,
builder: {
queryData: [
{
queryName: 'm',
dataSource: 'metrics' as any,
aggregations: [{ metricName: 'signoz_calls_total' }] as any,
groupBy: [],
expression: '',
disabled: false,
functions: [],
legend: '',
having: [],
limit: null,
stepInterval: undefined,
orderBy: [],
filter: { expression: `operation = 'GET'` },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
};
const result = getViewQuery(metricsQuery, [], 'view_traces', 'm');
const expr = result?.builder.queryData[0]?.filter?.expression || '';
expect(expr).toContain(`name = 'GET'`);
expect(expr).not.toContain(`operation = 'GET'`);
});
it('metric-APM source -> logs target: operation rewrites to name, then dropped', () => {
const metricsQuery: Query = {
id: 'apm-metrics',
queryType: 'builder' as any,
builder: {
queryData: [
{
queryName: 'm',
dataSource: 'metrics' as any,
aggregations: [{ metricName: 'signoz_calls_total' }] as any,
groupBy: [],
expression: '',
disabled: false,
functions: [],
legend: '',
having: [],
limit: null,
stepInterval: undefined,
orderBy: [],
filter: { expression: `operation = 'GET'` },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
};
const result = getViewQuery(metricsQuery, [], 'view_logs', 'm');
const expr = result?.builder.queryData[0]?.filter?.expression || '';
expect(expr).not.toContain(`operation = 'GET'`);
expect(expr).not.toContain(`name = 'GET'`);
});
it('drilling to metrics does not apply target-side sanitisation', () => {
const result = getViewQuery(
makeQuery(`serviceName = 'svc' AND name = 'GET /api'`),
[],
'view_metrics',
'src',
);
const expr = result?.builder.queryData[0]?.filter?.expression || '';
expect(expr).toContain('serviceName');
expect(expr).toContain(`name = 'GET /api'`);
});
});
});

View File

@@ -7,10 +7,8 @@ import {
import ROUTES from 'constants/routes';
import { isApmMetric } from 'container/PanelWrapper/utils';
import {
applyMappingsToExpression,
DRILLDOWN_TO_LOGS_MAPPINGS,
DRILLDOWN_TO_TRACES_MAPPINGS,
METRIC_TO_LOGS_TRACES_MAPPINGS,
replaceKeysAndValuesInExpression,
} from 'container/QueryTable/Drilldown/metricsCorrelationUtils';
import cloneDeep from 'lodash-es/cloneDeep';
import {
@@ -349,41 +347,27 @@ export const getViewQuery = (
newQuery.builder.queryData[0].filter = newFilterExpression;
try {
// Drill-down filter sanitisation. Two stages:
// 1. Source-side: rewrite metric-APM-specific keys (operation, span.kind,
// status.code) so they map onto trace/log columns.
// 2. Target-side: normalise legacy keys to OTel-canonical (`serviceName`
// -> `service.name`) and drop keys with no equivalent in the target
// datasource (e.g. `name` for logs).
let expression = newFilterExpression?.expression || '';
// ===========================================
// TEMP LOGIC - TO BE REMOVED LATER
// ===========================================
// Apply metric-to-logs/traces transformations
const specificQuery = getQueryData(query, queryName);
const isMetricQuery = specificQuery?.dataSource === 'metrics';
const metricName = (specificQuery?.aggregations?.[0] as MetricAggregation)
?.metricName;
if (isMetricQuery && isApmMetric(metricName || '')) {
expression = applyMappingsToExpression(
expression,
const transformedExpression = replaceKeysAndValuesInExpression(
newFilterExpression?.expression || '',
METRIC_TO_LOGS_TRACES_MAPPINGS,
);
newQuery.builder.queryData[0].filter = {
expression: transformedExpression || '',
};
}
if (key === 'view_logs') {
expression = applyMappingsToExpression(
expression,
DRILLDOWN_TO_LOGS_MAPPINGS,
);
} else if (key === 'view_traces') {
expression = applyMappingsToExpression(
expression,
DRILLDOWN_TO_TRACES_MAPPINGS,
);
}
newQuery.builder.queryData[0].filter = { expression };
// ===========================================
} catch (error) {
console.error('Error sanitising drilldown filter expression:', error);
console.error('Error transforming metrics to logs/traces:', error);
}
return newQuery;

View File

@@ -1,8 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
formatValueForExpression,
removeKeysFromExpression,
} from 'components/QueryBuilderV2/utils';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { IQueryPair } from 'types/antlrQueryTypes';
import { extractQueryPairs } from 'utils/queryContextUtils';
@@ -11,7 +8,7 @@ import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
type KeyValueMapping = {
attribute: string;
newAttribute: string | null;
newAttribute: string;
valueMappings: Record<string, string>;
};
@@ -43,33 +40,8 @@ export const METRIC_TO_LOGS_TRACES_MAPPINGS: KeyValueMapping[] = [
},
];
export const DRILLDOWN_TO_LOGS_MAPPINGS: KeyValueMapping[] = [
{
attribute: 'serviceName',
newAttribute: 'service.name',
valueMappings: {},
},
{
attribute: 'name',
newAttribute: null,
valueMappings: {},
},
];
export const DRILLDOWN_TO_TRACES_MAPPINGS: KeyValueMapping[] = [
{
attribute: 'serviceName',
newAttribute: 'service.name',
valueMappings: {},
},
];
// Logic for rewriting key/values in an expression using provided mappings.
// Callers must pre-filter mappings to ensure newAttribute is non-null.
function modifyKeyVal(
pair: IQueryPair,
mapping: KeyValueMapping & { newAttribute: string },
): string {
function modifyKeyVal(pair: IQueryPair, mapping: KeyValueMapping): string {
const newKey = mapping.newAttribute;
const op = pair.operator;
@@ -135,18 +107,8 @@ export function replaceKeysAndValuesInExpression(
return expression;
}
// Only rewrite mappings (newAttribute non-null) are processed here.
// Drops are handled separately by applyMappingsToExpression via removeKeysFromExpression.
const attributeToMapping = new Map<
string,
KeyValueMapping & { newAttribute: string }
>(
mappingList
.filter(
(m): m is KeyValueMapping & { newAttribute: string } =>
m.newAttribute !== null,
)
.map((m) => [m.attribute.trim().toLowerCase(), m]),
const attributeToMapping = new Map<string, KeyValueMapping>(
mappingList.map((m) => [m.attribute.trim().toLowerCase(), m]),
);
const pairs: IQueryPair[] = extractQueryPairs(expression);
@@ -217,26 +179,3 @@ export function replaceKeysAndValuesInExpression(
return resultParts.join('');
}
// Apply a list of mappings to a filter expression. Rewrites are applied first
// (newAttribute is a string), then drops (newAttribute is null) via the
// ANTLR-parser-based removeKeysFromExpression which handles AND/OR/NOT/paren
// elision correctly.
export function applyMappingsToExpression(
expression: string,
mappings: KeyValueMapping[],
): string {
if (!expression || !mappings || mappings.length === 0) {
return expression;
}
const dropKeys = mappings
.filter((m) => m.newAttribute === null)
.map((m) => m.attribute);
let result = replaceKeysAndValuesInExpression(expression, mappings);
if (dropKeys.length > 0) {
result = removeKeysFromExpression(result, dropKeys);
}
return result;
}

View File

@@ -48,10 +48,11 @@ describe('useAuthZ', () => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeNull();
expect(result.current.permissions).toStrictEqual(expectedResponse);
});
it('should return error and null permissions when API errors', async () => {
it('should handle API errors', async () => {
const permission = buildPermission('read', 'role:*');
server.use(
@@ -190,6 +191,7 @@ describe('useAuthZ', () => {
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.permissions).toStrictEqual({});
});

View File

@@ -1,84 +0,0 @@
import getLabelName from 'lib/getLabelName';
describe('getLabelName', () => {
describe('with a legend template', () => {
it('substitutes a single variable that exists on the series', () => {
const result = getLabelName(
{ 'service.name': 'frontend' },
'A',
'{{service.name}}',
);
expect(result).toBe('frontend');
});
it('substitutes a template with surrounding literal text', () => {
const result = getLabelName(
{ 'service.name': 'frontend' },
'A',
'rate for {{service.name}}',
);
expect(result).toBe('rate for frontend');
});
it('substitutes multiple variables when all are present', () => {
const result = getLabelName(
{ 'service.name': 'frontend', 'http.target': 'GET /api' },
'A',
'{{service.name}} / {{http.target}}',
);
expect(result).toBe('frontend / GET /api');
});
it('falls back to query name when a referenced variable is missing', () => {
const result = getLabelName(
{ 'http.target': 'GET /api' },
'F1',
'{{service.name}}',
);
expect(result).toBe('F1');
});
it('falls back to query name even if literal text would still render', () => {
const result = getLabelName(
{ 'http.target': 'GET /api' },
'F1',
'label = {{label}}',
);
expect(result).toBe('F1');
});
it('falls back to query name when any of multiple variables is missing', () => {
const result = getLabelName(
{ 'service.name': 'frontend' },
'F1',
'{{service.name}} / {{http.target}}',
);
expect(result).toBe('F1');
});
it('treats a null label value as missing', () => {
const result = getLabelName(
{ 'service.name': null } as unknown as Record<string, string>,
'F1',
'{{service.name}}',
);
expect(result).toBe('F1');
});
});
describe('without a legend template', () => {
it('returns key="value" pairs for plain labels', () => {
const result = getLabelName(
{ 'service.name': 'frontend', 'http.target': 'GET /api' },
'A',
'',
);
expect(result).toBe('{service.name="frontend",http.target="GET /api"}');
});
it('returns query name when labels are empty', () => {
const result = getLabelName({}, 'A', '');
expect(result).toBe('A');
});
});
});

View File

@@ -18,17 +18,6 @@ const getLabelName = (
const results = variables.map((variable) => metric[variable]);
// Fall back to query name if any `{{var}}` references a label that
// isn't on this series — avoids rendering "undefined" in the legend.
const hasMissingVariable = variables.some(
(variable, index) =>
legends.includes(`{{${variable}}}`) &&
(results[index] === undefined || results[index] === null),
);
if (hasMissingVariable) {
return query;
}
let endResult = legends;
variables.forEach((e, index) => {

View File

@@ -119,12 +119,6 @@
flex-shrink: 0;
}
.statusMessageBadge {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.traceId {
color: var(--accent-primary);
overflow: hidden;

View File

@@ -1,6 +1,5 @@
import { ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
import ExpandableValue from 'periscope/components/ExpandableValue';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import styles from './SpanDetailsPanel.module.scss';
@@ -49,15 +48,7 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
label: 'STATUS MESSAGE',
render: (span): ReactNode | null =>
span.status_message ? (
<ExpandableValue value={span.status_message} title="Status message">
<Badge
color="vanilla"
textEllipsis="end"
className={styles.statusMessageBadge}
>
{span.status_message}
</Badge>
</ExpandableValue>
<Badge color="vanilla">{span.status_message}</Badge>
) : null,
},
];

View File

@@ -1,3 +0,0 @@
.traceOptionsDropdown {
z-index: 1100;
}

View File

@@ -6,8 +6,6 @@ import { Ellipsis } from '@signozhq/icons';
import { useTraceStore } from '../stores/traceStore';
import styles from './TraceOptionsMenu.module.scss';
interface TraceOptionsMenuProps {
showTraceDetails: boolean;
onToggleTraceDetails: () => void;
@@ -84,11 +82,7 @@ function TraceOptionsMenu({
]);
return (
<Dropdown
menu={{ items: menuItems }}
align="start"
className={styles.traceOptionsDropdown}
>
<Dropdown menu={{ items: menuItems }} align="start">
<Button
variant="ghost"
size="icon"

View File

@@ -1,49 +0,0 @@
.trigger {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden;
[data-truncated='true'] {
pointer-events: none;
}
}
.tooltipContent {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 480px;
padding: 8px;
}
.preview {
margin: 0;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-family-mono, monospace);
font-size: 12px;
line-height: 1.4;
}
.expandButton {
align-self: flex-end;
}
.dialog {
max-width: 80vw;
width: 80vw;
}
.fullValue {
margin: 0;
max-height: 70vh;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-family-mono, monospace);
font-size: 13px;
line-height: 1.5;
}

View File

@@ -1,78 +0,0 @@
import { ReactNode, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import {
TooltipContent,
TooltipProvider,
TooltipRoot,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Fullscreen } from '@signozhq/icons';
import styles from './ExpandableValue.module.scss';
const DEFAULT_THRESHOLD = 100;
const DEFAULT_DIALOG_TITLE = 'Value';
const DEFAULT_Z_INDEX = 1100;
interface ExpandableValueProps {
value: string;
title?: string;
threshold?: number;
zIndex?: number;
children: ReactNode;
}
function ExpandableValue({
value,
title = DEFAULT_DIALOG_TITLE,
threshold = DEFAULT_THRESHOLD,
zIndex = DEFAULT_Z_INDEX,
children,
}: ExpandableValueProps): JSX.Element {
const [isDialogOpen, setIsDialogOpen] = useState(false);
if (value.length <= threshold) {
return <>{children}</>;
}
return (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<span className={styles.trigger}>{children}</span>
</TooltipTrigger>
<TooltipContent
className={styles.tooltipContent}
side="top"
style={{ zIndex }}
>
<pre className={styles.preview}>{value}</pre>
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<Fullscreen size={14} />}
onClick={(): void => setIsDialogOpen(true)}
className={styles.expandButton}
>
Expand
</Button>
</TooltipContent>
</TooltipRoot>
<DialogWrapper
title={title}
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
className={styles.dialog}
style={{ zIndex }}
>
<pre className={styles.fullValue}>{value}</pre>
</DialogWrapper>
</TooltipProvider>
);
}
export default ExpandableValue;

View File

@@ -1 +0,0 @@
export { default } from './ExpandableValue';

View File

@@ -227,8 +227,10 @@ function PrettyView({
menu={{ items: menuItems }}
align="start"
className="pretty-view-actions-dropdown"
// Stop click bubbling so item clicks don't reach clickable ancestors
// of the trigger through the React tree.
// onClick on the dropdown content is forwarded to the underlying div via ...props
// but is not in the public type. Stop click bubbling so item clicks don't reach
// clickable ancestors of the trigger through the React tree.
// @ts-expect-error see comment above
onClick={(e: React.MouseEvent): void => e.stopPropagation()}
>
<span

View File

@@ -129,13 +129,14 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
const {
permissions: permissionsResult,
isFetching: isFetchingPermissions,
error: errorOnPermissions,
refetchPermissions,
} = useAuthZ([IsAdminPermission, IsEditorPermission, IsViewerPermission], {
enabled: isLoggedIn,
});
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
const userFetchError = userFetchDataError;
const userFetchError = userFetchDataError || errorOnPermissions;
const userRole = useMemo(() => {
if (permissionsResult?.[IsAdminPermission]?.isGranted) {

View File

@@ -298,32 +298,9 @@ describe('AppProvider when authz/check fails', () => {
beforeEach(() => {
queryClient.clear();
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
server.use(
rest.get(MY_USER_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
id: 'u-1',
displayName: 'Test User',
email: 'test@signoz.io',
orgId: 'org-1',
isRoot: false,
status: 'active',
},
}),
),
),
rest.get(MY_ORG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: { id: 'org-1', displayName: 'Org' } }),
),
),
);
});
it('does not set userFetchError when authz/check returns 500 (authz errors are ignored)', async () => {
it('sets userFetchError when authz/check returns 500 (same as user fetch error)', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })),
@@ -337,13 +314,13 @@ describe('AppProvider when authz/check fails', () => {
await waitFor(
() => {
expect(result.current.userFetchError).toBeFalsy();
expect(result.current.userFetchError).toBeTruthy();
},
{ timeout: 2000 },
);
});
it('does not set userFetchError when authz/check fails with network error (authz errors are ignored)', async () => {
it('sets userFetchError when authz/check fails with network error (same as user fetch error)', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_, res) => res.networkError('Network error')),
);
@@ -355,7 +332,7 @@ describe('AppProvider when authz/check fails', () => {
await waitFor(
() => {
expect(result.current.userFetchError).toBeFalsy();
expect(result.current.userFetchError).toBeTruthy();
},
{ timeout: 2000 },
);

View File

@@ -26,7 +26,7 @@ func buildClusterRecords(
records := make([]inframonitoringtypes.ClusterRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
clusterName := labels[inframonitoringtypes.ClusterNameAttrKey]
clusterName := labels[clusterNameAttrKey]
record := inframonitoringtypes.ClusterRecord{ // initialize with default values
ClusterName: clusterName,
@@ -87,9 +87,6 @@ func (m *module) getTopClusterGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.ClusterNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.ClusterNameAttrKey), nil
}
queryNamesForOrderBy := orderByToClustersQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]

View File

@@ -7,9 +7,14 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// TODO(nikhilmantri0902): change to k8s.cluster.uid after showing the missing
// data banner. Carried forward from v1 (see k8sClusterUIDAttrKey in
// pkg/query-service/app/inframetrics/clusters.go).
const clusterNameAttrKey = "k8s.cluster.name"
var clusterNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.ClusterNameAttrKey,
Name: clusterNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},

View File

@@ -25,7 +25,7 @@ func buildDaemonSetRecords(
records := make([]inframonitoringtypes.DaemonSetRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
daemonSetName := labels[inframonitoringtypes.DaemonSetNameAttrKey]
daemonSetName := labels[daemonSetNameAttrKey]
record := inframonitoringtypes.DaemonSetRecord{ // initialize with default values
DaemonSetName: daemonSetName,
@@ -95,9 +95,6 @@ func (m *module) getTopDaemonSetGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.DaemonSetNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.DaemonSetNameAttrKey), nil
}
queryNamesForOrderBy := orderByToDaemonSetsQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]

View File

@@ -7,11 +7,14 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const daemonSetsBaseFilterExpr = "k8s.daemonset.name != ''"
const (
daemonSetNameAttrKey = "k8s.daemonset.name"
daemonSetsBaseFilterExpr = "k8s.daemonset.name != ''"
)
var daemonSetNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.DaemonSetNameAttrKey,
Name: daemonSetNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},

View File

@@ -25,7 +25,7 @@ func buildDeploymentRecords(
records := make([]inframonitoringtypes.DeploymentRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
deploymentName := labels[inframonitoringtypes.DeploymentNameAttrKey]
deploymentName := labels[deploymentNameAttrKey]
record := inframonitoringtypes.DeploymentRecord{ // initialize with default values
DeploymentName: deploymentName,
@@ -95,9 +95,6 @@ func (m *module) getTopDeploymentGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.DeploymentNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.DeploymentNameAttrKey), nil
}
queryNamesForOrderBy := orderByToDeploymentsQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]

View File

@@ -7,11 +7,14 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const deploymentsBaseFilterExpr = "k8s.deployment.name != ''"
const (
deploymentNameAttrKey = "k8s.deployment.name"
deploymentsBaseFilterExpr = "k8s.deployment.name != ''"
)
var deploymentNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.DeploymentNameAttrKey,
Name: deploymentNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},

View File

@@ -38,7 +38,7 @@ func (m *module) getPerGroupHostStatusCounts(
uint64(req.Start), uint64(req.End), nil,
)
hostNameExpr := fmt.Sprintf("JSONExtractString(labels, '%s')", inframonitoringtypes.HostNameAttrKey)
hostNameExpr := fmt.Sprintf("JSONExtractString(labels, '%s')", hostNameAttrKey)
sb := sqlbuilder.NewSelectBuilder()
selectCols := make([]string, 0, len(req.GroupBy)+2)
@@ -48,7 +48,7 @@ func (m *module) getPerGroupHostStatusCounts(
)
}
activeHostsSQ := m.getActiveHostsQuery(metricNames, inframonitoringtypes.HostNameAttrKey, sinceUnixMilli)
activeHostsSQ := m.getActiveHostsQuery(metricNames, hostNameAttrKey, sinceUnixMilli)
selectCols = append(selectCols,
fmt.Sprintf("uniqExactIf(%s, %s GLOBAL IN (%s)) AS active_host_count", hostNameExpr, hostNameExpr, sb.Var(activeHostsSQ)),
fmt.Sprintf("uniqExactIf(%s, %s != '') AS total_host_count", hostNameExpr, hostNameExpr),
@@ -142,7 +142,7 @@ func buildHostRecords(
records := make([]inframonitoringtypes.HostRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
hostName := labels[inframonitoringtypes.HostNameAttrKey]
hostName := labels[hostNameAttrKey]
activeStatus := inframonitoringtypes.HostStatusNone
activeHostCount := 0
@@ -216,9 +216,6 @@ func (m *module) getTopHostGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.HostNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.HostNameAttrKey), nil
}
queryNamesForOrderBy := orderByToHostsQueryNames[orderByKey]
// The last entry is the formula/query whose value we sort by.
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
@@ -284,7 +281,7 @@ func (m *module) applyHostsActiveStatusFilter(req *inframonitoringtypes.Postable
if req.Filter.FilterByStatus == inframonitoringtypes.HostStatusInactive {
op = "NOT IN"
}
statusClause := fmt.Sprintf("%s %s (%s)", inframonitoringtypes.HostNameAttrKey, op, strings.Join(activeHosts, ", "))
statusClause := fmt.Sprintf("%s %s (%s)", hostNameAttrKey, op, strings.Join(activeHosts, ", "))
req.Filter.Expression = mergeFilterExpressions(req.Filter.Expression, statusClause)
return false
}

View File

@@ -7,10 +7,14 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
hostNameAttrKey = "host.name"
)
// Helper group-by key used across all queries.
var hostNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.HostNameAttrKey,
Name: hostNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},

View File

@@ -25,7 +25,7 @@ func buildJobRecords(
records := make([]inframonitoringtypes.JobRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
jobName := labels[inframonitoringtypes.JobNameAttrKey]
jobName := labels[jobNameAttrKey]
record := inframonitoringtypes.JobRecord{ // initialize with default values
JobName: jobName,
@@ -103,9 +103,6 @@ func (m *module) getTopJobGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.JobNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.JobNameAttrKey), nil
}
queryNamesForOrderBy := orderByToJobsQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]

View File

@@ -7,11 +7,14 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const jobsBaseFilterExpr = "k8s.job.name != ''"
const (
jobNameAttrKey = "k8s.job.name"
jobsBaseFilterExpr = "k8s.job.name != ''"
)
var jobNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.JobNameAttrKey,
Name: jobNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},

View File

@@ -100,7 +100,7 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
// Determine active hosts: those with metrics reported in the last 10 minutes.
// Compute the cutoff once so every downstream query/subquery agrees on what "active" means.
sinceUnixMilli := time.Now().Add(-10 * time.Minute).UTC().UnixMilli()
activeHostsMap, err := m.getActiveHosts(ctx, hostsTableMetricNamesList, inframonitoringtypes.HostNameAttrKey, sinceUnixMilli)
activeHostsMap, err := m.getActiveHosts(ctx, hostsTableMetricNamesList, hostNameAttrKey, sinceUnixMilli)
if err != nil {
return nil, err
}
@@ -146,7 +146,7 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
// When host.name is not in groupBy, we need to run an additional query to get the counts per group for the current page,
// using the same filter expression as the main query (including user filters + page groups IN clause).
hostCounts := make(map[string]groupHostStatusCounts)
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.HostNameAttrKey)
isHostNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, hostNameAttrKey)
if !isHostNameInGroupBy {
hostCounts, err = m.getPerGroupHostStatusCounts(ctx, req, hostsTableMetricNamesList, pageGroups, sinceUnixMilli)
if err != nil {
@@ -324,7 +324,7 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
return nil, err
}
isNodeNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, inframonitoringtypes.NodeNameAttrKey)
isNodeNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, nodeNameAttrKey)
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCounts, podPhaseCounts)
resp.Warning = queryResp.Warning

View File

@@ -24,7 +24,7 @@ func buildNamespaceRecords(
records := make([]inframonitoringtypes.NamespaceRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
namespaceName := labels[inframonitoringtypes.NamespaceNameAttrKey]
namespaceName := labels[namespaceNameAttrKey]
record := inframonitoringtypes.NamespaceRecord{ // initialize with default values
NamespaceName: namespaceName,
@@ -70,9 +70,6 @@ func (m *module) getTopNamespaceGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.NamespaceNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.NamespaceNameAttrKey), nil
}
queryNamesForOrderBy := orderByToNamespacesQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]

View File

@@ -7,9 +7,13 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
namespaceNameAttrKey = "k8s.namespace.name"
)
var namespaceNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.NamespaceNameAttrKey,
Name: namespaceNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},

View File

@@ -33,7 +33,7 @@ func buildNodeRecords(
records := make([]inframonitoringtypes.NodeRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
nodeName := labels[inframonitoringtypes.NodeNameAttrKey]
nodeName := labels[nodeNameAttrKey]
record := inframonitoringtypes.NodeRecord{ // initialize with default values
NodeName: nodeName,
@@ -105,9 +105,6 @@ func (m *module) getTopNodeGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.NodeNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.NodeNameAttrKey), nil
}
queryNamesForOrderBy := orderByToNodesQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
@@ -204,7 +201,7 @@ func (m *module) getPerGroupNodeConditionCounts(
timeSeriesFPs := sqlbuilder.NewSelectBuilder()
timeSeriesFPsSelectCols := []string{
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS node_name", timeSeriesFPs.Var(inframonitoringtypes.NodeNameAttrKey)),
fmt.Sprintf("JSONExtractString(labels, %s) AS node_name", timeSeriesFPs.Var(nodeNameAttrKey)),
}
for _, key := range groupBy {
timeSeriesFPsSelectCols = append(timeSeriesFPsSelectCols,

View File

@@ -7,11 +7,14 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const nodeConditionMetricName = "k8s.node.condition_ready"
const (
nodeNameAttrKey = "k8s.node.name"
nodeConditionMetricName = "k8s.node.condition_ready"
)
var nodeNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.NodeNameAttrKey,
Name: nodeNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},

View File

@@ -124,9 +124,6 @@ func (m *module) getTopPodGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.PodNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.PodNameAttrKey), nil
}
queryNamesForOrderBy := orderByToPodsQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]

View File

@@ -25,7 +25,7 @@ func buildStatefulSetRecords(
records := make([]inframonitoringtypes.StatefulSetRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
statefulSetName := labels[inframonitoringtypes.StatefulSetNameAttrKey]
statefulSetName := labels[statefulSetNameAttrKey]
record := inframonitoringtypes.StatefulSetRecord{ // initialize with default values
StatefulSetName: statefulSetName,
@@ -95,9 +95,6 @@ func (m *module) getTopStatefulSetGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.StatefulSetNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.StatefulSetNameAttrKey), nil
}
queryNamesForOrderBy := orderByToStatefulSetsQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]

View File

@@ -7,11 +7,14 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const statefulSetsBaseFilterExpr = "k8s.statefulset.name != ''"
const (
statefulSetNameAttrKey = "k8s.statefulset.name"
statefulSetsBaseFilterExpr = "k8s.statefulset.name != ''"
)
var statefulSetNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.StatefulSetNameAttrKey,
Name: statefulSetNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},

View File

@@ -23,7 +23,7 @@ func buildVolumeRecords(
records := make([]inframonitoringtypes.VolumeRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
pvcName := labels[inframonitoringtypes.PersistentVolumeClaimNameAttrKey]
pvcName := labels[persistentVolumeClaimNameAttrKey]
record := inframonitoringtypes.VolumeRecord{ // initialize with default values
PersistentVolumeClaimName: pvcName,
@@ -75,9 +75,6 @@ func (m *module) getTopVolumeGroups(
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
if orderByKey == inframonitoringtypes.PersistentVolumeClaimNameAttrKey {
return inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.PersistentVolumeClaimNameAttrKey), nil
}
queryNamesForOrderBy := orderByToVolumesQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]

View File

@@ -7,11 +7,14 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const volumesBaseFilterExpr = "k8s.persistentvolumeclaim.name != ''"
const (
persistentVolumeClaimNameAttrKey = "k8s.persistentvolumeclaim.name"
volumesBaseFilterExpr = "k8s.persistentvolumeclaim.name != ''"
)
var pvcNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.PersistentVolumeClaimNameAttrKey,
Name: persistentVolumeClaimNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},

View File

@@ -74,7 +74,7 @@ func (h *provider) BeforeQuery(ctx context.Context, _ *telemetrystore.QueryEvent
// TODO(srikanthccv): enable it when the "Cannot read all data" issue is fixed
// https://github.com/ClickHouse/ClickHouse/issues/82283
settings["secondary_indices_enable_bulk_filtering"] = false
settings["secondary_indices_enable_bulk_filtering"] = true
ctx = clickhouse.Context(ctx, clickhouse.WithSettings(settings))
return ctx

View File

@@ -10,7 +10,7 @@ import (
type Clusters struct {
Type ResponseType `json:"type" required:"true"`
Records []ClusterRecord `json:"records" required:"true" nullable:"false"`
Records []ClusterRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -19,8 +19,8 @@ type Clusters struct {
type ClusterRecord struct {
// TODO(nikhilmantri0902): once the underlying attr key is migrated to
// k8s.cluster.uid (see ClusterNameAttrKey), surface ClusterUID alongside
// (or replace) ClusterName.
// k8s.cluster.uid (see clusterNameAttrKey TODO in implinframonitoring),
// surface ClusterUID alongside (or replace) ClusterName.
ClusterName string `json:"clusterName" required:"true"`
ClusterCPU float64 `json:"clusterCPU" required:"true"`
ClusterCPUAllocatable float64 `json:"clusterCPUAllocatable" required:"true"`
@@ -88,9 +88,6 @@ func (req *PostableClusters) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == ClusterNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", ClusterNameAttrKey)
}
}
return nil

View File

@@ -1,7 +1,5 @@
package inframonitoringtypes
const ClusterNameAttrKey = "k8s.cluster.name"
const (
ClustersOrderByCPU = "cpu"
ClustersOrderByCPUAllocatable = "cpu_allocatable"
@@ -14,5 +12,4 @@ var ClustersValidOrderByKeys = []string{
ClustersOrderByCPUAllocatable,
ClustersOrderByMemory,
ClustersOrderByMemoryAllocatable,
ClusterNameAttrKey,
}

View File

@@ -275,57 +275,6 @@ func TestPostableClusters_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "orderBy name asc with empty groupBy is valid",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ClusterNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy name desc with empty groupBy is valid",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ClusterNameAttrKey},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy name with non-empty groupBy is rejected",
req: &PostableClusters{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "k8s.cluster.name"}},
},
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ClusterNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -10,7 +10,7 @@ import (
type DaemonSets struct {
Type ResponseType `json:"type" required:"true"`
Records []DaemonSetRecord `json:"records" required:"true" nullable:"false"`
Records []DaemonSetRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -88,9 +88,6 @@ func (req *PostableDaemonSets) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == DaemonSetNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", DaemonSetNameAttrKey)
}
}
return nil

View File

@@ -1,14 +1,12 @@
package inframonitoringtypes
const DaemonSetNameAttrKey = "k8s.daemonset.name"
const (
DaemonSetsOrderByCPU = "cpu"
DaemonSetsOrderByCPURequest = "cpu_request"
DaemonSetsOrderByCPULimit = "cpu_limit"
DaemonSetsOrderByMemory = "memory"
DaemonSetsOrderByMemoryRequest = "memory_request"
DaemonSetsOrderByMemoryLimit = "memory_limit"
DaemonSetsOrderByCPU = "cpu"
DaemonSetsOrderByCPURequest = "cpu_request"
DaemonSetsOrderByCPULimit = "cpu_limit"
DaemonSetsOrderByMemory = "memory"
DaemonSetsOrderByMemoryRequest = "memory_request"
DaemonSetsOrderByMemoryLimit = "memory_limit"
DaemonSetsOrderByDesiredNodes = "desired_nodes"
DaemonSetsOrderByCurrentNodes = "current_nodes"
)
@@ -22,5 +20,4 @@ var DaemonSetsValidOrderByKeys = []string{
DaemonSetsOrderByMemoryLimit,
DaemonSetsOrderByDesiredNodes,
DaemonSetsOrderByCurrentNodes,
DaemonSetNameAttrKey,
}

View File

@@ -257,63 +257,6 @@ func TestPostableDaemonSets_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "orderBy name asc with empty groupBy is valid",
req: &PostableDaemonSets{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: DaemonSetNameAttrKey,
},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy name desc with empty groupBy is valid",
req: &PostableDaemonSets{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: DaemonSetNameAttrKey,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy name with non-empty groupBy is rejected",
req: &PostableDaemonSets{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "k8s.namespace.name"}},
},
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: DaemonSetNameAttrKey,
},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -10,7 +10,7 @@ import (
type Deployments struct {
Type ResponseType `json:"type" required:"true"`
Records []DeploymentRecord `json:"records" required:"true" nullable:"false"`
Records []DeploymentRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -88,9 +88,6 @@ func (req *PostableDeployments) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == DeploymentNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", DeploymentNameAttrKey)
}
}
return nil

View File

@@ -1,7 +1,5 @@
package inframonitoringtypes
const DeploymentNameAttrKey = "k8s.deployment.name"
const (
DeploymentsOrderByCPU = "cpu"
DeploymentsOrderByCPURequest = "cpu_request"
@@ -22,5 +20,4 @@ var DeploymentsValidOrderByKeys = []string{
DeploymentsOrderByMemoryLimit,
DeploymentsOrderByDesiredPods,
DeploymentsOrderByAvailablePods,
DeploymentNameAttrKey,
}

View File

@@ -257,57 +257,6 @@ func TestPostableDeployments_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "orderBy name asc with empty groupBy is valid",
req: &PostableDeployments{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: DeploymentNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy name desc with empty groupBy is valid",
req: &PostableDeployments{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: DeploymentNameAttrKey},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy name with non-empty groupBy is rejected",
req: &PostableDeployments{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "k8s.namespace.name"}},
},
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: DeploymentNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -10,7 +10,7 @@ import (
type Hosts struct {
Type ResponseType `json:"type" required:"true"`
Records []HostRecord `json:"records" required:"true" nullable:"false"`
Records []HostRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -100,9 +100,6 @@ func (req *PostableHosts) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == HostNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", HostNameAttrKey)
}
}
return nil

View File

@@ -20,8 +20,6 @@ func (HostStatus) Enum() []any {
}
}
const HostNameAttrKey = "host.name"
const (
HostsOrderByCPU = "cpu"
HostsOrderByMemory = "memory"
@@ -36,5 +34,4 @@ var HostsValidOrderByKeys = []string{
HostsOrderByWait,
HostsOrderByDiskUsage,
HostsOrderByLoad15,
HostNameAttrKey,
}

View File

@@ -228,57 +228,6 @@ func TestHostsListRequest_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "orderBy name asc with empty groupBy is valid",
req: &PostableHosts{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: HostNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy name desc with empty groupBy is valid",
req: &PostableHosts{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: HostNameAttrKey},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy name with non-empty groupBy is rejected",
req: &PostableHosts{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "os.type"}},
},
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: HostNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -10,7 +10,7 @@ import (
type Jobs struct {
Type ResponseType `json:"type" required:"true"`
Records []JobRecord `json:"records" required:"true" nullable:"false"`
Records []JobRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -90,9 +90,6 @@ func (req *PostableJobs) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == JobNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", JobNameAttrKey)
}
}
return nil

View File

@@ -1,7 +1,5 @@
package inframonitoringtypes
const JobNameAttrKey = "k8s.job.name"
const (
JobsOrderByCPU = "cpu"
JobsOrderByCPURequest = "cpu_request"
@@ -26,5 +24,4 @@ var JobsValidOrderByKeys = []string{
JobsOrderByActivePods,
JobsOrderByFailedPods,
JobsOrderBySuccessfulPods,
JobNameAttrKey,
}

View File

@@ -293,57 +293,6 @@ func TestPostableJobs_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "orderBy name asc with empty groupBy is valid",
req: &PostableJobs{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: JobNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy name desc with empty groupBy is valid",
req: &PostableJobs{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: JobNameAttrKey},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy name with non-empty groupBy is rejected",
req: &PostableJobs{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "k8s.namespace.name"}},
},
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: JobNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -10,7 +10,7 @@ import (
type Namespaces struct {
Type ResponseType `json:"type" required:"true"`
Records []NamespaceRecord `json:"records" required:"true" nullable:"false"`
Records []NamespaceRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -82,9 +82,6 @@ func (req *PostableNamespaces) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == NamespaceNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", NamespaceNameAttrKey)
}
}
return nil

View File

@@ -1,7 +1,5 @@
package inframonitoringtypes
const NamespaceNameAttrKey = "k8s.namespace.name"
const (
NamespacesOrderByCPU = "cpu"
NamespacesOrderByMemory = "memory"
@@ -10,5 +8,4 @@ const (
var NamespacesValidOrderByKeys = []string{
NamespacesOrderByCPU,
NamespacesOrderByMemory,
NamespaceNameAttrKey,
}

View File

@@ -221,57 +221,6 @@ func TestPostableNamespaces_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "orderBy name asc with empty groupBy is valid",
req: &PostableNamespaces{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: NamespaceNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy name desc with empty groupBy is valid",
req: &PostableNamespaces{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: NamespaceNameAttrKey},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy name with non-empty groupBy is rejected",
req: &PostableNamespaces{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "k8s.cluster.name"}},
},
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: NamespaceNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -10,7 +10,7 @@ import (
type Nodes struct {
Type ResponseType `json:"type" required:"true"`
Records []NodeRecord `json:"records" required:"true" nullable:"false"`
Records []NodeRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -93,9 +93,6 @@ func (req *PostableNodes) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == NodeNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", NodeNameAttrKey)
}
}
return nil

View File

@@ -27,8 +27,6 @@ const (
NodeConditionNumNotReady = 0
)
const NodeNameAttrKey = "k8s.node.name"
const (
NodesOrderByCPU = "cpu"
NodesOrderByCPUAllocatable = "cpu_allocatable"
@@ -41,5 +39,4 @@ var NodesValidOrderByKeys = []string{
NodesOrderByCPUAllocatable,
NodesOrderByMemory,
NodesOrderByMemoryAllocatable,
NodeNameAttrKey,
}

View File

@@ -239,57 +239,6 @@ func TestPostableNodes_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "orderBy name asc with empty groupBy is valid",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: NodeNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy name desc with empty groupBy is valid",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: NodeNameAttrKey},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy name with non-empty groupBy is rejected",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "k8s.cluster.name"}},
},
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: NodeNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -1,55 +0,0 @@
package inframonitoringtypes
import (
"sort"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
// PaginateMetadataByName returns metadataMap groups sorted by name
// (lexicographic, asc or desc), paginated by offset/limit, and rebuilt into
// label maps using groupBy. When sortByMetaKey is non-empty, groups are sorted
// by metadataMap[k][sortByMetaKey]; otherwise sorted by composite key directly.
func PaginateMetadataByName(
metadataMap map[string]map[string]string,
groupBy []qbtypes.GroupByKey,
direction qbtypes.OrderDirection,
offset, limit int,
sortByMetaKey string,
) []map[string]string {
pageGroups := make([]map[string]string, 0)
if offset >= len(metadataMap) {
return pageGroups
}
type entry struct{ compositeKey, sortVal string }
entries := make([]entry, 0, len(metadataMap))
for ck, meta := range metadataMap {
sv := ck
if sortByMetaKey != "" {
sv = meta[sortByMetaKey]
}
entries = append(entries, entry{compositeKey: ck, sortVal: sv})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].sortVal != entries[j].sortVal {
if direction == qbtypes.OrderDirectionAsc {
return entries[i].sortVal < entries[j].sortVal
}
return entries[i].sortVal > entries[j].sortVal
}
return entries[i].compositeKey < entries[j].compositeKey
})
end := min(offset+limit, len(entries))
for _, e := range entries[offset:end] {
attrs := metadataMap[e.compositeKey]
labels := make(map[string]string, len(groupBy))
for _, gb := range groupBy {
labels[gb.Name] = attrs[gb.Name]
}
pageGroups = append(pageGroups, labels)
}
return pageGroups
}

View File

@@ -1,371 +0,0 @@
package inframonitoringtypes
import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
)
func gbKey(name string) qbtypes.GroupByKey {
return qbtypes.GroupByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name}}
}
// fiveHostMap returns a metadataMap with 5 host entries h1..h5.
func fiveHostMap() map[string]map[string]string {
m := make(map[string]map[string]string, 5)
for _, n := range []string{"h1", "h2", "h3", "h4", "h5"} {
m[n] = map[string]string{"host.name": n}
}
return m
}
func TestPaginateMetadataByName(t *testing.T) {
hostGB := []qbtypes.GroupByKey{gbKey("host.name")}
tests := []struct {
name string
metadataMap map[string]map[string]string
groupBy []qbtypes.GroupByKey
direction qbtypes.OrderDirection
offset, limit int
sortByMetaKey string
want []map[string]string
wantNotNil bool // assert empty non-nil slice instead of comparing to want
}{
// A. Array out of bounds
{
name: "offset_equals_len",
metadataMap: fiveHostMap(),
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 5,
limit: 10,
sortByMetaKey: "host.name",
wantNotNil: true,
},
{
name: "offset_way_past_len",
metadataMap: fiveHostMap(),
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 100,
limit: 10,
sortByMetaKey: "host.name",
wantNotNil: true,
},
{
name: "offset_plus_limit_exceeds_len",
metadataMap: fiveHostMap(),
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 3,
limit: 10,
sortByMetaKey: "host.name",
want: []map[string]string{
{"host.name": "h4"},
{"host.name": "h5"},
},
},
{
name: "limit_exceeds_len",
metadataMap: fiveHostMap(),
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 100,
sortByMetaKey: "host.name",
want: []map[string]string{
{"host.name": "h1"},
{"host.name": "h2"},
{"host.name": "h3"},
{"host.name": "h4"},
{"host.name": "h5"},
},
},
{
name: "limit_zero",
metadataMap: fiveHostMap(),
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 0,
sortByMetaKey: "host.name",
wantNotNil: true, // expect empty non-nil slice
},
{
name: "empty_map",
metadataMap: map[string]map[string]string{},
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
wantNotNil: true,
},
{
name: "exact_page",
metadataMap: fiveHostMap(),
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 5,
sortByMetaKey: "host.name",
want: []map[string]string{
{"host.name": "h1"},
{"host.name": "h2"},
{"host.name": "h3"},
{"host.name": "h4"},
{"host.name": "h5"},
},
},
{
name: "mid_page",
metadataMap: fiveHostMap(),
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 2,
limit: 2,
sortByMetaKey: "host.name",
want: []map[string]string{
{"host.name": "h3"},
{"host.name": "h4"},
},
},
{
name: "last_single",
metadataMap: fiveHostMap(),
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 4,
limit: 1,
sortByMetaKey: "host.name",
want: []map[string]string{
{"host.name": "h5"},
},
},
// B. Nil / missing
{
name: "nil_map",
metadataMap: nil,
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
wantNotNil: true,
},
{
name: "nil_groupBy",
metadataMap: map[string]map[string]string{
"h1": {"host.name": "h1"},
"h2": {"host.name": "h2"},
},
groupBy: nil,
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
want: []map[string]string{
{},
{},
},
},
{
name: "empty_groupBy",
metadataMap: map[string]map[string]string{
"h1": {"host.name": "h1"},
"h2": {"host.name": "h2"},
},
groupBy: []qbtypes.GroupByKey{},
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
want: []map[string]string{
{},
{},
},
},
{
name: "missing_key_in_entry",
metadataMap: map[string]map[string]string{
"h1": {"host.name": "h1", "os.type": "linux"},
"h2": {"host.name": "h2"}, // os.type absent
},
groupBy: []qbtypes.GroupByKey{gbKey("host.name"), gbKey("os.type")},
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
want: []map[string]string{
{"host.name": "h1", "os.type": "linux"},
{"host.name": "h2", "os.type": ""},
},
},
{
name: "nil_inner_map",
metadataMap: map[string]map[string]string{
"h1": nil,
"h2": {"host.name": "h2"},
},
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
// sortVal for h1 = "" (nil map read), for h2 = "h2".
// Asc order: "" < "h2", so h1 first.
want: []map[string]string{
{"host.name": ""},
{"host.name": "h2"},
},
},
{
name: "sortByMetaKey_empty_sorts_by_compositeKey",
metadataMap: map[string]map[string]string{
"x\x00z": {"a": "x", "b": "z"},
"x\x00y": {"a": "x", "b": "y"},
},
groupBy: []qbtypes.GroupByKey{gbKey("a"), gbKey("b")},
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 10,
sortByMetaKey: "",
want: []map[string]string{
{"a": "x", "b": "y"},
{"a": "x", "b": "z"},
},
},
{
name: "sortByMetaKey_absent_in_some_entries",
metadataMap: map[string]map[string]string{
"h1": {"host.name": "h1"},
"h2": {}, // host.name absent
"h3": {"host.name": "h3"},
},
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
// h2 sortVal="" sorts first; then h1, h3.
want: []map[string]string{
{"host.name": ""},
{"host.name": "h1"},
{"host.name": "h3"},
},
},
// C. Sort direction
{
name: "asc",
metadataMap: map[string]map[string]string{
"h2": {"host.name": "h2"},
"h1": {"host.name": "h1"},
"h3": {"host.name": "h3"},
},
groupBy: hostGB,
direction: qbtypes.OrderDirectionAsc,
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
want: []map[string]string{
{"host.name": "h1"},
{"host.name": "h2"},
{"host.name": "h3"},
},
},
{
name: "desc",
metadataMap: map[string]map[string]string{
"h2": {"host.name": "h2"},
"h1": {"host.name": "h1"},
"h3": {"host.name": "h3"},
},
groupBy: hostGB,
direction: qbtypes.OrderDirectionDesc,
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
want: []map[string]string{
{"host.name": "h3"},
{"host.name": "h2"},
{"host.name": "h1"},
},
},
{
name: "zero_value_direction_falls_into_desc",
metadataMap: map[string]map[string]string{
"h2": {"host.name": "h2"},
"h1": {"host.name": "h1"},
"h3": {"host.name": "h3"},
},
groupBy: hostGB,
direction: qbtypes.OrderDirection{},
offset: 0,
limit: 10,
sortByMetaKey: "host.name",
want: []map[string]string{
{"host.name": "h3"},
{"host.name": "h2"},
{"host.name": "h1"},
},
},
{
name: "tie_breaks_on_compositeKey_asc",
metadataMap: map[string]map[string]string{
"a\x00b": {"k": "a", "x": "tie"},
"a\x00c": {"k": "a", "x": "tie"},
},
groupBy: []qbtypes.GroupByKey{gbKey("k"), gbKey("x")},
direction: qbtypes.OrderDirectionDesc,
offset: 0,
limit: 10,
sortByMetaKey: "x",
// Both sortVals "tie"; tie-break on compositeKey asc:
// "a\x00b" < "a\x00c", so b first.
want: []map[string]string{
{"k": "a", "x": "tie"},
{"k": "a", "x": "tie"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := PaginateMetadataByName(
tt.metadataMap,
tt.groupBy,
tt.direction,
tt.offset,
tt.limit,
tt.sortByMetaKey,
)
if tt.wantNotNil {
assert.NotNil(t, got)
assert.Len(t, got, 0)
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestPaginateMetadataByName_Deterministic(t *testing.T) {
m := make(map[string]map[string]string, 10)
// 10 entries, several with tied host.name values to force compositeKey tie-break.
for i, n := range []string{"a", "a", "b", "b", "c", "d", "e", "e", "f", "g"} {
ck := n + "\x00" + string(rune('0'+i))
m[ck] = map[string]string{"host.name": n, "id": string(rune('0' + i))}
}
gb := []qbtypes.GroupByKey{gbKey("host.name"), gbKey("id")}
first := PaginateMetadataByName(m, gb, qbtypes.OrderDirectionAsc, 0, 10, "host.name")
for i := range 50 {
got := PaginateMetadataByName(m, gb, qbtypes.OrderDirectionAsc, 0, 10, "host.name")
assert.Equal(t, first, got, "iteration %d differed — map-iteration nondeterminism leaked through sort", i)
}
}

View File

@@ -10,7 +10,7 @@ import (
type Pods struct {
Type ResponseType `json:"type" required:"true"`
Records []PodRecord `json:"records" required:"true" nullable:"false"`
Records []PodRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -98,9 +98,6 @@ func (req *PostablePods) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == PodNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", PodNameAttrKey)
}
}
return nil

View File

@@ -36,8 +36,6 @@ const (
PodPhaseNumUnknown = 5
)
const PodNameAttrKey = "k8s.pod.name"
const (
PodsOrderByCPU = "cpu"
PodsOrderByCPURequest = "cpu_request"
@@ -54,5 +52,4 @@ var PodsValidOrderByKeys = []string{
PodsOrderByMemory,
PodsOrderByMemoryRequest,
PodsOrderByMemoryLimit,
PodNameAttrKey,
}

View File

@@ -203,57 +203,6 @@ func TestPostablePods_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "orderBy name asc with empty groupBy is valid",
req: &PostablePods{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: PodNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy name desc with empty groupBy is valid",
req: &PostablePods{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: PodNameAttrKey},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy name with non-empty groupBy is rejected",
req: &PostablePods{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "k8s.namespace.name"}},
},
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: PodNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -10,7 +10,7 @@ import (
type StatefulSets struct {
Type ResponseType `json:"type" required:"true"`
Records []StatefulSetRecord `json:"records" required:"true" nullable:"false"`
Records []StatefulSetRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -88,9 +88,6 @@ func (req *PostableStatefulSets) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == StatefulSetNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", StatefulSetNameAttrKey)
}
}
return nil

View File

@@ -1,7 +1,5 @@
package inframonitoringtypes
const StatefulSetNameAttrKey = "k8s.statefulset.name"
const (
StatefulSetsOrderByCPU = "cpu"
StatefulSetsOrderByCPURequest = "cpu_request"
@@ -22,5 +20,4 @@ var StatefulSetsValidOrderByKeys = []string{
StatefulSetsOrderByMemoryLimit,
StatefulSetsOrderByDesiredPods,
StatefulSetsOrderByCurrentPods,
StatefulSetNameAttrKey,
}

View File

@@ -257,57 +257,6 @@ func TestPostableStatefulSets_Validate(t *testing.T) {
},
wantErr: true,
},
{
name: "orderBy name asc with empty groupBy is valid",
req: &PostableStatefulSets{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: StatefulSetNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy name desc with empty groupBy is valid",
req: &PostableStatefulSets{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: StatefulSetNameAttrKey},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy name with non-empty groupBy is rejected",
req: &PostableStatefulSets{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "k8s.namespace.name"}},
},
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: StatefulSetNameAttrKey},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: true,
},
}
for _, tt := range tests {

View File

@@ -10,7 +10,7 @@ import (
type Volumes struct {
Type ResponseType `json:"type" required:"true"`
Records []VolumeRecord `json:"records" required:"true" nullable:"false"`
Records []VolumeRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
@@ -85,9 +85,6 @@ func (req *PostableVolumes) Validate() error {
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
if req.OrderBy.Key.Name == PersistentVolumeClaimNameAttrKey && len(req.GroupBy) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order by '%s' is only allowed when groupBy is empty", PersistentVolumeClaimNameAttrKey)
}
}
return nil

View File

@@ -1,7 +1,5 @@
package inframonitoringtypes
const PersistentVolumeClaimNameAttrKey = "k8s.persistentvolumeclaim.name"
const (
VolumesOrderByAvailable = "available"
VolumesOrderByCapacity = "capacity"
@@ -18,5 +16,4 @@ var VolumesValidOrderByKeys = []string{
VolumesOrderByInodes,
VolumesOrderByInodesFree,
VolumesOrderByInodesUsed,
PersistentVolumeClaimNameAttrKey,
}

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