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
14 changes: 13 additions & 1 deletion redisinsight/api/config/features-config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": 2,
"version": 2.32,
"features": {
"insightsRecommendations": {
"flag": true,
Expand Down Expand Up @@ -43,6 +43,18 @@
}
}
}
},
"redisModuleFilter": {
"flag": true,
"perc": [[0, 100]],
"data": {
"hideByName": [
{
"expression": "RedisGraphStub",
"options": "i"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does the "i" mean?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed to make filter case-insensitive. You can find full options list here
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#advanced_searching_with_flags

}
]
}
}
}
}
1 change: 1 addition & 0 deletions redisinsight/api/src/__mocks__/feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const mockFeaturesConfigService = jest.fn(() => ({
}));

export const mockFeatureService = jest.fn(() => ({
getByName: jest.fn().mockResolvedValue(undefined),
isFeatureEnabled: jest.fn().mockResolvedValue(true),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { when } from 'jest-when';
import { IRedisClusterNodeAddress, ReplyError } from 'src/models';
import {
mockFeatureService,
mockIOClusterNode1,
mockIOClusterNode2,
mockIORedisClient,
Expand All @@ -17,14 +18,15 @@ import {
mockSentinelMasterEndpoint,
mockSentinelMasterInDownState,
mockSentinelMasterInOkState,
mockStandaloneRedisInfoReply,
mockStandaloneRedisInfoReply, MockType,
} from 'src/__mocks__';
import { REDIS_MODULES_COMMANDS, AdditionalRedisModuleName } from 'src/constants';
import { DatabaseInfoProvider } from 'src/modules/database/providers/database-info.provider';
import { RedisDatabaseInfoResponse } from 'src/modules/database/dto/redis-info.dto';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { SentinelMasterStatus } from 'src/modules/redis-sentinel/models/sentinel-master';
import ERROR_MESSAGES from 'src/constants/error-messages';
import { FeatureService } from 'src/modules/feature/feature.service';

const mockClusterNodeAddresses: IRedisClusterNodeAddress[] = [
{
Expand Down Expand Up @@ -78,14 +80,22 @@ const mockSentinelConnectionOptions = {

describe('DatabaseInfoProvider', () => {
let service: DatabaseInfoProvider;
let featureService: MockType<FeatureService>;

beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [DatabaseInfoProvider],
providers: [
DatabaseInfoProvider,
{
provide: FeatureService,
useFactory: mockFeatureService,
},
],
}).compile();

service = await module.get(DatabaseInfoProvider);
featureService = await module.get(FeatureService);
});

describe('isCluster', () => {
Expand Down Expand Up @@ -239,7 +249,7 @@ describe('DatabaseInfoProvider', () => {
});

describe('determineDatabaseModules', () => {
it('get modules by using MODULE LIST command', async () => {
it('get modules by using MODULE LIST command (without filters)', async () => {
when(mockIORedisClient.call)
.calledWith('module', ['list'])
.mockResolvedValue(mockRedisModuleList);
Expand All @@ -258,7 +268,37 @@ describe('DatabaseInfoProvider', () => {
{ name: 'customModule', version: 10000, semanticVersion: undefined },
]);
});
it('detect all modules by using COMMAND INFO command', async () => {
it('get modules by using MODULE LIST command (with filters applied)', async () => {
when(mockIORedisClient.call)
.calledWith('module', ['list'])
.mockResolvedValue(mockRedisModuleList);
featureService.getByName.mockResolvedValue({
flag: true,
data: {
hideByName: [
{
expression: 'rejSoN',
options: 'i',
},
],
},
});

const result = await service.determineDatabaseModules(mockIORedisClient);

expect(mockIORedisClient.call).not.toHaveBeenCalledWith('command', expect.anything());
expect(result).toEqual([
{ name: AdditionalRedisModuleName.RedisAI, version: 10000, semanticVersion: '1.0.0' },
{ name: AdditionalRedisModuleName.RedisGraph, version: 10000, semanticVersion: '1.0.0' },
{ name: AdditionalRedisModuleName.RedisGears, version: 10000, semanticVersion: '1.0.0' },
{ name: AdditionalRedisModuleName.RedisBloom, version: 10000, semanticVersion: '1.0.0' },
// { name: AdditionalRedisModuleName.RedisJSON, version: 10000, semanticVersion: '1.0.0' }, should be ignored
{ name: AdditionalRedisModuleName.RediSearch, version: 10000, semanticVersion: '1.0.0' },
{ name: AdditionalRedisModuleName.RedisTimeSeries, version: 10000, semanticVersion: '1.0.0' },
{ name: 'customModule', version: 10000, semanticVersion: undefined },
]);
});
it('detect all modules by using COMMAND INFO command (without filter)', async () => {
when(mockIORedisClient.call)
.calledWith('module', ['list'])
.mockRejectedValue(mockUnknownCommandModule);
Expand All @@ -282,6 +322,41 @@ describe('DatabaseInfoProvider', () => {
{ name: AdditionalRedisModuleName.RedisTimeSeries },
]);
});
it('detect all modules by using COMMAND INFO command (with filter)', async () => {
when(mockIORedisClient.call)
.calledWith('module', ['list'])
.mockRejectedValue(mockUnknownCommandModule);
when(mockIORedisClient.call)
.calledWith('command', expect.anything())
.mockResolvedValue([
null,
['somecommand', -1, ['readonly'], 0, 0, -1, []],
]);
featureService.getByName.mockResolvedValue({
flag: true,
data: {
hideByName: [
{
expression: 'rejSoN',
options: 'i',
},
],
},
});

const result = await service.determineDatabaseModules(mockIORedisClient);

expect(mockIORedisClient.call).toHaveBeenCalledTimes(REDIS_MODULES_COMMANDS.size + 1);
expect(result).toEqual([
{ name: AdditionalRedisModuleName.RedisAI },
{ name: AdditionalRedisModuleName.RedisGraph },
{ name: AdditionalRedisModuleName.RedisGears },
{ name: AdditionalRedisModuleName.RedisBloom },
// { name: AdditionalRedisModuleName.RedisJSON }, should be ignored
{ name: AdditionalRedisModuleName.RediSearch },
{ name: AdditionalRedisModuleName.RedisTimeSeries },
]);
});
it('detect only RediSearch module by using COMMAND INFO command', async () => {
when(mockIORedisClient.call)
.calledWith('module', ['list'])
Expand Down Expand Up @@ -372,7 +447,7 @@ describe('DatabaseInfoProvider', () => {
});
it('should throw an error if no permission to run \'info\' command', async () => {
mockIORedisClient.info.mockRejectedValue({
message: 'NOPERM this user has no permissions to run the \'info\' command'
message: 'NOPERM this user has no permissions to run the \'info\' command',
});

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,15 @@ import { SentinelMaster, SentinelMasterStatus } from 'src/modules/redis-sentinel
import ERROR_MESSAGES from 'src/constants/error-messages';
import { Endpoint } from 'src/common/models';
import { RedisDatabaseInfoResponse } from 'src/modules/database/dto/redis-info.dto';
import { FeatureService } from 'src/modules/feature/feature.service';
import { KnownFeatures } from 'src/modules/feature/constants';

@Injectable()
export class DatabaseInfoProvider {
constructor(
private readonly featureService: FeatureService,
) {}

/**
* Check weather current database is a cluster
* @param client
Expand Down Expand Up @@ -67,6 +73,27 @@ export class DatabaseInfoProvider {
}));
}

public async filterRawModules(modules: any[]): Promise<any[]> {
let filteredModules = modules;

try {
const filterModules = await this.featureService.getByName(KnownFeatures.RedisModuleFilter);

if (filterModules?.flag && filterModules.data?.hideByName?.length) {
filteredModules = modules.filter(({ name }) => {
const match = filterModules.data.hideByName.find((filter) => filter.expression
&& (new RegExp(filter.expression, filter.options)).test(name));

return !match;
});
}
} catch (e) {
// ignore
}

return filteredModules;
}

/**
* Determine database modules using "module list" command
* In case when "module" command is not available use "command info" approach
Expand All @@ -75,7 +102,10 @@ export class DatabaseInfoProvider {
public async determineDatabaseModules(client: any): Promise<AdditionalRedisModule[]> {
try {
const reply = await client.call('module', ['list']);
const modules = reply.map((module: any[]) => convertStringsArrayToObject(module));
const modules = await this.filterRawModules(
reply.map((module: any[]) => convertStringsArrayToObject(module)),
);

return modules.map(({ name, ver }) => ({
name: SUPPORTED_REDIS_MODULES[name] ?? name,
version: ver,
Expand Down Expand Up @@ -120,7 +150,8 @@ export class DatabaseInfoProvider {
// continue regardless of error
}
}));
return modules;

return await this.filterRawModules(modules);
}

/**
Expand Down
1 change: 1 addition & 0 deletions redisinsight/api/src/modules/feature/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export enum FeatureConfigConfigDestination {
export enum KnownFeatures {
InsightsRecommendations = 'insightsRecommendations',
CloudSso = 'cloudSso',
RedisModuleFilter = 'redisModuleFilter',
}

export interface IFeatureFlag {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ export const knownFeatures: Record<KnownFeatures, IFeatureFlag> = {
storage: FeatureStorage.Database,
factory: CloudSsoFeatureFlag.getFeature,
},
[KnownFeatures.RedisModuleFilter]: {
name: KnownFeatures.RedisModuleFilter,
storage: FeatureStorage.Database,
},
};
8 changes: 8 additions & 0 deletions redisinsight/api/src/modules/feature/feature.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export class FeatureService {
private readonly analytics: FeatureAnalytics,
) {}

async getByName(name: string): Promise<Feature> {
try {
return await this.repository.get(name);
} catch (e) {
return null;
}
}

/**
* Check if feature enabled
* @param name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SettingsService } from 'src/modules/settings/settings.service';
import { IFeatureFlag, KnownFeatures } from 'src/modules/feature/constants';
import { CloudSsoFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/cloud-sso.flag.strategy';
import { Feature } from 'src/modules/feature/model/feature';
import { SimpleFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/simple.flag.strategy';

@Injectable()
export class FeatureFlagProvider {
Expand All @@ -30,6 +31,10 @@ export class FeatureFlagProvider {
this.featuresConfigService,
this.settingsService,
));
this.strategies.set(KnownFeatures.RedisModuleFilter, new SimpleFlagStrategy(
this.featuresConfigService,
this.settingsService,
));
}

getStrategy(name: string): FeatureFlagStrategy {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FeatureFlagStrategy } from 'src/modules/feature/providers/feature-flag/strategies/feature.flag.strategy';
import { Feature } from 'src/modules/feature/model/feature';
import { IFeatureFlag } from 'src/modules/feature/constants';

export class SimpleFlagStrategy extends FeatureFlagStrategy {
async calculate(knownFeature: IFeatureFlag, featureConfig: any): Promise<Feature> {
const isInRange = await this.isInTargetRange(featureConfig?.perc);

return {
name: knownFeature.name,
flag: isInRange && await this.filter(featureConfig?.filters) ? !!featureConfig?.flag : !featureConfig?.flag,
data: featureConfig?.data,
};
}
}
8 changes: 7 additions & 1 deletion redisinsight/desktop/src/lib/app/app.handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { app } from 'electron'
import log from 'electron-log'
import { getBackendGracefulShutdown, WindowType, getWindows, windowFactory, windows } from 'desktopSrc/lib'
import { deepLinkHandler } from 'desktopSrc/lib/app/deep-link.handlers';
import { deepLinkHandler } from 'desktopSrc/lib/app/deep-link.handlers'

export const initAppHandlers = () => {
app.on('activate', () => {
Expand Down Expand Up @@ -49,6 +49,12 @@ export const initAppHandlers = () => {
event.preventDefault()
// todo: implement url handler to map url to a proper function
await deepLinkHandler(url)

if (windows.size) {
const win = windows.values().next().value
if (win.isMinimized()) win.restore()
win.focus()
}
})

// deep link open (win)
Expand Down