Skip to content

Commit aa764cf

Browse files
author
Artem
committed
Merge branch 'main' into build/rstack
2 parents 086d307 + c8e1912 commit aa764cf

File tree

14 files changed

+490
-33
lines changed

14 files changed

+490
-33
lines changed

redisinsight/api/src/modules/core/models/database-instance.entity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export class DatabaseInstanceEntity {
192192
@Column({ nullable: true })
193193
encryption: string;
194194

195-
constructor(partial: Partial<ClientCertificateEntity>) {
195+
constructor(partial: Partial<DatabaseInstanceEntity>) {
196196
Object.assign(this, partial);
197197
}
198198
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
Inject, Injectable, Logger, OnModuleInit,
3+
} from '@nestjs/common';
4+
import { DatabasesProvider } from 'src/modules/shared/services/instances-business/databases.provider';
5+
import { RedisService } from 'src/modules/core/services/redis/redis.service';
6+
import { AppTool } from 'src/models';
7+
import { InstancesBusinessService } from 'src/modules/shared/services/instances-business/instances-business.service';
8+
import { getAvailableEndpoints, getRunningProcesses, getTCP4Endpoints } from 'src/utils/auto-discovery-helper';
9+
import { convertRedisInfoReplyToObject } from 'src/utils';
10+
import { ISettingsProvider } from 'src/modules/core/models/settings-provider.interface';
11+
import config from 'src/utils/config';
12+
13+
const SERVER_CONFIG = config.get('server');
14+
15+
@Injectable()
16+
export class AutoDiscoveryService implements OnModuleInit {
17+
private logger = new Logger('AutoDiscoveryService');
18+
19+
constructor(
20+
@Inject('SETTINGS_PROVIDER')
21+
private settingsService: ISettingsProvider,
22+
private databaseProvider: DatabasesProvider,
23+
private redisService: RedisService,
24+
private databaseService: InstancesBusinessService,
25+
) {}
26+
27+
/**
28+
* Run auto discovery on first launch only
29+
*/
30+
async onModuleInit() {
31+
try {
32+
// no need to auto discover for Redis Stack
33+
if (SERVER_CONFIG.buildType === 'REDIS_STACK') {
34+
return;
35+
}
36+
37+
const settings = await this.settingsService.getSettings();
38+
// check agreements to understand if it is first launch
39+
if (!settings.agreements) {
40+
await this.discoverDatabases();
41+
}
42+
} catch (e) {
43+
this.logger.warn('Unable to discover redis database', e);
44+
}
45+
}
46+
47+
/**
48+
* Try to add standalone databases without auth from processes running on the host machine listening on TCP4
49+
* Database alias will be "host:port"
50+
* @private
51+
*/
52+
private async discoverDatabases() {
53+
const endpoints = await getAvailableEndpoints(getTCP4Endpoints(await getRunningProcesses()));
54+
55+
// Add redis databases or resolve after 1s to not block app startup for a long time
56+
await Promise.race([
57+
Promise.all(endpoints.map(this.addRedisDatabase.bind(this))),
58+
new Promise((resolve) => setTimeout(resolve, 1000)),
59+
]);
60+
}
61+
62+
/**
63+
* Add standalone database without credentials using host and port only
64+
* @param endpoint
65+
* @private
66+
*/
67+
private async addRedisDatabase(endpoint: { host: string, port: number }) {
68+
try {
69+
const client = await this.redisService.createStandaloneClient(
70+
endpoint,
71+
AppTool.Common,
72+
false,
73+
'redisinsight-auto-discovery',
74+
);
75+
76+
const info = convertRedisInfoReplyToObject(
77+
await client.send_command('info'),
78+
);
79+
80+
if (info?.server?.redis_mode === 'standalone') {
81+
await this.databaseService.addDatabase({
82+
name: `${endpoint.host}:${endpoint.port}`,
83+
...endpoint,
84+
});
85+
}
86+
} catch (e) {
87+
// ignore error
88+
}
89+
}
90+
}

redisinsight/api/src/modules/shared/shared.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { DatabasesProvider } from 'src/modules/shared/services/instances-busines
1010
import { OverviewService } from 'src/modules/shared/services/instances-business/overview.service';
1111
import { RedisToolFactory } from 'src/modules/shared/services/base/redis-tool.factory';
1212
import { StackDatabasesProvider } from 'src/modules/shared/services/instances-business/stack.databases.provider';
13+
import { AutoDiscoveryService } from 'src/modules/shared/services/instances-business/auto-discovery.service';
1314
import { InstancesBusinessService } from './services/instances-business/instances-business.service';
1415
import { RedisEnterpriseBusinessService } from './services/redis-enterprise-business/redis-enterprise-business.service';
1516
import { RedisCloudBusinessService } from './services/redis-cloud-business/redis-cloud-business.service';
@@ -42,6 +43,7 @@ const SERVER_CONFIG = config.get('server');
4243
RedisSentinelBusinessService,
4344
AutodiscoveryAnalyticsService,
4445
RedisToolFactory,
46+
AutoDiscoveryService,
4547
],
4648
exports: [
4749
InstancesBusinessService,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {
2+
getTCP4Endpoints,
3+
} from 'src/utils/auto-discovery-helper';
4+
5+
const winNetstat = ''
6+
+ 'Proto Local Address Foreign Address State PID\n'
7+
+ 'TCP 0.0.0.0:5000 0.0.0.0:0 LISTENING 13728\n'
8+
+ 'TCP 0.0.0.0:6379 0.0.0.0:0 LISTENING 13728\n'
9+
+ 'TCP 127.0.0.1:6379 0.0.0.0:0 LISTENING 13728\n'
10+
+ 'TCP *:6380 0.0.0.0:0 LISTENING 13728\n'
11+
+ 'TCP [::]:135 [::]:0 LISTENING 1100\n'
12+
+ 'TCP [::]:445 [::]:0 LISTENING 4\n'
13+
+ 'TCP [::]:808 [::]:0 LISTENING 6084\n'
14+
+ 'TCP [::]:2701 [::]:0 LISTENING 6056\n'
15+
+ 'TCP *:* LISTENING 6056';
16+
17+
const linuxNetstat = ''
18+
+ 'Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name \n'
19+
+ 'tcp 0 0 0.0.0.0:5000 0.0.0.0:* LISTEN - \n'
20+
+ 'tcp 0 0 0.0.0.0:6379 0.0.0.0:* LISTEN - \n'
21+
+ 'tcp 0 0 127.0.0.1:6379 0.0.0.0:* LISTEN - \n'
22+
+ 'tcp 0 0 *:6380 0.0.0.0:* LISTEN - \n'
23+
+ 'tcp6 0 0 :::28100 :::* LISTEN - \n'
24+
+ 'tcp6 0 0 :::8100 :::* LISTEN - \n'
25+
+ 'tcp6 0 0 :::8101 :::* LISTEN - \n'
26+
+ 'tcp6 0 0 :::8102 :::* LISTEN - \n'
27+
+ 'tcp6 0 0 :::8103 :::* LISTEN - \n'
28+
+ 'tcp6 0 0 :::8200 :::* LISTEN - \n'
29+
+ 'tcp6 0 0 ::1:6379 :::* LISTEN - \n';
30+
31+
/* eslint-disable max-len */
32+
const macNetstat = ''
33+
+ 'Proto Recv-Q Send-Q Local Address Foreign Address (state) rhiwat shiwat pid epid state options\n'
34+
+ 'tcp4 0 0 10.55.1.235.5000 10.55.1.235.52217 FIN_WAIT_2 407280 146988 30555 0 0x2131 0x00000104\n'
35+
+ 'tcp4 0 0 10.55.1.235.6379 10.55.1.235.5001 CLOSE_WAIT 407682 146988 872 0 0x0122 0x00000008\n'
36+
+ 'tcp4 0 0 127.0.0.1.6379 127.0.0.1.52216 FIN_WAIT_2 403346 146988 24687 0 0x2131 0x00000104\n'
37+
+ 'tcp46 0 0 *.6380 *.* LISTEN 131072 131072 31195 0 0x0100 0x00000106\n'
38+
+ 'tcp6 0 0 ::1.5002 ::1.52167 ESTABLISHED 405692 146808 31195 0 0x0102 0x00000104\n'
39+
+ 'tcp6 0 0 ::1.52167 ::1.5002 ESTABLISHED 406172 146808 31200 0 0x0102 0x00000008\n';
40+
/* eslint-enable max-len */
41+
42+
const getTCP4EndpointsTests = [
43+
{
44+
name: 'win output',
45+
input: winNetstat.split('\n'),
46+
output: [
47+
{ host: 'localhost', port: 5000 },
48+
{ host: 'localhost', port: 6379 },
49+
{ host: 'localhost', port: 6380 },
50+
],
51+
},
52+
{
53+
name: 'linux output',
54+
input: linuxNetstat.split('\n'),
55+
output: [
56+
{ host: 'localhost', port: 5000 },
57+
{ host: 'localhost', port: 6379 },
58+
{ host: 'localhost', port: 6380 },
59+
],
60+
},
61+
{
62+
name: 'mac output',
63+
input: macNetstat.split('\n'),
64+
output: [
65+
{ host: 'localhost', port: 5000 },
66+
{ host: 'localhost', port: 6379 },
67+
{ host: 'localhost', port: 6380 },
68+
],
69+
},
70+
];
71+
72+
describe('getTCP4Endpoints', () => {
73+
getTCP4EndpointsTests.forEach((test) => {
74+
it(`Should return endpoints to test ${test.name}`, async () => {
75+
const result = getTCP4Endpoints(test.input);
76+
77+
expect(result).toEqual(test.output);
78+
});
79+
});
80+
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import * as os from 'os';
2+
import * as net from 'net';
3+
import { spawn } from 'child_process';
4+
import { isObject } from 'lodash';
5+
6+
interface Endpoint {
7+
host: string,
8+
port: number,
9+
}
10+
11+
/**
12+
* Get "netstat" command and args based on operation system
13+
*/
14+
export const getSpawnArgs = (): [string, string[]] => {
15+
switch (os.type()) {
16+
case 'Linux':
17+
return ['netstat', ['-anpt']];
18+
case 'Darwin':
19+
return ['netstat', ['-anvp', 'tcp']];
20+
case 'Windows_NT':
21+
return ['netstat.exe', ['-a', '-n', '-o']];
22+
default:
23+
throw new Error('Unsupported operation system');
24+
}
25+
};
26+
27+
/**
28+
* Get list of processes running on local machine
29+
*/
30+
export const getRunningProcesses = async (): Promise<string[]> => new Promise((resolve, reject) => {
31+
try {
32+
let stdoutData = '';
33+
const proc = spawn(...getSpawnArgs());
34+
35+
proc.stdout.on('data', (data) => {
36+
stdoutData += data.toString();
37+
});
38+
39+
proc.stdout.on('error', (e) => {
40+
reject(e);
41+
});
42+
43+
proc.stdout.on('end', () => {
44+
resolve(stdoutData.split('\n'));
45+
});
46+
} catch (e) {
47+
reject(e);
48+
}
49+
});
50+
51+
/**
52+
* Return list of unique endpoints (host is hardcoded) to test
53+
* @param processes
54+
*/
55+
export const getTCP4Endpoints = (processes: string[]): Endpoint[] => {
56+
const regExp = /(\d+\.\d+\.\d+\.\d+|\*)[:.](\d+)/;
57+
const endpoints = new Map();
58+
59+
processes.forEach((line) => {
60+
const match = line.match(regExp);
61+
62+
if (match) {
63+
endpoints.set(match[2], {
64+
host: 'localhost',
65+
port: parseInt(match[2], 10),
66+
});
67+
}
68+
});
69+
70+
return [...endpoints.values()];
71+
};
72+
73+
/**
74+
* Check RESP protocol response from tcp connection
75+
* @param endpoint
76+
*/
77+
export const testEndpoint = async (endpoint: Endpoint): Promise<Endpoint> => new Promise((resolve) => {
78+
const client = net.createConnection({
79+
host: endpoint.host,
80+
port: endpoint.port,
81+
}, () => {
82+
client.write('PING\r\n');
83+
});
84+
85+
client.on('data', (data) => {
86+
client.end();
87+
88+
if (data.toString().startsWith('+PONG')) {
89+
resolve(endpoint);
90+
} else {
91+
resolve(null);
92+
}
93+
});
94+
95+
client.on('error', () => {
96+
resolve(null);
97+
});
98+
99+
setTimeout(() => {
100+
client.end();
101+
resolve(null);
102+
}, 1000);
103+
});
104+
105+
/**
106+
* Get endpoints that we are able to connect and receive expected RESP protocol response
107+
* @param endpoints
108+
*/
109+
export const getAvailableEndpoints = async (
110+
endpoints: Endpoint[],
111+
): Promise<Endpoint[]> => (await Promise.all(endpoints.map(testEndpoint))).filter(isObject);

redisinsight/ui/src/components/instance-header/InstanceHeader.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const InstanceHeader = () => {
4545
<div className={cx(styles.container)}>
4646
<EuiFlexGroup gutterSize="none" responsive={false}>
4747
<EuiFlexItem style={{ overflow: 'hidden' }}>
48-
<div className={styles.breadcrumbsContainer}>
48+
<div className={styles.breadcrumbsContainer} data-testid="breadcrumbs-container">
4949
<div>
5050
<EuiToolTip
5151
position="bottom"
@@ -57,6 +57,7 @@ const InstanceHeader = () => {
5757
iconSize="l"
5858
iconType="sortLeft"
5959
aria-label="My Redis databases"
60+
data-testid="my-redis-db-icon"
6061
onClick={goHome}
6162
/>
6263
</EuiToolTip>

tests/e2e/pageObjects/browser-page.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export class BrowserPage {
124124
treeViewMessage: Selector
125125
totalKeysNumber: Selector
126126
keysScanned: Selector
127+
breadcrumbsContainer: Selector
127128
databaseInfoIcon: Selector
128129
databaseInfoToolTip: Selector
129130
removeHashFieldButton: Selector
@@ -250,6 +251,7 @@ export class BrowserPage {
250251
this.overviewCommandsSec = Selector('[data-test-subj=overview-commands-sec]');
251252
this.overviewCpu = Selector('[data-test-subj=overview-cpu]');
252253
this.selectedFilterTypeString = Selector('[data-testid=filter-option-type-selected-string]');
254+
this.breadcrumbsContainer = Selector('[data-testid=breadcrumbs-container]');
253255
this.treeViewArea = Selector('');
254256
this.treeViewScannedValue = Selector('');
255257
this.treeViewKeysNumber = Selector('');

tests/e2e/pageObjects/my-redis-databases-page.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export class MyRedisDatabasePage {
2121
deleteButtonInPopover: Selector
2222
confirmDeleteAllDbButton: Selector
2323
browserButton: Selector
24+
editDatabaseButton: Selector
25+
editAliasButton: Selector
26+
aliasInput: Selector
27+
applyButton: Selector
28+
submitChangesButton: Selector
2429
databaseInfoMessage: Selector;
2530

2631
constructor() {
@@ -42,13 +47,22 @@ export class MyRedisDatabasePage {
4247
this.selectAllCheckbox = Selector('[data-test-subj=checkboxSelectAll]');
4348
this.deleteButtonInPopover = Selector('#deletePopover button');
4449
this.confirmDeleteAllDbButton = Selector('[data-testid=delete-selected-dbs]');
50+
this.editDatabaseButton = Selector('[data-testid^=edit-instance]');
51+
this.editAliasButton = Selector('[data-testid=edit-alias-btn]');
52+
this.applyButton = Selector('[data-testid=apply-btn]');
53+
this.submitChangesButton = Selector('[data-testid=btn-submit]');
4554
// TEXT INPUTS (also referred to as 'Text fields')
4655
this.dbNameList = Selector('[data-testid^=instance-name]');
4756
this.tableRowContent = Selector('[data-test-subj=database-alias-column]');
4857
this.databaseInfoMessage = Selector('[data-test-subj=euiToastHeader]');
4958
this.hostPort = Selector('[data-testid=host-port]');
59+
this.aliasInput = Selector('[data-testid=alias-input]');
5060
}
5161

62+
/**
63+
* Click on the database by name
64+
* @param dbName The name of the database to be opened
65+
*/
5266
async clickOnDBByName(dbName: string): Promise<void>{
5367
if (await this.toastCloseButton.exists) {
5468
await t.click(this.toastCloseButton);
@@ -90,4 +104,20 @@ export class MyRedisDatabasePage {
90104
}
91105
}
92106
}
107+
108+
/**
109+
* Click on the edit database button by name
110+
* @param databaseName The name of the database to be edited
111+
*/
112+
async clickOnEditDBByName(databaseName: string): Promise<void>{
113+
const dbNames = this.tableRowContent;
114+
const count = await dbNames.count;
115+
116+
for(let i = 0; i < count; i++) {
117+
if((await dbNames.nth(i).innerText || '').includes(databaseName)) {
118+
await t.click(this.editDatabaseButton.nth(i));
119+
break;
120+
}
121+
}
122+
}
93123
}

0 commit comments

Comments
 (0)