fix: SSH Tunnel creation with dynamic form (#24196)

(cherry picked from commit 226c7f807d)
This commit is contained in:
Hugh A. Miles II
2023-07-02 23:48:48 -04:00
committed by Elizabeth Thompson
parent 0bc0865692
commit f3bb63be26
11 changed files with 188 additions and 127 deletions

View File

@@ -62,8 +62,8 @@ describe('Add database', () => {
it('show error alerts on dynamic form for bad host', () => {
// click postgres dynamic form
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').focus().type('badhost');
cy.get('input[name="port"]').focus().type('5432');
cy.get('input[name="host"]').focus().type('badhost', { force: true });
cy.get('input[name="port"]').focus().type('5432', { force: true });
cy.get('.ant-form-item-explain-error').contains(
"The hostname provided can't be resolved",
);
@@ -72,8 +72,8 @@ describe('Add database', () => {
it('show error alerts on dynamic form for bad port', () => {
// click postgres dynamic form
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').focus().type('localhost');
cy.get('input[name="port"]').focus().type('123');
cy.get('input[name="host"]').focus().type('localhost', { force: true });
cy.get('input[name="port"]').focus().type('123', { force: true });
cy.get('input[name="database"]').focus();
cy.get('.ant-form-item-explain-error').contains('The port is closed');
});

View File

@@ -27,4 +27,5 @@ export default interface Database {
server_cert: string;
sqlalchemy_uri: string;
catalog: object;
parameters: any;
}

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import React from 'react';
import { isEmpty } from 'lodash';
import { SupersetTheme, t } from '@superset-ui/core';
import { AntdSwitch } from 'src/components';
import InfoTooltip from 'src/components/InfoTooltip';
@@ -250,3 +251,33 @@ export const forceSSLField = ({
/>
</div>
);
export const SSHTunnelSwitch = ({
isEditMode,
changeMethods,
db,
}: FieldPropTypes) => (
<div css={(theme: SupersetTheme) => infoTooltip(theme)}>
<AntdSwitch
disabled={isEditMode && !isEmpty(db?.ssh_tunnel)}
checked={db?.parameters?.ssh}
onChange={changed => {
changeMethods.onParametersChange({
target: {
type: 'toggle',
name: 'ssh',
checked: true,
value: changed,
},
});
}}
data-test="ssh-tunnel-switch"
/>
<span css={toggleStyle}>{t('SSH Tunnel')}</span>
<InfoTooltip
tooltip={t('SSH Tunnel configuration parameters')}
placement="right"
viewBox="0 -5 24 24"
/>
</div>
);

View File

@@ -31,6 +31,7 @@ import {
portField,
queryField,
usernameField,
SSHTunnelSwitch,
} from './CommonParameters';
import { validatedInputField } from './ValidatedInputField';
import { EncryptedField } from './EncryptedField';
@@ -55,6 +56,7 @@ export const FormFieldOrder = [
'account',
'warehouse',
'role',
'ssh',
];
export interface FieldPropTypes {
@@ -102,6 +104,7 @@ const FORM_FIELD_MAP = {
warehouse: validatedInputField,
role: validatedInputField,
account: validatedInputField,
ssh: SSHTunnelSwitch,
};
interface DatabaseConnectionFormProps {

View File

@@ -127,6 +127,10 @@ fetchMock.mock(AVAILABLE_DB_ENDPOINT, {
description: 'Additional parameters',
type: 'object',
},
ssh: {
description: 'Create SSH Tunnel',
type: 'boolean',
},
username: {
description: 'Username',
nullable: true,

View File

@@ -706,15 +706,18 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
});
}
// make sure that button spinner animates
setLoading(true);
const errors = await getValidation(dbToUpdate, true);
if ((validationErrors && !isEmpty(validationErrors)) || errors) {
// only do validation for non ssh tunnel connections
if (!dbToUpdate?.ssh_tunnel) {
// make sure that button spinner animates
setLoading(true);
const errors = await getValidation(dbToUpdate, true);
if ((validationErrors && !isEmpty(validationErrors)) || errors) {
setLoading(false);
return;
}
// end spinner animation
setLoading(false);
return;
}
setLoading(false);
// end spinner animation
const parameters_schema = isEditMode
? dbToUpdate.parameters_schema?.properties
@@ -1431,18 +1434,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
validationErrors={validationErrors}
getPlaceholder={getPlaceholder}
/>
<SSHTunnelContainer>
<SSHTunnelSwitchComponent
isEditMode={isEditMode}
dbFetched={dbFetched}
disableSSHTunnelingForEngine={disableSSHTunnelingForEngine}
useSSHTunneling={useSSHTunneling}
setUseSSHTunneling={setUseSSHTunneling}
setDB={setDB}
isSSHTunneling={isSSHTunneling}
/>
</SSHTunnelContainer>
{useSSHTunneling && (
{db?.parameters?.ssh && (
<SSHTunnelContainer>{renderSSHTunnelForm()}</SSHTunnelContainer>
)}
</>

View File

@@ -68,6 +68,7 @@ export type DatabaseObject = {
warehouse?: string;
role?: string;
account?: string;
ssh?: boolean;
};
// Performance

View File

@@ -692,123 +692,132 @@ export function useDatabaseValidation() {
null,
);
const getValidation = useCallback(
(database: Partial<DatabaseObject> | null, onCreate = false) =>
SupersetClient.post({
endpoint: '/api/v1/database/validate_parameters/',
body: JSON.stringify(transformDB(database)),
headers: { 'Content-Type': 'application/json' },
})
.then(() => {
setValidationErrors(null);
(database: Partial<DatabaseObject> | null, onCreate = false) => {
if (database?.parameters?.ssh) {
// when ssh tunnel is enabled we don't want to render any validation errors
setValidationErrors(null);
return [];
}
return (
SupersetClient.post({
endpoint: '/api/v1/database/validate_parameters/',
body: JSON.stringify(transformDB(database)),
headers: { 'Content-Type': 'application/json' },
})
// eslint-disable-next-line consistent-return
.catch(e => {
if (typeof e.json === 'function') {
return e.json().then(({ errors = [] }: JsonObject) => {
const parsedErrors = errors
.filter((error: { error_type: string }) => {
const skipValidationError = ![
'CONNECTION_MISSING_PARAMETERS_ERROR',
'CONNECTION_ACCESS_DENIED_ERROR',
].includes(error.error_type);
return skipValidationError || onCreate;
})
.reduce(
(
obj: {},
{
error_type,
extra,
message,
}: {
error_type: string;
extra: {
invalid?: string[];
missing?: string[];
name: string;
catalog: {
.then(() => {
setValidationErrors(null);
})
// eslint-disable-next-line consistent-return
.catch(e => {
if (typeof e.json === 'function') {
return e.json().then(({ errors = [] }: JsonObject) => {
const parsedErrors = errors
.filter((error: { error_type: string }) => {
const skipValidationError = ![
'CONNECTION_MISSING_PARAMETERS_ERROR',
'CONNECTION_ACCESS_DENIED_ERROR',
].includes(error.error_type);
return skipValidationError || onCreate;
})
.reduce(
(
obj: {},
{
error_type,
extra,
message,
}: {
error_type: string;
extra: {
invalid?: string[];
missing?: string[];
name: string;
url: string;
idx: number;
catalog: {
name: string;
url: string;
idx: number;
};
issue_codes?: {
code?: number;
message?: string;
}[];
};
issue_codes?: {
code?: number;
message?: string;
}[];
};
message: string;
},
) => {
if (extra.catalog) {
if (extra.catalog.name) {
message: string;
},
) => {
if (extra.catalog) {
if (extra.catalog.name) {
return {
...obj,
error_type,
[extra.catalog.idx]: {
name: message,
},
};
}
if (extra.catalog.url) {
return {
...obj,
error_type,
[extra.catalog.idx]: {
url: message,
},
};
}
return {
...obj,
error_type,
[extra.catalog.idx]: {
name: message,
},
};
}
if (extra.catalog.url) {
return {
...obj,
error_type,
[extra.catalog.idx]: {
url: message,
},
};
}
// if extra.invalid doesn't exist then the
// error can't be mapped to a parameter
// so leave it alone
if (extra.invalid) {
return {
...obj,
[extra.invalid[0]]: message,
error_type,
};
}
if (extra.missing) {
return {
...obj,
error_type,
...Object.assign(
{},
...extra.missing.map(field => ({
[field]: 'This is a required field',
})),
),
};
}
if (extra.issue_codes?.length) {
return {
...obj,
error_type,
description: message || extra.issue_codes[0]?.message,
};
}
return {
...obj,
error_type,
[extra.catalog.idx]: {
name: message,
url: message,
},
};
}
// if extra.invalid doesn't exist then the
// error can't be mapped to a parameter
// so leave it alone
if (extra.invalid) {
return {
...obj,
[extra.invalid[0]]: message,
error_type,
};
}
if (extra.missing) {
return {
...obj,
error_type,
...Object.assign(
{},
...extra.missing.map(field => ({
[field]: 'This is a required field',
})),
),
};
}
if (extra.issue_codes?.length) {
return {
...obj,
error_type,
description: message || extra.issue_codes[0]?.message,
};
}
return obj;
},
{},
);
setValidationErrors(parsedErrors);
return parsedErrors;
});
}
// eslint-disable-next-line no-console
console.error(e);
}),
return obj;
},
{},
);
setValidationErrors(parsedErrors);
return parsedErrors;
});
}
// eslint-disable-next-line no-console
console.error(e);
})
);
},
[setValidationErrors],
);

View File

@@ -1773,6 +1773,10 @@ class BasicParametersSchema(Schema):
encryption = fields.Boolean(
required=False, description=__("Use an encrypted connection to the database")
)
ssh = fields.Boolean(
required=False,
metadata={"description": __("Use an ssh tunnel connection to the database")},
)
class BasicParametersType(TypedDict, total=False):

View File

@@ -2437,6 +2437,10 @@ class TestDatabaseApi(SupersetTestCase):
"description": "Additional parameters",
"type": "object",
},
"ssh": {
"description": "Use an ssh tunnel connection to the database",
"type": "boolean",
},
"username": {
"description": "Username",
"nullable": True,
@@ -2512,6 +2516,10 @@ class TestDatabaseApi(SupersetTestCase):
"description": "Additional parameters",
"type": "object",
},
"ssh": {
"description": "Use an ssh tunnel connection to the database",
"type": "boolean",
},
"username": {
"description": "Username",
"nullable": True,
@@ -2587,6 +2595,10 @@ class TestDatabaseApi(SupersetTestCase):
"description": "Additional parameters",
"type": "object",
},
"ssh": {
"description": "Use an ssh tunnel connection to the database",
"type": "boolean",
},
"username": {
"description": "Username",
"nullable": True,

View File

@@ -511,6 +511,10 @@ def test_base_parameters_mixin():
"description": "Additional parameters",
"additionalProperties": {},
},
"ssh": {
"description": "Use an ssh tunnel connection to the database",
"type": "boolean",
},
},
"required": ["database", "host", "port", "username"],
}