Compare commits

..

2 Commits

Author SHA1 Message Date
Vinícius Lourenço
a35ebb7f91 fix(query-builder): fallback to valid array instead of undefined 2026-03-26 10:16:38 -03:00
Vinícius Lourenço
e2d8b0eead fix(alert-rules): be defensive with invalid data on getSelectedQueryOptions 2026-03-26 10:15:24 -03:00
36 changed files with 260 additions and 848 deletions

View File

@@ -205,25 +205,6 @@ module.exports = {
],
},
overrides: [
{
files: ['src/**/*.{jsx,tsx,ts}'],
excludedFiles: [
'**/*.test.{js,jsx,ts,tsx}',
'**/*.spec.{js,jsx,ts,tsx}',
'**/__tests__/**/*.{js,jsx,ts,tsx}',
],
rules: {
'no-restricted-properties': [
'error',
{
object: 'navigator',
property: 'clipboard',
message:
'Do not use navigator.clipboard directly since it does not work well with specific browsers. Use hook useCopyToClipboard from react-use library. https://streamich.github.io/react-use/?path=/story/side-effects-usecopytoclipboard--docs',
},
],
},
},
{
files: [
'**/*.test.{js,jsx,ts,tsx}',

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
@@ -178,30 +177,26 @@ function EditMemberDrawer({
}
}, [member, isInvited, setLinkType, onClose]);
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopyResetLink = useCallback(async (): Promise<void> => {
if (!resetLink) {
return;
}
copyToClipboard(resetLink);
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
toast.success(
linkType === 'invite'
? 'Invite link copied to clipboard'
: 'Reset link copied to clipboard',
{ richColors: true },
);
}, [resetLink, copyToClipboard, linkType]);
useEffect(() => {
if (copyState.error) {
try {
await navigator.clipboard.writeText(resetLink);
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
toast.success(
linkType === 'invite'
? 'Invite link copied to clipboard'
: 'Reset link copied to clipboard',
{ richColors: true },
);
} catch {
toast.error('Failed to copy link', {
richColors: true,
});
}
}, [copyState.error]);
}, [resetLink, linkType]);
const handleClose = useCallback((): void => {
setShowDeleteConfirm(false);

View File

@@ -7,7 +7,13 @@ import {
useUpdateUserDeprecated,
} from 'api/generated/services/users';
import { MemberStatus } from 'container/MembersSettings/utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { ROLES } from 'types/roles';
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
@@ -59,16 +65,6 @@ jest.mock('@signozhq/sonner', () => ({
},
}));
const mockCopyToClipboard = jest.fn();
const mockCopyState = { value: undefined, error: undefined };
jest.mock('react-use', () => ({
useCopyToClipboard: (): [typeof mockCopyState, typeof mockCopyToClipboard] => [
mockCopyState,
mockCopyToClipboard,
],
}));
const mockUpdateMutate = jest.fn();
const mockDeleteMutate = jest.fn();
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
@@ -365,14 +361,32 @@ describe('EditMemberDrawer', () => {
});
describe('Generate Password Reset Link', () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined);
let clipboardSpy: jest.SpyInstance | undefined;
beforeAll(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: (): Promise<void> => Promise.resolve() },
configurable: true,
writable: true,
});
});
beforeEach(() => {
mockCopyToClipboard.mockClear();
mockWriteText.mockClear();
clipboardSpy = jest
.spyOn(navigator.clipboard, 'writeText')
.mockImplementation(mockWriteText);
mockGetResetPasswordToken.mockResolvedValue({
status: 'success',
data: { token: 'reset-tok-abc', id: 'user-1' },
});
});
afterEach(() => {
clipboardSpy?.mockRestore();
});
it('calls getResetPasswordToken and opens the reset link dialog with the generated link', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -407,7 +421,7 @@ describe('EditMemberDrawer', () => {
});
expect(dialog).toHaveTextContent('reset-tok-abc');
await user.click(screen.getByRole('button', { name: /^copy$/i }));
fireEvent.click(screen.getByRole('button', { name: /^copy$/i }));
await waitFor(() => {
expect(mockToast.success).toHaveBeenCalledWith(
@@ -416,7 +430,7 @@ describe('EditMemberDrawer', () => {
);
});
expect(mockCopyToClipboard).toHaveBeenCalledWith(
expect(mockWriteText).toHaveBeenCalledWith(
expect.stringContaining('reset-tok-abc'),
);
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();

View File

@@ -202,8 +202,19 @@ function InviteMembersModal({
onComplete?.();
} catch (err) {
const apiErr = err as APIError;
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(errorMessage, { richColors: true });
if (apiErr?.getHttpStatusCode() === 409) {
toast.error(
touchedRows.length === 1
? `${touchedRows[0].email} is already a member`
: 'Invite for one or more users already exists',
{ richColors: true },
);
} else {
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(`Failed to send invites: ${errorMessage}`, {
richColors: true,
});
}
} finally {
setIsSubmitting(false);
}

View File

@@ -1,18 +1,9 @@
import { toast } from '@signozhq/sonner';
import inviteUsers from 'api/v1/invite/bulk/create';
import sendInvite from 'api/v1/invite/create';
import { StatusCodes } from 'http-status-codes';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import APIError from 'types/api/error';
import InviteMembersModal from '../InviteMembersModal';
const makeApiError = (message: string, code = StatusCodes.CONFLICT): APIError =>
new APIError({
httpStatusCode: code,
error: { code: 'already_exists', message, url: '', errors: [] },
});
jest.mock('api/v1/invite/create');
jest.mock('api/v1/invite/bulk/create');
jest.mock('@signozhq/sonner', () => ({
@@ -151,90 +142,6 @@ describe('InviteMembersModal', () => {
});
});
describe('error handling', () => {
it('shows BE message on single invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockSendInvite.mockRejectedValue(
makeApiError('An invite already exists for this email: single@signoz.io'),
);
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'single@signoz.io',
);
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: single@signoz.io',
expect.anything(),
);
});
});
it('shows BE message on bulk invite 409', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockInviteUsers.mockRejectedValue(
makeApiError('An invite already exists for this email: alice@signoz.io'),
);
render(<InviteMembersModal {...defaultProps} />);
const emailInputs = screen.getAllByPlaceholderText('john@signoz.io');
await user.type(emailInputs[0], 'alice@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.type(emailInputs[1], 'bob@signoz.io');
await user.click(screen.getAllByText('Select roles')[0]);
const editorOptions = await screen.findAllByText('Editor');
await user.click(editorOptions[editorOptions.length - 1]);
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'An invite already exists for this email: alice@signoz.io',
expect.anything(),
);
});
});
it('shows BE message on generic error', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockSendInvite.mockRejectedValue(
makeApiError('Internal server error', StatusCodes.INTERNAL_SERVER_ERROR),
);
render(<InviteMembersModal {...defaultProps} />);
await user.type(
screen.getAllByPlaceholderText('john@signoz.io')[0],
'single@signoz.io',
);
await user.click(screen.getAllByText('Select roles')[0]);
await user.click(await screen.findByText('Viewer'));
await user.click(
screen.getByRole('button', { name: /invite team members/i }),
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
'Internal server error',
expect.anything(),
);
});
});
});
it('uses inviteUsers (bulk) when multiple rows are filled', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onComplete = jest.fn();

View File

@@ -17,7 +17,6 @@ function CodeCopyBtn({
let copiedText = '';
if (children && Array.isArray(children)) {
setIsSnippetCopied(true);
// eslint-disable-next-line no-restricted-properties
navigator.clipboard.writeText(children[0].props.children[0]).finally(() => {
copiedText = (children[0].props.children[0] as string).slice(0, 200); // slicing is done due to the limitation in accepted char length in attributes
setTimeout(() => {

View File

@@ -401,7 +401,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const textToCopy = selectedTexts.join(', ');
// eslint-disable-next-line no-restricted-properties
navigator.clipboard.writeText(textToCopy).catch(console.error);
}, [selectedChips, selectedValues]);

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { DialogWrapper } from '@signozhq/dialog';
import { toast } from '@signozhq/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -106,23 +105,19 @@ function AddKeyModal(): JSX.Element {
});
}
const [copyState, copyToClipboard] = useCopyToClipboard();
const handleCopy = useCallback(async (): Promise<void> => {
if (!createdKey?.key) {
return;
}
copyToClipboard(createdKey.key);
setHasCopied(true);
setTimeout(() => setHasCopied(false), 2000);
toast.success('Key copied to clipboard', { richColors: true });
}, [copyToClipboard, createdKey?.key]);
useEffect(() => {
if (copyState.error) {
try {
await navigator.clipboard.writeText(createdKey.key);
setHasCopied(true);
setTimeout(() => setHasCopied(false), 2000);
toast.success('Key copied to clipboard', { richColors: true });
} catch {
toast.error('Failed to copy key', { richColors: true });
}
}, [copyState.error]);
}, [createdKey]);
const handleClose = useCallback((): void => {
setIsAddKeyOpen(null);

View File

@@ -9,16 +9,6 @@ jest.mock('@signozhq/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const mockCopyToClipboard = jest.fn();
const mockCopyState = { value: undefined, error: undefined };
jest.mock('react-use', () => ({
useCopyToClipboard: (): [typeof mockCopyState, typeof mockCopyToClipboard] => [
mockCopyState,
mockCopyToClipboard,
],
}));
const mockToast = jest.mocked(toast);
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys';
@@ -45,9 +35,16 @@ function renderModal(): ReturnType<typeof render> {
}
describe('AddKeyModal', () => {
beforeAll(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: jest.fn().mockResolvedValue(undefined) },
configurable: true,
writable: true,
});
});
beforeEach(() => {
jest.clearAllMocks();
mockCopyToClipboard.mockClear();
server.use(
rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json(createdKeyResponse)),
@@ -93,6 +90,9 @@ describe('AddKeyModal', () => {
it('copy button writes key to clipboard and shows toast.success', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const writeTextSpy = jest
.spyOn(navigator.clipboard, 'writeText')
.mockResolvedValue(undefined);
renderModal();
@@ -115,12 +115,14 @@ describe('AddKeyModal', () => {
await user.click(copyBtn);
await waitFor(() => {
expect(mockCopyToClipboard).toHaveBeenCalledWith('snz_abc123xyz456secret');
expect(writeTextSpy).toHaveBeenCalledWith('snz_abc123xyz456secret');
expect(mockToast.success).toHaveBeenCalledWith(
'Key copied to clipboard',
expect.anything(),
);
});
writeTextSpy.mockRestore();
});
it('Cancel button closes the modal', async () => {

View File

@@ -16,9 +16,9 @@ function AverageResolutionCard({
}: TotalTriggeredCardProps): JSX.Element {
return (
<StatsCard
displayValue={formatTime(+currentAvgResolutionTime)}
totalCurrentCount={+currentAvgResolutionTime}
totalPastCount={+pastAvgResolutionTime}
displayValue={formatTime(currentAvgResolutionTime)}
totalCurrentCount={currentAvgResolutionTime}
totalPastCount={pastAvgResolutionTime}
title="Avg. Resolution Time"
timeSeries={timeSeries}
/>

View File

@@ -800,10 +800,14 @@
.ant-table-cell:has(.top-services-item-latency) {
text-align: center;
opacity: 0.8;
background: rgba(171, 189, 255, 0.04);
}
.ant-table-cell:has(.top-services-item-latency-title) {
text-align: center;
opacity: 0.8;
background: rgba(171, 189, 255, 0.04);
}
.ant-table-tbody > tr:hover > td {

View File

@@ -9,6 +9,7 @@
.api-quick-filters-header {
padding: 12px;
border-bottom: 1px solid var(--bg-slate-400);
border-right: 1px solid var(--bg-slate-400);
display: flex;
align-items: center;
@@ -25,7 +26,6 @@
width: 100%;
.toolbar {
border-top: 0px;
border-bottom: 1px solid var(--bg-slate-400);
}
@@ -220,18 +220,6 @@
}
.lightMode {
.api-quick-filter-left-section {
.api-quick-filters-header {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}
.api-module-right-section {
.toolbar {
border-bottom: 1px solid var(--bg-vanilla-300);
}
}
.no-filtered-domains-message-container {
.no-filtered-domains-message-content {
.no-filtered-domains-message {

View File

@@ -53,17 +53,24 @@ export const getUpdatedStepInterval = (evalWindow?: string): number => {
};
export const getSelectedQueryOptions = (
queries: Array<
| IBuilderQuery
| IBuilderTraceOperator
| IBuilderFormula
| IClickHouseQuery
| IPromQLQuery
>,
): SelectProps['options'] =>
queries
queries:
| Array<
| IBuilderQuery
| IBuilderTraceOperator
| IBuilderFormula
| IClickHouseQuery
| IPromQLQuery
>
| undefined
| null,
): SelectProps['options'] => {
if (!queries) {
return [];
}
return queries
.filter((query) => !query.disabled)
.map((query) => ({
label: 'queryName' in query ? query.queryName : query.name,
value: 'queryName' in query ? query.queryName : query.name,
}));
};

View File

@@ -464,10 +464,14 @@ function GeneralSettings({
onModalToggleHandler(type);
};
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const {
isCloudUser: isCloudUserVal,
isEnterpriseSelfHostedUser,
} = useGetTenantLicense();
const isAdmin = user.role === USER_ROLES.ADMIN;
const showCustomDomainSettings = isCloudUserVal && isAdmin;
const showCustomDomainSettings =
(isCloudUserVal || isEnterpriseSelfHostedUser) && isAdmin;
const renderConfig = [
{

View File

@@ -38,6 +38,7 @@ jest.mock('hooks/useComponentPermission', () => ({
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(() => ({
isCloudUser: false,
isEnterpriseSelfHostedUser: false,
})),
}));
@@ -388,6 +389,7 @@ describe('GeneralSettings - S3 Logs Retention', () => {
beforeEach(() => {
(useGetTenantLicense as jest.Mock).mockReturnValue({
isCloudUser: true,
isEnterpriseSelfHostedUser: false,
});
});
@@ -409,14 +411,15 @@ describe('GeneralSettings - S3 Logs Retention', () => {
});
});
describe('Non-cloud user rendering', () => {
describe('Enterprise Self-Hosted User Rendering', () => {
beforeEach(() => {
(useGetTenantLicense as jest.Mock).mockReturnValue({
isCloudUser: false,
isEnterpriseSelfHostedUser: true,
});
});
it('should not render CustomDomainSettings or GeneralSettingsCloud', () => {
it('should render CustomDomainSettings but not GeneralSettingsCloud', () => {
render(
<GeneralSettings
metricsTtlValuesPayload={mockMetricsRetention}
@@ -429,14 +432,12 @@ describe('GeneralSettings - S3 Logs Retention', () => {
/>,
);
expect(
screen.queryByTestId('custom-domain-settings'),
).not.toBeInTheDocument();
expect(screen.getByTestId('custom-domain-settings')).toBeInTheDocument();
expect(
screen.queryByTestId('general-settings-cloud'),
).not.toBeInTheDocument();
// Save buttons should be visible for non-cloud users (these are from retentions)
// Save buttons should be visible for self-hosted
const saveButtons = screen.getAllByRole('button', { name: /save/i });
expect(saveButtons.length).toBeGreaterThan(0);
});

View File

@@ -1,65 +0,0 @@
import APIError from 'types/api/error';
import { errorDetails } from '../utils';
function makeAPIError(
message: string,
code = 'SOME_CODE',
errors: { message: string }[] = [],
): APIError {
return new APIError({
httpStatusCode: 500,
error: { code, message, url: '', errors },
});
}
describe('errorDetails', () => {
describe('when passed an APIError', () => {
it('returns the error message', () => {
const error = makeAPIError('something went wrong');
expect(errorDetails(error)).toBe('something went wrong');
});
it('appends details when errors array is non-empty', () => {
const error = makeAPIError('query failed', 'QUERY_ERROR', [
{ message: 'field X is invalid' },
{ message: 'field Y is missing' },
]);
const result = errorDetails(error);
expect(result).toContain('query failed');
expect(result).toContain('field X is invalid');
expect(result).toContain('field Y is missing');
});
it('does not append details when errors array is empty', () => {
const error = makeAPIError('simple error', 'CODE', []);
const result = errorDetails(error);
expect(result).toBe('simple error');
expect(result).not.toContain('Details');
});
});
describe('when passed a plain Error (not an APIError)', () => {
it('does not throw', () => {
const error = new Error('timeout exceeded');
expect(() => errorDetails(error)).not.toThrow();
});
it('returns the plain error message', () => {
const error = new Error('timeout exceeded');
expect(errorDetails(error)).toBe('timeout exceeded');
});
it('returns fallback when plain Error has no message', () => {
const error = new Error('');
expect(errorDetails(error)).toBe('Unknown error occurred');
});
});
describe('fallback behaviour', () => {
it('returns "Unknown error occurred" when message is undefined', () => {
const error = makeAPIError('');
expect(errorDetails(error)).toBe('Unknown error occurred');
});
});
});

View File

@@ -249,14 +249,13 @@ export const handleGraphClick = async ({
}
};
export const errorDetails = (error: APIError | Error): string => {
const { message, errors } =
(error instanceof APIError ? error.getErrorDetails()?.error : null) || {};
export const errorDetails = (error: APIError): string => {
const { message, errors } = error.getErrorDetails()?.error || {};
const details =
errors && errors.length > 0
errors?.length > 0
? `\n\nDetails: ${errors.map((e) => e.message).join('\n')}`
: '';
const errorDetails = `${message ?? error.message} ${details}`;
return errorDetails.trim() || 'Unknown error occurred';
const errorDetails = `${message} ${details}`;
return errorDetails || 'Unknown error occurred';
};

View File

@@ -217,7 +217,7 @@ function K8sVolumesList({
{
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute: GetK8sEntityToAggregateAttribute(
K8sCategory.VOLUMES,
K8sCategory.NODES,
dotMetricsEnabled,
),
aggregateOperator: 'noop',
@@ -228,7 +228,7 @@ function K8sVolumesList({
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
},
true,
K8sCategory.VOLUMES,
K8sCategory.NODES,
);
const query = useMemo(() => {
@@ -597,7 +597,7 @@ function K8sVolumesList({
isLoadingGroupByFilters={isLoadingGroupByFilters}
handleGroupByChange={handleGroupByChange}
selectedGroupBy={groupBy}
entity={K8sCategory.VOLUMES}
entity={K8sCategory.NODES}
showAutoRefresh={!selectedVolumeData}
/>
{isError && <Typography>{data?.error || 'Something went wrong'}</Typography>}

View File

@@ -1,162 +0,0 @@
import setupCommonMocks from '../commonMocks';
setupCommonMocks();
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import K8sVolumesList from 'container/InfraMonitoringK8s/Volumes/K8sVolumesList';
import { rest, server } from 'mocks-server/server';
import { IAppContext, IUser } from 'providers/App/types';
import store from 'store';
import { LicenseResModel } from 'types/api/licensesV3/getActive';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const SERVER_URL = 'http://localhost/api';
describe('K8sVolumesList - useGetAggregateKeys Category Regression', () => {
let requestsMade: Array<{
url: string;
params: URLSearchParams;
body?: any;
}> = [];
beforeEach(() => {
requestsMade = [];
queryClient.clear();
server.use(
rest.get(`${SERVER_URL}/v3/autocomplete/attribute_keys`, (req, res, ctx) => {
const url = req.url.toString();
const params = req.url.searchParams;
requestsMade.push({
url,
params,
});
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
attributeKeys: [],
},
}),
);
}),
rest.post(`${SERVER_URL}/v1/pvcs/list`, (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
type: 'list',
records: [],
groups: null,
total: 0,
sentAnyHostMetricsData: false,
isSendingK8SAgentMetrics: false,
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
it('should call aggregate keys API with k8s_volume_capacity', async () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<K8sVolumesList
isFiltersVisible={false}
handleFilterVisibilityChange={jest.fn()}
quickFiltersLastUpdated={-1}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>,
);
await waitFor(() => {
expect(requestsMade.length).toBeGreaterThan(0);
});
// Find the attribute_keys request
const attributeKeysRequest = requestsMade.find((req) =>
req.url.includes('/autocomplete/attribute_keys'),
);
expect(attributeKeysRequest).toBeDefined();
const aggregateAttribute = attributeKeysRequest?.params.get(
'aggregateAttribute',
);
expect(aggregateAttribute).toBe('k8s_volume_capacity');
});
it('should call aggregate keys API with k8s.volume.capacity when dotMetrics enabled', async () => {
jest
.spyOn(await import('providers/App/App'), 'useAppContext')
.mockReturnValue({
featureFlags: [
{
name: FeatureKeys.DOT_METRICS_ENABLED,
active: true,
usage: 0,
usage_limit: 0,
route: '',
},
],
user: { role: 'ADMIN' } as IUser,
activeLicense: (null as unknown) as LicenseResModel,
} as IAppContext);
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<K8sVolumesList
isFiltersVisible={false}
handleFilterVisibilityChange={jest.fn()}
quickFiltersLastUpdated={-1}
/>
</MemoryRouter>
</Provider>
</QueryClientProvider>,
);
await waitFor(() => {
expect(requestsMade.length).toBeGreaterThan(0);
});
const attributeKeysRequest = requestsMade.find((req) =>
req.url.includes('/autocomplete/attribute_keys'),
);
expect(attributeKeysRequest).toBeDefined();
const aggregateAttribute = attributeKeysRequest?.params.get(
'aggregateAttribute',
);
expect(aggregateAttribute).toBe('k8s.volume.capacity');
});
});

View File

@@ -137,7 +137,7 @@ describe('useIsPanelWaitingOnVariable', () => {
expect(result.current).toBe(false);
});
it('should return false for DYNAMIC variable with allSelected=true that is loading but has a selectedValue', () => {
it('should return true for DYNAMIC variable with allSelected=true that is loading', () => {
setFetchStates({ dyn: 'loading' });
setDashboardVariables({
variables: {
@@ -152,10 +152,10 @@ describe('useIsPanelWaitingOnVariable', () => {
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
expect(result.current).toBe(true);
});
it('should return false for DYNAMIC variable with allSelected=true that is waiting but has a selectedValue', () => {
it('should return true for DYNAMIC variable with allSelected=true that is waiting', () => {
setFetchStates({ dyn: 'waiting' });
setDashboardVariables({
variables: {
@@ -170,7 +170,7 @@ describe('useIsPanelWaitingOnVariable', () => {
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
expect(result.current).toBe(false);
expect(result.current).toBe(true);
});
it('should return false for DYNAMIC variable with allSelected=true that is idle', () => {
@@ -313,39 +313,4 @@ describe('useIsPanelWaitingOnVariable', () => {
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
expect(result.current).toBe(true);
});
it('should find variable by name when store key differs from variable name', () => {
setFetchStates({ myVar: 'loading' });
setDashboardVariables({
variables: {
'uuid-abc-123': makeVariable({
id: 'uuid-abc-123',
name: 'myVar',
selectedValue: undefined,
}),
},
variableTypes: { myVar: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
expect(result.current).toBe(true);
});
it('should respect selectedValue when store key differs from variable name', () => {
// When the variable has a value, it should not block even if loading
setFetchStates({ myVar: 'loading' });
setDashboardVariables({
variables: {
'uuid-abc-123': makeVariable({
id: 'uuid-abc-123',
name: 'myVar',
selectedValue: 'production',
}),
},
variableTypes: { myVar: 'QUERY' },
});
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
expect(result.current).toBe(false);
});
});

View File

@@ -226,41 +226,6 @@ describe('useVariablesFromUrl', () => {
expect(urlVariables.undefinedVar).toBeUndefined();
});
it('should return empty object when URL param contains a bare % that makes decodeURIComponent throw', () => {
// Simulate a URL where the variables param is a raw JSON string containing a literal %
// (e.g. a metric value like "cpu_usage_50%"). URLSearchParams.get() returns the value
// as-is when it was set directly; if it contains a bare %, decodeURIComponent throws a URIError.
const rawJson = JSON.stringify({ threshold: '50%' });
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=${rawJson}`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
// Should parse successfully via the raw fallback, not throw or return {}
const vars = result.current.getUrlVariables();
expect(vars).toEqual({ threshold: '50%' });
});
it('should return empty object when URL param is completely unparseable', () => {
// A value that fails both decodeURIComponent and JSON.parse
const history = createMemoryHistory({
initialEntries: [`/?${QueryParams.variables}=not-json-%ZZ`],
});
const { result } = renderHook(() => useVariablesFromUrl(), {
wrapper: ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
),
});
expect(result.current.getUrlVariables()).toEqual({});
});
it('should update variables with array values correctly', () => {
const history = createMemoryHistory({
initialEntries: ['/'],

View File

@@ -133,19 +133,21 @@ export function useVariableFetchState(
export function useIsPanelWaitingOnVariable(variableNames: string[]): boolean {
const states = useVariableFetchSelector((s) => s.states);
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
const variableTypesMap = useDashboardVariablesSelector((s) => s.variableTypes);
return variableNames.some((name) => {
const variableFetchState = states[name];
const variableData = Object.values(dashboardVariables).find(
(v) => v.name === name,
);
const { selectedValue } = variableData || {};
const { selectedValue, allSelected } = dashboardVariables?.[name] || {};
const isVariableInFetchingOrWaitingState =
variableFetchState === 'loading' ||
variableFetchState === 'revalidating' ||
variableFetchState === 'waiting';
if (variableTypesMap[name] === 'DYNAMIC' && allSelected) {
return isVariableInFetchingOrWaitingState;
}
return isEmpty(selectedValue) ? isVariableInFetchingOrWaitingState : false;
});
}

View File

@@ -32,14 +32,7 @@ const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
}
try {
const decoded = ((): string => {
try {
return decodeURIComponent(variablesParam);
} catch {
return variablesParam;
}
})();
return JSON.parse(decoded);
return JSON.parse(decodeURIComponent(variablesParam));
} catch (error) {
Sentry.captureEvent({
message: `Failed to parse dashboard variables from URL: ${error}`,

View File

@@ -39,7 +39,6 @@ export function useCopyToClipboard(
const copyToClipboard = useCallback(
(text: string, id?: ID): void => {
// eslint-disable-next-line no-restricted-properties
navigator.clipboard.writeText(text).then(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);

View File

@@ -1,5 +1,3 @@
import { useMemo } from 'react';
import * as Sentry from '@sentry/react';
import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon';
import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon';
import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon';
@@ -7,50 +5,34 @@ import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon';
import './AlertSeverity.styles.scss';
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
critical: {
text: 'Critical',
className: 'alert-severity--critical',
icon: <SeverityCriticalIcon />,
},
error: {
text: 'Error',
className: 'alert-severity--error',
icon: <SeverityErrorIcon />,
},
warning: {
text: 'Warning',
className: 'alert-severity--warning',
icon: <SeverityWarningIcon />,
},
info: {
text: 'Info',
className: 'alert-severity--info',
icon: <SeverityInfoIcon />,
},
};
export default function AlertSeverity({
severity,
}: {
severity: string;
}): JSX.Element {
const severityDetails = useMemo(() => {
if (severityConfig[severity]) {
return severityConfig[severity];
}
Sentry.captureEvent({
message: `Received unknown severity on Alert Details: ${severity}`,
level: 'error',
});
return {
text: severity,
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
critical: {
text: 'Critical',
className: 'alert-severity--critical',
icon: <SeverityCriticalIcon />,
},
error: {
text: 'Error',
className: 'alert-severity--error',
icon: <SeverityErrorIcon />,
},
warning: {
text: 'Warning',
className: 'alert-severity--warning',
icon: <SeverityWarningIcon />,
},
info: {
text: 'Info',
className: 'alert-severity--info',
icon: <SeverityInfoIcon />,
};
}, [severity]);
},
};
const severityDetails = severityConfig[severity];
return (
<div className={`alert-severity ${severityDetails.className}`}>
<div className="alert-severity__icon">{severityDetails.icon}</div>

View File

@@ -20,6 +20,7 @@ import AlertHistory from 'container/AlertHistory';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
import { urlKey } from 'container/AllError/utils';
import { RelativeTimeMap } from 'container/TopNav/DateTimeSelectionV2/constants';
import useAxiosError from 'hooks/useAxiosError';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -46,8 +47,6 @@ import { PayloadProps } from 'types/api/alerts/get';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { nanoToMilli } from 'utils/timeUtils';
const DEFAULT_TIME_RANGE = '30m';
export const useAlertHistoryQueryParams = (): {
ruleId: string | null;
startTime: number;
@@ -62,8 +61,8 @@ export const useAlertHistoryQueryParams = (): {
const relativeTimeParam = params.get(QueryParams.relativeTime);
const relativeTime =
(relativeTimeParam === 'null' ? null : relativeTimeParam) ||
DEFAULT_TIME_RANGE;
(relativeTimeParam === 'null' ? null : relativeTimeParam) ??
RelativeTimeMap['6hr'];
const intStartTime = parseInt(startTime || '0', 10);
const intEndTime = parseInt(endTime || '0', 10);

View File

@@ -165,20 +165,23 @@ export function QueryBuilderProvider({
const prepareQueryBuilderData = useCallback(
(query: Query): Query => {
const builder: QueryBuilderData = {
queryData: query.builder.queryData?.map((item) => ({
...initialQueryBuilderFormValuesMap[
initialDataSource || DataSource.METRICS
],
...item,
})),
queryFormulas: query.builder.queryFormulas?.map((item) => ({
...initialFormulaBuilderFormValues,
...item,
})),
queryTraceOperator: query.builder.queryTraceOperator?.map((item) => ({
...initialQueryBuilderFormTraceOperatorValues,
...item,
})),
queryData:
query.builder.queryData?.map((item) => ({
...initialQueryBuilderFormValuesMap[
initialDataSource || DataSource.METRICS
],
...item,
})) ?? [],
queryFormulas:
query.builder.queryFormulas?.map((item) => ({
...initialFormulaBuilderFormValues,
...item,
})) ?? [],
queryTraceOperator:
query.builder.queryTraceOperator?.map((item) => ({
...initialQueryBuilderFormTraceOperatorValues,
...item,
})) ?? [],
};
const setupedQueryData = builder.queryData.map((item) => {
@@ -211,15 +214,17 @@ export function QueryBuilderProvider({
return currentElement;
});
const promql: IPromQLQuery[] = query.promql.map((item) => ({
...initialQueryPromQLData,
...item,
}));
const promql: IPromQLQuery[] =
query.promql?.map((item) => ({
...initialQueryPromQLData,
...item,
})) ?? [];
const clickHouse: IClickHouseQuery[] = query.clickhouse_sql.map((item) => ({
...initialClickHouseData,
...item,
}));
const clickHouse: IClickHouseQuery[] =
query.clickhouse_sql?.map((item) => ({
...initialClickHouseData,
...item,
})) ?? [];
const newQueryState: QueryState = {
clickhouse_sql: clickHouse,

View File

@@ -56,8 +56,8 @@ export interface AlertRuleStats {
totalPastTriggers: number;
currentTriggersSeries: CurrentTriggersSeries;
pastTriggersSeries: CurrentTriggersSeries | null;
currentAvgResolutionTime: string;
pastAvgResolutionTime: string;
currentAvgResolutionTime: number;
pastAvgResolutionTime: number;
currentAvgResolutionTimeSeries: CurrentTriggersSeries;
pastAvgResolutionTimeSeries: any | null;
}

View File

@@ -112,12 +112,6 @@ export function formatEpochTimestamp(epoch: number): string {
*/
export function formatTime(seconds: number): string {
seconds = +seconds;
if (Number.isNaN(seconds)) {
return '-';
}
const days = seconds / 86400;
if (days >= 1) {

View File

@@ -1090,21 +1090,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
}
processingPostCache := time.Now()
// When req.Limit is 0 (not set by the client), selectAllSpans is set to false
// preserving the old paged behaviour for backward compatibility
limit := min(req.Limit, tracedetail.MaxLimitToSelectAllSpans)
selectAllSpans := totalSpans <= uint64(limit)
var (
selectedSpans []*model.Span
uncollapsedSpans []string
rootServiceName, rootServiceEntryPoint string
)
if selectAllSpans {
selectedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetAllSpans(traceRoots)
} else {
selectedSpans, uncollapsedSpans, rootServiceName, rootServiceEntryPoint = tracedetail.GetSelectedSpans(req.UncollapsedSpans, req.SelectedSpanID, traceRoots, spanIdToSpanNodeMap, req.IsSelectedSpanIDUnCollapsed)
}
selectedSpans, uncollapsedSpans, rootServiceName, rootServiceEntryPoint := tracedetail.GetSelectedSpans(req.UncollapsedSpans, req.SelectedSpanID, traceRoots, spanIdToSpanNodeMap, req.IsSelectedSpanIDUnCollapsed)
r.logger.Info("getWaterfallSpansForTraceWithMetadata: processing post cache", "duration", time.Since(processingPostCache), "traceID", traceID)
// convert start timestamp to millis because right now frontend is expecting it in millis
@@ -1117,7 +1103,7 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
}
response.Spans = selectedSpans
response.UncollapsedSpans = uncollapsedSpans // ignoring if all spans are returning
response.UncollapsedSpans = uncollapsedSpans
response.StartTimestampMillis = startTime / 1000000
response.EndTimestampMillis = endTime / 1000000
response.TotalSpansCount = totalSpans
@@ -1126,7 +1112,6 @@ func (r *ClickHouseReader) GetWaterfallSpansForTraceWithMetadata(ctx context.Con
response.RootServiceEntryPoint = rootServiceEntryPoint
response.ServiceNameToTotalDurationMap = serviceNameToTotalDurationMap
response.HasMissingSpans = hasMissingSpans
response.HasMore = !selectAllSpans
return response, nil
}
@@ -3386,8 +3371,8 @@ func (r *ClickHouseReader) GetMetricAggregateAttributes(ctx context.Context, org
// Query all relevant metric names from time_series_v4, but leave metadata retrieval to cache/db
query := fmt.Sprintf(
`SELECT DISTINCT metric_name
FROM %s.%s
`SELECT DISTINCT metric_name
FROM %s.%s
WHERE metric_name ILIKE $1 AND __normalized = $2`,
signozMetricDBName, signozTSTableNameV41Day)
@@ -3464,8 +3449,8 @@ func (r *ClickHouseReader) GetMeterAggregateAttributes(ctx context.Context, orgI
var response v3.AggregateAttributeResponse
// Query all relevant metric names from time_series_v4, but leave metadata retrieval to cache/db
query := fmt.Sprintf(
`SELECT metric_name,type,temporality,is_monotonic
FROM %s.%s
`SELECT metric_name,type,temporality,is_monotonic
FROM %s.%s
WHERE metric_name ILIKE $1
GROUP BY metric_name,type,temporality,is_monotonic`,
signozMeterDBName, signozMeterSamplesName)
@@ -5161,7 +5146,7 @@ func (r *ClickHouseReader) GetOverallStateTransitions(ctx context.Context, ruleI
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5172,7 +5157,7 @@ resolution_events AS (
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5293,7 +5278,7 @@ WITH firing_events AS (
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5304,7 +5289,7 @@ resolution_events AS (
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5350,7 +5335,7 @@ WITH firing_events AS (
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5361,7 +5346,7 @@ resolution_events AS (
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
@@ -5623,7 +5608,7 @@ func (r *ClickHouseReader) GetMetricsDataPoints(ctx context.Context, metricName
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetMetricsDataPoints",
})
query := fmt.Sprintf(`SELECT
query := fmt.Sprintf(`SELECT
sum(count) as data_points
FROM %s.%s
WHERE metric_name = ?
@@ -5643,7 +5628,7 @@ func (r *ClickHouseReader) GetMetricsLastReceived(ctx context.Context, metricNam
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetMetricsLastReceived",
})
query := fmt.Sprintf(`SELECT
query := fmt.Sprintf(`SELECT
MAX(unix_milli) AS last_received_time
FROM %s.%s
WHERE metric_name = ?
@@ -5654,7 +5639,7 @@ WHERE metric_name = ?
if err != nil {
return 0, &model.ApiError{Typ: "ClickHouseError", Err: err}
}
query = fmt.Sprintf(`SELECT
query = fmt.Sprintf(`SELECT
MAX(unix_milli) AS last_received_time
FROM %s.%s
WHERE metric_name = ? and unix_milli > ?
@@ -5673,7 +5658,7 @@ func (r *ClickHouseReader) GetTotalTimeSeriesForMetricName(ctx context.Context,
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetTotalTimeSeriesForMetricName",
})
query := fmt.Sprintf(`SELECT
query := fmt.Sprintf(`SELECT
uniq(fingerprint) AS timeSeriesCount
FROM %s.%s
WHERE metric_name = ?;`, signozMetricDBName, signozTSTableNameV41Week)
@@ -5705,7 +5690,7 @@ func (r *ClickHouseReader) GetAttributesForMetricName(ctx context.Context, metri
}
const baseQueryTemplate = `
SELECT
SELECT
kv.1 AS key,
arrayMap(x -> trim(BOTH '"' FROM x), groupUniqArray(1000)(kv.2)) AS values,
length(groupUniqArray(10000)(kv.2)) AS valueCount
@@ -5814,7 +5799,7 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.
sampleTable, countExp := utils.WhichSampleTableToUse(req.Start, req.End)
metricsQuery := fmt.Sprintf(
`SELECT
`SELECT
t.metric_name AS metric_name,
ANY_VALUE(t.description) AS description,
ANY_VALUE(t.type) AS metric_type,
@@ -5879,11 +5864,11 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.
if whereClause != "" {
sb.WriteString(fmt.Sprintf(
`SELECT
`SELECT
s.samples,
s.metric_name
FROM (
SELECT
SELECT
dm.metric_name,
%s AS samples
FROM %s.%s AS dm
@@ -5913,11 +5898,11 @@ func (r *ClickHouseReader) ListSummaryMetrics(ctx context.Context, orgID valuer.
} else {
// If no filters, it is a simpler query.
sb.WriteString(fmt.Sprintf(
`SELECT
`SELECT
s.samples,
s.metric_name
FROM (
SELECT
SELECT
metric_name,
%s AS samples
FROM %s.%s
@@ -6022,16 +6007,16 @@ func (r *ClickHouseReader) GetMetricsTimeSeriesPercentage(ctx context.Context, r
// Construct the query without backticks
query := fmt.Sprintf(`
SELECT
SELECT
metric_name,
total_value,
(total_value * 100.0 / total_time_series) AS percentage
FROM (
SELECT
SELECT
metric_name,
uniq(fingerprint) AS total_value,
(SELECT uniq(fingerprint)
FROM %s.%s
(SELECT uniq(fingerprint)
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ? AND __normalized = ?) AS total_time_series
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ? AND NOT startsWith(metric_name, 'signoz') AND __normalized = ? %s
@@ -6107,7 +6092,7 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
queryLimit := 50 + req.Limit
metricsQuery := fmt.Sprintf(
`SELECT
`SELECT
ts.metric_name AS metric_name,
uniq(ts.fingerprint) AS timeSeries
FROM %s.%s AS ts
@@ -6164,13 +6149,13 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
FROM %s.%s
WHERE unix_milli BETWEEN ? AND ?
)
SELECT
SELECT
s.samples,
s.metric_name,
COALESCE((s.samples * 100.0 / t.total_samples), 0) AS percentage
FROM
FROM
(
SELECT
SELECT
dm.metric_name,
%s AS samples
FROM %s.%s AS dm`,
@@ -6191,7 +6176,7 @@ func (r *ClickHouseReader) GetMetricsSamplesPercentage(ctx context.Context, req
if whereClause != "" {
sb.WriteString(fmt.Sprintf(
` AND dm.fingerprint IN (
SELECT ts.fingerprint
SELECT ts.fingerprint
FROM %s.%s AS ts
WHERE ts.metric_name IN (%s)
AND unix_milli BETWEEN ? AND ?
@@ -6262,7 +6247,7 @@ func (r *ClickHouseReader) GetNameSimilarity(ctx context.Context, req *metrics_e
}
query := fmt.Sprintf(`
SELECT
SELECT
metric_name,
any(type) as type,
any(temporality) as temporality,
@@ -6321,7 +6306,7 @@ func (r *ClickHouseReader) GetAttributeSimilarity(ctx context.Context, req *metr
// Get target labels
extractedLabelsQuery := fmt.Sprintf(`
SELECT
SELECT
kv.1 AS label_key,
topK(10)(JSONExtractString(kv.2)) AS label_values
FROM %s.%s
@@ -6365,12 +6350,12 @@ func (r *ClickHouseReader) GetAttributeSimilarity(ctx context.Context, req *metr
priorityListString := strings.Join(priorityList, ", ")
candidateLabelsQuery := fmt.Sprintf(`
WITH
arrayDistinct([%s]) AS filter_keys,
WITH
arrayDistinct([%s]) AS filter_keys,
arrayDistinct([%s]) AS filter_values,
[%s] AS priority_pairs_input,
%d AS priority_multiplier
SELECT
SELECT
metric_name,
any(type) as type,
any(temporality) as temporality,
@@ -6476,17 +6461,17 @@ func (r *ClickHouseReader) GetMetricsAllResourceAttributes(ctx context.Context,
instrumentationtypes.CodeFunctionName: "GetMetricsAllResourceAttributes",
})
start, end, attTable, _ := utils.WhichAttributesTableToUse(start, end)
query := fmt.Sprintf(`SELECT
key,
query := fmt.Sprintf(`SELECT
key,
count(distinct value) AS distinct_value_count
FROM (
SELECT key, value
FROM %s.%s
ARRAY JOIN
ARRAY JOIN
arrayConcat(mapKeys(resource_attributes)) AS key,
arrayConcat(mapValues(resource_attributes)) AS value
WHERE unix_milli between ? and ?
)
)
GROUP BY key
ORDER BY distinct_value_count DESC;`, signozMetadataDbName, attTable)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
@@ -6633,11 +6618,11 @@ func (r *ClickHouseReader) GetInspectMetricsFingerprints(ctx context.Context, at
start, end, tsTable, _ := utils.WhichTSTableToUse(req.Start, req.End)
query := fmt.Sprintf(`
SELECT
SELECT
arrayDistinct(groupArray(toString(fingerprint))) AS fingerprints
FROM
(
SELECT
SELECT
metric_name, labels, fingerprint,
%s
FROM %s.%s
@@ -6808,14 +6793,14 @@ func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID
var stillMissing []string
if len(missingMetrics) > 0 {
metricList := "'" + strings.Join(missingMetrics, "', '") + "'"
query := fmt.Sprintf(`SELECT
query := fmt.Sprintf(`SELECT
metric_name,
argMax(type, created_at) AS type,
argMax(description, created_at) AS description,
argMax(temporality, created_at) AS temporality,
argMax(is_monotonic, created_at) AS is_monotonic,
argMax(unit, created_at) AS unit
FROM %s.%s
FROM %s.%s
WHERE metric_name IN (%s)
GROUP BY metric_name;`,
signozMetricDBName,
@@ -6863,7 +6848,7 @@ func (r *ClickHouseReader) GetUpdatedMetricsMetadata(ctx context.Context, orgID
if len(stillMissing) > 0 {
metricList := "'" + strings.Join(stillMissing, "', '") + "'"
query := fmt.Sprintf(`SELECT DISTINCT metric_name, type, description, temporality, is_monotonic, unit
FROM %s.%s
FROM %s.%s
WHERE metric_name IN (%s)`, signozMetricDBName, signozTSTableNameV4, metricList)
valueCtx := context.WithValue(ctx, "clickhouse_max_threads", constants.MetricsExplorerClickhouseThreads)
rows, err := r.db.Query(valueCtx, query)

View File

@@ -10,9 +10,6 @@ import (
var (
SPAN_LIMIT_PER_REQUEST_FOR_WATERFALL float64 = 500
maxDepthForSelectedSpanChildren int = 5
MaxLimitToSelectAllSpans uint = 10_000
)
type Interval struct {
@@ -92,23 +89,12 @@ func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string) (b
// throughout the recursion. Per-call state (level, isPartOfPreOrder, etc.)
// is passed as direct arguments.
type traverseOpts struct {
uncollapsedSpans map[string]struct{}
selectedSpanID string
isSelectedSpanUncollapsed bool
selectAll bool
uncollapsedSpans map[string]struct{}
selectedSpanID string
}
func traverseTrace(
span *model.Span,
opts traverseOpts,
level uint64,
isPartOfPreOrder bool,
hasSibling bool,
autoExpandDepth int,
) ([]*model.Span, []string) {
func traverseTrace(span *model.Span, opts traverseOpts, level uint64, isPartOfPreOrder bool, hasSibling bool) []*model.Span {
preOrderTraversal := []*model.Span{}
autoExpandedSpans := []string{}
// sort the children to maintain the order across requests
sort.Slice(span.Children, func(i, j int) bool {
@@ -145,36 +131,16 @@ func traverseTrace(
preOrderTraversal = append(preOrderTraversal, &nodeWithoutChildren)
}
remainingAutoExpandDepth := 0
if span.SpanID == opts.selectedSpanID && opts.isSelectedSpanUncollapsed {
remainingAutoExpandDepth = maxDepthForSelectedSpanChildren
} else if autoExpandDepth > 0 {
remainingAutoExpandDepth = autoExpandDepth - 1
}
_, isAlreadyUncollapsed := opts.uncollapsedSpans[span.SpanID]
for index, child := range span.Children {
// A child is included in the pre-order output if its parent is uncollapsed
// OR if the child falls within MAX_DEPTH_FOR_SELECTED_SPAN_CHILDREN levels
// below the selected span.
isChildWithinMaxDepth := remainingAutoExpandDepth > 0
childIsPartOfPreOrder := opts.selectAll || (isPartOfPreOrder && (isAlreadyUncollapsed || isChildWithinMaxDepth))
if isPartOfPreOrder && isChildWithinMaxDepth && !isAlreadyUncollapsed {
if !slices.Contains(autoExpandedSpans, span.SpanID) {
autoExpandedSpans = append(autoExpandedSpans, span.SpanID)
}
}
_childTraversal, _autoExpanded := traverseTrace(child, opts, level+1, childIsPartOfPreOrder, index != (len(span.Children)-1), remainingAutoExpandDepth)
_childTraversal := traverseTrace(child, opts, level+1, isPartOfPreOrder && isAlreadyUncollapsed, index != (len(span.Children)-1))
preOrderTraversal = append(preOrderTraversal, _childTraversal...)
autoExpandedSpans = append(autoExpandedSpans, _autoExpanded...)
nodeWithoutChildren.SubTreeNodeCount += child.SubTreeNodeCount + 1
span.SubTreeNodeCount += child.SubTreeNodeCount + 1
}
nodeWithoutChildren.SubTreeNodeCount += 1
return preOrderTraversal, autoExpandedSpans
return preOrderTraversal
}
@@ -221,15 +187,10 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
}
opts := traverseOpts{
uncollapsedSpans: uncollapsedSpanMap,
selectedSpanID: selectedSpanID,
isSelectedSpanUncollapsed: isSelectedSpanIDUnCollapsed,
}
_preOrderTraversal, _autoExpanded := traverseTrace(rootNode, opts, 0, true, false, 0)
// Merge auto-expanded spans into updatedUncollapsedSpans for returning in response
for _, spanID := range _autoExpanded {
uncollapsedSpanMap[spanID] = struct{}{}
uncollapsedSpans: uncollapsedSpanMap,
selectedSpanID: selectedSpanID,
}
_preOrderTraversal := traverseTrace(rootNode, opts, 0, true, false)
_selectedSpanIndex := findIndexForSelectedSpanFromPreOrder(_preOrderTraversal, selectedSpanID)
if _selectedSpanIndex != -1 {
@@ -273,15 +234,3 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
return preOrderTraversal[startIndex:endIndex], slices.Collect(maps.Keys(uncollapsedSpanMap)), rootServiceName, rootServiceEntryPoint
}
func GetAllSpans(traceRoots []*model.Span) (spans []*model.Span, rootServiceName, rootEntryPoint string) {
if len(traceRoots) > 0 {
rootServiceName = traceRoots[0].ServiceName
rootEntryPoint = traceRoots[0].Name
}
for _, root := range traceRoots {
childSpans, _ := traverseTrace(root, traverseOpts{selectAll: true}, 0, true, false, 0)
spans = append(spans, childSpans...)
}
return
}

View File

@@ -81,6 +81,28 @@ func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
assert.Equal(t, "root1-op", entryPoint, "metadata comes from first root")
}
// isSelectedSpanIDUnCollapsed=true opens only the selected span's direct children,
// not deeper descendants.
//
// root → selected (expanded)
// ├─ child1 ✓
// │ └─ grandchild ✗ (only one level opened)
// └─ child2 ✓
func TestGetSelectedSpans_ExpandedSelectedSpan(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("selected", "svc",
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
mkSpan("child2", "svc"),
),
)
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, true)
// root and selected are on the auto-uncollapsed path; child1/child2 are direct
// children of the expanded selected span; grandchild stays hidden.
assert.Equal(t, []string{"root", "selected", "child1", "child2"}, spanIDs(spans))
}
// Multiple spans uncollapsed simultaneously: children of all uncollapsed spans
// are visible at once.
//
@@ -161,7 +183,7 @@ func TestGetSelectedSpans_PathReturnedInUncollapsed(t *testing.T) {
spanMap := buildSpanMap(root)
spans, uncollapsed, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, false)
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
assert.Equal(t, []string{"root", "parent", "selected"}, spanIDs(spans))
}
@@ -184,7 +206,7 @@ func TestGetSelectedSpans_SiblingsNotExpanded(t *testing.T) {
// children of root sort alphabetically: parent < unrelated; unrelated-child stays hidden
assert.Equal(t, []string{"root", "parent", "selected", "unrelated"}, spanIDs(spans))
// only the path nodes are tracked as uncollapsed — unrelated is not
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
}
// An unknown selectedSpanID must not panic; returns a window from index 0.
@@ -298,119 +320,6 @@ func TestGetSelectedSpans_WindowShiftsAtStart(t *testing.T) {
assert.Equal(t, "span10", spans[10].SpanID, "selected span still in window")
}
// Auto-expanded span IDs from ALL branches are returned in
// updatedUncollapsedSpans. Only internal nodes (spans with children) are
// tracked — leaf spans are never added.
//
// root (selected)
// ├─ childA (internal ✓)
// │ └─ grandchildA (internal ✓)
// │ └─ leafA (leaf ✗)
// └─ childB (internal ✓)
// └─ grandchildB (internal ✓)
// └─ leafB (leaf ✗)
func TestGetSelectedSpans_AutoExpandedSpansReturnedInUncollapsed(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc",
mkSpan("grandchildA", "svc",
mkSpan("leafA", "svc"),
),
),
mkSpan("childB", "svc",
mkSpan("grandchildB", "svc",
mkSpan("leafB", "svc"),
),
),
)
spanMap := buildSpanMap(root)
_, uncollapsed, _, _ := GetSelectedSpans([]string{}, "root", []*model.Span{root}, spanMap, true)
// all internal nodes across both branches must be tracked
assert.Contains(t, uncollapsed, "root")
assert.Contains(t, uncollapsed, "childA", "internal node depth 1, branch A")
assert.Contains(t, uncollapsed, "childB", "internal node depth 1, branch B")
assert.Contains(t, uncollapsed, "grandchildA", "internal node depth 2, branch A")
assert.Contains(t, uncollapsed, "grandchildB", "internal node depth 2, branch B")
// leaves have no children to show — never added to uncollapsedSpans
assert.NotContains(t, uncollapsed, "leafA", "leaf spans are never added to uncollapsedSpans")
assert.NotContains(t, uncollapsed, "leafB", "leaf spans are never added to uncollapsedSpans")
}
// ─────────────────────────────────────────────────────────────────────────────
// maxDepthForSelectedSpanChildren boundary tests
// ─────────────────────────────────────────────────────────────────────────────
// Depth is measured from the selected span, not the trace root.
// Ancestors appear via the path-to-root logic, not the depth limit.
// Each depth level has two children to confirm the limit is enforced on all
// branches, not just the first.
//
// root
// └─ A ancestor ✓ (path-to-root)
// └─ selected
// ├─ d1a depth 1 ✓
// │ ├─ d2a depth 2 ✓
// │ │ ├─ d3a depth 3 ✓
// │ │ │ ├─ d4a depth 4 ✓
// │ │ │ │ ├─ d5a depth 5 ✓
// │ │ │ │ │ └─ d6a depth 6 ✗
// │ │ │ │ └─ d5b depth 5 ✓
// │ │ │ └─ d4b depth 4 ✓
// │ │ └─ d3b depth 3 ✓
// │ └─ d2b depth 2 ✓
// └─ d1b depth 1 ✓
func TestGetSelectedSpans_DepthCountedFromSelectedSpan(t *testing.T) {
selected := mkSpan("selected", "svc",
mkSpan("d1a", "svc",
mkSpan("d2a", "svc",
mkSpan("d3a", "svc",
mkSpan("d4a", "svc",
mkSpan("d5a", "svc",
mkSpan("d6a", "svc"), // depth 6 — excluded
),
mkSpan("d5b", "svc"), // depth 5 — included
),
mkSpan("d4b", "svc"), // depth 4 — included
),
mkSpan("d3b", "svc"), // depth 3 — included
),
mkSpan("d2b", "svc"), // depth 2 — included
),
mkSpan("d1b", "svc"), // depth 1 — included
)
root := mkSpan("root", "svc", mkSpan("A", "svc", selected))
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, true)
ids := spanIDs(spans)
assert.Contains(t, ids, "root", "ancestor shown via path-to-root")
assert.Contains(t, ids, "A", "ancestor shown via path-to-root")
for _, id := range []string{"d1a", "d1b", "d2a", "d2b", "d3a", "d3b", "d4a", "d4b", "d5a", "d5b"} {
assert.Contains(t, ids, id, "depth ≤ 5 — must be included")
}
assert.NotContains(t, ids, "d6a", "depth 6 > limit — excluded")
}
func TestGetAllSpans(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc",
mkSpan("grandchildA", "svc",
mkSpan("leafA", "svc2"),
),
),
mkSpan("childB", "svc3",
mkSpan("grandchildB", "svc",
mkSpan("leafB", "svc2"),
),
),
)
spans, rootServiceName, rootEntryPoint := GetAllSpans([]*model.Span{root})
assert.ElementsMatch(t, spanIDs(spans), []string{"root", "childA", "grandchildA", "leafA", "childB", "grandchildB", "leafB"})
assert.Equal(t, rootServiceName, "svc")
assert.Equal(t, rootEntryPoint, "root-op")
}
func mkSpan(id, service string, children ...*model.Span) *model.Span {
return &model.Span{
SpanID: id,

View File

@@ -333,7 +333,6 @@ type GetWaterfallSpansForTraceWithMetadataParams struct {
SelectedSpanID string `json:"selectedSpanId"`
IsSelectedSpanIDUnCollapsed bool `json:"isSelectedSpanIDUnCollapsed"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
Limit uint `json:"limit"`
}
type GetFlamegraphSpansForTraceParams struct {

View File

@@ -329,7 +329,6 @@ type GetWaterfallSpansForTraceWithMetadataResponse struct {
HasMissingSpans bool `json:"hasMissingSpans"`
// this is needed for frontend and query service sync
UncollapsedSpans []string `json:"uncollapsedSpans"`
HasMore bool `json:"hasMore"`
}
type GetFlamegraphSpansForTraceResponse struct {

View File

@@ -102,10 +102,6 @@ func (fn FunctionName) Validate() error {
// ApplyFunction applies the given function to the result data
func ApplyFunction(fn Function, result *TimeSeries) *TimeSeries {
if len(result.Values) == 0 {
return result
}
// Extract the function name and arguments
name := fn.Name
args := fn.Args

View File

@@ -599,14 +599,6 @@ func TestApplyFunction(t *testing.T) {
values []float64
want []float64
}{
{
name: "test with empty series",
function: Function{
Name: FunctionNameRunningDiff,
},
values: []float64{},
want: []float64{},
},
{
name: "cutOffMin function",
function: Function{