mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-11 15:30:47 +01:00
Compare commits
11 Commits
issue_5602
...
fix/field-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe3107866 | ||
|
|
13f2ba7d34 | ||
|
|
848046de91 | ||
|
|
68e61af0be | ||
|
|
84780acee1 | ||
|
|
c70b2be4d5 | ||
|
|
cda2955b93 | ||
|
|
1a293652f7 | ||
|
|
0f3fb71067 | ||
|
|
b4f5b3eddf | ||
|
|
b9f4fcd681 |
@@ -376,7 +376,19 @@ function App(): JSX.Element {
|
||||
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
beforeSend(event) {
|
||||
beforeSend(event, hint) {
|
||||
const error = hint?.originalException as
|
||||
| { name?: string; code?: string | number }
|
||||
| undefined;
|
||||
|
||||
// Ignore benign aborted/cancelled requests (axios + fetch).
|
||||
if (error?.code === 'ERR_CANCELED' || error?.code === 'ECONNABORTED') {
|
||||
return null;
|
||||
}
|
||||
if (error?.name === 'AbortError') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Drop the event if its level is 'warning' or 'info'
|
||||
if (event.level === 'warning' || event.level === 'info') {
|
||||
return null;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/user/resetPassword';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useResetPassword` hook (or `resetPassword` fetcher) from
|
||||
* `api/generated/services/users` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const resetPassword = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>(`/resetPassword`, {
|
||||
...props,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default resetPassword;
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
useCreateResetPasswordToken,
|
||||
useDeleteUser,
|
||||
useGetResetPasswordToken,
|
||||
useGetRolesByUserID,
|
||||
useCreateUserRole,
|
||||
useDeleteUserRole,
|
||||
useGetUser,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
useUpdateMyUserV2,
|
||||
useUpdateUser,
|
||||
} from 'api/generated/services/users';
|
||||
@@ -25,15 +24,14 @@ import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
|
||||
jest.mock('api/generated/services/users', () => ({
|
||||
useDeleteUser: jest.fn(),
|
||||
useGetUser: jest.fn(),
|
||||
useGetRolesByUserID: jest.fn(),
|
||||
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
|
||||
useDeleteUserRole: jest.fn(),
|
||||
useUpdateUser: jest.fn(),
|
||||
useUpdateMyUserV2: jest.fn(),
|
||||
useSetRoleByUserID: jest.fn(),
|
||||
useCreateUserRole: jest.fn(),
|
||||
useGetResetPasswordToken: jest.fn(),
|
||||
useCreateResetPasswordToken: jest.fn(),
|
||||
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
|
||||
`/api/v2/users/${id}/roles`,
|
||||
getGetUserQueryKey: ({ id }: { id: string }): string[] => [
|
||||
`/api/v2/users/${id}`,
|
||||
],
|
||||
}));
|
||||
|
||||
@@ -194,11 +192,7 @@ describe('EditMemberDrawer', () => {
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
(useGetRolesByUserID as jest.Mock).mockReturnValue({
|
||||
data: { data: [managedRoles[0]] },
|
||||
isLoading: false,
|
||||
});
|
||||
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
|
||||
(useDeleteUserRole as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockRemoveMutateAsync.mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
@@ -210,7 +204,7 @@ describe('EditMemberDrawer', () => {
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
(useCreateUserRole as jest.Mock).mockReturnValue({
|
||||
mutateAsync: jest.fn().mockResolvedValue({}),
|
||||
isLoading: false,
|
||||
});
|
||||
@@ -312,12 +306,12 @@ describe('EditMemberDrawer', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adding a new role calls setRole without removing existing ones', async () => {
|
||||
it('adding a new role creates a user role without removing existing ones', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockSet = jest.fn().mockResolvedValue({});
|
||||
|
||||
(useSetRoleByUserID as jest.Mock).mockReturnValue({
|
||||
(useCreateUserRole as jest.Mock).mockReturnValue({
|
||||
mutateAsync: mockSet,
|
||||
isLoading: false,
|
||||
});
|
||||
@@ -334,15 +328,14 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1' },
|
||||
data: { name: 'signoz-editor' },
|
||||
data: { userId: 'user-1', roleId: managedRoles[1].id },
|
||||
});
|
||||
expect(mockRemoveMutateAsync).not.toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('deselecting a role calls removeRole with the role id', async () => {
|
||||
it('deselecting a role deletes the user role by its assignment id', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -361,7 +354,7 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
|
||||
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
|
||||
pathParams: { id: 'ur-1' },
|
||||
});
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -183,15 +183,14 @@ function QuerySearch({
|
||||
isProgrammaticChangeRef.current = true;
|
||||
}
|
||||
|
||||
const changes = view.state.changes({
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: value,
|
||||
});
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: currentValue.length,
|
||||
insert: value,
|
||||
},
|
||||
selection: {
|
||||
anchor: value.length,
|
||||
},
|
||||
changes,
|
||||
selection: { anchor: changes.newLength },
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -301,6 +301,66 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not crash when the expression contains CRLF line breaks (issue #5869)', async () => {
|
||||
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
|
||||
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
|
||||
const initialExpression = "service.name = 'frontend'";
|
||||
// Filtering on a multi-line log value (CRLF) used to throw
|
||||
// "RangeError: Selection points outside of document".
|
||||
const crlfExpression = "body CONTAINS 'line1\r\nline2\r\nline3'";
|
||||
|
||||
const baseQueryData = {
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
filter: { expression: initialExpression },
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<QuerySearch
|
||||
onChange={onChange}
|
||||
queryData={baseQueryData}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
const editorContent = document.querySelector(
|
||||
CM_EDITOR_SELECTOR,
|
||||
) as HTMLElement;
|
||||
expect(editorContent.textContent || '').toBe(initialExpression);
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
|
||||
rerender(
|
||||
<QuerySearch
|
||||
onChange={onChange}
|
||||
queryData={{ ...baseQueryData, filter: { expression: crlfExpression } }}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The programmatic replace dispatched without throwing, and the selection anchor
|
||||
// stayed within the CRLF-normalized document (the bug set it past the end).
|
||||
await waitFor(() => {
|
||||
const spec = dispatchSpy.mock.calls
|
||||
.map(
|
||||
(call) =>
|
||||
call[0] as {
|
||||
selection?: { anchor?: number };
|
||||
changes?: { newLength?: number };
|
||||
},
|
||||
)
|
||||
.find((s) => s?.selection?.anchor != null && s?.changes?.newLength != null);
|
||||
expect(spec).toBeDefined();
|
||||
expect(spec?.selection?.anchor).toBeLessThanOrEqual(
|
||||
spec?.changes?.newLength as number,
|
||||
);
|
||||
});
|
||||
|
||||
dispatchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('fetches key suggestions for metrics even without aggregateAttribute.key when showFilterSuggestionsWithoutMetric is true', async () => {
|
||||
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
|
||||
@@ -22,6 +22,7 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
|
||||
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
@@ -116,6 +117,7 @@ function EntityEventsContent({
|
||||
: newUserExpression || '',
|
||||
);
|
||||
if (validation.isValid) {
|
||||
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
|
||||
querySearchOnRun(newUserExpression || '');
|
||||
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
|
||||
@@ -29,6 +29,7 @@ import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
|
||||
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
@@ -132,6 +133,7 @@ function EntityLogsContent({
|
||||
);
|
||||
|
||||
if (validation.isValid) {
|
||||
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
|
||||
querySearchOnRun(newUserExpression);
|
||||
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
@@ -98,6 +99,7 @@ function EntityTracesContent({
|
||||
: newUserExpression || '',
|
||||
);
|
||||
if (validation.isValid) {
|
||||
saveRecentQueryByExpression(DataSource.TRACES, newUserExpression);
|
||||
querySearchOnRun(newUserExpression || '');
|
||||
|
||||
void logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { MetricsSearchProps } from './types';
|
||||
@@ -23,12 +24,14 @@ function MetricsSearch({
|
||||
);
|
||||
|
||||
const handleStageAndRunQuery = useCallback(() => {
|
||||
saveRecentQueryByExpression(DataSource.METRICS, currentQueryFilterExpression);
|
||||
onChange(currentQueryFilterExpression);
|
||||
onRunQuery?.();
|
||||
}, [currentQueryFilterExpression, onChange, onRunQuery]);
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(expression: string): void => {
|
||||
saveRecentQueryByExpression(DataSource.METRICS, expression);
|
||||
setCurrentQueryFilterExpression(expression);
|
||||
onChange(expression);
|
||||
},
|
||||
|
||||
@@ -387,4 +387,42 @@ describe('useOptionsMenu', () => {
|
||||
expect(remaining).toHaveLength(seedColumns.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fieldsSelector.value drops legacy columns without a name', () => {
|
||||
it('excludes entries missing name while keeping valid columns', () => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: { data: { data: { keys: {} } } },
|
||||
isFetching: false,
|
||||
});
|
||||
(usePreferenceContext as jest.Mock).mockReturnValue({
|
||||
traces: {
|
||||
preferences: {
|
||||
columns: [
|
||||
{ name: 'body', fieldContext: 'log' },
|
||||
{ key: 'legacy-key-no-name', fieldContext: 'log' },
|
||||
{ name: 'timestamp', fieldContext: 'log' },
|
||||
],
|
||||
formatting: { format: 'table', maxLines: 1, fontSize: 'small' },
|
||||
},
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
logs: {
|
||||
preferences: { columns: [], formatting: {} },
|
||||
updateColumns: mockUpdateColumns,
|
||||
updateFormatting: mockUpdateFormatting,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
}),
|
||||
);
|
||||
|
||||
const fields = result.current.config.fieldsSelector?.value ?? [];
|
||||
expect(fields.map((f) => f.name)).toStrictEqual(['body', 'timestamp']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -399,7 +399,7 @@ const useOptionsMenu = ({
|
||||
onReorder: reorderSelectColumns,
|
||||
},
|
||||
fieldsSelector: {
|
||||
value: preferences?.columns ?? [],
|
||||
value: preferences?.columns?.filter((item) => has(item, 'name')) ?? [],
|
||||
onFieldsChange: updateColumns,
|
||||
},
|
||||
format: {
|
||||
|
||||
@@ -33,7 +33,7 @@ jest.mock('hooks/useNotifications', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const RESET_PASSWORD_ENDPOINT = '*/resetPassword';
|
||||
const RESET_PASSWORD_ENDPOINT = '*/api/v2/factor_password/reset';
|
||||
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-use';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Form, Input as AntdInput } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useResetPassword } from 'api/generated/services/users';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -14,7 +15,6 @@ import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
|
||||
import { Label } from 'pages/SignUp/styles';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { FormContainer } from './styles';
|
||||
|
||||
@@ -26,40 +26,41 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
const [confirmPasswordError, setConfirmPasswordError] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<APIError | null>();
|
||||
|
||||
const [isValidPassword, setIsValidPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation(['common']);
|
||||
const { search } = useLocation();
|
||||
const params = new URLSearchParams(search);
|
||||
const token = params.get('token');
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const {
|
||||
mutate: resetPassword,
|
||||
isLoading,
|
||||
error: mutationError,
|
||||
} = useResetPassword();
|
||||
|
||||
const errorMessage = useMemo(
|
||||
() => convertToApiError(mutationError),
|
||||
[mutationError],
|
||||
);
|
||||
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const handleFormSubmit: () => Promise<void> = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
const { password } = form.getFieldsValue();
|
||||
const handleFormSubmit = (): void => {
|
||||
const { password } = form.getFieldsValue();
|
||||
|
||||
await resetPasswordApi({
|
||||
password,
|
||||
token: token || '',
|
||||
});
|
||||
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
history.push(ROUTES.LOGIN);
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
setErrorMessage(error as APIError);
|
||||
}
|
||||
resetPassword(
|
||||
{ data: { password, token: token || '' } },
|
||||
{
|
||||
onSuccess: (): void => {
|
||||
notifications.success({
|
||||
message: t('success', {
|
||||
ns: 'common',
|
||||
}),
|
||||
});
|
||||
history.push(ROUTES.LOGIN);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const validatePassword = (): boolean => {
|
||||
@@ -222,7 +223,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
|
||||
color="primary"
|
||||
type="submit"
|
||||
data-attr="reset-password"
|
||||
disabled={!isValidPassword || loading}
|
||||
disabled={!isValidPassword || isLoading}
|
||||
className="reset-password-submit-button"
|
||||
suffix={<ArrowRight size={16} />}
|
||||
>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
AuthtypesGettableRoleDTO,
|
||||
AuthtypesUserRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
getGetRolesByUserIDQueryKey,
|
||||
useGetRolesByUserID,
|
||||
useRemoveUserRoleByUserIDAndRoleID,
|
||||
useSetRoleByUserID,
|
||||
useCreateUserRole,
|
||||
useDeleteUserRole,
|
||||
useGetUser,
|
||||
} from 'api/generated/services/users';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
const enum PromiseStatus {
|
||||
Fulfilled = 'fulfilled',
|
||||
Rejected = 'rejected',
|
||||
}
|
||||
|
||||
// Stable identity so the memos below do not recompute on every render.
|
||||
const EMPTY_USER_ROLES: AuthtypesUserRoleDTO[] = [];
|
||||
|
||||
export interface MemberRoleUpdateFailure {
|
||||
roleName: string;
|
||||
error: unknown;
|
||||
@@ -33,31 +36,31 @@ export function useMemberRoleManager(
|
||||
userId: string,
|
||||
enabled: boolean,
|
||||
): UseMemberRoleManagerResult {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useGetRolesByUserID(
|
||||
const { data, isLoading } = useGetUser(
|
||||
{ id: userId },
|
||||
{ query: { enabled: !!userId && enabled } },
|
||||
);
|
||||
|
||||
const userRoles = data?.data?.userRoles ?? EMPTY_USER_ROLES;
|
||||
|
||||
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
() => userRoles.map((userRole) => userRole.role),
|
||||
[userRoles],
|
||||
);
|
||||
|
||||
const { mutateAsync: setRole } = useSetRoleByUserID({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
|
||||
const invalidateRoles = useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries(getGetRolesByUserIDQueryKey({ id: userId })),
|
||||
[userId, queryClient],
|
||||
// DELETE /api/v2/user_roles/{id} is keyed by the user_role join row, not the role.
|
||||
const assignmentIdByRoleId = useMemo(
|
||||
() => new Map(userRoles.map((userRole) => [userRole.roleId, userRole.id])),
|
||||
[userRoles],
|
||||
);
|
||||
|
||||
const { mutateAsync: createUserRole } = useCreateUserRole({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
const { mutateAsync: deleteUserRole } = useDeleteUserRole({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
@@ -80,30 +83,33 @@ export function useMemberRoleManager(
|
||||
const allOperations = [
|
||||
...addedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof setRole> =>
|
||||
setRole({
|
||||
pathParams: { id: userId },
|
||||
data: { name: role.name ?? '' },
|
||||
}),
|
||||
})),
|
||||
...removedRoles.map((role) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof removeRole> =>
|
||||
removeRole({ pathParams: { id: userId, roleId: role.id ?? '' } }),
|
||||
run: (): ReturnType<typeof createUserRole> =>
|
||||
createUserRole({ data: { userId, roleId: role.id ?? '' } }),
|
||||
})),
|
||||
...removedRoles
|
||||
.map((role) => ({
|
||||
role,
|
||||
assignmentId: assignmentIdByRoleId.get(role.id ?? ''),
|
||||
}))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
role: AuthtypesGettableRoleDTO;
|
||||
assignmentId: string;
|
||||
} => !!entry.assignmentId,
|
||||
)
|
||||
.map(({ role, assignmentId }) => ({
|
||||
role,
|
||||
run: (): ReturnType<typeof deleteUserRole> =>
|
||||
deleteUserRole({ pathParams: { id: assignmentId } }),
|
||||
})),
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
allOperations.map((op) => op.run()),
|
||||
);
|
||||
|
||||
const successCount = results.filter(
|
||||
(r) => r.status === PromiseStatus.Fulfilled,
|
||||
).length;
|
||||
if (successCount > 0) {
|
||||
await invalidateRoles();
|
||||
}
|
||||
|
||||
const failures: MemberRoleUpdateFailure[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === PromiseStatus.Rejected) {
|
||||
@@ -113,7 +119,6 @@ export function useMemberRoleManager(
|
||||
error: result.reason,
|
||||
onRetry: async (): Promise<void> => {
|
||||
await run();
|
||||
await invalidateRoles();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -121,7 +126,7 @@ export function useMemberRoleManager(
|
||||
|
||||
return failures;
|
||||
},
|
||||
[userId, currentRoles, setRole, removeRole, invalidateRoles],
|
||||
[userId, currentRoles, assignmentIdByRoleId, createUserRole, deleteUserRole],
|
||||
);
|
||||
|
||||
return { currentRoles, isLoading, applyDiff };
|
||||
|
||||
@@ -19,6 +19,30 @@ type CompositeWithBuilder = {
|
||||
builder?: { queryData?: IBuilderQuery[] };
|
||||
};
|
||||
|
||||
export function saveRecentQueryByExpression(
|
||||
dataSource: IBuilderQuery['dataSource'],
|
||||
expression: string | null | undefined,
|
||||
source = '',
|
||||
): void {
|
||||
const trimmed = expression?.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const validation = validateQuery(trimmed);
|
||||
if (!validation.isValid) {
|
||||
return;
|
||||
}
|
||||
const signal = toSignal(dataSource);
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
store.save({
|
||||
signal,
|
||||
source,
|
||||
filter: { expression: trimmed },
|
||||
});
|
||||
}
|
||||
|
||||
// Persists each builder query in the composite as a recent entry. Call this
|
||||
// only from explicit user-driven Run triggers — reacting to stagedQuery or any
|
||||
// other derived state pollutes recents with navigation/refresh/go-to traffic.
|
||||
@@ -31,22 +55,10 @@ export function saveRecentQuery(
|
||||
}
|
||||
|
||||
queryData.forEach((q) => {
|
||||
const expression = q.filter?.expression?.trim();
|
||||
if (!expression) {
|
||||
return;
|
||||
}
|
||||
const validation = validateQuery(expression);
|
||||
if (!validation.isValid) {
|
||||
return;
|
||||
}
|
||||
const signal = toSignal(q.dataSource);
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
store.save({
|
||||
signal,
|
||||
source: q.source ?? '',
|
||||
filter: q.filter ?? { expression: '' },
|
||||
});
|
||||
saveRecentQueryByExpression(
|
||||
q.dataSource,
|
||||
q.filter?.expression,
|
||||
q.source ?? '',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,4 +182,56 @@ describe('ValueSelector', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('opening and closing without touching the list', () => {
|
||||
function renderWith(
|
||||
selection: VariableSelection,
|
||||
options: string[],
|
||||
): jest.Mock {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ValueSelector
|
||||
options={options}
|
||||
variableType="dynamic"
|
||||
multiSelect
|
||||
showAllOption
|
||||
selection={selection}
|
||||
onChange={onChange}
|
||||
emptyFallback={{ value: [], allSelected: false }}
|
||||
testId="variable-select-env"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
async function openThenClose(): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const control = screen.getByTestId('variable-select-env');
|
||||
await user.click(control.querySelector('input') as HTMLInputElement);
|
||||
await user.keyboard('{Escape}');
|
||||
}
|
||||
|
||||
it('does not promote a pick that covers every available option to ALL', async () => {
|
||||
// A narrow time range can leave only the selected value in the list. That is
|
||||
// still an explicit pick, not "everything, always".
|
||||
const onChange = renderWith(
|
||||
{ value: ['checkout-service-prod'], allSelected: false },
|
||||
['checkout-service-prod'],
|
||||
);
|
||||
|
||||
await openThenClose();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not rewrite a dynamic ALL into concrete values', async () => {
|
||||
const onChange = renderWith({ value: null, allSelected: true }, OPTIONS);
|
||||
|
||||
await openThenClose();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,133 @@ describe('reconcileWithOptions', () => {
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
describe('preserveSelection (options moved on their own — time range, reload)', () => {
|
||||
const multi = model({
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
dynamicAttribute: 'service.name',
|
||||
});
|
||||
|
||||
it('keeps a multi-select pick the new option list no longer offers', () => {
|
||||
expect(
|
||||
reconcileWithOptions(multi, { value: ['frontend'], allSelected: false }, [
|
||||
'backend',
|
||||
'cart',
|
||||
]),
|
||||
).toStrictEqual({ value: null, allSelected: true });
|
||||
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{ value: ['frontend'], allSelected: false },
|
||||
['backend', 'cart'],
|
||||
{ preserveSelection: true },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('still materializes ALL, which must track the option list', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
{ value: ['a'], allSelected: true },
|
||||
['a', 'b'],
|
||||
{ preserveSelection: true },
|
||||
),
|
||||
).toStrictEqual({ value: ['a', 'b'], allSelected: true });
|
||||
});
|
||||
|
||||
it('still fills the default when nothing is selected yet', () => {
|
||||
expect(
|
||||
reconcileWithOptions(multi, { value: [], allSelected: false }, ['a', 'b'], {
|
||||
preserveSelection: true,
|
||||
}),
|
||||
).toStrictEqual({ value: null, allSelected: true });
|
||||
});
|
||||
});
|
||||
|
||||
// A typed value is in no option list, so no refetch can invalidate it.
|
||||
describe('customValues (typed in, never offered by the data)', () => {
|
||||
const multi = model({
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
dynamicAttribute: 'service.name',
|
||||
});
|
||||
|
||||
it('keeps them through a re-scope that drops a fetched value', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{
|
||||
value: ['frontend', 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
},
|
||||
['backend', 'cart'],
|
||||
),
|
||||
).toStrictEqual({
|
||||
value: ['typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
|
||||
it('never re-defaults a selection made only of them', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{ value: ['typed-in'], allSelected: false, customValues: ['typed-in'] },
|
||||
['backend', 'cart'],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// An inert marker is not worth a store write + dependent refetch to prune.
|
||||
it('leaves a stale marker alone when it drops nothing', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{
|
||||
value: ['frontend', 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in', 'removed-earlier'],
|
||||
},
|
||||
['frontend'],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('prunes markers for values it does drop', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{
|
||||
value: ['stale', 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
},
|
||||
['frontend'],
|
||||
),
|
||||
).toStrictEqual({
|
||||
value: ['typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
|
||||
it('still drops an unmarked value the list no longer offers', () => {
|
||||
expect(
|
||||
reconcileWithOptions(
|
||||
multi,
|
||||
{ value: ['frontend', 'stale'], allSelected: false },
|
||||
['frontend'],
|
||||
),
|
||||
).toStrictEqual({ value: ['frontend'], allSelected: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuredDefaultValue', () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import { selectionFromCommittedValues } from '../utils/selectionUtils';
|
||||
|
||||
const OPTIONS = ['checkout', 'payments', 'cart'];
|
||||
const FALLBACK: VariableSelection = { value: null, allSelected: true };
|
||||
|
||||
function commit(
|
||||
values: string[],
|
||||
overrides: Partial<Parameters<typeof selectionFromCommittedValues>[0]> = {},
|
||||
): VariableSelection {
|
||||
return selectionFromCommittedValues({
|
||||
values,
|
||||
options: OPTIONS,
|
||||
showAllOption: true,
|
||||
emptyFallback: FALLBACK,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// What a multi-select commit resolves to. The option list is known only here, so this
|
||||
// is the one place a typed value can be recognised.
|
||||
describe('selectionFromCommittedValues', () => {
|
||||
it('marks values the option list did not offer as typed in', () => {
|
||||
expect(commit(['checkout', 'typed-in'])).toStrictEqual({
|
||||
value: ['checkout', 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a selection made only of typed-in values', () => {
|
||||
expect(commit(['a', 'b'])).toStrictEqual({
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
customValues: ['a', 'b'],
|
||||
});
|
||||
});
|
||||
|
||||
it('records no marker when every pick came from the list', () => {
|
||||
expect(commit(['checkout', 'cart'])).toStrictEqual({
|
||||
value: ['checkout', 'cart'],
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a set covering every option as ALL', () => {
|
||||
expect(commit(OPTIONS)).toStrictEqual({
|
||||
value: OPTIONS,
|
||||
allSelected: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ALL re-materializes to the option set, so recording this as ALL would drop the
|
||||
// typed value on the next refetch.
|
||||
it('does not read every option PLUS a typed value as ALL', () => {
|
||||
expect(commit([...OPTIONS, 'typed-in'])).toStrictEqual({
|
||||
value: [...OPTIONS, 'typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
|
||||
// Derived from the values + options at commit time, never from the old selection.
|
||||
it('recomputes the marker: a typed value the data now offers is a normal pick', () => {
|
||||
expect(
|
||||
commit(['checkout', 'was-typed'], {
|
||||
options: [...OPTIONS, 'was-typed'],
|
||||
}),
|
||||
).toStrictEqual({ value: ['checkout', 'was-typed'], allSelected: false });
|
||||
});
|
||||
|
||||
it('does not read it as ALL when the variable offers no ALL', () => {
|
||||
expect(commit(OPTIONS, { showAllOption: false })).toStrictEqual({
|
||||
value: OPTIONS,
|
||||
allSelected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an empty commit to the variable fallback', () => {
|
||||
expect(commit([])).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('marks everything while the options have not arrived', () => {
|
||||
// Nothing to judge against yet; erring this way keeps a value rather than dropping it.
|
||||
expect(commit(['typed-in'], { options: [] })).toStrictEqual({
|
||||
value: ['typed-in'],
|
||||
allSelected: false,
|
||||
customValues: ['typed-in'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import { VariableCycleReason } from '../../store/slices/variableFetchSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
import { useAutoSelect } from '../hooks/useAutoSelect';
|
||||
|
||||
@@ -15,7 +17,11 @@ function run(
|
||||
variable: VariableFormModel,
|
||||
options: string[],
|
||||
selection: VariableSelection,
|
||||
cycleReason?: VariableCycleReason,
|
||||
): VariableSelection | undefined {
|
||||
useDashboardStore.setState({
|
||||
variableCycleReasons: cycleReason ? { [variable.name]: cycleReason } : {},
|
||||
});
|
||||
const onAutoSelect = jest.fn();
|
||||
renderHook(() => useAutoSelect(variable, options, selection, onAutoSelect));
|
||||
return onAutoSelect.mock.calls[0]?.[0];
|
||||
@@ -70,11 +76,13 @@ describe('useAutoSelect', () => {
|
||||
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
|
||||
});
|
||||
|
||||
it('falls back to ALL, not the first option, when every selected value is gone', () => {
|
||||
// Re-scoped options only — a time-range refetch must NOT re-default; see below.
|
||||
it('re-scoped: falls back to ALL, not the first option, when every selected value is gone', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
|
||||
['x', 'y'],
|
||||
{ value: ['a', 'b'], allSelected: false },
|
||||
VariableCycleReason.ValueCascade,
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
|
||||
});
|
||||
@@ -102,20 +110,23 @@ describe('useAutoSelect', () => {
|
||||
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
|
||||
it('re-scoped: keeps the still-valid subset of a multi-select', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true }),
|
||||
['a', 'b', 'd'],
|
||||
{ value: ['a', 'b', 'c'], allSelected: false },
|
||||
VariableCycleReason.ValueCascade,
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: false });
|
||||
});
|
||||
|
||||
it('re-defaults a multi-select when none of the selected values remain', () => {
|
||||
const next = run(model({ type: 'QUERY', multiSelect: true }), ['x', 'y'], {
|
||||
value: ['a', 'b'],
|
||||
allSelected: false,
|
||||
});
|
||||
it('re-scoped: re-defaults a multi-select when none of the selected values remain', () => {
|
||||
const next = run(
|
||||
model({ type: 'QUERY', multiSelect: true }),
|
||||
['x', 'y'],
|
||||
{ value: ['a', 'b'], allSelected: false },
|
||||
VariableCycleReason.ValueCascade,
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['x'], allSelected: false });
|
||||
});
|
||||
|
||||
@@ -151,4 +162,45 @@ describe('useAutoSelect', () => {
|
||||
});
|
||||
expect(next).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('by cycle reason', () => {
|
||||
const service = model({
|
||||
name: 'service',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: true,
|
||||
showAllOption: true,
|
||||
dynamicAttribute: 'service.name',
|
||||
});
|
||||
const gone: VariableSelection = { value: ['frontend'], allSelected: false };
|
||||
|
||||
it('keeps the selection when a full cycle refetched the options', () => {
|
||||
// The new window has no data for the selected service — no reason to widen to ALL.
|
||||
const next = run(
|
||||
service,
|
||||
['backend', 'cart'],
|
||||
gone,
|
||||
VariableCycleReason.FullCycle,
|
||||
);
|
||||
expect(next).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-scopes the selection when a value cascade refetched the options', () => {
|
||||
const next = run(
|
||||
service,
|
||||
['backend', 'cart'],
|
||||
gone,
|
||||
VariableCycleReason.ValueCascade,
|
||||
);
|
||||
expect(next).toStrictEqual({ value: null, allSelected: true });
|
||||
});
|
||||
|
||||
it('reconciles a variable with no cycle of its own (custom definition change)', () => {
|
||||
const next = run(
|
||||
model({ name: 'env', type: 'CUSTOM', multiSelect: true }),
|
||||
['staging', 'prod'],
|
||||
{ value: ['dev'], allSelected: false },
|
||||
);
|
||||
expect(next).toStrictEqual({ value: ['staging'], allSelected: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,11 @@ jest.mock('nuqs', () => ({
|
||||
useQueryState: (): unknown => [null, jest.fn()],
|
||||
}));
|
||||
|
||||
const mockGlobalTime = { minTime: 1, maxTime: 2, selectedTime: '5m' };
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({
|
||||
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
|
||||
}),
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
|
||||
@@ -150,3 +150,57 @@ describe('useVariableSelection — setSelection', () => {
|
||||
expect(svcCycleId()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVariableSelection — what a time-range change enqueues', () => {
|
||||
// Longer than FETCH_CYCLE_DEBOUNCE_MS, which the hook keeps private.
|
||||
const PAST_DEBOUNCE = 400;
|
||||
|
||||
function reasons(): Record<string, string> {
|
||||
return useDashboardStore.getState().variableCycleReasons;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockGlobalTime.selectedTime = '5m';
|
||||
useDashboardStore.setState({
|
||||
variableValues: {},
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableCycleReasons: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// The tag is what stops the reconcile re-defaulting a user's selection.
|
||||
it('tags every variable as a full cycle, overriding an earlier cascade tag', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useVariableSelection(dashboard),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(PAST_DEBOUNCE);
|
||||
});
|
||||
expect(reasons()).toStrictEqual({ env: 'full-cycle', svc: 'full-cycle' });
|
||||
|
||||
// A value change re-scopes the dependent's options: it may drop what no longer applies.
|
||||
act(() => {
|
||||
result.current.setSelection('env', { value: ['prod'], allSelected: false });
|
||||
});
|
||||
expect(reasons().svc).toBe('value-cascade');
|
||||
|
||||
mockGlobalTime.selectedTime = '30m';
|
||||
rerender();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(PAST_DEBOUNCE);
|
||||
});
|
||||
|
||||
expect(reasons()).toStrictEqual({ env: 'full-cycle', svc: 'full-cycle' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
|
||||
import type { VariableSelection } from '../../selectionTypes';
|
||||
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
|
||||
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
|
||||
import OverflowValuesTooltip from './OverflowValuesTooltip';
|
||||
import styles from '../../VariablesBar.module.scss';
|
||||
|
||||
@@ -75,13 +76,23 @@ function ValueSelector({
|
||||
options.every((option) => draft.includes(option));
|
||||
|
||||
const commit = (values: string[]): void => {
|
||||
// CustomMultiSelect emits the full value set when ALL is picked.
|
||||
const isAll =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
const next: VariableSelection =
|
||||
values.length === 0 ? emptyFallback : { value: values, allSelected: isAll };
|
||||
// A close that left the list as it opened commits nothing — else a pick covering
|
||||
// every option this window offers would be promoted to a standing ALL.
|
||||
if (
|
||||
areSelectionsEqual(
|
||||
{ value: values, allSelected: false },
|
||||
{ value: committedValues, allSelected: false },
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = selectionFromCommittedValues({
|
||||
values,
|
||||
options,
|
||||
showAllOption,
|
||||
emptyFallback,
|
||||
});
|
||||
|
||||
// Closing without actually changing the selection must not re-fire onChange —
|
||||
// that would needlessly re-cascade to dependent variables/panels.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
|
||||
import {
|
||||
selectVariableCycleReason,
|
||||
VariableCycleReason,
|
||||
} from '../../store/slices/variableFetchSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { reconcileWithOptions } from '../utils/resolveVariableSelection';
|
||||
import type { VariableSelection } from '../selectionTypes';
|
||||
|
||||
@@ -9,6 +14,9 @@ import type { VariableSelection } from '../selectionTypes';
|
||||
* `onAutoSelect` only when the value must change. The reconcile rule lives in
|
||||
* {@link reconcileWithOptions} (shared with seed + payload defaulting) so the bar
|
||||
* and the panel query can never disagree about a variable's default.
|
||||
*
|
||||
* Only a value cascade may re-default the selection; a full cycle (time range,
|
||||
* reload) leaves the user's pick alone. Types with no cycle of their own reconcile.
|
||||
*/
|
||||
export function useAutoSelect(
|
||||
variable: VariableFormModel,
|
||||
@@ -16,8 +24,14 @@ export function useAutoSelect(
|
||||
selection: VariableSelection,
|
||||
onAutoSelect: (selection: VariableSelection) => void,
|
||||
): void {
|
||||
const cycleReason = useDashboardStore(
|
||||
selectVariableCycleReason(variable.name),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const next = reconcileWithOptions(variable, selection, options);
|
||||
const next = reconcileWithOptions(variable, selection, options, {
|
||||
preserveSelection: cycleReason === VariableCycleReason.FullCycle,
|
||||
});
|
||||
if (next) {
|
||||
onAutoSelect(next);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ export interface VariableSelection {
|
||||
value: SelectedVariableValue;
|
||||
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
|
||||
allSelected: boolean;
|
||||
/**
|
||||
* Entries of `value` the user typed rather than picked. Never in any option list,
|
||||
* so the reconcile keeps them instead of reading them as invalid.
|
||||
*/
|
||||
customValues?: string[];
|
||||
}
|
||||
|
||||
/** Selected values for a dashboard's variables, keyed by variable name. */
|
||||
|
||||
@@ -134,12 +134,23 @@ export function resolveDefaultSelection(
|
||||
return { value: model.multiSelect ? [] : '', allSelected: false };
|
||||
}
|
||||
|
||||
interface ReconcileOptions {
|
||||
/**
|
||||
* Set when no other variable caused this refetch (time-range change, reload): the
|
||||
* selection then outranks the options and is kept as-is. Leave false for a
|
||||
* dependency cascade, where a selection that no longer applies must give way.
|
||||
*/
|
||||
preserveSelection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles a variable's current selection against its freshly-fetched options.
|
||||
* Returns the next selection, or null when nothing should change (a valid pick is
|
||||
* left untouched — local-first). Behaviour, in order:
|
||||
* - materialize ALL to the full option set (query/custom);
|
||||
* - keep a still-valid multi-select subset, dropping only invalid entries;
|
||||
* - keep a multi-select selection outright when `preserveSelection` is set;
|
||||
* - keep a still-valid multi-select subset, dropping only entries the list no longer
|
||||
* offers and the user did not type in (`customValues`);
|
||||
* - otherwise auto-pick the default (or first option) so dependent variables and
|
||||
* panels always resolve against a usable value.
|
||||
*/
|
||||
@@ -147,6 +158,7 @@ export function reconcileWithOptions(
|
||||
model: VariableFormModel,
|
||||
current: VariableSelection,
|
||||
options: string[],
|
||||
{ preserveSelection = false }: ReconcileOptions = {},
|
||||
): VariableSelection | null {
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
@@ -161,13 +173,31 @@ export function reconcileWithOptions(
|
||||
Array.isArray(current.value) &&
|
||||
current.value.length > 0
|
||||
) {
|
||||
const valid = current.value.map(String).filter((c) => options.includes(c));
|
||||
// A pick this window has no data for is still the user's filter; re-defaulting it
|
||||
// here is what widened a single pick to ALL on every time-range change.
|
||||
if (preserveSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A typed value is in no option list, so it is never "no longer offered".
|
||||
const custom = new Set(current.customValues ?? []);
|
||||
const valid = current.value
|
||||
.map(String)
|
||||
.filter((c) => options.includes(c) || custom.has(c));
|
||||
|
||||
if (valid.length === current.value.length) {
|
||||
return null;
|
||||
}
|
||||
return valid.length > 0
|
||||
? { value: valid, allSelected: false }
|
||||
: fillDefault(model, options);
|
||||
if (valid.length === 0) {
|
||||
return fillDefault(model, options);
|
||||
}
|
||||
|
||||
const customValues = valid.filter((v) => custom.has(v));
|
||||
return {
|
||||
value: valid,
|
||||
allSelected: false,
|
||||
...(customValues.length > 0 && { customValues }),
|
||||
};
|
||||
}
|
||||
|
||||
if (!model.multiSelect) {
|
||||
|
||||
@@ -47,6 +47,43 @@ export function hasUsableValue(
|
||||
return value !== '' && value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
interface CommittedValues {
|
||||
values: string[];
|
||||
options: string[];
|
||||
showAllOption: boolean;
|
||||
emptyFallback: VariableSelection;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection a multi-select commit resolves to. Options are known only here, so
|
||||
* this is where a value the list never offered is recorded as typed in.
|
||||
*/
|
||||
export function selectionFromCommittedValues({
|
||||
values,
|
||||
options,
|
||||
showAllOption,
|
||||
emptyFallback,
|
||||
}: CommittedValues): VariableSelection {
|
||||
if (values.length === 0) {
|
||||
return emptyFallback;
|
||||
}
|
||||
|
||||
const customValues = values.filter((value) => !options.includes(value));
|
||||
// ALL re-materializes to the option set, so a set carrying a typed value is not ALL
|
||||
// — the next refetch would expand it back and drop what the user typed.
|
||||
const allSelected =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
customValues.length === 0 &&
|
||||
options.every((option) => values.includes(option));
|
||||
|
||||
return {
|
||||
value: values,
|
||||
allSelected,
|
||||
...(customValues.length > 0 && { customValues }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
|
||||
export function selectionToPayload(
|
||||
selection: VariableSelectionMap,
|
||||
|
||||
@@ -34,6 +34,7 @@ function reset(names: string[], context: VariableFetchContext): void {
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableCycleReasons: {},
|
||||
variableFetchContext: null,
|
||||
});
|
||||
store().initVariableFetch(names, context);
|
||||
@@ -133,6 +134,33 @@ describe('variableFetchSlice', () => {
|
||||
expect(states().q1).toBe('error');
|
||||
expect(states().q2).toBe('idle');
|
||||
});
|
||||
|
||||
// The reason is what tells the post-fetch reconcile whether it may re-default a
|
||||
// selection: a full cycle must not, a value cascade must.
|
||||
it('tags a full cycle, then re-tags only the cascaded variables', () => {
|
||||
store().enqueueFetchAll();
|
||||
expect(store().variableCycleReasons).toStrictEqual({
|
||||
q1: 'full-cycle',
|
||||
q2: 'full-cycle',
|
||||
d1: 'full-cycle',
|
||||
d2: 'full-cycle',
|
||||
});
|
||||
|
||||
resolve('q1');
|
||||
store().enqueueDescendants('q1');
|
||||
expect(store().variableCycleReasons).toStrictEqual({
|
||||
q1: 'full-cycle',
|
||||
q2: 'value-cascade',
|
||||
d1: 'full-cycle',
|
||||
d2: 'full-cycle',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the reason for a variable that no longer exists', () => {
|
||||
store().enqueueFetchAll();
|
||||
store().initVariableFetch(['q1'], context);
|
||||
expect(store().variableCycleReasons).toStrictEqual({ q1: 'full-cycle' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('variableFetchSlice — query depends on a dynamic', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type FetchMaps,
|
||||
isVariableInActiveFetchState,
|
||||
resolveFetchState,
|
||||
VariableCycleReason,
|
||||
VariableFetchState,
|
||||
} from './variableFetchSlice.utils';
|
||||
|
||||
@@ -30,7 +31,10 @@ function queryParentsHaveValues(
|
||||
);
|
||||
}
|
||||
|
||||
export { VariableFetchState } from './variableFetchSlice.utils';
|
||||
export {
|
||||
VariableCycleReason,
|
||||
VariableFetchState,
|
||||
} from './variableFetchSlice.utils';
|
||||
|
||||
/**
|
||||
* Runtime fetch orchestration for dashboard variables — native port of V1's
|
||||
@@ -45,6 +49,8 @@ export interface VariableFetchSlice {
|
||||
variableFetchStates: Record<string, VariableFetchState>;
|
||||
variableLastUpdated: Record<string, number>;
|
||||
variableCycleIds: Record<string, number>;
|
||||
/** Why each variable's current cycle was enqueued, read by the post-fetch reconcile. */
|
||||
variableCycleReasons: Record<string, VariableCycleReason>;
|
||||
/**
|
||||
* Whether a QUERY/DYNAMIC variable settled its fetch with zero options (so it
|
||||
* will never get a value). Lets a dependent panel fall through to "no data"
|
||||
@@ -106,6 +112,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableCycleReasons: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
@@ -115,6 +122,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchStates: {},
|
||||
variableLastUpdated: {},
|
||||
variableCycleIds: {},
|
||||
variableCycleReasons: {},
|
||||
variableResolvedEmpty: {},
|
||||
variableFetchContext: null,
|
||||
lastFetchAllKey: null,
|
||||
@@ -132,6 +140,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
initVariableFetch: (names, context): void => {
|
||||
const maps = cloneMaps(get());
|
||||
const resolvedEmpty = { ...get().variableResolvedEmpty };
|
||||
const reasons = { ...get().variableCycleReasons };
|
||||
names.forEach((name) => {
|
||||
if (!maps.states[name]) {
|
||||
maps.states[name] = VariableFetchState.Idle;
|
||||
@@ -144,12 +153,14 @@ export const createVariableFetchSlice: StateCreator<
|
||||
delete maps.lastUpdated[name];
|
||||
delete maps.cycleIds[name];
|
||||
delete resolvedEmpty[name];
|
||||
delete reasons[name];
|
||||
}
|
||||
});
|
||||
set({
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableCycleReasons: reasons,
|
||||
variableResolvedEmpty: resolvedEmpty,
|
||||
variableFetchContext: context,
|
||||
});
|
||||
@@ -171,6 +182,11 @@ export const createVariableFetchSlice: StateCreator<
|
||||
dynamicVariableOrder,
|
||||
} = variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
const reasons = { ...get().variableCycleReasons };
|
||||
const bump = (name: string): void => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
reasons[name] = VariableCycleReason.FullCycle;
|
||||
};
|
||||
|
||||
// Query variables wait only for their QUERY parents. A DYNAMIC parent does not
|
||||
// gate: its option fetch feeds only its own dropdown, while its selected value
|
||||
@@ -178,7 +194,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
// dependent query substitutes it immediately and refetches via the cascade if
|
||||
// it later changes. Text/custom parents resolve synchronously, so nothing waits.
|
||||
queryVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
bump(name);
|
||||
const parents = dependencyData.parentGraph[name] || [];
|
||||
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
|
||||
maps.states[name] = hasQueryParents
|
||||
@@ -192,7 +208,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
const orderedQuery = new Set(queryVariableOrder);
|
||||
Object.keys(variableTypes).forEach((name) => {
|
||||
if (variableTypes[name] === 'QUERY' && !orderedQuery.has(name)) {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
bump(name);
|
||||
maps.states[name] = resolveFetchState(maps, name);
|
||||
}
|
||||
});
|
||||
@@ -203,7 +219,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
// populate fast even when query variables are slow; a sibling selection change
|
||||
// later refetches them via `enqueueDescendantsBatch`.
|
||||
dynamicVariableOrder.forEach((name) => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
bump(name);
|
||||
maps.states[name] = resolveFetchState(maps, name);
|
||||
});
|
||||
|
||||
@@ -211,6 +227,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableCycleReasons: reasons,
|
||||
lastFetchAllKey: key ?? get().lastFetchAllKey,
|
||||
});
|
||||
},
|
||||
@@ -290,6 +307,11 @@ export const createVariableFetchSlice: StateCreator<
|
||||
const { dependencyData, variableTypes, dynamicVariableOrder } =
|
||||
variableFetchContext;
|
||||
const maps = cloneMaps(get());
|
||||
const reasons = { ...get().variableCycleReasons };
|
||||
const bump = (name: string): void => {
|
||||
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
|
||||
reasons[name] = VariableCycleReason.ValueCascade;
|
||||
};
|
||||
const changed = new Set(names);
|
||||
// Callers commit values before this runs, so the gate sees the new parent values.
|
||||
const selection = selectVariableValues(get().dashboardId)(get());
|
||||
@@ -305,7 +327,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
});
|
||||
});
|
||||
queryDescendants.forEach((desc) => {
|
||||
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
|
||||
bump(desc);
|
||||
maps.states[desc] = queryParentsHaveValues(
|
||||
desc,
|
||||
variableFetchContext,
|
||||
@@ -322,7 +344,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
dynamicVariableOrder
|
||||
.filter((dynName) => !changed.has(dynName))
|
||||
.forEach((dynName) => {
|
||||
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
|
||||
bump(dynName);
|
||||
maps.states[dynName] = resolveFetchState(maps, dynName);
|
||||
});
|
||||
}
|
||||
@@ -331,6 +353,7 @@ export const createVariableFetchSlice: StateCreator<
|
||||
variableFetchStates: maps.states,
|
||||
variableLastUpdated: maps.lastUpdated,
|
||||
variableCycleIds: maps.cycleIds,
|
||||
variableCycleReasons: reasons,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -347,6 +370,12 @@ export const selectVariableCycleId =
|
||||
(state: DashboardStore): number =>
|
||||
state.variableCycleIds[name] ?? 0;
|
||||
|
||||
/** Selector: why a variable's cycle was enqueued. Undefined for types that never fetch. */
|
||||
export const selectVariableCycleReason =
|
||||
(name: string) =>
|
||||
(state: DashboardStore): VariableCycleReason | undefined =>
|
||||
state.variableCycleReasons[name];
|
||||
|
||||
/** Selector: whether a variable has completed at least one fetch. */
|
||||
export const selectVariableFetchedOnce =
|
||||
(name: string) =>
|
||||
|
||||
@@ -7,6 +7,14 @@ export enum VariableFetchState {
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
/** Why a cycle was started — only a cascade may re-default a user's selection. */
|
||||
export enum VariableCycleReason {
|
||||
/** `enqueueFetchAll`: load, time-range or variable-order change. */
|
||||
FullCycle = 'full-cycle',
|
||||
/** `enqueueDescendantsBatch`: a parent or sibling variable's value changed. */
|
||||
ValueCascade = 'value-cascade',
|
||||
}
|
||||
|
||||
/** Mutable clones a fetch action works over before committing back in one `set`. */
|
||||
export interface FetchMaps {
|
||||
states: Record<string, VariableFetchState>;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface Props {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -245,11 +245,13 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
|
||||
}
|
||||
|
||||
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
// Read the storable, not the decoded v2 dashboard: deleting must work even
|
||||
// when the stored data is corrupt or never migrated off the v1 schema.
|
||||
storable, err := module.store.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := existing.ErrIfNotDeletable(); err != nil {
|
||||
if err := storable.ErrIfNotDeletable(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,18 @@ func (storableDashboardData *StorableDashboardData) GetWidgetIds() []string {
|
||||
return widgetIds
|
||||
}
|
||||
|
||||
// ErrIfNotDeletable gates deletion on the columns alone, never on Data, so a
|
||||
// dashboard whose data is corrupt or stuck on the v1 schema stays deletable.
|
||||
func (storable StorableDashboard) ErrIfNotDeletable() error {
|
||||
if storable.Locked {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
|
||||
}
|
||||
if !storable.Source.isUserDeletable() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be deleted", storable.Source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dashboard *Dashboard) ErrIfNotMutable() error {
|
||||
if dashboard.Source == SourceIntegration {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -81,3 +82,64 @@ func TestCanUpdate_MultipleDeletions_ByDiff(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorableDashboardErrIfNotDeletable(t *testing.T) {
|
||||
testCases := []struct {
|
||||
subtestName string
|
||||
locked bool
|
||||
source Source
|
||||
data StorableDashboardData
|
||||
expectDeletable bool
|
||||
}{
|
||||
{
|
||||
subtestName: "user dashboard on the v2 schema",
|
||||
source: SourceUser,
|
||||
data: StorableDashboardData{"metadata": map[string]any{"schemaVersion": SchemaVersion}},
|
||||
expectDeletable: true,
|
||||
},
|
||||
{
|
||||
subtestName: "user dashboard still on the v1 schema",
|
||||
source: SourceUser,
|
||||
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
|
||||
expectDeletable: true,
|
||||
},
|
||||
{
|
||||
subtestName: "user dashboard with unreadable data",
|
||||
source: SourceUser,
|
||||
data: StorableDashboardData{"metadata": "not-an-object"},
|
||||
expectDeletable: true,
|
||||
},
|
||||
{
|
||||
subtestName: "locked user dashboard",
|
||||
locked: true,
|
||||
source: SourceUser,
|
||||
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
|
||||
expectDeletable: false,
|
||||
},
|
||||
{
|
||||
subtestName: "system dashboard",
|
||||
source: SourceSystem,
|
||||
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
|
||||
expectDeletable: false,
|
||||
},
|
||||
{
|
||||
subtestName: "integration dashboard",
|
||||
source: SourceIntegration,
|
||||
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
|
||||
expectDeletable: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.subtestName, func(t *testing.T) {
|
||||
storable := StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
OrgID: valuer.GenerateUUID(),
|
||||
Locked: tc.locked,
|
||||
Source: tc.source,
|
||||
Data: tc.data,
|
||||
}
|
||||
assert.Equal(t, tc.expectDeletable, storable.ErrIfNotDeletable() == nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,16 +129,6 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotDeletable() error {
|
||||
if d.Locked {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
|
||||
}
|
||||
if !d.Source.isUserDeletable() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be deleted", d.Source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotClonable() error {
|
||||
if !d.Source.isClonable() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be cloned", d.Source)
|
||||
|
||||
78
pkg/types/telemetrytypes/logical_field.go
Normal file
78
pkg/types/telemetrytypes/logical_field.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package telemetrytypes
|
||||
|
||||
import "strings"
|
||||
|
||||
// LogicalField is one queryable field. Its Name is the spelling that the
|
||||
// request used. Its Members are the physical keys that store the field.
|
||||
// LogicalField is the output type of name resolution: resolution changes a
|
||||
// referenced name into logical fields, and compilers make SQL from them.
|
||||
//
|
||||
// A []*LogicalField shows ambiguity. Ambiguity means that possibly different
|
||||
// fields have the same name. Each logical field in the slice gets its own
|
||||
// condition. The operator tells the compiler how to connect the conditions.
|
||||
//
|
||||
// One LogicalField with more than one member shows a semantic-convention
|
||||
// family. A family is one field that has more than one spelling. The members
|
||||
// are in current-first order. The compiler merges the members into one
|
||||
// expression, and the current name wins.
|
||||
//
|
||||
// Members always has one entry or more. A field that is not a family has
|
||||
// exactly one member. The members point to the metadata map entries. Do not
|
||||
// change the members.
|
||||
type LogicalField struct {
|
||||
// Name is the spelling that the request used. Aliases, series labels,
|
||||
// and warnings use this spelling. Because of this, the response shows
|
||||
// the same spelling as the request.
|
||||
Name string
|
||||
|
||||
// Signal, FieldContext, and FieldDataType are the identity that all
|
||||
// members share. Members with a different signal, field context, or
|
||||
// data type are parts of different logical fields.
|
||||
Signal Signal
|
||||
FieldContext FieldContext
|
||||
FieldDataType FieldDataType
|
||||
|
||||
// Members are the physical keys that store this field, in current-first
|
||||
// order. Each member has its own physical data (Materialized,
|
||||
// Evolutions, JSONPlan, ...). A per-member accessor does not need data
|
||||
// from the other members.
|
||||
Members []*TelemetryFieldKey
|
||||
}
|
||||
|
||||
// SingleLogicalField makes a logical field that has one physical key.
|
||||
func SingleLogicalField(name string, key *TelemetryFieldKey) *LogicalField {
|
||||
return &LogicalField{
|
||||
Name: name,
|
||||
Signal: key.Signal,
|
||||
FieldContext: key.FieldContext,
|
||||
FieldDataType: key.FieldDataType,
|
||||
Members: []*TelemetryFieldKey{key},
|
||||
}
|
||||
}
|
||||
|
||||
// Single returns the only member of a single-member field. A decision that
|
||||
// uses only the shared identity can also use Single on a family. This is
|
||||
// safe because all members have the same signal, context, and data type.
|
||||
func (l *LogicalField) Single() *TelemetryFieldKey {
|
||||
return l.Members[0]
|
||||
}
|
||||
|
||||
// IsFamily returns true when the field has more than one physical member.
|
||||
func (l *LogicalField) IsFamily() bool {
|
||||
return len(l.Members) > 1
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer. A single-member field prints as its
|
||||
// member. Because of this, a message made from the field and a message made
|
||||
// from the key are the same. A family prints its shared identity and its
|
||||
// member spellings.
|
||||
func (l *LogicalField) String() string {
|
||||
if len(l.Members) == 1 {
|
||||
return l.Members[0].String()
|
||||
}
|
||||
names := make([]string, 0, len(l.Members))
|
||||
for _, member := range l.Members {
|
||||
names = append(names, member.Name)
|
||||
}
|
||||
return l.Name + "(" + l.FieldContext.StringValue() + ", " + l.FieldDataType.StringValue() + ", members: " + strings.Join(names, ", ") + ")"
|
||||
}
|
||||
50
pkg/types/telemetrytypes/logical_field_test.go
Normal file
50
pkg/types/telemetrytypes/logical_field_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package telemetrytypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSingleLogicalFieldSharesIdentityAndAliasesKey(t *testing.T) {
|
||||
key := &TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
Signal: SignalTraces,
|
||||
FieldContext: FieldContextResource,
|
||||
FieldDataType: FieldDataTypeString,
|
||||
}
|
||||
|
||||
logical := SingleLogicalField("resource.service.name", key)
|
||||
|
||||
assert.Equal(t, "resource.service.name", logical.Name, "the identity is the spelling that the request used, not the stored spelling")
|
||||
assert.Equal(t, key.Signal, logical.Signal)
|
||||
assert.Equal(t, key.FieldContext, logical.FieldContext)
|
||||
assert.Equal(t, key.FieldDataType, logical.FieldDataType)
|
||||
assert.False(t, logical.IsFamily())
|
||||
assert.Same(t, key, logical.Single(), "the member points to the key; there is no copy")
|
||||
}
|
||||
|
||||
func TestStringDelegatesForSingleMember(t *testing.T) {
|
||||
key := &TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: FieldContextResource,
|
||||
FieldDataType: FieldDataTypeString,
|
||||
}
|
||||
assert.Equal(t, key.String(), SingleLogicalField(key.Name, key).String(),
|
||||
"a message made from a single-member field must be the same as a message made from the key")
|
||||
}
|
||||
|
||||
func TestStringListsFamilyMembers(t *testing.T) {
|
||||
logical := &LogicalField{
|
||||
Name: "deployment.environment.name",
|
||||
Signal: SignalTraces,
|
||||
FieldContext: FieldContextResource,
|
||||
FieldDataType: FieldDataTypeString,
|
||||
Members: []*TelemetryFieldKey{
|
||||
{Name: "deployment.environment.name"},
|
||||
{Name: "deployment.environment"},
|
||||
},
|
||||
}
|
||||
assert.True(t, logical.IsFamily())
|
||||
assert.Equal(t, "deployment.environment.name(resource, string, members: deployment.environment.name, deployment.environment)", logical.String())
|
||||
}
|
||||
27
tests/fixtures/cloudintegrations.py
vendored
27
tests/fixtures/cloudintegrations.py
vendored
@@ -26,14 +26,14 @@ class ProviderAccountSpec:
|
||||
provider: str
|
||||
# params for the account created by default.
|
||||
initial_params: dict
|
||||
# params for the config an update (PUT) test sends.
|
||||
updated_params: dict
|
||||
# params -> the provider-keyed `config` block for a POST/PUT body.
|
||||
build_config: Callable[[dict], dict]
|
||||
# params -> the full config block the API is expected to return under
|
||||
# config[provider] on GET/list. This may differ from what build_config sends:
|
||||
# e.g. AWS accepts deploymentRegion on POST but the API does not echo it back.
|
||||
expected_config: Callable[[dict], dict]
|
||||
# only the suites that exercise updates need to supply it.
|
||||
updated_params: dict = field(default_factory=dict)
|
||||
# id shown in parametrized test names; defaults to the provider slug.
|
||||
id: str = field(default="")
|
||||
|
||||
@@ -42,6 +42,29 @@ class ProviderAccountSpec:
|
||||
object.__setattr__(self, "id", self.provider)
|
||||
|
||||
|
||||
# Per-provider service shape.
|
||||
@dataclass(frozen=True)
|
||||
class ProviderServiceSpec:
|
||||
provider: str
|
||||
service_id: str
|
||||
# GCP ships every service with supportedSignals.logs false, so a logs block
|
||||
# is neither required on write nor persisted.
|
||||
supports_logs: bool
|
||||
account_config: dict
|
||||
# id shown in parametrized test names; defaults to the provider slug.
|
||||
id: str = field(default="")
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.id:
|
||||
object.__setattr__(self, "id", self.provider)
|
||||
|
||||
def build_service_config(self, metrics_enabled: bool, logs_enabled: bool | None = None) -> dict:
|
||||
config: dict = {"metrics": {"enabled": metrics_enabled}}
|
||||
if self.supports_logs:
|
||||
config["logs"] = {"enabled": metrics_enabled if logs_enabled is None else logs_enabled}
|
||||
return {self.provider: config}
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def deprecated_create_cloud_integration_account(
|
||||
request: pytest.FixtureRequest,
|
||||
|
||||
10000
tests/integration/testdata/filter_expressions_10000.txt
vendored
10000
tests/integration/testdata/filter_expressions_10000.txt
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,52 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.cloudintegrations import ProviderAccountSpec
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
provider="aws",
|
||||
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1", "us-west-2"]},
|
||||
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
|
||||
expected_config=lambda p: {"regions": p["regions"]},
|
||||
)
|
||||
|
||||
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
provider="gcp",
|
||||
initial_params={
|
||||
"deployment_project_id": "signoz-test-project",
|
||||
"deployment_region": "us-central1",
|
||||
"project_ids": ["signoz-test-project"],
|
||||
},
|
||||
build_config=lambda p: {
|
||||
"gcp": {
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
}
|
||||
},
|
||||
expected_config=lambda p: {
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
},
|
||||
)
|
||||
|
||||
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
|
||||
|
||||
provider_spec = pytest.mark.parametrize(
|
||||
"spec",
|
||||
PROVIDER_ACCOUNT_SPECS,
|
||||
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
|
||||
)
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: types.SigNoz,
|
||||
@@ -20,19 +58,19 @@ def test_apply_license(
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_create_account(
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
cloud_provider = "aws"
|
||||
|
||||
data = create_cloud_integration_account(
|
||||
admin_token,
|
||||
cloud_provider,
|
||||
deployment_region="us-east-1",
|
||||
regions=["us-east-1", "us-west-2"],
|
||||
spec.provider,
|
||||
config=spec.build_config(spec.initial_params),
|
||||
)
|
||||
|
||||
assert "id" in data, "Response data should contain 'id' field"
|
||||
@@ -40,12 +78,17 @@ def test_create_account(
|
||||
|
||||
assert "connectionArtifact" in data, "Response data should contain 'connectionArtifact' field"
|
||||
artifact = data["connectionArtifact"]
|
||||
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
|
||||
assert "connectionUrl" in artifact["aws"], "connectionArtifact.aws should contain 'connectionUrl'"
|
||||
|
||||
connection_url = artifact["aws"]["connectionUrl"]
|
||||
assert "console.aws.amazon.com/cloudformation" in connection_url, "connectionUrl should be an AWS CloudFormation URL"
|
||||
assert "region=us-east-1" in connection_url, "connectionUrl should contain the deployment region"
|
||||
if spec.provider == "aws":
|
||||
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
|
||||
assert "connectionUrl" in artifact["aws"], "connectionArtifact.aws should contain 'connectionUrl'"
|
||||
|
||||
connection_url = artifact["aws"]["connectionUrl"]
|
||||
assert "console.aws.amazon.com/cloudformation" in connection_url, "connectionUrl should be an AWS CloudFormation URL"
|
||||
assert f"region={spec.initial_params['deployment_region']}" in connection_url, "connectionUrl should contain the deployment region"
|
||||
else:
|
||||
# GCP is a manual flow: no one-click install artifact.
|
||||
assert artifact.get("gcp") is None, f"GCP should not return a connection artifact, got: {artifact}"
|
||||
|
||||
|
||||
def test_create_account_unsupported_provider(
|
||||
@@ -76,3 +119,36 @@ def test_create_account_unsupported_provider(
|
||||
|
||||
response_data = response.json()
|
||||
assert "error" in response_data, "Response should contain 'error' field"
|
||||
|
||||
|
||||
def test_create_gcp_account_without_project_ids(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""GCP account config requires at least one project ID to monitor."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/accounts"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"config": {
|
||||
"gcp": {
|
||||
"deploymentProjectId": "signoz-test-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": [],
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"sigNozApiURL": "https://test.signoz.cloud",
|
||||
"sigNozApiKey": "test-key",
|
||||
"ingestionUrl": "https://ingest.test.signoz.cloud",
|
||||
"ingestionKey": "test-ingestion-key",
|
||||
},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400 for empty projectIds, got {response.status_code}: {response.text}"
|
||||
assert "error" in response.json(), "Response should contain 'error' field"
|
||||
|
||||
@@ -2,14 +2,53 @@ import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.cloudintegrations import simulate_agent_checkin
|
||||
from fixtures.cloudintegrations import (
|
||||
ProviderAccountSpec,
|
||||
simulate_agent_checkin,
|
||||
)
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
CLOUD_PROVIDER = "aws"
|
||||
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
provider="aws",
|
||||
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1"]},
|
||||
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
|
||||
expected_config=lambda p: {"regions": p["regions"]},
|
||||
)
|
||||
|
||||
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
|
||||
provider="gcp",
|
||||
initial_params={
|
||||
"deployment_project_id": "signoz-test-project",
|
||||
"deployment_region": "us-central1",
|
||||
"project_ids": ["signoz-test-project"],
|
||||
},
|
||||
build_config=lambda p: {
|
||||
"gcp": {
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
}
|
||||
},
|
||||
expected_config=lambda p: {
|
||||
"deploymentProjectId": p["deployment_project_id"],
|
||||
"deploymentRegion": p["deployment_region"],
|
||||
"projectIds": p["project_ids"],
|
||||
},
|
||||
)
|
||||
|
||||
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
|
||||
|
||||
provider_spec = pytest.mark.parametrize(
|
||||
"spec",
|
||||
PROVIDER_ACCOUNT_SPECS,
|
||||
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
|
||||
)
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
@@ -22,22 +61,28 @@ def test_apply_license(
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_agent_check_in(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
|
||||
account = create_cloud_integration_account(
|
||||
admin_token,
|
||||
spec.provider,
|
||||
config=spec.build_config(spec.initial_params),
|
||||
)
|
||||
account_id = account["id"]
|
||||
provider_account_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(
|
||||
signoz,
|
||||
admin_token,
|
||||
CLOUD_PROVIDER,
|
||||
spec.provider,
|
||||
account_id,
|
||||
provider_account_id,
|
||||
data={"version": "v0.0.8"},
|
||||
@@ -47,57 +92,63 @@ def test_agent_check_in(
|
||||
|
||||
data = response.json()["data"]
|
||||
|
||||
# New camelCase fields
|
||||
assert data["cloudIntegrationId"] == account_id, "cloudIntegrationId should match"
|
||||
assert data["providerAccountId"] == provider_account_id, "providerAccountId should match"
|
||||
assert "integrationConfig" in data, "Response should contain 'integrationConfig'"
|
||||
assert data["removedAt"] is None, "removedAt should be null for a live account"
|
||||
|
||||
# Backward-compat snake_case fields
|
||||
assert data["account_id"] == account_id, "account_id (compat) should match"
|
||||
assert data["cloud_account_id"] == provider_account_id, "cloud_account_id (compat) should match"
|
||||
assert "integration_config" in data, "Response should contain 'integration_config' (compat)"
|
||||
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
|
||||
if spec.provider == "aws":
|
||||
# Backward compat for agents deployed before the camelCase response; AWS only.
|
||||
assert data["account_id"] == account_id, "account_id (compat) should match"
|
||||
assert data["cloud_account_id"] == provider_account_id, "cloud_account_id (compat) should match"
|
||||
assert "integration_config" in data, "Response should contain 'integration_config' (compat)"
|
||||
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
|
||||
|
||||
# integrationConfig should reflect the configured regions
|
||||
integration_config = data["integrationConfig"]
|
||||
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
|
||||
assert integration_config["aws"]["enabledRegions"] == ["us-east-1"], "enabledRegions should match account config"
|
||||
integration_config = data["integrationConfig"]
|
||||
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
|
||||
assert integration_config["aws"]["enabledRegions"] == spec.initial_params["regions"], "enabledRegions should match account config"
|
||||
else:
|
||||
# GCP is a manual flow: the agent carries its own configuration.
|
||||
assert data["integrationConfig"].get("gcp") is None, f"GCP should not return an integration config, got: {data['integrationConfig']}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_agent_check_in_account_not_found(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, fake_id, str(uuid.uuid4()))
|
||||
response = simulate_agent_checkin(signoz, admin_token, spec.provider, fake_id, str(uuid.uuid4()))
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_duplicate_cloud_account_checkins(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderAccountSpec,
|
||||
) -> None:
|
||||
"""Test that two different accounts cannot check in with the same providerAccountId."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account1 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account2 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account1 = create_cloud_integration_account(admin_token, spec.provider, config=spec.build_config(spec.initial_params))
|
||||
account2 = create_cloud_integration_account(admin_token, spec.provider, config=spec.build_config(spec.initial_params))
|
||||
|
||||
assert account1["id"] != account2["id"], "Two accounts should have different IDs"
|
||||
|
||||
same_provider_account_id = str(uuid.uuid4())
|
||||
|
||||
# First check-in: account1 claims the provider account ID
|
||||
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account1["id"], same_provider_account_id)
|
||||
response = simulate_agent_checkin(signoz, admin_token, spec.provider, account1["id"], same_provider_account_id)
|
||||
assert response.status_code == HTTPStatus.OK, f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# Second check-in: account2 tries to claim the same provider account ID → 409
|
||||
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account2["id"], same_provider_account_id)
|
||||
response = simulate_agent_checkin(signoz, admin_token, spec.provider, account2["id"], same_provider_account_id)
|
||||
assert response.status_code == HTTPStatus.CONFLICT, f"Expected 409 for duplicate providerAccountId, got {response.status_code}: {response.text}"
|
||||
|
||||
@@ -2,18 +2,47 @@ import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from sqlalchemy import bindparam, sql
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
|
||||
from fixtures.cloudintegrations import simulate_agent_checkin
|
||||
from fixtures.cloudintegrations import (
|
||||
ProviderServiceSpec,
|
||||
simulate_agent_checkin,
|
||||
)
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
CLOUD_PROVIDER = "aws"
|
||||
SERVICE_ID = "rds"
|
||||
AWS_SERVICE_SPEC = ProviderServiceSpec(
|
||||
provider="aws",
|
||||
service_id="rds",
|
||||
supports_logs=True,
|
||||
account_config={"aws": {"deploymentRegion": "us-east-1", "regions": ["us-east-1"]}},
|
||||
)
|
||||
|
||||
GCP_SERVICE_SPEC = ProviderServiceSpec(
|
||||
provider="gcp",
|
||||
service_id="cloudsql_postgres",
|
||||
supports_logs=False,
|
||||
account_config={
|
||||
"gcp": {
|
||||
"deploymentProjectId": "signoz-test-project",
|
||||
"deploymentRegion": "us-central1",
|
||||
"projectIds": ["signoz-test-project"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
PROVIDER_SERVICE_SPECS = [AWS_SERVICE_SPEC, GCP_SERVICE_SPEC]
|
||||
|
||||
provider_spec = pytest.mark.parametrize(
|
||||
"spec",
|
||||
PROVIDER_SERVICE_SPECS,
|
||||
ids=[s.id for s in PROVIDER_SERVICE_SPECS],
|
||||
)
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
@@ -26,16 +55,18 @@ def test_apply_license(
|
||||
add_license(signoz, make_http_mocks, get_token)
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_list_services_without_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""List the cloud provider's supported services"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -53,35 +84,37 @@ def test_list_services_without_account(
|
||||
assert "icon" in service, "Service should have 'icon' field"
|
||||
assert "enabled" in service, "Service should have 'enabled' field"
|
||||
|
||||
|
||||
EC2_SERVICE_ID = "ec2"
|
||||
listed_ids = {s["id"] for s in data["services"]}
|
||||
assert spec.service_id in listed_ids, f"'{spec.service_id}' should be listed for {spec.provider}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_list_account_services(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""ListAccountServicesMetadata reflects enabled state after enabling a service."""
|
||||
"""ListAccountServicesMetadata reflects enabled state per service."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
put_response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{EC2_SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
|
||||
json={"config": spec.build_service_config(True)},
|
||||
timeout=10,
|
||||
)
|
||||
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable ec2 failed: {put_response.status_code}: {put_response.text}"
|
||||
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable {spec.service_id} failed: {put_response.status_code}: {put_response.text}"
|
||||
|
||||
list_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -92,21 +125,28 @@ def test_list_account_services(
|
||||
assert isinstance(data["services"], list), "services should be a list"
|
||||
assert len(data["services"]) > 0, "services list should be non-empty"
|
||||
|
||||
ec2_service = next((s for s in data["services"] if s["id"] == EC2_SERVICE_ID), None)
|
||||
assert ec2_service is not None, f"EC2 service '{EC2_SERVICE_ID}' not found in services list"
|
||||
assert ec2_service["enabled"] is True, f"EC2 service should be enabled, got: {ec2_service['enabled']}"
|
||||
enabled_service = next((s for s in data["services"] if s["id"] == spec.service_id), None)
|
||||
assert enabled_service is not None, f"Service '{spec.service_id}' not found in services list"
|
||||
assert enabled_service["enabled"] is True, f"Service should be enabled, got: {enabled_service['enabled']}"
|
||||
|
||||
# The listing must report state per service, not blanket-enable or echo the write.
|
||||
untouched_service = next((s for s in data["services"] if s["id"] != spec.service_id), None)
|
||||
assert untouched_service is not None, "Expected more than one service in the listing"
|
||||
assert untouched_service["enabled"] is False, f"Service '{untouched_service['id']}' was never enabled, got: {untouched_service['enabled']}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_get_service_details_without_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""Get full service definition without specifying an account."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -114,31 +154,36 @@ def test_get_service_details_without_account(
|
||||
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
|
||||
assert data["id"] == spec.service_id, f"id should be '{spec.service_id}'"
|
||||
assert "title" in data, "Service should have 'title'"
|
||||
assert "overview" in data, "Service should have 'overview' (markdown)"
|
||||
assert "assets" in data, "Service should have 'assets'"
|
||||
assert isinstance(data["assets"]["dashboards"], list), "assets.dashboards should be a list"
|
||||
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null without account context"
|
||||
|
||||
assert data["supportedSignals"]["metrics"] is True, "metrics should be a supported signal"
|
||||
assert data["supportedSignals"]["logs"] is spec.supports_logs, f"logs support should be {spec.supports_logs} for {spec.provider}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_get_account_service(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""Get service for a specific account — all disabled by default."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -146,20 +191,22 @@ def test_get_account_service(
|
||||
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
|
||||
assert data["id"] == spec.service_id, f"id should be '{spec.service_id}'"
|
||||
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null before any config is set"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_get_service_not_found(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""Get a non-existent service ID returns 400 (invalid service ID is a bad request)."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/non-existent-service"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services/non-existent-service"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -167,32 +214,34 @@ def test_get_service_not_found(
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400, got {response.status_code}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_update_service_config(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""Enable a service and verify the config is persisted via GET."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
put_response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
|
||||
json={"config": spec.build_service_config(True)},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
|
||||
|
||||
get_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -201,33 +250,39 @@ def test_update_service_config(
|
||||
data = get_response.json()["data"]
|
||||
svc = data["cloudIntegrationService"]
|
||||
assert svc is not None, "cloudIntegrationService should be non-null after UpdateService"
|
||||
assert svc["config"]["aws"]["metrics"]["enabled"] is True, "metrics should be enabled"
|
||||
assert svc["config"]["aws"]["logs"]["enabled"] is True, "logs should be enabled"
|
||||
assert svc["config"][spec.provider]["metrics"]["enabled"] is True, "metrics should be enabled"
|
||||
assert svc["cloudIntegrationId"] == account_id, "cloudIntegrationId should match the account"
|
||||
|
||||
if spec.supports_logs:
|
||||
assert svc["config"][spec.provider]["logs"]["enabled"] is True, "logs should be enabled"
|
||||
else:
|
||||
assert svc["config"][spec.provider].get("logs") is None, f"logs should not be stored for {spec.provider}, got: {svc['config'][spec.provider]}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_update_service_config_disable(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""Enable then disable a service — config change is persisted."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}")
|
||||
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}")
|
||||
|
||||
# Enable
|
||||
r = requests.put(
|
||||
endpoint,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
|
||||
json={"config": spec.build_service_config(True)},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == HTTPStatus.NO_CONTENT, f"Enable failed: {r.status_code}: {r.text}"
|
||||
@@ -236,13 +291,13 @@ def test_update_service_config_disable(
|
||||
r = requests.put(
|
||||
endpoint,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}}},
|
||||
json={"config": spec.build_service_config(False)},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {r.status_code}: {r.text}"
|
||||
|
||||
get_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -250,28 +305,57 @@ def test_update_service_config_disable(
|
||||
assert get_response.status_code == HTTPStatus.OK
|
||||
svc = get_response.json()["data"]["cloudIntegrationService"]
|
||||
assert svc is not None, "cloudIntegrationService should still be present after disable"
|
||||
assert svc["config"]["aws"]["metrics"]["enabled"] is False, "metrics should be disabled"
|
||||
assert svc["config"]["aws"]["logs"]["enabled"] is False, "logs should be disabled"
|
||||
assert svc["config"][spec.provider]["metrics"]["enabled"] is False, "metrics should be disabled"
|
||||
|
||||
if spec.supports_logs:
|
||||
assert svc["config"][spec.provider]["logs"]["enabled"] is False, "logs should be disabled"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_update_service_account_not_found(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""PUT with a non-existent account UUID returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}}}},
|
||||
json={"config": spec.build_service_config(True)},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
|
||||
|
||||
|
||||
def test_update_gcp_service_without_metrics_config(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""GCP services support metrics only, so a config omitting metrics is rejected."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, "gcp", config=GCP_SERVICE_SPEC.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}/services/{GCP_SERVICE_SPEC.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"gcp": {"logs": {"enabled": True}}}},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400 when metrics config is missing, got {response.status_code}: {response.text}"
|
||||
|
||||
|
||||
def test_list_services_unsupported_provider(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -289,30 +373,32 @@ def test_list_services_unsupported_provider(
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400, got {response.status_code}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_list_services_account_removed(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""List services for a deleted account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -320,30 +406,32 @@ def test_list_services_account_removed(
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_get_service_details_account_removed(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""Get service details for a deleted account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -351,64 +439,68 @@ def test_get_service_details_account_removed(
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_update_service_account_removed(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""PUT service config for a deleted account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}}}},
|
||||
json={"config": spec.build_service_config(True)},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_enable_metrics_provisions_dashboards(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""Enabling metrics provisions dashboards visible in GetService and present in the DB."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
put_response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": False}}}},
|
||||
json={"config": spec.build_service_config(True, logs_enabled=False)},
|
||||
timeout=10,
|
||||
)
|
||||
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
|
||||
|
||||
# Assertion 1: GetService returns provisioned dashboard UUIDs
|
||||
get_svc_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -417,7 +509,7 @@ def test_enable_metrics_provisions_dashboards(
|
||||
data = get_svc_response.json()["data"]
|
||||
svc = data["cloudIntegrationService"]
|
||||
assert svc is not None, "cloudIntegrationService should be non-null after enabling metrics"
|
||||
assert svc["config"]["aws"]["metrics"]["enabled"] is True
|
||||
assert svc["config"][spec.provider]["metrics"]["enabled"] is True
|
||||
|
||||
dashboards_in_service = data["assets"]["dashboards"]
|
||||
assert isinstance(dashboards_in_service, list) and len(dashboards_in_service) > 0, "assets.dashboards should be non-empty after enabling metrics"
|
||||
@@ -445,35 +537,37 @@ def test_enable_metrics_provisions_dashboards(
|
||||
assert provisioned_ids == db_ids, f"Dashboards {provisioned_ids - db_ids} are missing from the DB"
|
||||
|
||||
|
||||
@provider_spec
|
||||
def test_disable_metrics_deprovisions_dashboards(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
spec: ProviderServiceSpec,
|
||||
) -> None:
|
||||
"""Disabling metrics removes provisioned dashboards from both GetService and the dashboards list."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}")
|
||||
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}")
|
||||
|
||||
# Enable metrics to provision dashboards first
|
||||
enable_response = requests.put(
|
||||
endpoint,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": False}}}},
|
||||
json={"config": spec.build_service_config(True, logs_enabled=False)},
|
||||
timeout=10,
|
||||
)
|
||||
assert enable_response.status_code == HTTPStatus.NO_CONTENT, f"Enable failed: {enable_response.status_code}: {enable_response.text}"
|
||||
|
||||
# Capture the provisioned dashboard IDs before disabling
|
||||
get_svc_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -485,14 +579,14 @@ def test_disable_metrics_deprovisions_dashboards(
|
||||
disable_response = requests.put(
|
||||
endpoint,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={"config": {"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}}},
|
||||
json={"config": spec.build_service_config(False)},
|
||||
timeout=10,
|
||||
)
|
||||
assert disable_response.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {disable_response.status_code}: {disable_response.text}"
|
||||
|
||||
# Assertion 1: GetService no longer returns UUID dashboard IDs
|
||||
get_svc_after = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import get_column_data_from_response, make_query_request
|
||||
|
||||
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
|
||||
FILTER_EXPRESSIONS_FILE = os.path.join(TESTDATA_DIR, "filter_expressions_10000.txt")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expression,expected_logs",
|
||||
@@ -180,101 +174,3 @@ def test_not_filter_expression(
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
assert set(get_column_data_from_response(response.json(), "body")) == expected_logs
|
||||
|
||||
|
||||
def test_filter_expressions_no_server_error(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
insert_logs,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Reads every line from filter_expressions_10000.txt and fires it as a filter
|
||||
expression against the logs query endpoint.
|
||||
|
||||
Expressions may be valid (200) or invalid (400) — both are acceptable.
|
||||
A 500 means the server crashed on the input and is a test failure.
|
||||
All failing expressions are collected before asserting so the full list is
|
||||
visible in one run.
|
||||
"""
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
body="alpha-log",
|
||||
resources={
|
||||
"f1": "v10",
|
||||
"f2": "v20",
|
||||
"f3": "v30",
|
||||
},
|
||||
attributes={
|
||||
"f4": 40,
|
||||
"f5": 50,
|
||||
"f6": 60,
|
||||
},
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=3),
|
||||
body="beta-log",
|
||||
resources={
|
||||
"f4": "v41",
|
||||
"f5": "v51",
|
||||
"f6": "v61",
|
||||
},
|
||||
attributes={
|
||||
"f1": 11,
|
||||
"f2": 21,
|
||||
"f3": 31,
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def _make_raw_logs_query(
|
||||
signoz: types.SigNoz,
|
||||
token: str,
|
||||
filter_expression: str,
|
||||
) -> requests.Response:
|
||||
"""Helper to query raw logs with a filter expression over the last 30 seconds."""
|
||||
now = datetime.now(tz=UTC)
|
||||
return make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"disabled": False,
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"filter": {"expression": filter_expression},
|
||||
"order": [
|
||||
{"key": {"name": "timestamp"}, "direction": "desc"},
|
||||
{"key": {"name": "id"}, "direction": "desc"},
|
||||
],
|
||||
"having": {"expression": ""},
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
failures: list[str] = []
|
||||
with ThreadPoolExecutor(max_workers=40) as executor:
|
||||
with open(FILTER_EXPRESSIONS_FILE, encoding="utf-8") as f:
|
||||
futures = {executor.submit(_make_raw_logs_query, signoz, token, expr.rstrip("\n")): expr.rstrip("\n") for expr in f}
|
||||
for future in as_completed(futures):
|
||||
expr = futures[future]
|
||||
if future.result().status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
|
||||
failures.append(expr)
|
||||
|
||||
assert len(failures) <= 0, f"{len(failures)} expression(s) caused HTTP 500:\n" + "\n".join(f" {expr!r}" for expr in failures)
|
||||
|
||||
Reference in New Issue
Block a user