diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index 74eac065c6..ff7807b4ea 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -1,4 +1,5 @@ import { join } from 'path'; +import * as os from 'os'; const homedir = join(__dirname, '..'); @@ -15,6 +16,7 @@ const defaultsDir = process.env.BUILD_TYPE === 'ELECTRON' && process['resourcesP export default { dir_path: { + tmpDir: os.tmpdir(), homedir, prevHomedir: homedir, staticDir, @@ -87,10 +89,14 @@ export default { redis_cli: { unsupportedCommands: JSON.parse(process.env.CLI_UNSUPPORTED_COMMANDS || '[]'), }, + profiler: { + logFileIdleThreshold: parseInt(process.env.PROFILER_LOG_FILE_IDLE_THRESHOLD, 10) || 1000 * 60, // 1min + }, analytics: { writeKey: process.env.SEGMENT_WRITE_KEY || 'SOURCE_WRITE_KEY', }, logger: { + logLevel: process.env.LOG_LEVEL || 'info', // log level stdout: process.env.STDOUT_LOGGER ? process.env.STDOUT_LOGGER === 'true' : false, // disabled by default files: process.env.FILES_LOGGER ? process.env.FILES_LOGGER === 'true' : true, // enabled by default omitSensitiveData: process.env.LOGGER_OMIT_DATA ? process.env.LOGGER_OMIT_DATA === 'true' : true, diff --git a/redisinsight/api/config/development.ts b/redisinsight/api/config/development.ts index 83e0d610ba..57b6feb085 100644 --- a/redisinsight/api/config/development.ts +++ b/redisinsight/api/config/development.ts @@ -10,6 +10,7 @@ export default { migrationsRun: process.env.DB_MIGRATIONS ? process.env.DB_MIGRATIONS === 'true' : false, }, logger: { + logLevel: process.env.LOG_LEVEL || 'debug', stdout: process.env.STDOUT_LOGGER ? process.env.STDOUT_LOGGER === 'true' : true, // enabled by default omitSensitiveData: process.env.LOGGER_OMIT_DATA ? process.env.LOGGER_OMIT_DATA === 'true' : false, }, diff --git a/redisinsight/api/config/logger.ts b/redisinsight/api/config/logger.ts index aeebcd72d3..99149ecb80 100644 --- a/redisinsight/api/config/logger.ts +++ b/redisinsight/api/config/logger.ts @@ -58,6 +58,7 @@ if (LOGGER_CONFIG.files) { const logger: WinstonModuleOptions = { format: format.errors({ stack: true }), transports: transportsConfig, + level: LOGGER_CONFIG.logLevel, }; export default logger; diff --git a/redisinsight/api/src/__mocks__/monitor.ts b/redisinsight/api/src/__mocks__/monitor.ts index a0c4432c3f..b04ca9275b 100644 --- a/redisinsight/api/src/__mocks__/monitor.ts +++ b/redisinsight/api/src/__mocks__/monitor.ts @@ -1,6 +1,7 @@ import { v4 as uuidv4 } from 'uuid'; -import { IClientMonitorObserver } from 'src/modules/monitor/helpers/client-monitor-observer'; -import { IMonitorObserver, IShardObserver, MonitorObserverStatus } from 'src/modules/monitor/helpers/monitor-observer'; +import { IClientMonitorObserver } from 'src/modules/profiler/helpers/client-monitor-observer'; +import { IMonitorObserver, IShardObserver, MonitorObserverStatus } from 'src/modules/profiler/helpers/monitor-observer'; +import { ILogsEmitter } from 'src/modules/profiler/interfaces/logs-emitter.interface'; export const mockClientMonitorObserver: IClientMonitorObserver = { id: uuidv4(), @@ -34,3 +35,11 @@ export const mockRedisMonitorObserver: IShardObserver = { once: jest.fn(), disconnect: jest.fn(), }; + +export const mockLogEmitter: ILogsEmitter = { + id: 'test', + emit: jest.fn(), + addProfilerClient: jest.fn(), + removeProfilerClient: jest.fn(), + flushLogs: jest.fn(), +}; diff --git a/redisinsight/api/src/app.module.ts b/redisinsight/api/src/app.module.ts index f1f8a87020..2987e8cd93 100644 --- a/redisinsight/api/src/app.module.ts +++ b/redisinsight/api/src/app.module.ts @@ -16,7 +16,7 @@ import { InstancesModule } from './modules/instances/instances.module'; import { BrowserModule } from './modules/browser/browser.module'; import { RedisEnterpriseModule } from './modules/redis-enterprise/redis-enterprise.module'; import { RedisSentinelModule } from './modules/redis-sentinel/redis-sentinel.module'; -import { MonitorModule } from './modules/monitor/monitor.module'; +import { ProfilerModule } from './modules/profiler/profiler.module'; import { CliModule } from './modules/cli/cli.module'; import { StaticsManagementModule } from './modules/statics-management/statics-management.module'; import { SettingsController } from './controllers/settings.controller'; @@ -41,7 +41,7 @@ const PATH_CONFIG = config.get('dir_path'); WorkbenchModule, PluginModule, CommandsModule, - MonitorModule, + ProfilerModule, EventEmitterModule.forRoot(), ...(SERVER_CONFIG.staticContent ? [ diff --git a/redisinsight/api/src/constants/error-messages.ts b/redisinsight/api/src/constants/error-messages.ts index 4f8f71f5ec..b635436495 100644 --- a/redisinsight/api/src/constants/error-messages.ts +++ b/redisinsight/api/src/constants/error-messages.ts @@ -2,6 +2,7 @@ export default { INVALID_DATABASE_INSTANCE_ID: 'Invalid database instance id.', COMMAND_EXECUTION_NOT_FOUND: 'Command execution was not found.', + PROFILER_LOG_FILE_NOT_FOUND: 'Profiler log file was not found.', PLUGIN_STATE_NOT_FOUND: 'Plugin state was not found.', UNDEFINED_INSTANCE_ID: 'Undefined redis database instance id.', NO_CONNECTION_TO_REDIS_DB: 'No connection to the Redis Database.', diff --git a/redisinsight/api/src/constants/telemetry-events.ts b/redisinsight/api/src/constants/telemetry-events.ts index e5ccfe2524..5be88bb3a9 100644 --- a/redisinsight/api/src/constants/telemetry-events.ts +++ b/redisinsight/api/src/constants/telemetry-events.ts @@ -37,4 +37,8 @@ export enum TelemetryEvents { WorkbenchCommandExecuted = 'WORKBENCH_COMMAND_EXECUTED', WorkbenchCommandErrorReceived = 'WORKBENCH_COMMAND_ERROR_RECEIVED', WorkbenchCommandDeleted = 'WORKBENCH_COMMAND_DELETE_COMMAND', + + // Profiler + ProfilerLogDownloaded = 'PROFILER_LOG_DOWNLOADED', + ProfilerLogDeleted = 'PROFILER_LOG_DELETED', } diff --git a/redisinsight/api/src/main.ts b/redisinsight/api/src/main.ts index 8c7b7360d7..dc215dbbf9 100644 --- a/redisinsight/api/src/main.ts +++ b/redisinsight/api/src/main.ts @@ -44,6 +44,8 @@ export default async function bootstrap() { ); } + app.enableShutdownHooks(); + await app.listen(port); logger.log({ message: `Server is running on http(s)://localhost:${port}`, diff --git a/redisinsight/api/src/modules/monitor/constants/events.ts b/redisinsight/api/src/modules/monitor/constants/events.ts deleted file mode 100644 index cd6dc6984b..0000000000 --- a/redisinsight/api/src/modules/monitor/constants/events.ts +++ /dev/null @@ -1,8 +0,0 @@ -export enum MonitorGatewayClientEvents { - Monitor = 'monitor', -} - -export enum MonitorGatewayServerEvents { - Data = 'monitorData', - Exception = 'exception', -} diff --git a/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/client-monitor-observer.interface.ts b/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/client-monitor-observer.interface.ts deleted file mode 100644 index a09b5159f7..0000000000 --- a/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/client-monitor-observer.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import IORedis from 'ioredis'; - -export interface IOnDatePayload { - time: string; - args: string[]; - source: string; - database: number; - shardOptions: IORedis.RedisOptions -} - -export interface IClientMonitorObserver { - id: string; - handleOnData: (data: IOnDatePayload) => void; - handleOnDisconnect: () => void; -} diff --git a/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/client-monitor-observer.spec.ts b/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/client-monitor-observer.spec.ts deleted file mode 100644 index 7f495e207d..0000000000 --- a/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/client-monitor-observer.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { WsException } from '@nestjs/websockets'; -import * as MockedSocket from 'socket.io-mock'; -import ERROR_MESSAGES from 'src/constants/error-messages'; -import { MonitorGatewayServerEvents } from 'src/modules/monitor/constants/events'; -import ClientMonitorObserver from './client-monitor-observer'; -import { IOnDatePayload } from './client-monitor-observer.interface'; - -describe('ClientMonitorObserver', () => { - let socketClient; - - beforeEach(() => { - socketClient = new MockedSocket(); - socketClient.id = '123'; - socketClient.emit = jest.fn(); - }); - - it.only('should be defined', () => { - const client = new ClientMonitorObserver(socketClient.id, socketClient); - - expect(client.id).toEqual(socketClient.id); - }); - it.only('should emit event on monitorData', async () => { - const client = new ClientMonitorObserver(socketClient.id, socketClient); - const monitorData = { - // unix timestamp - time: `${(new Date()).getTime() / 1000}`, - source: '127.0.0.1:58612', - database: 0, - args: ['set', 'foo', 'bar'], - }; - const payload: IOnDatePayload = { ...monitorData, shardOptions: { host: '127.0.0.1', port: 6379 } }; - - client.handleOnData(payload); - - await new Promise((r) => setTimeout(r, 500)); - - expect(socketClient.emit).toHaveBeenCalledWith(MonitorGatewayServerEvents.Data, [monitorData]); - }); - it.only('should emit exception event', () => { - const client = new ClientMonitorObserver(socketClient.id, socketClient); - - client.handleOnDisconnect(); - - expect(socketClient.emit).toHaveBeenCalledWith( - MonitorGatewayServerEvents.Exception, - new WsException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), - ); - }); -}); diff --git a/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/client-monitor-observer.ts b/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/client-monitor-observer.ts deleted file mode 100644 index ee1757faac..0000000000 --- a/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/client-monitor-observer.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Socket } from 'socket.io'; -import { MonitorGatewayServerEvents } from 'src/modules/monitor/constants/events'; -import { WsException } from '@nestjs/websockets'; -import ERROR_MESSAGES from 'src/constants/error-messages'; -import { debounce } from 'lodash'; -import { IClientMonitorObserver, IOnDatePayload } from './client-monitor-observer.interface'; - -class ClientMonitorObserver implements IClientMonitorObserver { - public readonly id: string; - - private readonly client: Socket; - - private filters: any[]; - - private readonly debounce: any; - - private items: any[]; - - constructor(id: string, client: Socket) { - this.id = id; - this.client = client; - this.items = []; - this.debounce = debounce(() => { - if (this.items.length) { - this.client.emit(MonitorGatewayServerEvents.Data, this.items); - this.items = []; - } - }, 10, { - maxWait: 50, - }); - } - - public handleOnData(payload: IOnDatePayload) { - const { - time, args, source, database, - } = payload; - - this.items.push({ - time, args, source, database, - }); - - this.debounce(); - } - - public handleOnDisconnect() { - this.client.emit( - MonitorGatewayServerEvents.Exception, - new WsException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), - ); - } -} -export default ClientMonitorObserver; diff --git a/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/index.ts b/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/index.ts deleted file mode 100644 index 0605f6419d..0000000000 --- a/redisinsight/api/src/modules/monitor/helpers/client-monitor-observer/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './client-monitor-observer' -export * from './client-monitor-observer.interface' diff --git a/redisinsight/api/src/modules/monitor/helpers/monitor-observer/index.ts b/redisinsight/api/src/modules/monitor/helpers/monitor-observer/index.ts deleted file mode 100644 index 6ef9a6501d..0000000000 --- a/redisinsight/api/src/modules/monitor/helpers/monitor-observer/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './monitor-observer.interface'; -export * from './monitor-observer'; -export * from './shard-obsever.interface'; diff --git a/redisinsight/api/src/modules/monitor/helpers/monitor-observer/monitor-observer.interface.ts b/redisinsight/api/src/modules/monitor/helpers/monitor-observer/monitor-observer.interface.ts deleted file mode 100644 index 6c331c00e9..0000000000 --- a/redisinsight/api/src/modules/monitor/helpers/monitor-observer/monitor-observer.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IClientMonitorObserver } from '../client-monitor-observer'; - -export enum MonitorObserverStatus { - Wait = 'wait', - Ready = 'ready', - End = 'end', - Error = 'error', -} - -export interface IMonitorObserver { - status: MonitorObserverStatus; - subscribe: (client: IClientMonitorObserver) => Promise; - unsubscribe: (id: string) => void; - getSize: () => number; - clear: () => void; -} diff --git a/redisinsight/api/src/modules/monitor/helpers/monitor-observer/monitor-observer.spec.ts b/redisinsight/api/src/modules/monitor/helpers/monitor-observer/monitor-observer.spec.ts deleted file mode 100644 index ffde3a2d74..0000000000 --- a/redisinsight/api/src/modules/monitor/helpers/monitor-observer/monitor-observer.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ForbiddenException } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import { mockClientMonitorObserver, mockRedisMonitorObserver } from 'src/__mocks__/monitor'; -import { ReplyError } from 'src/models'; -import { mockRedisNoPermError } from 'src/__mocks__'; -import { MonitorObserverStatus } from './monitor-observer.interface'; -import { MonitorObserver } from './monitor-observer'; - -const nodeClient = Object.create(Redis.prototype); -nodeClient.monitor = jest.fn().mockResolvedValue(mockRedisMonitorObserver); -nodeClient.status = 'ready'; -nodeClient.options = { ...nodeClient.options, host: 'localhost', port: 6379 }; - -const clusterClient = Object.create(Redis.Cluster.prototype); -const mockClusterNode1 = nodeClient; -const mockClusterNode2 = nodeClient; -mockClusterNode1.options = { ...nodeClient.options, host: 'localhost', port: 5000 }; -mockClusterNode2.options = { ...nodeClient.options, host: 'localhost', port: 5001 }; - -clusterClient.nodes = jest.fn().mockReturnValue([mockClusterNode1, mockClusterNode2]); - -const NO_PERM_ERROR: ReplyError = { - ...mockRedisNoPermError, - command: 'MONITOR', -}; - -describe('MonitorObserver', () => { - describe('for redis standalone', () => { - let monitorObserver; - beforeEach(() => { - MonitorObserver.isMonitorAvailable = jest.fn().mockResolvedValue(true); - monitorObserver = new MonitorObserver(nodeClient); - }); - - it('should create shard observer only on first subscribe call', async () => { - const connectMethod = jest.spyOn(monitorObserver, 'connect'); - - await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); - await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '2' }); - await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '2' }); - - expect(monitorObserver.status).toBe(MonitorObserverStatus.Ready); - expect(connectMethod).toHaveBeenCalledTimes(1); - expect(monitorObserver.clientMonitorObservers.has('1')).toEqual(true); - expect(monitorObserver.clientMonitorObservers.has('2')).toEqual(true); - expect(monitorObserver.shardsObservers.length).toEqual(1); - expect(monitorObserver.getSize()).toEqual(2); - }); - it('should be set to END status on clear', async () => { - await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); - await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '2' }); - - monitorObserver.clear(); - - expect(monitorObserver.status).toBe(MonitorObserverStatus.End); - expect(monitorObserver.getSize()).toEqual(0); - expect(monitorObserver.shardsObservers.length).toEqual(0); - }); - it('should be set to END status if there are no more observers', async () => { - await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); - await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '2' }); - - monitorObserver.unsubscribe('1'); - monitorObserver.unsubscribe('2'); - - expect(monitorObserver.status).toBe(MonitorObserverStatus.End); - expect(monitorObserver.getSize()).toEqual(0); - expect(monitorObserver.shardsObservers.length).toEqual(0); - }); - it('should throw ForbiddenException if a user has no permissions', async () => { - MonitorObserver.isMonitorAvailable = jest.fn().mockRejectedValue(NO_PERM_ERROR); - - await expect( - monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }), - ).rejects.toThrow(ForbiddenException); - }); - }); - describe('for redis cluster', () => { - let monitorObserver; - beforeEach(() => { - MonitorObserver.isMonitorAvailable = jest.fn().mockResolvedValue(true); - monitorObserver = new MonitorObserver(clusterClient); - }); - - it('should create shard observer only on first subscribe call', async () => { - const connectMethod = jest.spyOn(monitorObserver, 'connect'); - - await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); - - expect(monitorObserver.status).toBe(MonitorObserverStatus.Ready); - expect(connectMethod).toHaveBeenCalledTimes(1); - expect(monitorObserver.clientMonitorObservers.has('1')).toEqual(true); - expect(monitorObserver.shardsObservers.length).toEqual(2); - expect(monitorObserver.getSize()).toEqual(1); - }); - it('should be set to END status on clear', async () => { - await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); - - monitorObserver.clear(); - - expect(monitorObserver.status).toBe(MonitorObserverStatus.End); - expect(monitorObserver.getSize()).toEqual(0); - expect(monitorObserver.shardsObservers.length).toEqual(0); - }); - // eslint-disable-next-line sonarjs/no-identical-functions - it('should throw ForbiddenException if a user has no permissions', async () => { - MonitorObserver.isMonitorAvailable = jest.fn().mockRejectedValue(NO_PERM_ERROR); - - await expect( - monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }), - ).rejects.toThrow(ForbiddenException); - }); - }); -}); diff --git a/redisinsight/api/src/modules/monitor/helpers/monitor-observer/monitor-observer.ts b/redisinsight/api/src/modules/monitor/helpers/monitor-observer/monitor-observer.ts deleted file mode 100644 index 36a1f72362..0000000000 --- a/redisinsight/api/src/modules/monitor/helpers/monitor-observer/monitor-observer.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { ForbiddenException, ServiceUnavailableException } from '@nestjs/common'; -import IORedis from 'ioredis'; -import ERROR_MESSAGES from 'src/constants/error-messages'; -import { RedisErrorCodes } from 'src/constants'; -import { IMonitorObserver, MonitorObserverStatus } from './monitor-observer.interface'; -import { IShardObserver } from './shard-obsever.interface'; -import { IClientMonitorObserver } from '../client-monitor-observer'; - -export class MonitorObserver implements IMonitorObserver { - private readonly redis: IORedis.Redis | IORedis.Cluster; - - private clientMonitorObservers: Map = new Map(); - - private shardsObservers: IShardObserver[] = []; - - public status: MonitorObserverStatus; - - constructor(redis: IORedis.Redis | IORedis.Cluster) { - this.redis = redis; - this.status = MonitorObserverStatus.Wait; - } - - public async subscribe(client: IClientMonitorObserver) { - if (this.status !== MonitorObserverStatus.Ready) { - await this.connect(); - } - if (this.clientMonitorObservers.has(client.id)) { - return; - } - - this.shardsObservers.forEach((observer) => { - observer.on('monitor', (time, args, source, database) => { - client.handleOnData({ - time, args, database, source, shardOptions: observer.options, - }); - }); - observer.on('end', () => { - client.handleOnDisconnect(); - this.clear(); - }); - }); - this.clientMonitorObservers.set(client.id, client); - } - - public unsubscribe(id: string) { - this.clientMonitorObservers.delete(id); - if (this.clientMonitorObservers.size === 0) { - this.clear(); - } - } - - public clear() { - this.clientMonitorObservers.clear(); - this.shardsObservers.forEach((observer) => observer.disconnect()); - this.shardsObservers = []; - this.status = MonitorObserverStatus.End; - } - - public getSize(): number { - return this.clientMonitorObservers.size; - } - - private async connect(): Promise { - try { - if (this.redis instanceof IORedis.Cluster) { - this.shardsObservers = await Promise.all( - this.redis.nodes('all').filter((node) => node.status === 'ready').map(this.createShardObserver), - ); - } else { - this.shardsObservers = [await this.createShardObserver(this.redis)]; - } - this.status = MonitorObserverStatus.Ready; - } catch (error) { - this.status = MonitorObserverStatus.Error; - - if (error?.message?.includes(RedisErrorCodes.NoPermission)) { - throw new ForbiddenException(error.message); - } - - throw new ServiceUnavailableException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB); - } - } - - private async createShardObserver(redis: IORedis.Redis): Promise { - // HACK: ioredis impropriety throw error a user has no permissions to run the 'monitor' command - await MonitorObserver.isMonitorAvailable(redis); - return await redis.monitor() as IShardObserver; - } - - static async isMonitorAvailable(redis: IORedis.Redis): Promise { - // @ts-ignore - const duplicate = redis.duplicate({ - ...redis.options, - monitor: false, - lazyLoading: false, - }); - - await duplicate.send_command('monitor'); - duplicate.disconnect(); - - return true; - } -} diff --git a/redisinsight/api/src/modules/monitor/monitor.gateway.ts b/redisinsight/api/src/modules/monitor/monitor.gateway.ts deleted file mode 100644 index 47d17046ab..0000000000 --- a/redisinsight/api/src/modules/monitor/monitor.gateway.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Logger } from '@nestjs/common'; -import { - OnGatewayConnection, - OnGatewayDisconnect, - OnGatewayInit, - SubscribeMessage, - WebSocketGateway, - WebSocketServer, - WsException, -} from '@nestjs/websockets'; -import { Socket, Server } from 'socket.io'; -import { get } from 'lodash'; -import config from 'src/utils/config'; -import { MonitorService } from './monitor.service'; -import { MonitorGatewayClientEvents } from './constants/events'; -import ClientMonitorObserver from './helpers/client-monitor-observer/client-monitor-observer'; - -const SOCKETS_CONFIG = config.get('sockets'); - -@WebSocketGateway({ namespace: 'monitor', cors: SOCKETS_CONFIG.cors, serveClient: SOCKETS_CONFIG.serveClient }) -export class MonitorGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { - @WebSocketServer() wss: Server; - - private logger: Logger = new Logger('MonitorGateway'); - - constructor(private service: MonitorService) {} - - afterInit(): void { - this.logger.log('Initialized'); - } - - async handleConnection(socketClient: Socket): Promise { - // eslint-disable-next-line sonarjs/no-duplicate-string - const instanceId = get(socketClient, 'handshake.query.instanceId'); - this.logger.log(`Client connected: ${socketClient.id}, instanceId: ${instanceId}`); - } - - @SubscribeMessage(MonitorGatewayClientEvents.Monitor) - async monitor(socketClient: Socket): Promise { - try { - const instanceId = get(socketClient, 'handshake.query.instanceId'); - await this.service.addListenerForInstance( - instanceId, - new ClientMonitorObserver(socketClient.id, socketClient), - ); - return { status: 'ok' }; - } catch (error) { - throw new WsException(error); - } - } - - handleDisconnect(socketClient: Socket): void { - const instanceId = get(socketClient, 'handshake.query.instanceId'); - this.logger.log(`Client disconnected: ${socketClient.id}, instanceId: ${instanceId}`); - this.service.removeListenerFromInstance(instanceId, socketClient.id); - } -} diff --git a/redisinsight/api/src/modules/monitor/monitor.module.ts b/redisinsight/api/src/modules/monitor/monitor.module.ts deleted file mode 100644 index 56e3e6d8a3..0000000000 --- a/redisinsight/api/src/modules/monitor/monitor.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SharedModule } from 'src/modules/shared/shared.module'; -import { MonitorGateway } from './monitor.gateway'; -import { MonitorService } from './monitor.service'; - -@Module({ - imports: [SharedModule], - providers: [MonitorGateway, MonitorService], -}) -export class MonitorModule {} diff --git a/redisinsight/api/src/modules/monitor/monitor.service.spec.ts b/redisinsight/api/src/modules/monitor/monitor.service.spec.ts deleted file mode 100644 index 53027278b5..0000000000 --- a/redisinsight/api/src/modules/monitor/monitor.service.spec.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { ServiceUnavailableException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { v4 as uuidv4 } from 'uuid'; -import { mockClientMonitorObserver, mockMonitorObserver } from 'src/__mocks__/monitor'; -import ERROR_MESSAGES from 'src/constants/error-messages'; -import { RedisService } from 'src/modules/core/services/redis/redis.service'; -import { mockRedisClientInstance } from 'src/modules/shared/services/base/redis-consumer.abstract.service.spec'; -import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; -import { MonitorService } from './monitor.service'; -import { MonitorObserver } from './helpers/monitor-observer'; - -jest.mock('./helpers/monitor-observer'); - -describe('MonitorService', () => { - let service; - let redisService; - let instancesBusinessService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MonitorService, - { - provide: RedisService, - useFactory: () => ({ - getClientInstance: jest.fn(), - isClientConnected: jest.fn(), - }), - }, - { - provide: InstancesBusinessService, - useFactory: () => ({ - connectToInstance: jest.fn(), - }), - }, - ], - }).compile(); - - service = module.get(MonitorService); - redisService = await module.get(RedisService); - instancesBusinessService = await module.get(InstancesBusinessService); - }); - - describe('addListenerForInstance', () => { - let getRedisClientForInstance; - beforeEach(() => { - getRedisClientForInstance = jest.spyOn(service, 'getRedisClientForInstance'); - service.monitorObservers = {}; - }); - - it('should use exist redis client and create new monitor observer', async () => { - const { instanceId } = mockRedisClientInstance; - redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); - redisService.isClientConnected.mockReturnValue(true); - - await service.addListenerForInstance(instanceId, mockClientMonitorObserver); - - expect(getRedisClientForInstance).toHaveBeenCalledWith(instanceId); - expect(MonitorObserver).toHaveBeenCalled(); - expect(service.monitorObservers[instanceId]).toBeDefined(); - }); - it('should use exist monitor observer for instance', async () => { - const { instanceId } = mockRedisClientInstance; - service.monitorObservers = { [instanceId]: { ...mockMonitorObserver, status: 'ready' } }; - - await service.addListenerForInstance(instanceId, mockClientMonitorObserver); - await service.addListenerForInstance(instanceId, mockClientMonitorObserver); - - expect(getRedisClientForInstance).not.toHaveBeenCalled(); - expect(Object.keys(service.monitorObservers).length).toEqual(1); - }); - it('should recreate exist monitor observer', async () => { - const { instanceId } = mockRedisClientInstance; - service.monitorObservers = { [instanceId]: { ...mockMonitorObserver, status: 'end' } }; - redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); - redisService.isClientConnected.mockReturnValue(true); - - await service.addListenerForInstance(instanceId, mockClientMonitorObserver); - - expect(MonitorObserver).toHaveBeenCalled(); - expect(getRedisClientForInstance).toHaveBeenCalled(); - expect(Object.keys(service.monitorObservers).length).toEqual(1); - }); - it('should recreate redis client', async () => { - const { instanceId } = mockRedisClientInstance; - redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); - redisService.isClientConnected.mockReturnValue(false); - instancesBusinessService.connectToInstance.mockResolvedValue(mockRedisClientInstance); - - await service.addListenerForInstance(instanceId, mockClientMonitorObserver); - - expect(instancesBusinessService.connectToInstance).toHaveBeenCalled(); - }); - it('should throw timeout exception on create redis client', async () => { - const { instanceId } = mockRedisClientInstance; - redisService.getClientInstance.mockReturnValue(null); - instancesBusinessService.connectToInstance = jest.fn() - .mockReturnValue(new Promise(() => {})); - - try { - await service.addListenerForInstance(instanceId, mockClientMonitorObserver); - } catch (error) { - expect(error).toEqual(new ServiceUnavailableException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB)); - } - }); - }); - describe('removeListenerFromInstance', () => { - beforeEach(() => { - service.monitorObservers = {}; - }); - - it('should unsubscribe listeners from monitor observer', async () => { - const { instanceId } = mockRedisClientInstance; - const listenerId = uuidv4(); - const monitorObserver = { ...mockMonitorObserver, status: 'ready', unsubscribe: jest.fn() }; - service.monitorObservers = { [instanceId]: monitorObserver }; - - service.removeListenerFromInstance(instanceId, listenerId); - - expect(monitorObserver.unsubscribe).toHaveBeenCalledWith(listenerId); - }); - it('should be ignored if monitor observer does not exist for instance', () => { - const { instanceId } = mockRedisClientInstance; - const listenerId = uuidv4(); - const monitorObserver = { ...mockMonitorObserver, status: 'ready', unsubscribe: jest.fn() }; - service.monitorObservers = { [instanceId]: monitorObserver }; - - service.removeListenerFromInstance(uuidv4(), listenerId); - - expect(monitorObserver.unsubscribe).not.toHaveBeenCalled(); - }); - }); - - describe('handleInstanceDeletedEvent', () => { - beforeEach(() => { - service.monitorObservers = {}; - }); - - it('should clear exist monitor observer fro instance', () => { - const { instanceId } = mockRedisClientInstance; - const monitorObserver = { ...mockMonitorObserver, status: 'ready', clear: jest.fn() }; - service.monitorObservers = { [instanceId]: monitorObserver }; - - service.handleInstanceDeletedEvent(instanceId); - - expect(monitorObserver.clear).toHaveBeenCalled(); - expect(service.monitorObservers[instanceId]).not.toBeDefined(); - }); - it('should be ignored if monitor observer does not exist for instance', () => { - const { instanceId } = mockRedisClientInstance; - const monitorObserver = { ...mockMonitorObserver, status: 'ready', clear: jest.fn() }; - service.monitorObservers = { [instanceId]: monitorObserver }; - - service.handleInstanceDeletedEvent(uuidv4()); - - expect(monitorObserver.clear).not.toHaveBeenCalled(); - expect(service.monitorObservers[instanceId]).toBeDefined(); - }); - }); -}); diff --git a/redisinsight/api/src/modules/monitor/monitor.service.ts b/redisinsight/api/src/modules/monitor/monitor.service.ts deleted file mode 100644 index 69819a4380..0000000000 --- a/redisinsight/api/src/modules/monitor/monitor.service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import IORedis from 'ioredis'; -import { AppTool } from 'src/models'; -import config from 'src/utils/config'; -import { AppRedisInstanceEvents } from 'src/constants'; -import ERROR_MESSAGES from 'src/constants/error-messages'; -import { withTimeout } from 'src/utils/promise-with-timeout'; -import { RedisService } from 'src/modules/core/services/redis/redis.service'; -import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; -import { IMonitorObserver, MonitorObserver, MonitorObserverStatus } from './helpers/monitor-observer'; -import { IClientMonitorObserver } from './helpers/client-monitor-observer/client-monitor-observer.interface'; - -const serverConfig = config.get('server'); - -@Injectable() -export class MonitorService { - private logger = new Logger('MonitorService'); - - private monitorObservers: Record = {}; - - constructor( - private redisService: RedisService, - private instancesBusinessService: InstancesBusinessService, - ) {} - - async addListenerForInstance(instanceId: string, client: IClientMonitorObserver) { - this.logger.log(`Add listener for instance: ${instanceId}.`); - const monitorObserver = await this.getMonitorObserver(instanceId); - await monitorObserver.subscribe(client); - } - - removeListenerFromInstance(instanceId: string, listenerId: string) { - this.logger.log(`Remove listener from instance: ${instanceId}.`); - const observer = this.monitorObservers[instanceId]; - if (observer) { - observer.unsubscribe(listenerId); - } - } - - @OnEvent(AppRedisInstanceEvents.Deleted) - handleInstanceDeletedEvent(instanceId: string) { - this.logger.log(`Handle instance deleted event. instance: ${instanceId}.`); - try { - const monitorObserver = this.monitorObservers[instanceId]; - if (monitorObserver) { - monitorObserver.clear(); - delete this.monitorObservers[instanceId]; - } - } catch (e) { - // continue regardless of error - } - } - - private async getMonitorObserver(instanceId: string): Promise { - this.logger.log('Getting redis monitor observer...'); - try { - if ( - !this.monitorObservers[instanceId] - || this.monitorObservers[instanceId].status !== MonitorObserverStatus.Ready - ) { - const redisClient = await this.getRedisClientForInstance(instanceId); - this.monitorObservers[instanceId] = new MonitorObserver(redisClient); - } - this.logger.log('Succeed to get monitor observer.'); - return this.monitorObservers[instanceId]; - } catch (error) { - this.logger.error(`Failed to get monitor observer. ${error.message}.`, JSON.stringify(error)); - throw error; - } - } - - private async getRedisClientForInstance(instanceId: string): Promise { - const tool = AppTool.Common; - const commonClient = this.redisService.getClientInstance({ instanceId, tool })?.client; - if (commonClient && this.redisService.isClientConnected(commonClient)) { - return commonClient; - } - return withTimeout( - this.instancesBusinessService.connectToInstance(instanceId, tool, true), - serverConfig.requestTimeout, - new ServiceUnavailableException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), - ); - } -} diff --git a/redisinsight/api/src/modules/profiler/constants/index.ts b/redisinsight/api/src/modules/profiler/constants/index.ts new file mode 100644 index 0000000000..3b3e78fb61 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/constants/index.ts @@ -0,0 +1,20 @@ +export enum ProfilerClientEvents { + Monitor = 'monitor', + Pause = 'pause', + FlushLogs = 'flushLogs', +} + +export enum ProfilerServerEvents { + Data = 'monitorData', + Exception = 'exception', +} + +export enum RedisObserverStatus { + Empty = 'empty', + Initializing = 'initializing', + Connected = 'connected', + Wait = 'wait', + Ready = 'ready', + End = 'end', + Error = 'error', +} diff --git a/redisinsight/api/src/modules/profiler/emitters/client.logs-emitter.ts b/redisinsight/api/src/modules/profiler/emitters/client.logs-emitter.ts new file mode 100644 index 0000000000..cadc56a7a7 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/emitters/client.logs-emitter.ts @@ -0,0 +1,24 @@ +import { Socket } from 'socket.io'; +import { ILogsEmitter } from 'src/modules/profiler/interfaces/logs-emitter.interface'; +import { ProfilerServerEvents } from 'src/modules/profiler/constants'; + +export class ClientLogsEmitter implements ILogsEmitter { + private readonly client: Socket; + + public readonly id: string; + + constructor(client: Socket) { + this.id = client.id; + this.client = client; + } + + public async emit(items: any[]) { + return this.client.emit(ProfilerServerEvents.Data, items); + } + + public addProfilerClient() {} + + public removeProfilerClient() {} + + public flushLogs() {} +} diff --git a/redisinsight/api/src/modules/profiler/emitters/file.logs-emitter.ts b/redisinsight/api/src/modules/profiler/emitters/file.logs-emitter.ts new file mode 100644 index 0000000000..cdfb9fd2bf --- /dev/null +++ b/redisinsight/api/src/modules/profiler/emitters/file.logs-emitter.ts @@ -0,0 +1,46 @@ +import { ILogsEmitter } from 'src/modules/profiler/interfaces/logs-emitter.interface'; +import { LogFile } from 'src/modules/profiler/models/log-file'; + +class FileLogsEmitter implements ILogsEmitter { + public readonly id: string; + + private readonly logFile: LogFile; + + constructor(logFile: LogFile) { + this.id = logFile.id; + this.logFile = logFile; + } + + /** + * Write batch of logs to a file + */ + async emit(items: any[]) { + try { + if (!this.logFile.getWriteStream()) { + return; + } + + const text = items.map((item) => { + const args = (item.args.map((arg) => `${JSON.stringify(arg)}`)).join(' '); + return `${item.time} [${item.database} ${item.source}] ${args}`; + }).join('\n'); + + this.logFile.getWriteStream().write(`${text}\n`); + } catch (e) { + // ignore error + } + } + + async addProfilerClient(id: string) { + return this.logFile.addProfilerClient(id); + } + + async removeProfilerClient(id: string) { + return this.logFile.removeProfilerClient(id); + } + + async flushLogs() { + return this.logFile.destroy(); + } +} +export default FileLogsEmitter; diff --git a/redisinsight/api/src/modules/profiler/interfaces/logs-emitter.interface.ts b/redisinsight/api/src/modules/profiler/interfaces/logs-emitter.interface.ts new file mode 100644 index 0000000000..ca10f1b3bb --- /dev/null +++ b/redisinsight/api/src/modules/profiler/interfaces/logs-emitter.interface.ts @@ -0,0 +1,7 @@ +export interface ILogsEmitter { + id: string; + emit: (items: any[]) => void; + addProfilerClient: (id: string) => void; + removeProfilerClient: (id: string) => void; + flushLogs: () => void; +} diff --git a/redisinsight/api/src/modules/profiler/interfaces/monitor-data.interface.ts b/redisinsight/api/src/modules/profiler/interfaces/monitor-data.interface.ts new file mode 100644 index 0000000000..b378b465e5 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/interfaces/monitor-data.interface.ts @@ -0,0 +1,9 @@ +import IORedis from 'ioredis'; + +export interface IMonitorData { + time: string; + args: string[]; + source: string; + database: number; + shardOptions: IORedis.RedisOptions +} diff --git a/redisinsight/api/src/modules/monitor/helpers/monitor-observer/shard-obsever.interface.ts b/redisinsight/api/src/modules/profiler/interfaces/shard-observer.interface.ts similarity index 100% rename from redisinsight/api/src/modules/monitor/helpers/monitor-observer/shard-obsever.interface.ts rename to redisinsight/api/src/modules/profiler/interfaces/shard-observer.interface.ts diff --git a/redisinsight/api/src/modules/profiler/models/log-file.spec.ts b/redisinsight/api/src/modules/profiler/models/log-file.spec.ts new file mode 100644 index 0000000000..cf37ba047c --- /dev/null +++ b/redisinsight/api/src/modules/profiler/models/log-file.spec.ts @@ -0,0 +1,83 @@ +xdescribe('dummy', () => { + it('dummy', () => {}); +}); + +// import * as fs from 'fs-extra'; +// import { WsException } from '@nestjs/websockets'; +// import * as MockedSocket from 'socket.io-mock'; +// import ERROR_MESSAGES from 'src/constants/error-messages'; +// import { LogFile } from 'src/modules/profiler/models/log-file'; +// import { ProfilerClient } from './profiler.client'; +// +// describe('LogFile', () => { +// let socketClient; +// +// beforeEach(() => { +// socketClient = new MockedSocket(); +// socketClient.id = '123'; +// socketClient.emit = jest.fn(); +// }); +// +// it('should create log file', async () => { +// const logFile = new LogFile('1234'); +// const writeStream = logFile.getWriteStream(); +// writeStream.on('error', (e) => { +// console.log('ERROR: ', e); +// }); +// writeStream.on('close', () => { +// console.log('CLOSED: '); +// }); +// await new Promise((res) => { +// writeStream.on('ready', () => { +// console.log('READY'); +// res(null); +// }); +// writeStream.on('open', () => { +// console.log('OPENED'); +// res(null); +// }); +// +// }); +// await writeStream.write('aaa'); +// await writeStream.write('bbb'); +// const { path } = writeStream; +// console.log('1', fs.readFileSync(path).toString()) +// await fs.unlink(path); +// // console.log('2', fs.readFileSync(path).toString()) +// writeStream.write('ccc'); +// writeStream.write('ddd'); +// writeStream.close(); +// // console.log('3', fs.readFileSync(path).toString()) +// // const client = new ProfilerClient(socketClient.id, socketClient); +// +// // expect(client.id).toEqual(socketClient.id); +// await new Promise((res) => setTimeout(res, 2000)); +// }); +// // it('should emit event on monitorData', async () => { +// // const client = new ProfilerClient(socketClient.id, socketClient); +// // const monitorData = { +// // // unix timestamp +// // time: `${(new Date()).getTime() / 1000}`, +// // source: '127.0.0.1:58612', +// // database: 0, +// // args: ['set', 'foo', 'bar'], +// // }; +// // const payload: IOnDatePayload = { ...monitorData, shardOptions: { host: '127.0.0.1', port: 6379 } }; +// // +// // client.handleOnData(payload); +// // +// // await new Promise((r) => setTimeout(r, 500)); +// // +// // expect(socketClient.emit).toHaveBeenCalledWith(MonitorGatewayServerEvents.Data, [monitorData]); +// // }); +// // it.only('should emit exception event', () => { +// // const client = new ProfilerClient(socketClient.id, socketClient); +// // +// // client.handleOnDisconnect(); +// // +// // expect(socketClient.emit).toHaveBeenCalledWith( +// // MonitorGatewayServerEvents.Exception, +// // new WsException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), +// // ); +// // }); +// }); diff --git a/redisinsight/api/src/modules/profiler/models/log-file.ts b/redisinsight/api/src/modules/profiler/models/log-file.ts new file mode 100644 index 0000000000..3277b35be5 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/models/log-file.ts @@ -0,0 +1,132 @@ +import { join } from 'path'; +import * as fs from 'fs-extra'; +import { ReadStream, WriteStream } from 'fs'; +import config from 'src/utils/config'; +import FileLogsEmitter from 'src/modules/profiler/emitters/file.logs-emitter'; +import { TelemetryEvents } from 'src/constants'; + +const DIR_PATH = config.get('dir_path'); +const PROFILER = config.get('profiler'); + +export class LogFile { + private readonly filePath: string; + + private startTime: Date; + + private writeStream: WriteStream; + + private emitter: FileLogsEmitter; + + private readonly clientObservers: Map = new Map(); + + private idleSince: number = 0; + + private alias: string; + + private analyticsEvents: Map; + + public readonly instanceId: string; + + public readonly id: string; + + constructor(instanceId: string, id: string, analyticsEvents?: Map) { + this.instanceId = instanceId; + this.id = id; + this.alias = id; + this.filePath = join(DIR_PATH.tmpDir, this.id); + this.startTime = new Date(); + this.analyticsEvents = analyticsEvents || new Map(); + } + + /** + * Get or create file write stream to write logs + */ + getWriteStream(): WriteStream { + if (!this.writeStream) { + this.writeStream = fs.createWriteStream(this.filePath, { flags: 'a' }); + } + this.writeStream.on('error', () => {}); + return this.writeStream; + } + + /** + * Get readable stream of the logs file + * Used to download file using http server + */ + getReadStream(): ReadStream { + const stream = fs.createReadStream(this.filePath); + stream.once('end', () => { + stream.destroy(); + try { + this.analyticsEvents.get(TelemetryEvents.ProfilerLogDownloaded)(this.instanceId, this.getFileSize()); + } catch (e) { + // ignore analytics errors + } + // logFile.destroy(); + }); + + return stream; + } + + /** + * Get or create logs emitter to use on each 'monitor' event + */ + getEmitter(): FileLogsEmitter { + if (!this.emitter) { + this.emitter = new FileLogsEmitter(this); + } + + return this.emitter; + } + + /** + * Generate file name + */ + getFilename(): string { + return `${this.alias}-${this.startTime.getTime()}-${Date.now()}`; + } + + getFileSize(): number { + const stats = fs.statSync(this.filePath); + return stats.size; + } + + setAlias(alias: string) { + this.alias = alias; + } + + addProfilerClient(id: string) { + this.clientObservers.set(id, id); + this.idleSince = 0; + } + + removeProfilerClient(id: string) { + this.clientObservers.delete(id); + + if (!this.clientObservers.size) { + this.idleSince = Date.now(); + + setTimeout(() => { + if (this?.idleSince && Date.now() - this.idleSince >= PROFILER.logFileIdleThreshold) { + this.destroy(); + } + }, PROFILER.logFileIdleThreshold); + } + } + + /** + * Remove file and delete write stream after finish + */ + async destroy() { + try { + this.writeStream?.close(); + this.writeStream = null; + const size = this.getFileSize(); + await fs.unlink(this.filePath); + + this.analyticsEvents.get(TelemetryEvents.ProfilerLogDeleted)(this.instanceId, size); + } catch (e) { + // ignore error + } + } +} diff --git a/redisinsight/api/src/modules/profiler/models/monitor-settings.ts b/redisinsight/api/src/modules/profiler/models/monitor-settings.ts new file mode 100644 index 0000000000..f3c6f10818 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/models/monitor-settings.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class MonitorSettings { + @IsString() + logFileId: string; +} diff --git a/redisinsight/api/src/modules/profiler/models/profiler.client.spec.ts b/redisinsight/api/src/modules/profiler/models/profiler.client.spec.ts new file mode 100644 index 0000000000..fbeeb9d0b6 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/models/profiler.client.spec.ts @@ -0,0 +1,53 @@ +xdescribe('dummy', () => { + it('dummy', () => {}); +}); + +// import { WsException } from '@nestjs/websockets'; +// import * as MockedSocket from 'socket.io-mock'; +// import ERROR_MESSAGES from 'src/constants/error-messages'; +// import { MonitorGatewayServerEvents } from 'src/modules/profiler/constants/events'; +// import { ProfilerClient } from './profiler.client'; +// import { IOnDatePayload } from '../interfaces/client-monitor-observer.interface'; +// +// describe('ClientMonitorObserver', () => { +// let socketClient; +// +// beforeEach(() => { +// socketClient = new MockedSocket(); +// socketClient.id = '123'; +// socketClient.emit = jest.fn(); +// }); +// +// it.only('should be defined', () => { +// const client = new ProfilerClient(socketClient.id, socketClient); +// +// expect(client.id).toEqual(socketClient.id); +// }); +// it.only('should emit event on monitorData', async () => { +// const client = new ProfilerClient(socketClient.id, socketClient); +// const monitorData = { +// // unix timestamp +// time: `${(new Date()).getTime() / 1000}`, +// source: '127.0.0.1:58612', +// database: 0, +// args: ['set', 'foo', 'bar'], +// }; +// const payload: IOnDatePayload = { ...monitorData, shardOptions: { host: '127.0.0.1', port: 6379 } }; +// +// client.handleOnData(payload); +// +// await new Promise((r) => setTimeout(r, 500)); +// +// expect(socketClient.emit).toHaveBeenCalledWith(MonitorGatewayServerEvents.Data, [monitorData]); +// }); +// it.only('should emit exception event', () => { +// const client = new ProfilerClient(socketClient.id, socketClient); +// +// client.handleOnDisconnect(); +// +// expect(socketClient.emit).toHaveBeenCalledWith( +// MonitorGatewayServerEvents.Exception, +// new WsException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), +// ); +// }); +// }); diff --git a/redisinsight/api/src/modules/profiler/models/profiler.client.ts b/redisinsight/api/src/modules/profiler/models/profiler.client.ts new file mode 100644 index 0000000000..dceea997e6 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/models/profiler.client.ts @@ -0,0 +1,83 @@ +import { Socket } from 'socket.io'; +import { debounce } from 'lodash'; +import { WsException } from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { ProfilerServerEvents } from 'src/modules/profiler/constants'; +import { ILogsEmitter } from 'src/modules/profiler/interfaces/logs-emitter.interface'; +import { IMonitorData } from 'src/modules/profiler/interfaces/monitor-data.interface'; +import ERROR_MESSAGES from 'src/constants/error-messages'; + +export class ProfilerClient { + private logger = new Logger('ProfilerClient'); + + public readonly id: string; + + private readonly client: Socket; + + private logsEmitters: Map = new Map(); + + private filters: any[]; + + private readonly debounce: any; + + private items: any[]; + + constructor(id: string, client: Socket) { + this.id = id; + this.client = client; + this.items = []; + this.debounce = debounce(() => { + if (this.items.length) { + this.logsEmitters.forEach((emitter) => { + emitter.emit(this.items); + }); + this.items = []; + } + }, 10, { + maxWait: 50, + }); + } + + public handleOnData(payload: IMonitorData) { + const { + time, args, source, database, + } = payload; + + this.items.push({ + time, args, source, database, + }); + + this.debounce(); + } + + public handleOnDisconnect() { + this.client.emit( + ProfilerServerEvents.Exception, + new WsException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), + ); + } + + public addLogsEmitter(emitter: ILogsEmitter) { + this.logsEmitters.set(emitter.id, emitter); + emitter.addProfilerClient(this.id); + this.logCurrentState(); + } + + async flushLogs() { + this.logsEmitters.forEach((emitter) => emitter.flushLogs()); + } + + public destroy() { + this.logsEmitters.forEach((emitter) => emitter.removeProfilerClient(this.id)); + } + + /** + * Logs useful information about current state for debug purposes + * @private + */ + private logCurrentState() { + this.logger.debug( + `Emitters: ${this.logsEmitters.size}`, + ); + } +} diff --git a/redisinsight/api/src/modules/profiler/models/redis.observer.spec.ts b/redisinsight/api/src/modules/profiler/models/redis.observer.spec.ts new file mode 100644 index 0000000000..8cecccbd23 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/models/redis.observer.spec.ts @@ -0,0 +1,118 @@ +xdescribe('dummy', () => { + it('dummy', () => {}); +}); + +// import { ForbiddenException } from '@nestjs/common'; +// import * as Redis from 'ioredis'; +// import { mockClientMonitorObserver, mockRedisMonitorObserver } from 'src/__mocks__/monitor'; +// import { ReplyError } from 'src/models'; +// import { mockRedisNoPermError } from 'src/__mocks__'; +// import { MonitorObserverStatus } from '../helpers/monitor-observer/monitor-observer.interface'; +// import { RedisMonitorClient } from '../../observers/monitor-observer'; +// +// const nodeClient = Object.create(Redis.prototype); +// nodeClient.monitor = jest.fn().mockResolvedValue(mockRedisMonitorObserver); +// nodeClient.status = 'ready'; +// nodeClient.options = { ...nodeClient.options, host: 'localhost', port: 6379 }; +// +// const clusterClient = Object.create(Redis.Cluster.prototype); +// const mockClusterNode1 = nodeClient; +// const mockClusterNode2 = nodeClient; +// mockClusterNode1.options = { ...nodeClient.options, host: 'localhost', port: 5000 }; +// mockClusterNode2.options = { ...nodeClient.options, host: 'localhost', port: 5001 }; +// +// clusterClient.nodes = jest.fn().mockReturnValue([mockClusterNode1, mockClusterNode2]); +// +// const NO_PERM_ERROR: ReplyError = { +// ...mockRedisNoPermError, +// command: 'MONITOR', +// }; +// +// describe('MonitorObserver', () => { +// describe('for redis standalone', () => { +// let monitorObserver; +// beforeEach(() => { +// RedisMonitorClient.isMonitorAvailable = jest.fn().mockResolvedValue(true); +// monitorObserver = new RedisMonitorClient(nodeClient); +// }); +// +// it('should create shard observer only on first subscribe call', async () => { +// const connectMethod = jest.spyOn(monitorObserver, 'connect'); +// +// await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); +// await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '2' }); +// await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '2' }); +// +// expect(monitorObserver.status).toBe(MonitorObserverStatus.Ready); +// expect(connectMethod).toHaveBeenCalledTimes(1); +// expect(monitorObserver.clientMonitorObservers.has('1')).toEqual(true); +// expect(monitorObserver.clientMonitorObservers.has('2')).toEqual(true); +// expect(monitorObserver.shardsObservers.length).toEqual(1); +// expect(monitorObserver.getSize()).toEqual(2); +// }); +// it('should be set to END status on clear', async () => { +// await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); +// await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '2' }); +// +// monitorObserver.clear(); +// +// expect(monitorObserver.status).toBe(MonitorObserverStatus.End); +// expect(monitorObserver.getSize()).toEqual(0); +// expect(monitorObserver.shardsObservers.length).toEqual(0); +// }); +// it('should be set to END status if there are no more observers', async () => { +// await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); +// await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '2' }); +// +// monitorObserver.unsubscribe('1'); +// monitorObserver.unsubscribe('2'); +// +// expect(monitorObserver.status).toBe(MonitorObserverStatus.End); +// expect(monitorObserver.getSize()).toEqual(0); +// expect(monitorObserver.shardsObservers.length).toEqual(0); +// }); +// it('should throw ForbiddenException if a user has no permissions', async () => { +// RedisMonitorClient.isMonitorAvailable = jest.fn().mockRejectedValue(NO_PERM_ERROR); +// +// await expect( +// monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }), +// ).rejects.toThrow(ForbiddenException); +// }); +// }); +// describe('for redis cluster', () => { +// let monitorObserver; +// beforeEach(() => { +// RedisMonitorClient.isMonitorAvailable = jest.fn().mockResolvedValue(true); +// monitorObserver = new RedisMonitorClient(clusterClient); +// }); +// +// it('should create shard observer only on first subscribe call', async () => { +// const connectMethod = jest.spyOn(monitorObserver, 'connect'); +// +// await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); +// +// expect(monitorObserver.status).toBe(MonitorObserverStatus.Ready); +// expect(connectMethod).toHaveBeenCalledTimes(1); +// expect(monitorObserver.clientMonitorObservers.has('1')).toEqual(true); +// expect(monitorObserver.shardsObservers.length).toEqual(2); +// expect(monitorObserver.getSize()).toEqual(1); +// }); +// it('should be set to END status on clear', async () => { +// await monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }); +// +// monitorObserver.clear(); +// +// expect(monitorObserver.status).toBe(MonitorObserverStatus.End); +// expect(monitorObserver.getSize()).toEqual(0); +// expect(monitorObserver.shardsObservers.length).toEqual(0); +// }); +// // eslint-disable-next-line sonarjs/no-identical-functions +// it('should throw ForbiddenException if a user has no permissions', async () => { +// RedisMonitorClient.isMonitorAvailable = jest.fn().mockRejectedValue(NO_PERM_ERROR); +// +// await expect( +// monitorObserver.subscribe({ ...mockClientMonitorObserver, id: '1' }), +// ).rejects.toThrow(ForbiddenException); +// }); +// }); +// }); diff --git a/redisinsight/api/src/modules/profiler/models/redis.observer.ts b/redisinsight/api/src/modules/profiler/models/redis.observer.ts new file mode 100644 index 0000000000..8565a7bd31 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/models/redis.observer.ts @@ -0,0 +1,215 @@ +import IORedis from 'ioredis'; +import { ForbiddenException, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { RedisErrorCodes } from 'src/constants'; +import { ProfilerClient } from 'src/modules/profiler/models/profiler.client'; +import { RedisObserverStatus } from 'src/modules/profiler/constants'; +import { IShardObserver } from 'src/modules/profiler/interfaces/shard-observer.interface'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +export class RedisObserver extends EventEmitter2 { + private logger = new Logger('RedisObserver'); + + private redis: IORedis.Redis | IORedis.Cluster; + + private profilerClients: Map = new Map(); + + private profilerClientsListeners: Map = new Map(); + + private shardsObservers: IShardObserver[] = []; + + public status: RedisObserverStatus; + + constructor() { + super(); + this.status = RedisObserverStatus.Empty; + } + + init(func: () => Promise) { + this.status = RedisObserverStatus.Initializing; + + func() + .then((redis) => { + this.redis = redis; + this.status = RedisObserverStatus.Connected; + this.emit('connect'); + }) + .catch((err) => { + this.emit('connect_error', err); + }); + } + + /** + * Create "monitor" clients for each shard if not exists + * Subscribe profiler client to each each shard + * Ignore when profiler client with such id already exists + * @param profilerClient + */ + public async subscribe(profilerClient: ProfilerClient) { + if (this.status !== RedisObserverStatus.Ready) { + await this.connect(); + } + + if (this.profilerClients.has(profilerClient.id)) { + return; + } + + if (!this.profilerClientsListeners.get(profilerClient.id)) { + this.profilerClientsListeners.set(profilerClient.id, []); + } + + const profilerListeners = this.profilerClientsListeners.get(profilerClient.id); + + this.shardsObservers.forEach((observer) => { + const monitorListenerFn = (time, args, source, database) => { + profilerClient.handleOnData({ + time, args, database, source, shardOptions: observer.options, + }); + }; + const endListenerFn = () => { + profilerClient.handleOnDisconnect(); + this.clear(); + }; + + observer.on('monitor', monitorListenerFn); + observer.on('end', endListenerFn); + + profilerListeners.push(monitorListenerFn, endListenerFn); + this.logger.debug(`Subscribed to shard observer. Current listeners: ${observer.listenerCount('monitor')}`); + }); + this.profilerClients.set(profilerClient.id, profilerClient); + + this.logger.debug(`Profiler Client with id:${profilerClient.id} was added`); + this.logCurrentState(); + } + + public removeShardsListeners(profilerClientId: string) { + this.shardsObservers.forEach((observer) => { + (this.profilerClientsListeners.get(profilerClientId) || []).forEach((listener) => { + observer.removeListener('monitor', listener); + observer.removeListener('end', listener); + }); + + this.logger.debug( + `Unsubscribed from from shard observer. Current listeners: ${observer.listenerCount('monitor')}`, + ); + }); + } + + public unsubscribe(id: string) { + this.removeShardsListeners(id); + this.profilerClients.delete(id); + this.profilerClientsListeners.delete(id); + if (this.profilerClients.size === 0) { + this.clear(); + } + + this.logger.debug(`Profiler Client with id:${id} was unsubscribed`); + this.logCurrentState(); + } + + public disconnect(id: string) { + this.removeShardsListeners(id); + const profilerClient = this.profilerClients.get(id); + if (profilerClient) { + profilerClient.destroy(); + } + this.profilerClients.delete(id); + this.profilerClientsListeners.delete(id); + if (this.profilerClients.size === 0) { + this.clear(); + } + + this.logger.debug(`Profiler Client with id:${id} was disconnected`); + this.logCurrentState(); + } + + /** + * Logs useful inforation about current state for debug purposes + * @private + */ + private logCurrentState() { + this.logger.debug( + `Status: ${this.status}; Shards: ${this.shardsObservers.length}; Listeners: ${this.getProfilerClientsSize()}`, + ); + } + + public clear() { + this.profilerClients.clear(); + this.shardsObservers.forEach((observer) => { + observer.removeAllListeners('monitor'); + observer.removeAllListeners('end'); + observer.disconnect(); + }); + this.shardsObservers = []; + this.status = RedisObserverStatus.End; + } + + /** + * Return number of profilerClients for current Redis Observer instance + */ + public getProfilerClientsSize(): number { + return this.profilerClients.size; + } + + /** + * Create shard observer for each Redis shard to receive "monitor" data + * @private + */ + private async connect(): Promise { + try { + if (this.redis instanceof IORedis.Cluster) { + this.shardsObservers = await Promise.all( + this.redis.nodes('all').filter((node) => node.status === 'ready').map(RedisObserver.createShardObserver), + ); + } else { + this.shardsObservers = [await RedisObserver.createShardObserver(this.redis)]; + } + + this.shardsObservers.forEach((observer) => { + observer.on('error', (e) => { + this.logger.error('Error on shard observer', e); + }); + }); + + this.status = RedisObserverStatus.Ready; + } catch (error) { + this.status = RedisObserverStatus.Error; + + if (error?.message?.includes(RedisErrorCodes.NoPermission)) { + throw new ForbiddenException(error.message); + } + + throw new ServiceUnavailableException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB); + } + } + + /** + * Create and return shard observer using IORedis common client + * @param redis + */ + static async createShardObserver(redis: IORedis.Redis): Promise { + await RedisObserver.isMonitorAvailable(redis); + return await redis.monitor() as IShardObserver; + } + + /** + * HACK: ioredis do not handle error when a user has no permissions to run the 'monitor' command + * Here we try to send "monitor" command directly to throw error (like NOPERM) if any + * @param redis + */ + static async isMonitorAvailable(redis: IORedis.Redis): Promise { + // @ts-ignore + const duplicate = redis.duplicate({ + ...redis.options, + monitor: false, + lazyLoading: false, + connectionName: `redisinsight-monitor-perm-check-${Math.random()}`, + }); + + await duplicate.send_command('monitor'); + duplicate.disconnect(); + + return true; + } +} diff --git a/redisinsight/api/src/modules/profiler/monitor.service.spec.ts b/redisinsight/api/src/modules/profiler/monitor.service.spec.ts new file mode 100644 index 0000000000..bc3c7de532 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/monitor.service.spec.ts @@ -0,0 +1,164 @@ +xdescribe('dummy', () => { + it('dummy', () => {}); +}); + +// import { ServiceUnavailableException } from '@nestjs/common'; +// import { Test, TestingModule } from '@nestjs/testing'; +// import { v4 as uuidv4 } from 'uuid'; +// import { mockClientMonitorObserver, mockMonitorObserver } from 'src/__mocks__/monitor'; +// import ERROR_MESSAGES from 'src/constants/error-messages'; +// import { RedisService } from 'src/modules/core/services/redis/redis.service'; +// import { mockRedisClientInstance } from 'src/modules/shared/services/base/redis-consumer.abstract.service.spec'; +// import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +// import { ProfilerService } from './monitor.service'; +// import { RedisMonitorClient } from './helpers/monitor-observer'; +// +// jest.mock('./helpers/monitor-observer'); +// +// describe('MonitorService', () => { +// let service; +// let redisService; +// let instancesBusinessService; +// +// beforeEach(async () => { +// const module: TestingModule = await Test.createTestingModule({ +// providers: [ +// ProfilerService, +// { +// provide: RedisService, +// useFactory: () => ({ +// getClientInstance: jest.fn(), +// isClientConnected: jest.fn(), +// }), +// }, +// { +// provide: InstancesBusinessService, +// useFactory: () => ({ +// connectToInstance: jest.fn(), +// }), +// }, +// ], +// }).compile(); +// +// service = module.get(ProfilerService); +// redisService = await module.get(RedisService); +// instancesBusinessService = await module.get(InstancesBusinessService); +// }); +// +// describe('addListenerForInstance', () => { +// let getRedisClientForInstance; +// beforeEach(() => { +// getRedisClientForInstance = jest.spyOn(service, 'getRedisClientForInstance'); +// service.monitorObservers = {}; +// }); +// +// it('should use exist redis client and create new monitor observer', async () => { +// const { instanceId } = mockRedisClientInstance; +// redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); +// redisService.isClientConnected.mockReturnValue(true); +// +// await service.addListenerForInstance(instanceId, mockClientMonitorObserver); +// +// expect(getRedisClientForInstance).toHaveBeenCalledWith(instanceId); +// expect(RedisMonitorClient).toHaveBeenCalled(); +// expect(service.monitorObservers[instanceId]).toBeDefined(); +// }); +// it('should use exist monitor observer for instance', async () => { +// const { instanceId } = mockRedisClientInstance; +// service.monitorObservers = { [instanceId]: { ...mockMonitorObserver, status: 'ready' } }; +// +// await service.addListenerForInstance(instanceId, mockClientMonitorObserver); +// await service.addListenerForInstance(instanceId, mockClientMonitorObserver); +// +// expect(getRedisClientForInstance).not.toHaveBeenCalled(); +// expect(Object.keys(service.monitorObservers).length).toEqual(1); +// }); +// it('should recreate exist monitor observer', async () => { +// const { instanceId } = mockRedisClientInstance; +// service.monitorObservers = { [instanceId]: { ...mockMonitorObserver, status: 'end' } }; +// redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); +// redisService.isClientConnected.mockReturnValue(true); +// +// await service.addListenerForInstance(instanceId, mockClientMonitorObserver); +// +// expect(RedisMonitorClient).toHaveBeenCalled(); +// expect(getRedisClientForInstance).toHaveBeenCalled(); +// expect(Object.keys(service.monitorObservers).length).toEqual(1); +// }); +// it('should recreate redis client', async () => { +// const { instanceId } = mockRedisClientInstance; +// redisService.getClientInstance.mockReturnValue(mockRedisClientInstance); +// redisService.isClientConnected.mockReturnValue(false); +// instancesBusinessService.connectToInstance.mockResolvedValue(mockRedisClientInstance); +// +// await service.addListenerForInstance(instanceId, mockClientMonitorObserver); +// +// expect(instancesBusinessService.connectToInstance).toHaveBeenCalled(); +// }); +// it('should throw timeout exception on create redis client', async () => { +// const { instanceId } = mockRedisClientInstance; +// redisService.getClientInstance.mockReturnValue(null); +// instancesBusinessService.connectToInstance = jest.fn() +// .mockReturnValue(new Promise(() => {})); +// +// try { +// await service.addListenerForInstance(instanceId, mockClientMonitorObserver); +// } catch (error) { +// expect(error).toEqual(new ServiceUnavailableException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB)); +// } +// }); +// }); +// describe('removeListenerFromInstance', () => { +// beforeEach(() => { +// service.monitorObservers = {}; +// }); +// +// it('should unsubscribe listeners from monitor observer', async () => { +// const { instanceId } = mockRedisClientInstance; +// const listenerId = uuidv4(); +// const monitorObserver = { ...mockMonitorObserver, status: 'ready', unsubscribe: jest.fn() }; +// service.monitorObservers = { [instanceId]: monitorObserver }; +// +// service.removeListenerFromInstance(instanceId, listenerId); +// +// expect(monitorObserver.unsubscribe).toHaveBeenCalledWith(listenerId); +// }); +// it('should be ignored if monitor observer does not exist for instance', () => { +// const { instanceId } = mockRedisClientInstance; +// const listenerId = uuidv4(); +// const monitorObserver = { ...mockMonitorObserver, status: 'ready', unsubscribe: jest.fn() }; +// service.monitorObservers = { [instanceId]: monitorObserver }; +// +// service.removeListenerFromInstance(uuidv4(), listenerId); +// +// expect(monitorObserver.unsubscribe).not.toHaveBeenCalled(); +// }); +// }); +// +// describe('handleInstanceDeletedEvent', () => { +// beforeEach(() => { +// service.monitorObservers = {}; +// }); +// +// it('should clear exist monitor observer fro instance', () => { +// const { instanceId } = mockRedisClientInstance; +// const monitorObserver = { ...mockMonitorObserver, status: 'ready', clear: jest.fn() }; +// service.monitorObservers = { [instanceId]: monitorObserver }; +// +// service.handleInstanceDeletedEvent(instanceId); +// +// expect(monitorObserver.clear).toHaveBeenCalled(); +// expect(service.monitorObservers[instanceId]).not.toBeDefined(); +// }); +// it('should be ignored if monitor observer does not exist for instance', () => { +// const { instanceId } = mockRedisClientInstance; +// const monitorObserver = { ...mockMonitorObserver, status: 'ready', clear: jest.fn() }; +// service.monitorObservers = { [instanceId]: monitorObserver }; +// +// service.handleInstanceDeletedEvent(uuidv4()); +// +// expect(monitorObserver.clear).not.toHaveBeenCalled(); +// expect(service.monitorObservers[instanceId]).toBeDefined(); +// }); +// }); +// }); diff --git a/redisinsight/api/src/modules/profiler/profiler-analytics.service.ts b/redisinsight/api/src/modules/profiler/profiler-analytics.service.ts new file mode 100644 index 0000000000..ed841ec740 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/profiler-analytics.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TelemetryEvents } from 'src/constants'; +import { TelemetryBaseService } from 'src/modules/shared/services/base/telemetry.base.service'; +import { CommandExecutionStatus } from 'src/modules/cli/dto/cli.dto'; +import { RedisError, ReplyError } from 'src/models'; + +export interface IExecResult { + response: any; + status: CommandExecutionStatus; + error?: RedisError | ReplyError | Error, +} + +@Injectable() +export class ProfilerAnalyticsService extends TelemetryBaseService { + private events: Map = new Map(); + + constructor(protected eventEmitter: EventEmitter2) { + super(eventEmitter); + this.events.set(TelemetryEvents.ProfilerLogDownloaded, this.sendLogDownloaded.bind(this)); + this.events.set(TelemetryEvents.ProfilerLogDeleted, this.sendLogDeleted.bind(this)); + } + + sendLogDeleted(databaseId: string, fileSize: number): void { + try { + this.sendEvent( + TelemetryEvents.ProfilerLogDeleted, + { + databaseId, + fileSize, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + sendLogDownloaded(databaseId: string, fileSize: number): void { + try { + this.sendEvent( + TelemetryEvents.ProfilerLogDownloaded, + { + databaseId, + fileSize, + }, + ); + } catch (e) { + // continue regardless of error + } + } + + getEventsEmitters(): Map { + return this.events; + } +} diff --git a/redisinsight/api/src/modules/profiler/profiler.controller.ts b/redisinsight/api/src/modules/profiler/profiler.controller.ts new file mode 100644 index 0000000000..5f8b2f3147 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/profiler.controller.ts @@ -0,0 +1,32 @@ +import { Response } from 'express'; +import { + Controller, Get, Param, Res, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { LogFileProvider } from 'src/modules/profiler/providers/log-file.provider'; +import { ApiEndpoint } from 'src/decorators/api-endpoint.decorator'; + +@ApiTags('Profiler') +@Controller('profiler') +export class ProfilerController { + constructor(private logFileProvider: LogFileProvider) {} + + @ApiEndpoint({ + description: 'Endpoint do download profiler log file', + statusCode: 200, + }) + @Get('/logs/:id') + async downloadLogsFile( + @Res() res: Response, + @Param('id') id: string, + ) { + const { stream, filename } = await this.logFileProvider.getDownloadData(id); + + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment;filename="${filename}.txt"`); + + stream + .on('error', () => res.status(404).send()) + .pipe(res); + } +} diff --git a/redisinsight/api/src/modules/profiler/profiler.gateway.ts b/redisinsight/api/src/modules/profiler/profiler.gateway.ts new file mode 100644 index 0000000000..09f0218488 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/profiler.gateway.ts @@ -0,0 +1,74 @@ +import { get } from 'lodash'; +import { Socket, Server } from 'socket.io'; +import { + OnGatewayConnection, + OnGatewayDisconnect, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, + WsException, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { MonitorSettings } from 'src/modules/profiler/models/monitor-settings'; +import { ProfilerClientEvents } from 'src/modules/profiler/constants'; +import { ProfilerService } from 'src/modules/profiler/profiler.service'; +import config from 'src/utils/config'; + +const SOCKETS_CONFIG = config.get('sockets'); + +@WebSocketGateway({ namespace: 'monitor', cors: SOCKETS_CONFIG.cors, serveClient: SOCKETS_CONFIG.serveClient }) +export class ProfilerGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() wss: Server; + + private logger: Logger = new Logger('MonitorGateway'); + + constructor(private service: ProfilerService) {} + + @SubscribeMessage(ProfilerClientEvents.Monitor) + async monitor(client: Socket, settings: MonitorSettings = null): Promise { + try { + await this.service.addListenerForInstance(ProfilerGateway.getInstanceId(client), client, settings); + return { status: 'ok' }; + } catch (error) { + this.logger.error('Unable to add listener', error); + throw new WsException(error); + } + } + + @SubscribeMessage(ProfilerClientEvents.Pause) + async pause(client: Socket): Promise { + try { + await this.service.removeListenerFromInstance(ProfilerGateway.getInstanceId(client), client.id); + return { status: 'ok' }; + } catch (error) { + this.logger.error('Unable to pause monitor', error); + throw new WsException(error); + } + } + + @SubscribeMessage(ProfilerClientEvents.FlushLogs) + async flushLogs(client: Socket): Promise { + try { + await this.service.flushLogs(client.id); + return { status: 'ok' }; + } catch (error) { + this.logger.error('Unable to flush logs', error); + throw new WsException(error); + } + } + + async handleConnection(client: Socket): Promise { + const instanceId = ProfilerGateway.getInstanceId(client); + this.logger.log(`Client connected: ${client.id}, instanceId: ${instanceId}`); + } + + async handleDisconnect(client: Socket): Promise { + const instanceId = ProfilerGateway.getInstanceId(client); + await this.service.disconnectListenerFromInstance(instanceId, client.id); + this.logger.log(`Client disconnected: ${client.id}, instanceId: ${instanceId}`); + } + + static getInstanceId(client: Socket): string { + return get(client, 'handshake.query.instanceId'); + } +} diff --git a/redisinsight/api/src/modules/profiler/profiler.module.ts b/redisinsight/api/src/modules/profiler/profiler.module.ts new file mode 100644 index 0000000000..88007075da --- /dev/null +++ b/redisinsight/api/src/modules/profiler/profiler.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/modules/shared/shared.module'; +import { LogFileProvider } from 'src/modules/profiler/providers/log-file.provider'; +import { ProfilerController } from 'src/modules/profiler/profiler.controller'; +import { RedisObserverProvider } from 'src/modules/profiler/providers/redis-observer.provider'; +import { ProfilerClientProvider } from 'src/modules/profiler/providers/profiler-client.provider'; +import { ProfilerAnalyticsService } from 'src/modules/profiler/profiler-analytics.service'; +import { ProfilerGateway } from './profiler.gateway'; +import { ProfilerService } from './profiler.service'; + +@Module({ + imports: [SharedModule], + providers: [ + ProfilerAnalyticsService, + RedisObserverProvider, + ProfilerClientProvider, + LogFileProvider, + ProfilerGateway, + ProfilerService, + ], + controllers: [ProfilerController], +}) +export class ProfilerModule {} diff --git a/redisinsight/api/src/modules/profiler/profiler.service.ts b/redisinsight/api/src/modules/profiler/profiler.service.ts new file mode 100644 index 0000000000..e158aa2a8c --- /dev/null +++ b/redisinsight/api/src/modules/profiler/profiler.service.ts @@ -0,0 +1,91 @@ +import { Socket } from 'socket.io'; +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { AppRedisInstanceEvents } from 'src/constants'; +import { MonitorSettings } from 'src/modules/profiler/models/monitor-settings'; +import { LogFileProvider } from 'src/modules/profiler/providers/log-file.provider'; +import { RedisObserverProvider } from 'src/modules/profiler/providers/redis-observer.provider'; +import { ProfilerClientProvider } from 'src/modules/profiler/providers/profiler-client.provider'; + +@Injectable() +export class ProfilerService { + private logger = new Logger('ProfilerService'); + + constructor( + private logFileProvider: LogFileProvider, + private redisObserverProvider: RedisObserverProvider, + private profilerClientProvider: ProfilerClientProvider, + ) {} + + /** + * Create or use existing user client to send monitor data from redis client to the user + * We are storing user clients to have a possibility to "pause" logs without disconnecting + * + * @param instanceId + * @param client + * @param settings + */ + async addListenerForInstance(instanceId: string, client: Socket, settings: MonitorSettings = null) { + this.logger.log(`Add listener for instance: ${instanceId}.`); + + const profilerClient = await this.profilerClientProvider.getOrCreateClient(instanceId, client, settings); + const monitorObserver = await this.redisObserverProvider.getOrCreateObserver(instanceId); + await monitorObserver.subscribe(profilerClient); + } + + /** + * Simply remove Profiler Client from the clients list of particular Redis Observer + * Basically used to remove listener that triggered by user action, e.g. "pause" action + * @param instanceId + * @param listenerId + */ + async removeListenerFromInstance(instanceId: string, listenerId: string) { + this.logger.log(`Remove listener from instance: ${instanceId}.`); + const redisObserver = await this.redisObserverProvider.getObserver(instanceId); + if (redisObserver) { + redisObserver.unsubscribe(listenerId); + } + } + + /** + * Remove Profiler Client from clients list of the particular Redis Observer + * Beside that under the hood will be triggered force remove of emitters, files, etc. after some time threshold + * Used when for sme reason socket connection between frontend and backend was lost + * @param instanceId + * @param listenerId + */ + async disconnectListenerFromInstance(instanceId: string, listenerId: string) { + this.logger.log(`Disconnect listener from instance: ${instanceId}.`); + const redisObserver = await this.redisObserverProvider.getObserver(instanceId); + if (redisObserver) { + redisObserver.disconnect(listenerId); + } + } + + /** + * Flush all persistent logs like FileLog + * Trigger by user action + * @param listenerId + */ + async flushLogs(listenerId: string) { + this.logger.log(`Flush logs for client ${listenerId}.`); + const profilerClient = await this.profilerClientProvider.getClient(listenerId); + if (profilerClient) { + await profilerClient.flushLogs(); + } + } + + @OnEvent(AppRedisInstanceEvents.Deleted) + async handleInstanceDeletedEvent(instanceId: string) { + this.logger.log(`Handle instance deleted event. instance: ${instanceId}.`); + try { + const redisObserver = await this.redisObserverProvider.getObserver(instanceId); + if (redisObserver) { + redisObserver.clear(); + await this.redisObserverProvider.removeObserver(instanceId); + } + } catch (e) { + // continue regardless of error + } + } +} diff --git a/redisinsight/api/src/modules/profiler/providers/log-file.provider.ts b/redisinsight/api/src/modules/profiler/providers/log-file.provider.ts new file mode 100644 index 0000000000..1ca2ab1862 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/providers/log-file.provider.ts @@ -0,0 +1,53 @@ +import { ReadStream } from 'fs'; +import { Injectable, NotFoundException, OnModuleDestroy } from '@nestjs/common'; +import { LogFile } from 'src/modules/profiler/models/log-file'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import { ProfilerAnalyticsService } from 'src/modules/profiler/profiler-analytics.service'; + +@Injectable() +export class LogFileProvider implements OnModuleDestroy { + private profilerLogFiles: Map = new Map(); + + constructor(private analyticsService: ProfilerAnalyticsService) {} + + /** + * Get or create Profiler Log File to work with + * @param instanceId + * @param id + */ + getOrCreate(instanceId: string, id: string): LogFile { + if (!this.profilerLogFiles.has(id)) { + this.profilerLogFiles.set(id, new LogFile(instanceId, id, this.analyticsService.getEventsEmitters())); + } + + return this.profilerLogFiles.get(id); + } + + /** + * Get Profiler Log File or throw an error + * @param id + */ + get(id: string): LogFile { + if (!this.profilerLogFiles.has(id)) { + throw new NotFoundException(ERROR_MESSAGES.PROFILER_LOG_FILE_NOT_FOUND); + } + + return this.profilerLogFiles.get(id); + } + + /** + * Get ReadableStream for download and filename + * Delete file after download finished + * @param id + */ + async getDownloadData(id): Promise<{ stream: ReadStream, filename: string }> { + const logFile = await this.get(id); + const stream = await logFile.getReadStream(); + + return { stream, filename: logFile.getFilename() }; + } + + async onModuleDestroy() { + await Promise.all(Array.from(this.profilerLogFiles.values()).map((logFile: LogFile) => logFile.destroy())); + } +} diff --git a/redisinsight/api/src/modules/profiler/providers/profiler-client.provider.ts b/redisinsight/api/src/modules/profiler/providers/profiler-client.provider.ts new file mode 100644 index 0000000000..b26fd13e45 --- /dev/null +++ b/redisinsight/api/src/modules/profiler/providers/profiler-client.provider.ts @@ -0,0 +1,47 @@ +import { get } from 'lodash'; +import { Socket } from 'socket.io'; +import { Injectable } from '@nestjs/common'; +import { ProfilerClient } from 'src/modules/profiler/models/profiler.client'; +import { ClientLogsEmitter } from 'src/modules/profiler/emitters/client.logs-emitter'; +import { MonitorSettings } from 'src/modules/profiler/models/monitor-settings'; +import { LogFileProvider } from 'src/modules/profiler/providers/log-file.provider'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; + +@Injectable() +export class ProfilerClientProvider { + private profilerClients: Map = new Map(); + + constructor( + private logFileProvider: LogFileProvider, + private instancesBusinessService: InstancesBusinessService, + ) {} + + async getOrCreateClient(instanceId: string, socket: Socket, settings: MonitorSettings): Promise { + if (!this.profilerClients.has(socket.id)) { + const clientObserver = new ProfilerClient(socket.id, socket); + this.profilerClients.set(socket.id, clientObserver); + + clientObserver.addLogsEmitter(new ClientLogsEmitter(socket)); + + if (settings?.logFileId) { + const profilerLogFile = this.logFileProvider.getOrCreate(instanceId, settings.logFileId); + + // set database alias as part of the log file name + const alias = (await this.instancesBusinessService.getOneById( + get(socket, 'handshake.query.instanceId'), + )).name; + profilerLogFile.setAlias(alias); + + clientObserver.addLogsEmitter(await profilerLogFile.getEmitter()); + } + + this.profilerClients.set(socket.id, clientObserver); + } + + return this.profilerClients.get(socket.id); + } + + async getClient(id: string) { + return this.profilerClients.get(id); + } +} diff --git a/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts b/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts new file mode 100644 index 0000000000..8325927f1b --- /dev/null +++ b/redisinsight/api/src/modules/profiler/providers/redis-observer.provider.ts @@ -0,0 +1,112 @@ +import IORedis from 'ioredis'; +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { RedisObserver } from 'src/modules/profiler/models/redis.observer'; +import { RedisObserverStatus } from 'src/modules/profiler/constants'; +import { AppTool } from 'src/models'; +import { withTimeout } from 'src/utils/promise-with-timeout'; +import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service'; +import { RedisService } from 'src/modules/core/services/redis/redis.service'; +import ERROR_MESSAGES from 'src/constants/error-messages'; +import config from 'src/utils/config'; + +const serverConfig = config.get('server'); + +@Injectable() +export class RedisObserverProvider { + private logger = new Logger('RedisObserverProvider'); + + private redisObservers: Map = new Map(); + + constructor( + private redisService: RedisService, + private instancesBusinessService: InstancesBusinessService, + ) {} + + /** + * Get existing redis observer or create a new one + * @param instanceId + */ + async getOrCreateObserver(instanceId: string): Promise { + this.logger.log('Getting redis observer...'); + + let redisObserver = this.redisObservers.get(instanceId); + + try { + if (!redisObserver) { + this.logger.debug('Creating new RedisObserver'); + redisObserver = new RedisObserver(); + this.redisObservers.set(instanceId, redisObserver); + + // initialize redis observer + redisObserver.init(this.getRedisClientFn(instanceId)); + } else { + switch (redisObserver.status) { + case RedisObserverStatus.Ready: + this.logger.debug(`Using existing RedisObserver with status: ${redisObserver.status}`); + return redisObserver; + case RedisObserverStatus.Empty: + case RedisObserverStatus.End: + case RedisObserverStatus.Error: + this.logger.debug(`Trying to reconnect. Current status: ${redisObserver.status}`); + // try to reconnect + redisObserver.init(this.getRedisClientFn(instanceId)); + break; + case RedisObserverStatus.Initializing: + case RedisObserverStatus.Wait: + case RedisObserverStatus.Connected: + default: + // wait until connect or error + this.logger.debug(`Waiting for ready. Current status: ${redisObserver.status}`); + } + } + + return new Promise((resolve, reject) => { + redisObserver.once('connect', () => { + resolve(redisObserver); + }); + redisObserver.once('connect_error', (e) => { + reject(e); + }); + }); + } catch (error) { + this.logger.error(`Failed to get monitor observer. ${error.message}.`, JSON.stringify(error)); + throw error; + } + } + + /** + * Get Redis Observer from existing ones + * @param instanceId + */ + async getObserver(instanceId: string) { + return this.redisObservers.get(instanceId); + } + + /** + * Remove Redis Observer + * @param instanceId + */ + async removeObserver(instanceId: string) { + this.redisObservers.delete(instanceId); + } + + /** + * Get Redis existing common IORedis client for instance or create a new common connection + * @param instanceId + * @private + */ + private getRedisClientFn(instanceId: string): () => Promise { + return async () => { + const tool = AppTool.Common; + const commonClient = this.redisService.getClientInstance({ instanceId, tool })?.client; + if (commonClient && this.redisService.isClientConnected(commonClient)) { + return commonClient; + } + return withTimeout( + this.instancesBusinessService.connectToInstance(instanceId, tool, true), + serverConfig.requestTimeout, + new ServiceUnavailableException(ERROR_MESSAGES.NO_CONNECTION_TO_REDIS_DB), + ); + }; + } +} diff --git a/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx b/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx index 3e7ca4d8fc..c4f12f3059 100644 --- a/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx +++ b/redisinsight/ui/src/components/monitor-config/MonitorConfig.spec.tsx @@ -3,7 +3,7 @@ import { cloneDeep } from 'lodash' import React from 'react' import MockedSocket from 'socket.io-mock' import socketIO from 'socket.io-client' -import { monitorSelector, setSocket, stopMonitor, toggleRunMonitor } from 'uiSrc/slices/cli/monitor' +import { monitorSelector, setMonitorLoadingPause, setSocket, stopMonitor } from 'uiSrc/slices/cli/monitor' import { cleanup, mockedStore, render } from 'uiSrc/utils/test-utils' import { MonitorEvent, SocketEvent } from 'uiSrc/constants' import MonitorConfig from './MonitorConfig' @@ -52,6 +52,7 @@ describe('MonitorConfig', () => { const { unmount } = render() const afterRenderActions = [ setSocket(socket), + setMonitorLoadingPause(true) ] expect(store.getActions()).toEqual([...afterRenderActions]) @@ -66,7 +67,7 @@ describe('MonitorConfig', () => { const { unmount } = render() - socket.on(MonitorEvent.MonitorData, (data) => { + socket.on(MonitorEvent.MonitorData, (data: []) => { expect(data).toEqual(['message1', 'message2']) }) @@ -74,6 +75,7 @@ describe('MonitorConfig', () => { const afterRenderActions = [ setSocket(socket), + setMonitorLoadingPause(true) ] expect(store.getActions()).toEqual([...afterRenderActions]) @@ -88,7 +90,7 @@ describe('MonitorConfig', () => { }) monitorSelector.mockImplementation(monitorSelectorMock) - socket.on(MonitorEvent.Exception, (error) => { + socket.on(MonitorEvent.Exception, (error: Error) => { expect(error).toEqual({ message: 'test', name: 'error' }) // done() }) @@ -97,7 +99,8 @@ describe('MonitorConfig', () => { const afterRenderActions = [ setSocket(socket), - toggleRunMonitor() + setMonitorLoadingPause(true), + stopMonitor() ] expect(store.getActions()).toEqual([...afterRenderActions]) @@ -112,7 +115,7 @@ describe('MonitorConfig', () => { }) monitorSelector.mockImplementation(monitorSelectorMock) - socket.on(SocketEvent.ConnectionError, (error) => { + socket.on(SocketEvent.ConnectionError, (error: Error) => { expect(error).toEqual({ message: 'test', name: 'error' }) }) @@ -120,7 +123,8 @@ describe('MonitorConfig', () => { const afterRenderActions = [ setSocket(socket), - toggleRunMonitor() + setMonitorLoadingPause(true), + stopMonitor() ] expect(store.getActions()).toEqual([...afterRenderActions]) @@ -139,6 +143,7 @@ describe('MonitorConfig', () => { const afterRenderActions = [ setSocket(socket), + setMonitorLoadingPause(true), stopMonitor() ] expect(store.getActions()).toEqual([...afterRenderActions]) diff --git a/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx b/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx index 8ae62cd842..7255d98a41 100644 --- a/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx +++ b/redisinsight/ui/src/components/monitor-config/MonitorConfig.tsx @@ -6,17 +6,18 @@ import { io } from 'socket.io-client' import { setSocket, monitorSelector, - toggleRunMonitor, concatMonitorItems, stopMonitor, setError, - resetMonitorItems + resetMonitorItems, + setStartTimestamp, + setMonitorLoadingPause } from 'uiSrc/slices/cli/monitor' import { getBaseApiUrl } from 'uiSrc/utils' import { MonitorErrorMessages, MonitorEvent, SocketErrors, SocketEvent } from 'uiSrc/constants' import { IMonitorDataPayload } from 'uiSrc/slices/interfaces' import { connectedInstanceSelector } from 'uiSrc/slices/instances' -import { IOnDatePayload } from 'apiSrc/modules/monitor/helpers/client-monitor-observer' +import { IMonitorData } from 'apiSrc/modules/profiler/interfaces/monitor-data.interface' import ApiStatusCode from '../../constants/apiStatusCode' @@ -25,7 +26,7 @@ interface IProps { } const MonitorConfig = ({ retryDelay = 10000 } : IProps) => { const { id: instanceId = '' } = useSelector(connectedInstanceSelector) - const { socket, isRunning, isMinimizedMonitor, isShowMonitor } = useSelector(monitorSelector) + const { socket, isRunning, isPaused, isSaveToFile, isMinimizedMonitor, isShowMonitor } = useSelector(monitorSelector) const dispatch = useDispatch() @@ -59,7 +60,8 @@ const MonitorConfig = ({ retryDelay = 10000 } : IProps) => { let payloads: IMonitorDataPayload[] = [] const handleMonitorEvents = () => { - newSocket.on(MonitorEvent.MonitorData, (payload:IOnDatePayload[]) => { + dispatch(setMonitorLoadingPause(false)) + newSocket.on(MonitorEvent.MonitorData, (payload: IMonitorData[]) => { payloads = payloads.concat(payload) // set batch of payloads and then clear batch @@ -79,7 +81,13 @@ const MonitorConfig = ({ retryDelay = 10000 } : IProps) => { newSocket.on(SocketEvent.Connect, () => { // Trigger Monitor event clearTimeout(retryTimer) - newSocket.emit(MonitorEvent.Monitor, handleMonitorEvents) + const timestampStart = Date.now() + dispatch(setStartTimestamp(timestampStart)) + newSocket.emit( + MonitorEvent.Monitor, + { logFileId: isSaveToFile ? timestampStart.toString() : null }, + handleMonitorEvents + ) }) // Catch exceptions @@ -93,7 +101,7 @@ const MonitorConfig = ({ retryDelay = 10000 } : IProps) => { payloads.push({ isError: true, time: `${Date.now()}`, ...payload }) setNewItems(payloads, () => { payloads.length = 0 }) - dispatch(toggleRunMonitor()) + dispatch(stopMonitor()) }) // Catch disconnect @@ -109,12 +117,25 @@ const MonitorConfig = ({ retryDelay = 10000 } : IProps) => { newSocket.on(SocketEvent.ConnectionError, (error) => { payloads.push({ isError: true, time: `${Date.now()}`, message: getErrorMessage(error) }) setNewItems(payloads, () => { payloads.length = 0 }) - dispatch(toggleRunMonitor()) + dispatch(stopMonitor()) }) - }, [instanceId, isRunning]) + }, [instanceId, isRunning, isSaveToFile]) + + useEffect(() => { + if (!isRunning) return + + const pauseUnpause = async () => { + !isPaused && await new Promise((resolve) => socket?.emit(MonitorEvent.Monitor, () => resolve())) + isPaused && await new Promise((resolve) => socket?.emit(MonitorEvent.Pause, () => resolve())) + dispatch(setMonitorLoadingPause(false)) + } + dispatch(setMonitorLoadingPause(true)) + pauseUnpause().catch(console.error) + }, [isPaused, isRunning]) useEffect(() => { if (!isRunning) { + socket?.emit(MonitorEvent.FlushLogs) socket?.removeAllListeners() socket?.disconnect() } diff --git a/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx b/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx index 3dc7a797b8..68102c29bd 100644 --- a/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx +++ b/redisinsight/ui/src/components/monitor/Monitor/Monitor.tsx @@ -1,18 +1,20 @@ -import React from 'react' +import React, { useState } from 'react' import cx from 'classnames' import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiSwitch, EuiTextColor, EuiToolTip, } from '@elastic/eui' -import { AutoSizer } from 'react-virtualized' +import AutoSizer from 'react-virtualized-auto-sizer' import { IMonitorDataPayload } from 'uiSrc/slices/interfaces' import { ReactComponent as BanIcon } from 'uiSrc/assets/img/monitor/ban.svg' +import MonitorLog from '../MonitorLog' import MonitorOutputList from '../MonitorOutputList' import styles from './styles.module.scss' @@ -22,10 +24,12 @@ export interface Props { error: string isStarted: boolean isRunning: boolean + isPaused: boolean isShowHelper: boolean + isSaveToFile: boolean isShowCli: boolean scrollViewOnAppear: boolean - handleRunMonitor: () => void + handleRunMonitor: (isSaveToLog?: boolean) => void } const Monitor = (props: Props) => { @@ -34,10 +38,13 @@ const Monitor = (props: Props) => { error = '', isRunning = false, isStarted = false, + isPaused = false, isShowHelper = false, isShowCli = false, + isSaveToFile = false, handleRunMonitor = () => {} } = props + const [saveLogValue, setSaveLogValue] = useState(isSaveToFile) const MonitorNotStarted = () => (
@@ -47,16 +54,16 @@ const Monitor = (props: Props) => { display="inlineBlock" > handleRunMonitor(saveLogValue)} aria-label="start monitor" data-testid="start-monitor" />
Start Profiler
- + { style={{ paddingTop: 2 }} /> - + Running Profiler will decrease throughput, avoid running it in production databases
+
+ + Save Log} + checked={saveLogValue} + onChange={(e) => setSaveLogValue(e.target.checked)} + /> + +
) const MonitorError = () => (
- + {
) - const isMonitorStopped = !!items?.length && !isRunning - return ( <>
@@ -108,33 +127,38 @@ const Monitor = (props: Props) => { ? () : ( <> - {(!isStarted || (!isRunning && !items?.length)) && } - {!items?.length && isRunning && ( -
Profiler is started.
+ {!isStarted && } + {!items?.length && isRunning && !isPaused && ( +
+ Profiler is started. +
)} )} - {isStarted && !!items?.length && ( + {isStarted && (
- - {({ width, height }) => ( - <> + {!!items?.length && ( + + {({ width, height }) => ( - {isMonitorStopped && ( -
- Profiler is stopped. -
- )} - - )} -
+ )} +
+ )}
)} + {isStarted && isPaused && !isSaveToFile && ( +
+ Profiler is paused. +
+ )} + {(isStarted && isPaused && isSaveToFile) && ( + + )}
) diff --git a/redisinsight/ui/src/components/monitor/Monitor/styles.module.scss b/redisinsight/ui/src/components/monitor/Monitor/styles.module.scss index 682bcaf896..3daa1c1f52 100644 --- a/redisinsight/ui/src/components/monitor/Monitor/styles.module.scss +++ b/redisinsight/ui/src/components/monitor/Monitor/styles.module.scss @@ -7,7 +7,9 @@ height: calc(100% - 34px); position: relative; width: 100%; - padding-left: 12px; + + display: flex; + flex-direction: column; background-color: var(--browserTableRowEven); text-align: left; @@ -20,22 +22,17 @@ z-index: 10; overflow: auto; - - :global { - .euiFlexGroup, - .euiFlexItem { - margin: 0px !important; - } - } } .listWrapper { @include euiScrollBar; + flex: 1; width: 100%; height: 100%; position: relative; overflow: auto; + padding-left: 12px; } .content { @@ -46,6 +43,10 @@ position: relative; overflow: auto; + display: flex; + flex-direction: column; + margin-bottom: 6px; + &:first-child { padding-top: 10px; } @@ -62,17 +63,34 @@ justify-content: center; height: 100%; + padding-left: 12px; } .startContent { display: flex; align-items: center; + justify-content: center; + flex-grow: 1; max-width: 264px; flex-direction: column; font: normal normal normal 12px/18px Graphik, sans-serif; } +.monitorStoppedText { + padding-left: 12px; + padding-bottom: 4px; +} + +.saveLogContainer { + font: normal normal normal 13px/18px Graphik; + letter-spacing: -0.13px; + margin-bottom: 18px; + :global(.euiSwitch__label) { + padding-left: 0 !important; + } +} + .startContentError { max-width: 298px; padding-right: 12px; diff --git a/redisinsight/ui/src/components/monitor/MonitorHeader/MonitorHeader.spec.tsx b/redisinsight/ui/src/components/monitor/MonitorHeader/MonitorHeader.spec.tsx index bd03174176..d4bcc4853e 100644 --- a/redisinsight/ui/src/components/monitor/MonitorHeader/MonitorHeader.spec.tsx +++ b/redisinsight/ui/src/components/monitor/MonitorHeader/MonitorHeader.spec.tsx @@ -8,6 +8,7 @@ import { screen, } from 'uiSrc/utils/test-utils' import { + monitorSelector, resetMonitorItems, setMonitorInitialState, toggleHideMonitor, @@ -16,8 +17,21 @@ import { import MonitorHeader, { Props } from './MonitorHeader' const mockedProps = mock() +const monitorPath = 'uiSrc/slices/cli/monitor' let store: typeof mockedStore +jest.mock(monitorPath, () => { + const defaultState = jest.requireActual(monitorPath).initialState + return { + ...jest.requireActual(monitorPath), + monitorSelector: jest.fn().mockReturnValue({ + ...defaultState, + isMinimizedMonitor: false, + isShowMonitor: true, + }) + } +}) + beforeEach(() => { cleanup() store = cloneDeep(mockedStore) @@ -47,6 +61,10 @@ describe('MonitorHeader', () => { it('Should call handleRunMonitor after click on the play button', () => { const handleRunMonitor = jest.fn() + const monitorSelectorMock = jest.fn().mockReturnValue({ + isStarted: true, + }) + monitorSelector.mockImplementation(monitorSelectorMock) render() fireEvent.click(screen.getByTestId('toggle-run-monitor')) @@ -55,6 +73,10 @@ describe('MonitorHeader', () => { }) it('Should clear Monitor items after click on the clear button', () => { + const monitorSelectorMock = jest.fn().mockReturnValue({ + isStarted: true, + }) + monitorSelector.mockImplementation(monitorSelectorMock) render() fireEvent.click(screen.getByTestId('clear-monitor')) diff --git a/redisinsight/ui/src/components/monitor/MonitorHeader/MonitorHeader.tsx b/redisinsight/ui/src/components/monitor/MonitorHeader/MonitorHeader.tsx index 40bae9f14c..8194218073 100644 --- a/redisinsight/ui/src/components/monitor/MonitorHeader/MonitorHeader.tsx +++ b/redisinsight/ui/src/components/monitor/MonitorHeader/MonitorHeader.tsx @@ -29,7 +29,7 @@ export interface Props { const MonitorHeader = ({ handleRunMonitor }: Props) => { const { instanceId = '' } = useParams<{ instanceId: string }>() - const { isRunning, isStarted, items, error } = useSelector(monitorSelector) + const { isRunning, isPaused, isStarted, items = [], error, loadingPause } = useSelector(monitorSelector) const isErrorShown = !!error && !isRunning const dispatch = useDispatch() @@ -44,7 +44,6 @@ const MonitorHeader = ({ handleRunMonitor }: Props) => { event: TelemetryEvent.PROFILER_CLOSED, eventData: { databaseId: instanceId } }) - dispatch(setMonitorInitialState()) } @@ -79,31 +78,33 @@ const MonitorHeader = ({ handleRunMonitor }: Props) => { Profiler - - - - - - - - + {isStarted && ( + + + handleRunMonitor()} + aria-label="start/stop monitor" + data-testid="toggle-run-monitor" + disabled={isErrorShown || loadingPause} + /> + + + + + + )} { + cleanup() + store = cloneDeep(mockedStore) + store.clearActions() +}) + +describe('MonitorLog', () => { + it('should render', () => { + expect(render()).toBeTruthy() + }) + + it('should call proper actions on click reset profiler', () => { + render() + fireEvent.click(screen.getByTestId('reset-profiler-btn')) + + const expectedActions = [stopMonitor(), resetProfiler()] + expect(store.getActions()).toEqual(expectedActions) + }) +}) diff --git a/redisinsight/ui/src/components/monitor/MonitorLog/MonitorLog.tsx b/redisinsight/ui/src/components/monitor/MonitorLog/MonitorLog.tsx new file mode 100644 index 0000000000..5463615200 --- /dev/null +++ b/redisinsight/ui/src/components/monitor/MonitorLog/MonitorLog.tsx @@ -0,0 +1,115 @@ +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiToolTip } from '@elastic/eui' +import { format, formatDuration, intervalToDuration } from 'date-fns' +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import AutoSizer from 'react-virtualized-auto-sizer' +import { ApiEndpoints } from 'uiSrc/constants' +import { AppEnv } from 'uiSrc/constants/env' +import { monitorSelector, resetProfiler, stopMonitor } from 'uiSrc/slices/cli/monitor' +import { cutDurationText, getBaseApiUrl } from 'uiSrc/utils' + +import styles from './styles.module.scss' + +const MIDDLE_SCREEN_RESOLUTION = 460 +const SMALL_SCREEN_RESOLUTION = 360 + +const MonitorLog = () => { + const { timestamp } = useSelector(monitorSelector) + const dispatch = useDispatch() + + const duration = cutDurationText( + formatDuration( + intervalToDuration({ + end: timestamp.duration, + start: 0 + }) + ) + ) + const baseApiUrl = getBaseApiUrl() + const linkToDownload = `${baseApiUrl}/api/${ApiEndpoints.PROFILER_LOGS}/${timestamp.start}` + const isElectron = process.env.APP_ENV === AppEnv.ELECTRON + + const downloadBtnProps: any = {} + if (isElectron) { + downloadBtnProps.download = true + } else { + downloadBtnProps.target = '_blank' + } + + const onResetProfiler = () => { + dispatch(stopMonitor()) + dispatch(resetProfiler()) + } + + const getPaddingByWidth = (width: number): number => { + if (width < SMALL_SCREEN_RESOLUTION) return 6 + if (width < MIDDLE_SCREEN_RESOLUTION) return 12 + return 18 + } + + return ( +
+ + {({ width }) => ( +
+ + + {format(timestamp.start, 'hh:mm:ss')} +  –  + {format(timestamp.paused, 'hh:mm:ss')} +  ( + {duration} + {width > SMALL_SCREEN_RESOLUTION && ' Running time'} + ) + + + + + + {width > SMALL_SCREEN_RESOLUTION && ' Download '} + Log + + + + + + Reset + {width > SMALL_SCREEN_RESOLUTION && ' Profiler'} + + + +
+ )} +
+
+ ) +} + +export default MonitorLog diff --git a/redisinsight/ui/src/components/monitor/MonitorLog/index.ts b/redisinsight/ui/src/components/monitor/MonitorLog/index.ts new file mode 100644 index 0000000000..7c9d870c1e --- /dev/null +++ b/redisinsight/ui/src/components/monitor/MonitorLog/index.ts @@ -0,0 +1,3 @@ +import MonitorLog from './MonitorLog' + +export default MonitorLog diff --git a/redisinsight/ui/src/components/monitor/MonitorLog/styles.module.scss b/redisinsight/ui/src/components/monitor/MonitorLog/styles.module.scss new file mode 100644 index 0000000000..64ef4dc966 --- /dev/null +++ b/redisinsight/ui/src/components/monitor/MonitorLog/styles.module.scss @@ -0,0 +1,41 @@ +.monitorLogWrapper { + display: flex; + min-height: 72px; + + margin: 10px 6px 6px 6px; + padding: 6px 0; + + background: var(--euiColorLightestShade); + box-shadow: 0 3px 15px var(--controlsBoxShadowColor); + border-radius: 4px; + + font-family: 'Graphik', sans-serif; + font-size: 14px; + letter-spacing: -0.14px; + overflow: hidden; + + .time { + display: flex; + align-items: center; + :global(.euiIcon) { + margin-right: 6px; + } + } + + .actions { + margin-top: 6px; + } + + .btn { + height: 36px !important; + line-height: 36px !important; + box-shadow: none !important; + } + + .container { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + } +} diff --git a/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx b/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx index a6d5e7031c..712fd5b830 100644 --- a/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx +++ b/redisinsight/ui/src/components/monitor/MonitorOutputList/MonitorOutputList.tsx @@ -17,6 +17,8 @@ export interface Props { } const PROTRUDING_OFFSET = 2 +const MIDDLE_SCREEN_RESOLUTION = 460 +const SMALL_SCREEN_RESOLUTION = 360 const MonitorOutputList = (props: Props) => { const { compressed, items = [], width = 0, height = 0 } = props @@ -67,11 +69,13 @@ const MonitorOutputList = (props: Props) => {
{!isError && ( <> - - {getFormatTime(time)} -   - - {`[${database} ${source}] `} + {width > MIDDLE_SCREEN_RESOLUTION && ( + + {getFormatTime(time)} +   + + )} + {width > SMALL_SCREEN_RESOLUTION && ({`[${database} ${source}] `})} {getArgs(args)} )} diff --git a/redisinsight/ui/src/components/monitor/MonitorWrapper.tsx b/redisinsight/ui/src/components/monitor/MonitorWrapper.tsx index 19e25d1b8e..aa58eb4463 100644 --- a/redisinsight/ui/src/components/monitor/MonitorWrapper.tsx +++ b/redisinsight/ui/src/components/monitor/MonitorWrapper.tsx @@ -2,7 +2,7 @@ import React from 'react' import { useDispatch, useSelector } from 'react-redux' import { useParams } from 'react-router-dom' -import { monitorSelector, toggleRunMonitor } from 'uiSrc/slices/cli/monitor' +import { monitorSelector, startMonitor, togglePauseMonitor } from 'uiSrc/slices/cli/monitor' import { cliSettingsSelector } from 'uiSrc/slices/cli/cli-settings' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import Monitor from './Monitor' @@ -12,29 +12,42 @@ import styles from './Monitor/styles.module.scss' const MonitorWrapper = () => { const { instanceId = '' } = useParams<{ instanceId: string }>() - const { items, isStarted, isRunning, error } = useSelector(monitorSelector) + const { items, isStarted, isRunning, isPaused, isSaveToFile, error } = useSelector(monitorSelector) const { isShowCli, isShowHelper, } = useSelector(cliSettingsSelector) const dispatch = useDispatch() - const onRunMonitor = () => { + const handleRunMonitor = () => { sendEventTelemetry({ - event: isRunning ? TelemetryEvent.PROFILER_STOPPED : TelemetryEvent.PROFILER_STARTED, + event: isPaused ? TelemetryEvent.PROFILER_RESUMED : TelemetryEvent.PROFILER_PAUSED, eventData: { databaseId: instanceId } }) - dispatch(toggleRunMonitor()) + dispatch(togglePauseMonitor()) + } + + const onRunMonitor = (isSaveToLog?: boolean) => { + sendEventTelemetry({ + event: TelemetryEvent.PROFILER_STARTED, + eventData: { + databaseId: instanceId, + logSaving: isSaveToLog + } + }) + dispatch(startMonitor(isSaveToLog)) } return (
- + diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index a7992cd61d..9d03acb4d3 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -43,6 +43,8 @@ enum ApiEndpoints { SETTINGS = 'settings', SETTINGS_AGREEMENTS_SPEC = 'settings/agreements/spec', WORKBENCH_COMMAND_EXECUTIONS = 'workbench/command-executions', + PROFILER = 'profiler', + PROFILER_LOGS = 'profiler/logs', REDIS_COMMANDS = 'commands', GUIDES = 'static/guides/guides.json', diff --git a/redisinsight/ui/src/constants/monitorEvents.ts b/redisinsight/ui/src/constants/monitorEvents.ts index a401dbda0a..2a7fa9d1a9 100644 --- a/redisinsight/ui/src/constants/monitorEvents.ts +++ b/redisinsight/ui/src/constants/monitorEvents.ts @@ -2,4 +2,6 @@ export enum MonitorEvent { Monitor = 'monitor', MonitorData = 'monitorData', Exception = 'exception', + Pause = 'pause', + FlushLogs = 'flushLogs' } diff --git a/redisinsight/ui/src/slices/cli/monitor.ts b/redisinsight/ui/src/slices/cli/monitor.ts index 6bac4b2dca..ad71e83c25 100644 --- a/redisinsight/ui/src/slices/cli/monitor.ts +++ b/redisinsight/ui/src/slices/cli/monitor.ts @@ -1,16 +1,28 @@ import { createSlice } from '@reduxjs/toolkit' +import { MonitorEvent } from 'uiSrc/constants' import { IMonitorDataPayload, StateMonitor } from '../interfaces' import { RootState } from '../store' export const initialState: StateMonitor = { + loading: false, + loadingPause: false, isShowMonitor: false, isRunning: false, isStarted: false, + isPaused: false, + isSaveToFile: false, isMinimizedMonitor: false, socket: null, error: '', items: [], + logFile: null, + timestamp: { + start: 0, + paused: 0, + unPaused: 0, + duration: 0 + } } export const MONITOR_ITEMS_MAX_COUNT = 5_000 @@ -21,6 +33,7 @@ const monitorSlice = createSlice({ initialState, reducers: { setMonitorInitialState: (state) => { + state.socket?.emit(MonitorEvent.FlushLogs) state.socket?.removeAllListeners() state.socket?.disconnect() return { ...initialState } @@ -46,13 +59,49 @@ const monitorSlice = createSlice({ state.isStarted = true }, - toggleRunMonitor: (state) => { - state.isRunning = !state.isRunning + startMonitor: (state, { payload }) => { + state.isRunning = true state.error = '' + state.isSaveToFile = payload + }, + + setStartTimestamp: (state, { payload }) => { + state.timestamp.start = payload + state.timestamp.unPaused = state.timestamp.start + }, + + togglePauseMonitor: (state) => { + state.isPaused = !state.isPaused + if (!state.isPaused) { + state.timestamp.unPaused = Date.now() + } + if (state.isPaused) { + state.timestamp.paused = Date.now() + state.timestamp.duration += state.timestamp.paused - state.timestamp.unPaused + } + }, + + setMonitorLoadingPause: (state, { payload }) => { + state.loadingPause = payload }, stopMonitor: (state) => { state.isRunning = false + state.error = '' + state.timestamp.paused = Date.now() + state.timestamp.duration += state.timestamp.paused - state.timestamp.unPaused + state.isPaused = false + }, + + resetProfiler: (state) => { + state.socket?.emit(MonitorEvent.FlushLogs) + state.socket?.removeAllListeners() + state.socket?.disconnect() + return { + ...initialState, + isShowMonitor: state.isShowMonitor, + isMinimizedMonitor: state.isMinimizedMonitor + } }, concatMonitorItems: (state, { payload }: { payload: IMonitorDataPayload[] }) => { @@ -76,7 +125,7 @@ const monitorSlice = createSlice({ }, setError: (state, { payload }) => { state.error = payload - } + }, }, }) @@ -87,8 +136,12 @@ export const { toggleMonitor, toggleHideMonitor, setSocket, - toggleRunMonitor, + togglePauseMonitor, + startMonitor, + setStartTimestamp, + setMonitorLoadingPause, stopMonitor, + resetProfiler, concatMonitorItems, resetMonitorItems, setError diff --git a/redisinsight/ui/src/slices/interfaces/monitor.ts b/redisinsight/ui/src/slices/interfaces/monitor.ts index 10586233ef..059dc09cbd 100644 --- a/redisinsight/ui/src/slices/interfaces/monitor.ts +++ b/redisinsight/ui/src/slices/interfaces/monitor.ts @@ -1,18 +1,29 @@ import { Socket } from 'socket.io-client' import { Nullable } from 'uiSrc/utils' -import { IOnDatePayload } from 'apiSrc/modules/monitor/helpers/client-monitor-observer' +import { IMonitorData } from 'apiSrc/modules/profiler/interfaces/monitor-data.interface' -export interface IMonitorDataPayload extends Partial{ +export interface IMonitorDataPayload extends Partial{ isError?: boolean message?: string } export interface StateMonitor { + loading: boolean + loadingPause: boolean isShowMonitor: boolean isMinimizedMonitor: boolean isRunning: boolean isStarted: boolean + isPaused: boolean + isSaveToFile: boolean socket: Nullable items: IMonitorDataPayload[] error: string + logFile: any + timestamp: { + start: number + paused: number + unPaused: number + duration: number + } } diff --git a/redisinsight/ui/src/slices/tests/cli/monitor.spec.ts b/redisinsight/ui/src/slices/tests/cli/monitor.spec.ts index 772cd6e909..a549802a88 100644 --- a/redisinsight/ui/src/slices/tests/cli/monitor.spec.ts +++ b/redisinsight/ui/src/slices/tests/cli/monitor.spec.ts @@ -8,14 +8,21 @@ import reducer, { toggleMonitor, showMonitor, toggleHideMonitor, - toggleRunMonitor, + stopMonitor, + setError, + togglePauseMonitor, + startMonitor, + setStartTimestamp, setSocket, concatMonitorItems, - MONITOR_ITEMS_MAX_COUNT, stopMonitor, setError, + MONITOR_ITEMS_MAX_COUNT, } from '../../cli/monitor' let store: typeof mockedStore let socket: typeof MockedSocket +let dateNow: jest.SpyInstance +const timestamp = 1629128049027 + beforeEach(() => { cleanup() socket = new MockedSocket() @@ -24,6 +31,13 @@ beforeEach(() => { }) describe('monitor slice', () => { + beforeAll(() => { + dateNow = jest.spyOn(Date, 'now').mockImplementation(() => timestamp) + }) + + afterAll(() => { + dateNow.mockRestore() + }) describe('toggleMonitor', () => { it('default state.isShowMonitor should be falsy', () => { // Arrange @@ -99,16 +113,70 @@ describe('monitor slice', () => { }) }) - describe('toggleRunMonitor', () => { - it('should properly set !isRunning', () => { + describe('startMonitor', () => { + it('should properly set new state', () => { // Arrange const state: typeof initialState = { ...initialState, isRunning: true, + isSaveToFile: true + } + + // Act + const nextState = reducer(initialState, startMonitor(true)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + cli: { + monitor: nextState, + }, + }) + expect(monitorSelector(rootState)).toEqual(state) + }) + }) + + describe('setStartTimestamp', () => { + it('should properly set new state', () => { + // Arrange + const state: typeof initialState = { + ...initialState, + timestamp: { + ...initialState.timestamp, + start: timestamp, + unPaused: timestamp + } + } + + // Act + const nextState = reducer(initialState, setStartTimestamp(timestamp)) + + // Assert + const rootState = Object.assign(initialStateDefault, { + cli: { + monitor: nextState, + }, + }) + expect(monitorSelector(rootState)).toEqual(state) + }) + }) + + describe('togglePauseMonitor', () => { + it('should properly set new state', () => { + // Arrange + const diffTimestamp = 5 + const intermediateState = reducer(initialState, setStartTimestamp(timestamp - diffTimestamp)) + const state: typeof intermediateState = { + ...intermediateState, + isPaused: true, + timestamp: { + ...intermediateState.timestamp, + paused: timestamp, + duration: diffTimestamp + } } // Act - const nextState = reducer(initialState, toggleRunMonitor()) + const nextState = reducer(intermediateState, togglePauseMonitor()) // Assert const rootState = Object.assign(initialStateDefault, { @@ -123,10 +191,14 @@ describe('monitor slice', () => { describe('stopMonitor', () => { it('should properly set new state', () => { // Arrange - toggleRunMonitor() const state: typeof initialState = { ...initialState, isRunning: false, + timestamp: { + ...initialState.timestamp, + paused: Date.now(), + duration: Date.now() - initialState.timestamp.unPaused + } } // Act diff --git a/redisinsight/ui/src/styles/components/_tool_tip.scss b/redisinsight/ui/src/styles/components/_tool_tip.scss index 90ac0c303e..6c50c42cff 100644 --- a/redisinsight/ui/src/styles/components/_tool_tip.scss +++ b/redisinsight/ui/src/styles/components/_tool_tip.scss @@ -1,5 +1,5 @@ -.euiToolTip { - max-width: 470px; +body .euiToolTip { + max-width: 288px; padding: 12px 15px !important; color: var(--euiTooltipTextColor) !important; box-shadow: 0 3px 15px var(--controlsBoxShadowColor) !important; @@ -13,7 +13,7 @@ } .euiToolTip__title { border-bottom: none !important; - font: normal normal 500 12px/17px Graphik, sans-serif !important; + font: normal normal 500 13px/17px Graphik, sans-serif !important; letter-spacing: -0.12px; } .euiToolTip__content { diff --git a/redisinsight/ui/src/telemetry/events.ts b/redisinsight/ui/src/telemetry/events.ts index 6b07732d92..3bb00cc833 100644 --- a/redisinsight/ui/src/telemetry/events.ts +++ b/redisinsight/ui/src/telemetry/events.ts @@ -82,6 +82,8 @@ export enum TelemetryEvent { PROFILER_OPENED = 'PROFILER_OPENED', PROFILER_STARTED = 'PROFILER_STARTED', PROFILER_STOPPED = 'PROFILER_STOPPED', + PROFILER_PAUSED = 'PROFILER_PAUSED', + PROFILER_RESUMED = 'PROFILER_RESUMED', PROFILER_CLEARED = 'PROFILER_CLEARED', PROFILER_CLOSED = 'PROFILER_CLOSED', PROFILER_MINIMIZED = 'PROFILER_MINIMIZED', diff --git a/redisinsight/ui/src/utils/truncateTTL.ts b/redisinsight/ui/src/utils/truncateTTL.ts index 352db60bd7..3fa95cb23c 100644 --- a/redisinsight/ui/src/utils/truncateTTL.ts +++ b/redisinsight/ui/src/utils/truncateTTL.ts @@ -6,7 +6,7 @@ import { MAX_TTL_NUMBER } from './validations' const TRUNCATE_DELIMITER = ', ' // Replace default strings of duration to cutted // 94 years, 9 month, 3 minutes => 94 yr, 9mo, 3min -const cutDurationText = (text = '') => text +export const cutDurationText = (text = '') => text .replace(/years?/, 'yr') .replace(/months?/, 'mo') .replace(/days?/, 'd')