mirror of
https://github.com/apache/superset.git
synced 2026-07-26 16:42:32 +00:00
fix: SSH Tunnel creation with dynamic form (#24196)
(cherry picked from commit 226c7f807d)
This commit is contained in:
committed by
Elizabeth Thompson
parent
0bc0865692
commit
f3bb63be26
@@ -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');
|
||||
});
|
||||
|
||||
@@ -27,4 +27,5 @@ export default interface Database {
|
||||
server_cert: string;
|
||||
sqlalchemy_uri: string;
|
||||
catalog: object;
|
||||
parameters: any;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -68,6 +68,7 @@ export type DatabaseObject = {
|
||||
warehouse?: string;
|
||||
role?: string;
|
||||
account?: string;
|
||||
ssh?: boolean;
|
||||
};
|
||||
|
||||
// Performance
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user