Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions redisinsight/api/src/constants/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ export const ONE_NODE_RECOMMENDATIONS = [
RECOMMENDATION_NAMES.AVOID_LOGICAL_DATABASES,
RECOMMENDATION_NAMES.RTS,
RECOMMENDATION_NAMES.REDIS_VERSION,
RECOMMENDATION_NAMES.SET_PASSWORD,
];
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export class DatabaseAnalysis {
@Expose()
@Type(() => Recommendation)
recommendations: Recommendation[];

@ApiPropertyOptional({
description: 'Logical database number.',
type: Number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import { convertRedisInfoReplyToObject, convertBulkStringsToObject } from 'src/u
import {
RECOMMENDATION_NAMES, IS_TIMESTAMP, IS_INTEGER_NUMBER_REGEX, IS_NUMBER_REGEX,
} from 'src/constants';
import ERROR_MESSAGES from 'src/constants/error-messages';
import { ClusterNodeNotFoundError } from 'src/modules/cli/constants/errors';
import { checkRedirectionError, parseRedirectionError } from 'src/utils/cli-helper';
import { RedisDataType } from 'src/modules/browser/dto';
import { Recommendation } from 'src/modules/database-analysis/models/recommendation';
import { Key } from 'src/modules/database-analysis/models';
Expand Down Expand Up @@ -426,73 +423,15 @@ export class RecommendationProvider {
async determineSearchIndexesRecommendation(
redisClient: Redis,
keys: Key[],
client: any,
client: Redis | Cluster,
): Promise<Recommendation> {
try {
if (client.isCluster) {
let processedKeysNumber = 0;
let isJSONOrHash = false;
let sortedSetNumber = 0;
while (
processedKeysNumber < keys.length
&& !isJSONOrHash
&& sortedSetNumber <= sortedSetCountForCheck
) {
if (keys[processedKeysNumber].type !== RedisDataType.ZSet) {
processedKeysNumber += 1;
} else {
let keyType: string;
const sortedSetMember = await redisClient.sendCommand(
new Command('zrange', [keys[processedKeysNumber].name, 0, 0], { replyEncoding: 'utf8' }),
) as string[];
try {
keyType = await redisClient.sendCommand(
new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }),
) as string;
} catch (err) {
if (err && checkRedirectionError(err)) {
const { address } = parseRedirectionError(err);
const nodes = client.nodes('master');

const node: any = nodes.find(({ options: { host, port } }: Redis) => `${host}:${port}` === address);
if (!node) {
throw new ClusterNodeNotFoundError(
ERROR_MESSAGES.CLUSTER_NODE_NOT_FOUND(node),
);
}

keyType = await node.sendCommand(
new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }),
) as string;
}
}
if (keyType === RedisDataType.JSON || keyType === RedisDataType.Hash) {
isJSONOrHash = true;
}
processedKeysNumber += 1;
sortedSetNumber += 1;
}
}

return isJSONOrHash ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null;
const res = await this.determineSearchIndexesForCluster(keys, client);
return res ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null;
}
const sortedSets = keys
.filter(({ type }) => type === RedisDataType.ZSet)
.slice(0, 100);
const res = await redisClient.pipeline(sortedSets.map(({ name }) => ([
'zrange',
name,
0,
0,
]))).exec();

const types = await redisClient.pipeline(res.map(([, member]) => ([
'type',
member,
]))).exec();

const isHashOrJSONName = types.some(([, type]) => type === RedisDataType.JSON || type === RedisDataType.Hash);
return isHashOrJSONName ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null;
const res = await this.determineSearchIndexesForStandalone(keys, redisClient);
return res ? { name: RECOMMENDATION_NAMES.SEARCH_INDEXES } : null;
} catch (err) {
this.logger.error('Can not determine search indexes recommendation', err);
return null;
Expand Down Expand Up @@ -579,4 +518,51 @@ export class RecommendationProvider {
return false;
}
}

private async determineSearchIndexesForCluster(keys: Key[], client: Redis | Cluster): Promise<boolean> {
let processedKeysNumber = 0;
let isJSONOrHash = false;
let sortedSetNumber = 0;
while (
processedKeysNumber < keys.length
&& !isJSONOrHash
&& sortedSetNumber <= sortedSetCountForCheck
) {
if (keys[processedKeysNumber].type !== RedisDataType.ZSet) {
processedKeysNumber += 1;
} else {
const sortedSetMember = await client.sendCommand(
new Command('zrange', [keys[processedKeysNumber].name, 0, 0], { replyEncoding: 'utf8' }),
) as string[];
const keyType = await client.sendCommand(
new Command('type', [sortedSetMember[0]], { replyEncoding: 'utf8' }),
) as string;
if (keyType === RedisDataType.JSON || keyType === RedisDataType.Hash) {
isJSONOrHash = true;
}
processedKeysNumber += 1;
sortedSetNumber += 1;
}
}
return isJSONOrHash;
}

private async determineSearchIndexesForStandalone(keys: Key[], redisClient: Redis): Promise<boolean> {
const sortedSets = keys
.filter(({ type }) => type === RedisDataType.ZSet)
.slice(0, 100);
const res = await redisClient.pipeline(sortedSets.map(({ name }) => ([
'zrange',
name,
0,
0,
]))).exec();

const types = await redisClient.pipeline(res.map(([, member]) => ([
'type',
member,
]))).exec();

return types.some(([, type]) => type === RedisDataType.JSON || type === RedisDataType.Hash);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,19 +189,20 @@ describe('POST /databases/:instanceId/analysis', () => {
].map(mainCheckFn);
});

describe('setPassword recommendation', () => {
requirements('!rte.pass');
describe('redisVersion recommendation', () => {
// todo find solution for redis pass
requirements('rte.version <= 6', '!rte.pass');
[
{
name: 'Should create new database analysis with setPassword recommendation',
name: 'Should create new database analysis with redisVersion recommendation',
data: {
delimiter: '-',
},
statusCode: 201,
responseSchema,
checkFn: async ({ body }) => {
expect(body.recommendations).to.include.deep.members([
constants.TEST_SET_PASSWORD_RECOMMENDATION,
constants.TEST_REDIS_VERSION_RECOMMENDATION,
]);
},
after: async () => {
Expand All @@ -211,19 +212,19 @@ describe('POST /databases/:instanceId/analysis', () => {
].map(mainCheckFn);
});

describe('redisVersion recommendation', () => {
requirements('rte.version <= 6');
describe('setPassword recommendation', () => {
requirements('!rte.pass');
[
{
name: 'Should create new database analysis with redisVersion recommendation',
name: 'Should create new database analysis with setPassword recommendation',
data: {
delimiter: '-',
},
statusCode: 201,
responseSchema,
checkFn: async ({ body }) => {
expect(body.recommendations).to.include.deep.members([
constants.TEST_REDIS_VERSION_RECOMMENDATION,
constants.TEST_SET_PASSWORD_RECOMMENDATION,
]);
},
after: async () => {
Expand Down Expand Up @@ -257,7 +258,7 @@ describe('POST /databases/:instanceId/analysis', () => {
].map(mainCheckFn);
});

describe('rediSearch recommendation with ReJSON', () => {
describe('recommendations with ReJSON', () => {
requirements('rte.modules.rejson');
[
{
Expand All @@ -280,6 +281,53 @@ describe('POST /databases/:instanceId/analysis', () => {
expect(await repository.count()).to.eq(5);
}
},
{
name: 'Should create new database analysis with searchIndexes recommendation',
data: {
delimiter: '-',
},
statusCode: 201,
responseSchema,
before: async () => {
const jsonValue = JSON.stringify(constants.TEST_REJSON_VALUE_1);
await rte.data.sendCommand('ZADD', [constants.TEST_ZSET_KEY_1, constants.TEST_ZSET_MEMBER_1_SCORE, constants.TEST_ZSET_MEMBER_1]);
await rte.data.sendCommand('json.set', [constants.TEST_ZSET_MEMBER_1, '.', jsonValue]);
},
checkFn: async ({ body }) => {
expect(body.recommendations).to.include.deep.members([
constants.TEST_SEARCH_INDEXES_RECOMMENDATION,
]);
},
after: async () => {
expect(await repository.count()).to.eq(5);
}
},
].map(mainCheckFn);
});

describe('searchIndexes recommendation', () => {
requirements('!rte.pass');
[
{
name: 'Should create new database analysis with searchIndexes recommendation',
data: {
delimiter: '-',
},
statusCode: 201,
responseSchema,
before: async () => {
await rte.data.sendCommand('ZADD', [constants.TEST_ZSET_KEY_1, constants.TEST_ZSET_MEMBER_1_SCORE, constants.TEST_ZSET_MEMBER_1]);
await rte.data.sendCommand('HSET', [constants.TEST_ZSET_MEMBER_1, constants.TEST_HASH_FIELD_1_NAME, constants.TEST_HASH_FIELD_1_VALUE]);
},
checkFn: async ({ body }) => {
expect(body.recommendations).to.include.deep.members([
constants.TEST_SEARCH_INDEXES_RECOMMENDATION,
]);
},
after: async () => {
expect(await repository.count()).to.eq(5);
}
},
].map(mainCheckFn);
});

Expand Down Expand Up @@ -490,26 +538,6 @@ describe('POST /databases/:instanceId/analysis', () => {
expect(await repository.count()).to.eq(5);
}
},
// update with new requirements
// {
// name: 'Should create new database analysis with RTS recommendation',
// data: {
// delimiter: '-',
// },
// statusCode: 201,
// responseSchema,
// before: async () => {
// await rte.data.sendCommand('zadd', [constants.TEST_ZSET_TIMESTAMP_KEY, constants.TEST_ZSET_TIMESTAMP_MEMBER, constants.TEST_ZSET_TIMESTAMP_SCORE]);
// },
// checkFn: async ({ body }) => {
// expect(body.recommendations).to.include.deep.members([
// constants.TEST_RTS_RECOMMENDATION,
// ]);
// },
// after: async () => {
// expect(await repository.count()).to.eq(5);
// }
// },
].map(mainCheckFn);
});
});
1 change: 1 addition & 0 deletions redisinsight/api/test/api/database/GET-databases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const responseSchema = Joi.array().items(Joi.object().keys({
port: Joi.number().integer().required(),
db: Joi.number().integer().allow(null).required(),
name: Joi.string().required(),
provider: Joi.string().required(),
new: Joi.boolean().allow(null).required(),
connectionType: Joi.string().valid('STANDALONE', 'SENTINEL', 'CLUSTER', 'NOT CONNECTED').required(),
lastConnection: Joi.string().isoDate().allow(null).required(),
Expand Down
5 changes: 5 additions & 0 deletions redisinsight/api/test/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,11 @@ export const constants = {
name: RECOMMENDATION_NAMES.REDIS_SEARCH,
},


TEST_SEARCH_INDEXES_RECOMMENDATION: {
name: RECOMMENDATION_NAMES.SEARCH_INDEXES,
},

TEST_LUA_SCRIPT_VOTE_RECOMMENDATION: {
name: RECOMMENDATION_NAMES.LUA_SCRIPT,
vote: 'useful',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import React from 'react'
import { cloneDeep } from 'lodash'
import { instance, mock } from 'ts-mockito'
import { setRecommendationVote } from 'uiSrc/slices/analytics/dbAnalysis'
import { userSettingsConfigSelector } from 'uiSrc/slices/user/user-settings'
import { Vote } from 'uiSrc/constants/recommendations'

import {
act,
cleanup,
mockedStore,
fireEvent,
render,
screen,
waitForEuiPopoverVisible,
waitForEuiToolTipVisible,
} from 'uiSrc/utils/test-utils'

import RecommendationVoting, { Props } from './RecommendationVoting'
Expand All @@ -29,9 +33,13 @@ jest.mock('uiSrc/telemetry', () => ({
sendEventTelemetry: jest.fn(),
}))

jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
jest.mock('uiSrc/slices/user/user-settings', () => ({
...jest.requireActual('uiSrc/slices/user/user-settings'),
userSettingsConfigSelector: jest.fn().mockReturnValue({
agreements: {
analytics: true,
}
}),
}))

describe('RecommendationVoting', () => {
Expand All @@ -41,6 +49,7 @@ describe('RecommendationVoting', () => {

it('should call "setRecommendationVote" action be called after click "very-useful-vote-btn"', () => {
render(<RecommendationVoting {...instance(mockedProps)} />)
expect(screen.queryByTestId('very-useful-vote-btn')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('very-useful-vote-btn'))

const expectedActions = [setRecommendationVote()]
Expand Down Expand Up @@ -75,10 +84,26 @@ describe('RecommendationVoting', () => {
})

it('should render component where all buttons are disabled"', async () => {
render(<RecommendationVoting {...instance(mockedProps)} vote="useful" />)
render(<RecommendationVoting {...instance(mockedProps)} vote={Vote.Like} />)

expect(screen.getByTestId('very-useful-vote-btn')).toBeDisabled()
expect(screen.getByTestId('useful-vote-btn')).toBeDisabled()
expect(screen.getByTestId('not-useful-vote-btn')).toBeDisabled()
})

it('should render popover after click "not-useful-vote-btn"', async () => {
userSettingsConfigSelector.mockImplementation(() => ({
agreements: {
analytics: false,
},
}))
render(<RecommendationVoting {...instance(mockedProps)} />)

await act(async () => {
fireEvent.mouseOver(screen.getByTestId('not-useful-vote-btn'))
})
await waitForEuiToolTipVisible()

expect(screen.getByTestId('not-useful-vote-tooltip')).toHaveTextContent('Enable Analytics on the Settings page to vote for a recommendation')
})
})
Loading