diff --git a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts
index a3260250aa47f..3cc34cb64fb99 100644
--- a/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts
+++ b/superset-frontend/cypress-base/cypress/e2e/database/modal.test.ts
@@ -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');
});
diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx
index 99a414012b779..7078b49cbc8d5 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx
+++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx
@@ -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 = ({
/>
);
+
+export const SSHTunnelSwitch = ({
+ isEditMode,
+ changeMethods,
+ db,
+}: FieldPropTypes) => (
+
infoTooltip(theme)}>
+
{
+ changeMethods.onParametersChange({
+ target: {
+ type: 'toggle',
+ name: 'ssh',
+ checked: true,
+ value: changed,
+ },
+ });
+ }}
+ data-test="ssh-tunnel-switch"
+ />
+ {t('SSH Tunnel')}
+
+
+);
diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx
index f6284ae67d9a7..5dce73206f151 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx
+++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx
@@ -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 {
diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx
index bcff047be345e..385b771efe5e7 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx
+++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx
@@ -124,6 +124,10 @@ fetchMock.mock(AVAILABLE_DB_ENDPOINT, {
description: 'Additional parameters',
type: 'object',
},
+ ssh: {
+ description: 'Create SSH Tunnel',
+ type: 'boolean',
+ },
username: {
description: 'Username',
nullable: true,
diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx
index 25fbadd3e1487..1861cc46ed609 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx
+++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx
@@ -763,15 +763,18 @@ const DatabaseModal: FunctionComponent = ({
});
}
- // 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
@@ -1633,18 +1636,7 @@ const DatabaseModal: FunctionComponent = ({
validationErrors={validationErrors}
getPlaceholder={getPlaceholder}
/>
-
-
-
- {useSSHTunneling && (
+ {db?.parameters?.ssh && (
{renderSSHTunnelForm()}
)}
>
diff --git a/superset-frontend/src/features/databases/types.ts b/superset-frontend/src/features/databases/types.ts
index 7b92819fdb77e..a7e4f59b581b9 100644
--- a/superset-frontend/src/features/databases/types.ts
+++ b/superset-frontend/src/features/databases/types.ts
@@ -69,6 +69,7 @@ export type DatabaseObject = {
warehouse?: string;
role?: string;
account?: string;
+ ssh?: boolean;
};
// Performance
diff --git a/superset-frontend/src/types/Database.ts b/superset-frontend/src/types/Database.ts
index c4491dbb9943d..575d69e2f2be0 100644
--- a/superset-frontend/src/types/Database.ts
+++ b/superset-frontend/src/types/Database.ts
@@ -27,4 +27,5 @@ export default interface Database {
server_cert: string;
sqlalchemy_uri: string;
catalog: object;
+ parameters: any;
}
diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts
index 7947494f315bd..b53731754f1fb 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -740,123 +740,132 @@ export function useDatabaseValidation() {
null,
);
const getValidation = useCallback(
- (database: Partial | 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 | 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,
+ url: message,
},
};
}
- if (extra.catalog.url) {
+ // 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,
- [extra.catalog.idx]: {
- url: message,
- },
+ };
+ }
+ 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],
);
diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py
index d117766754c3b..0bacf30fa6baa 100644
--- a/superset/db_engine_specs/base.py
+++ b/superset/db_engine_specs/base.py
@@ -1891,6 +1891,10 @@ class BasicParametersSchema(Schema):
required=False,
metadata={"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):
diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py
index f2fe9380c3809..568ba0593443d 100644
--- a/tests/integration_tests/databases/api_tests.py
+++ b/tests/integration_tests/databases/api_tests.py
@@ -2888,6 +2888,10 @@ def test_available(self, app, get_available_engine_specs):
"description": "Additional parameters",
"type": "object",
},
+ "ssh": {
+ "description": "Use an ssh tunnel connection to the database",
+ "type": "boolean",
+ },
"username": {
"description": "Username",
"nullable": True,
@@ -2962,6 +2966,10 @@ def test_available(self, app, get_available_engine_specs):
"description": "Additional parameters",
"type": "object",
},
+ "ssh": {
+ "description": "Use an ssh tunnel connection to the database",
+ "type": "boolean",
+ },
"username": {
"description": "Username",
"nullable": True,
@@ -3036,6 +3044,10 @@ def test_available(self, app, get_available_engine_specs):
"description": "Additional parameters",
"type": "object",
},
+ "ssh": {
+ "description": "Use an ssh tunnel connection to the database",
+ "type": "boolean",
+ },
"username": {
"description": "Username",
"nullable": True,
diff --git a/tests/integration_tests/db_engine_specs/postgres_tests.py b/tests/integration_tests/db_engine_specs/postgres_tests.py
index 945e0667fc72e..2b543e8e252be 100644
--- a/tests/integration_tests/db_engine_specs/postgres_tests.py
+++ b/tests/integration_tests/db_engine_specs/postgres_tests.py
@@ -512,6 +512,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"],
}