diff --git a/console/src/Endpoints.ts b/console/src/Endpoints.ts index 9cad567535b18b..d1b38ab60f37b3 100644 --- a/console/src/Endpoints.ts +++ b/console/src/Endpoints.ts @@ -23,6 +23,7 @@ const Endpoints = { consoleNotificationsStg: 'https://notifications.hasura-stg.hasura-app.io/v1/graphql', consoleNotificationsProd: 'https://notifications.hasura.io/v1/graphql', + hasuraCloudDataGraphql: `${globals.cloudDataApiUrl}/v1/graphql`, }; const globalCookiePolicy = 'same-origin'; diff --git a/console/src/Globals.ts b/console/src/Globals.ts index cbf261acf6127f..4c2ad53d57d492 100644 --- a/console/src/Globals.ts +++ b/console/src/Globals.ts @@ -22,7 +22,11 @@ declare global { consolePath: string; cliUUID: string; consoleId: Nullable; + herokuOAuthClientId: string; + tenantID: Nullable; + projectID: Nullable; userRole: Nullable; + cloudRootDomain: Nullable; }; } const CONSOLE_ASSET_VERSION: string; @@ -55,6 +59,10 @@ const globals = { hasuraUUID: '', telemetryNotificationShown: false, isProduction, + herokuOAuthClientId: window.__env.herokuOAuthClientId, + hasuraCloudTenantId: window.__env.tenantID, + hasuraCloudProjectId: window.__env.projectID, + cloudDataApiUrl: `${window.location.protocol}//data.${window.__env.cloudRootDomain}`, }; if (globals.consoleMode === SERVER_CONSOLE_MODE) { if (!window.__env.dataApiUrl) { diff --git a/console/src/components/App/Actions.js b/console/src/components/App/Actions.js index 7aec7b21b644ee..33c9136c1ca26a 100644 --- a/console/src/components/App/Actions.js +++ b/console/src/components/App/Actions.js @@ -1,6 +1,6 @@ import defaultState from './State'; import { loadConsoleOpts } from '../../telemetry/Actions'; -import { fetchServerConfig } from '../Main/Actions'; +import { fetchServerConfig, fetchHerokuSession } from '../Main/Actions'; const LOAD_REQUEST = 'App/ONGOING_REQUEST'; const DONE_REQUEST = 'App/DONE_REQUEST'; @@ -13,6 +13,7 @@ export const requireAsyncGlobals = ({ dispatch }) => { Promise.all([ dispatch(loadConsoleOpts()), dispatch(fetchServerConfig), + dispatch(fetchHerokuSession()), ]).finally(callback); }; }; diff --git a/console/src/components/Common/Common.scss b/console/src/components/Common/Common.scss index adb58f872b1eca..f516b2c3875bf2 100644 --- a/console/src/components/Common/Common.scss +++ b/console/src/components/Common/Common.scss @@ -203,6 +203,10 @@ table tbody tr th { display: flex; justify-content: space-between; } +.flex_justify_center { + display: flex; + justify-content: center; +} .flex_0 { flex: 0; @@ -1469,7 +1473,9 @@ code { .db_item_actions { width: 14%; + display: flex; margin-right: 10px; + align-items: center; } .text_red { @@ -1501,11 +1507,13 @@ code { } .connect_db_radio_label { + margin-left: 4px; margin-right: 24px; } .connect_form_layout { width: 50%; + padding: 8px; display: flex; flex-direction: column; margin-top: 10px; @@ -1552,6 +1560,7 @@ code { .connnection_settings_form_input_layout { display: flex; flex-direction: column; + align-items: center; label { margin-bottom: 10px !important; diff --git a/console/src/components/Main/Actions.js b/console/src/components/Main/Actions.js index e8071d5d23bd27..727fd87b9b9520 100644 --- a/console/src/components/Main/Actions.js +++ b/console/src/components/Main/Actions.js @@ -38,6 +38,9 @@ const FETCH_CONSOLE_NOTIFICATIONS_SET_DEFAULT = 'Main/FETCH_CONSOLE_NOTIFICATIONS_SET_DEFAULT'; const FETCH_CONSOLE_NOTIFICATIONS_ERROR = 'Main/FETCH_CONSOLE_NOTIFICATIONS_ERROR'; +const FETCHING_HEROKU_SESSION = 'Main/FETCHING_HEROKU_SESSION'; +const FETCHING_HEROKU_SESSION_FAILED = 'Main/FETCHING_HEROKU_SESSION_FAILED'; +const SET_HEROKU_SESSION = 'Main/SET_HEROKU_SESSION'; const RUN_TIME_ERROR = 'Main/RUN_TIME_ERROR'; const registerRunTimeError = data => ({ @@ -455,6 +458,51 @@ const updateMigrationModeStatus = () => (dispatch, getState) => { // refresh console }; +export const setHerokuSession = session => ({ + type: SET_HEROKU_SESSION, + data: session, +}); + +// TODO to be queried via Apollo client +export const fetchHerokuSession = () => dispatch => { + if (!globals.herokuOAuthClientId || !globals.hasuraCloudTenantId) { + return; + } + dispatch({ + type: FETCHING_HEROKU_SESSION, + }); + return fetch(Endpoints.hasuraCloudDataGraphql, { + method: 'POST', + credentials: 'include', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + query: + 'mutation { getHerokuSession { access_token refresh_token expires_in token_type } }', + }), + }) + .then(r => r.json()) + .then(response => { + if (response.errors) { + dispatch({ type: FETCHING_HEROKU_SESSION_FAILED }); + console.error('Failed fetching heroku session'); + } else { + const session = response.data.getHerokuSession; + if (!session.access_token) { + dispatch({ type: FETCHING_HEROKU_SESSION_FAILED }); + console.error('Failed fetching heroku session'); + } else { + dispatch(setHerokuSession(session)); + } + } + }) + .catch(e => { + console.error('Failed fetching Heroku session'); + console.error(e); + }); +}; + const mainReducer = (state = defaultState, action) => { switch (action.type) { case SET_MIGRATION_STATUS_SUCCESS: @@ -591,6 +639,13 @@ const mainReducer = (state = defaultState, action) => { ...state, consoleNotifications: [errorNotification], }; + case SET_HEROKU_SESSION: + return { + ...state, + heroku: { + session: action.data, + }, + }; default: return state; } diff --git a/console/src/components/Main/Main.js b/console/src/components/Main/Main.js index 77d47c53c84cc1..f91597725b2983 100644 --- a/console/src/components/Main/Main.js +++ b/console/src/components/Main/Main.js @@ -85,7 +85,6 @@ class Main extends React.Component { componentDidMount() { const { dispatch } = this.props; - updateRequestHeaders(this.props); dispatch(loadServerVersion()).then(() => { dispatch(featureCompatibilityInit()); diff --git a/console/src/components/Main/State.ts b/console/src/components/Main/State.ts index 027e1f0298825a..5901710ce8e45a 100644 --- a/console/src/components/Main/State.ts +++ b/console/src/components/Main/State.ts @@ -1,4 +1,5 @@ import { ConsoleNotification } from './ConsoleNotification'; +import { HerokuSession } from '../Services/Data/DataSources/CreateDataSource/Heroku/types'; export interface MainState { migrationError: unknown | null; @@ -32,6 +33,9 @@ export interface MainState { featuresCompatibility: Record; postgresVersion: string | null; consoleNotifications: ConsoleNotification[]; + heroku: { + session?: HerokuSession; + }; } const defaultState: MainState = { @@ -66,6 +70,9 @@ const defaultState: MainState = { featuresCompatibility: {}, postgresVersion: null, consoleNotifications: [], + heroku: { + session: undefined, + }, }; export default defaultState; diff --git a/console/src/components/Services/Data/DataActions.js b/console/src/components/Services/Data/DataActions.js index b6a7c9cda058e5..79d4386fe396b3 100644 --- a/console/src/components/Services/Data/DataActions.js +++ b/console/src/components/Services/Data/DataActions.js @@ -70,6 +70,9 @@ const REQUEST_ERROR = 'ModifyTable/REQUEST_ERROR'; const SET_ADDITIONAL_COLUMNS_INFO = 'Data/SET_ADDITIONAL_COLUMNS_INFO'; +const SET_DB_CONNECTION_ENV_VAR = 'Data/SET_DB_CONNECTION_ENV_VAR'; +const RESET_DB_CONNECTION_ENV_VAR = 'Data/RESET_DB_CONNECTION_ENV_VAR'; + export const mergeRemoteRelationshipsWithSchema = ( remoteRelationships, table @@ -94,6 +97,19 @@ export const mergeRemoteRelationshipsWithSchema = ( }; }; +export const setDBConnectionDetails = details => { + return { + type: SET_DB_CONNECTION_ENV_VAR, + data: details, + }; +}; + +export const resetDBConnectionEnvVar = () => { + return { + type: RESET_DB_CONNECTION_ENV_VAR, + }; +}; + const setUntrackedRelations = () => (dispatch, getState) => { const untrackedRelations = getAllUnTrackedRelations( getState().tables.allSchemas, @@ -919,6 +935,19 @@ const dataReducer = (state = defaultState, action) => { }; }), }; + case SET_DB_CONNECTION_ENV_VAR: + return { + ...state, + dbConnection: action.data, + }; + case RESET_DB_CONNECTION_ENV_VAR: + return { + ...state, + dbConnection: { + envVar: '', + dbURL: '', + }, + }; default: return state; } diff --git a/console/src/components/Services/Data/DataRouter.js b/console/src/components/Services/Data/DataRouter.js index 42dd619005dd1e..88e8d89d702c58 100644 --- a/console/src/components/Services/Data/DataRouter.js +++ b/console/src/components/Services/Data/DataRouter.js @@ -24,6 +24,7 @@ import { ModifyCustomFunction, FunctionPermissions, ConnectedDatabaseManagePage, + ConnectedCreateDataSourcePage, } from '.'; import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions'; @@ -50,6 +51,8 @@ const makeDataRouter = ( + + diff --git a/console/src/components/Services/Data/DataSources/ConnectDatabase.tsx b/console/src/components/Services/Data/DataSources/ConnectDatabase.tsx index 2bd83d3287a64f..ec81bf7a0cea5b 100644 --- a/console/src/components/Services/Data/DataSources/ConnectDatabase.tsx +++ b/console/src/components/Services/Data/DataSources/ConnectDatabase.tsx @@ -1,11 +1,9 @@ import React, { ChangeEvent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import Helmet from 'react-helmet'; +import Tabbed from './TabbedDataSourceConnection'; import { ReduxState } from '../../../../types'; import { mapDispatchToPropsEmpty } from '../../../Common/utils/reactUtils'; -import { RightContainer } from '../../../Common/Layout/RightContainer'; -import BreadCrumb from '../../../Common/Layout/BreadCrumb/BreadCrumb'; import Button from '../../../Common/Button'; import { showErrorNotification } from '../../Common/Notification'; import _push from '../push'; @@ -15,7 +13,7 @@ import { connectDataSource, connectDBReducer, connectionTypes, - defaultState, + getDefaultState, } from './state'; import { getDatasourceURL, getErrorMessageFromMissingFields } from './utils'; import { LabeledInput } from '../../../Common/LabeledInput'; @@ -44,12 +42,17 @@ const connectionRadios = [ ]; const ConnectDatabase: React.FC = props => { + const { dispatch } = props; + const [connectDBInputState, connectDBDispatch] = React.useReducer( connectDBReducer, - defaultState + getDefaultState(props) ); + const [connectionType, changeConnectionType] = React.useState( - connectionTypes.DATABASE_URL + props.dbConnection.envVar + ? connectionTypes.ENV_VAR + : connectionTypes.DATABASE_URL ); const [openConnectionSettings, changeConnectionsParamState] = React.useState( false @@ -64,7 +67,6 @@ const ConnectDatabase: React.FC = props => { const currentSourceInfo = sources.find( source => source.name === editSourceName ); - React.useEffect(() => { if (isEditState && currentSourceInfo) { connectDBDispatch({ @@ -83,27 +85,10 @@ const ConnectDatabase: React.FC = props => { } }, [isEditState, currentSourceInfo]); - const crumbs = [ - { - title: 'Data', - url: `/data/${props.currentDataSource}/schema/${props.currentSchema}`, - }, - { - title: 'Manage Databases', - url: '/data/manage', - }, - { - title: `${isEditState ? 'Edit Connection' : 'Connect Database'}`, - url: '#', - }, - ]; - const onChangeConnectionType = (e: ChangeEvent) => { changeConnectionType(e.target.value); }; - const { dispatch } = props; - const resetState = () => { connectDBDispatch({ type: 'RESET_INPUT_STATE', @@ -211,273 +196,251 @@ const ConnectDatabase: React.FC = props => { }; return ( - - -
- -
-
-

- {isEditState ? 'Edit Connection' : 'Connect Database'} -

-
+ +
+

+ Connect Database Via +

+
+ {connectionRadios.map( + (radioBtn: { + value: string; + title: string; + disableOnEdit: boolean; + }) => ( + + ) + )}
-
-
-

- Connect Database Via -

-
- {connectionRadios.map( - (radioBtn: { - value: string; - title: string; - disableOnEdit: boolean; - }) => ( - - ) - )} -
-
+
+ + connectDBDispatch({ + type: 'UPDATE_DISPLAY_NAME', + data: e.target.value, + }) + } + value={connectDBInputState.displayName} + label="Database Display Name" + placeholder="database name" + /> + {/* + */} + {connectionType === connectionTypes.DATABASE_URL ? ( connectDBDispatch({ - type: 'UPDATE_DISPLAY_NAME', + type: 'UPDATE_DB_URL', data: e.target.value, }) } - value={connectDBInputState.displayName} - label="Database Display Name" - placeholder="database name" + value={connectDBInputState.databaseURLState.dbURL} + placeholder={defaultPGURL} + disabled={isEditState} /> - {/* - */} - {connectionType === connectionTypes.DATABASE_URL ? ( + ) : null} + {connectionType === connectionTypes.ENV_VAR ? ( + + connectDBDispatch({ + type: 'UPDATE_DB_URL_ENV_VAR', + data: e.target.value, + }) + } + value={connectDBInputState.envVarURLState.envVarURL} + /> + ) : null} + {connectionType === connectionTypes.CONNECTION_PARAMS ? ( + <> connectDBDispatch({ - type: 'UPDATE_DB_URL', + type: 'UPDATE_DB_HOST', data: e.target.value, }) } - value={connectDBInputState.databaseURLState.dbURL} - placeholder={defaultPGURL} - disabled={isEditState} + value={connectDBInputState.connectionParamState.host} /> - ) : null} - {connectionType === connectionTypes.ENV_VAR ? ( connectDBDispatch({ - type: 'UPDATE_DB_URL_ENV_VAR', + type: 'UPDATE_DB_PORT', data: e.target.value, }) } - value={connectDBInputState.envVarURLState.envVarURL} + value={connectDBInputState.connectionParamState.port} /> - ) : null} - {connectionType === connectionTypes.CONNECTION_PARAMS ? ( - <> - - connectDBDispatch({ - type: 'UPDATE_DB_HOST', - data: e.target.value, - }) - } - value={connectDBInputState.connectionParamState.host} - /> - - connectDBDispatch({ - type: 'UPDATE_DB_PORT', - data: e.target.value, - }) - } - value={connectDBInputState.connectionParamState.port} - /> - - connectDBDispatch({ - type: 'UPDATE_DB_USERNAME', - data: e.target.value, - }) - } - value={connectDBInputState.connectionParamState.username} - /> - - connectDBDispatch({ - type: 'UPDATE_DB_PASSWORD', - data: e.target.value, - }) - } - value={connectDBInputState.connectionParamState.password} - /> - - connectDBDispatch({ - type: 'UPDATE_DB_DATABASE_NAME', - data: e.target.value, - }) - } - value={connectDBInputState.connectionParamState.database} - /> - - ) : null} -
- - {openConnectionSettings ? ( -
-
- - connectDBDispatch({ - type: 'UPDATE_MAX_CONNECTIONS', - data: e.target.value, - }) - } - min="0" - labelInBold - /> -
-
- - connectDBDispatch({ - type: 'UPDATE_IDLE_TIMEOUT', - data: e.target.value, - }) - } - min="0" - labelInBold - /> -
-
- - connectDBDispatch({ - type: 'UPDATE_RETRIES', - data: e.target.value, - }) - } - min="0" - labelInBold - /> -
-
- ) : null} -
-
- + {openConnectionSettings ? ( + + ) : ( + + )} + {' '} + Connection Settings +
+ {openConnectionSettings ? ( +
+
+ + connectDBDispatch({ + type: 'UPDATE_MAX_CONNECTIONS', + data: e.target.value, + }) + } + min="0" + labelInBold + /> +
+
+ + connectDBDispatch({ + type: 'UPDATE_IDLE_TIMEOUT', + data: e.target.value, + }) + } + min="0" + labelInBold + /> +
+
+ + connectDBDispatch({ + type: 'UPDATE_RETRIES', + data: e.target.value, + }) + } + min="0" + labelInBold + /> +
+
+ ) : null} +
+
+
- + ); }; @@ -486,6 +449,7 @@ const mapStateToProps = (state: ReduxState) => { currentDataSource: state.tables.currentDataSource, currentSchema: state.tables.currentSchema, sources: state.metadata.metadataObject?.sources ?? [], + dbConnection: state.tables.dbConnection, pathname: state?.routing?.locationBeforeTransitions?.pathname, }; }; diff --git a/console/src/components/Services/Data/DataSources/CreateDataSource/Heroku/DBCreation.tsx b/console/src/components/Services/Data/DataSources/CreateDataSource/Heroku/DBCreation.tsx new file mode 100644 index 00000000000000..3f8963943296c0 --- /dev/null +++ b/console/src/components/Services/Data/DataSources/CreateDataSource/Heroku/DBCreation.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import styles from '../styles.scss'; +import { HerokuSession } from './types'; +import { + useHerokuDBCreation, + setDBURLInEnvVars, + verifyProjectHealthAndConnectDataSource, + startHerokuDBURLSync, +} from './utils'; +import DBCreationStatus from './DBCreationStatus'; +import { Dispatch } from '../../../../../../types'; +import { setDBConnectionDetails } from '../../../DataActions'; +import _push from '../../../push'; +import { + connectDataSource, + connectionTypes, + getDefaultState, +} from '../../../DataSources/state'; +import HerokuLogoComponent from './HerokuButtonLogo'; + +type Props = { + session: HerokuSession; + shouldStart: boolean; + dispatch: Dispatch; +}; + +const DBCreation: React.FC = ({ session, shouldStart, dispatch }) => { + const { start, state, inProgress } = useHerokuDBCreation( + session, + shouldStart, + dispatch + ); + const [isSettingEnvVar, setIsSettingEnvVar] = React.useState(false); + const [createdEnvVar, setCreatedEnvVar] = React.useState(''); + const [isConnectingDataSource, setIsConnectingDataSource] = React.useState( + false + ); + const loading = inProgress || isSettingEnvVar || isConnectingDataSource; + const herokuButtonClassName = loading + ? `${styles.herokuButtonBoxDisabled} ${styles.add_mar_bottom}` + : `${styles.herokuButtonBox} ${styles.add_mar_bottom}`; + + React.useEffect(() => { + // TODO move this to utils + if ( + state['getting-config'].status === 'success' && + state['creating-app'].status === 'success' + ) { + const dbURL = state['getting-config'].details.DATABASE_URL; + const appName = state['creating-app'].details.name; + const appID = state['creating-app'].details.id; + const dbName = `herokuapp-${appName}`; + setIsSettingEnvVar(true); + setDBURLInEnvVars(dbURL) + .then(envVar => { + setIsSettingEnvVar(false); + setCreatedEnvVar(envVar); + dispatch( + setDBConnectionDetails({ + envVar, + dbName, + }) + ); + setIsConnectingDataSource(true); + const connectEnvVarDataSource = () => { + connectDataSource( + dispatch, + connectionTypes.ENV_VAR, + getDefaultState({ + dbConnection: { + envVar, + dbName, + }, + }), + () => { + startHerokuDBURLSync(envVar, appName, appID); + dispatch(setDBConnectionDetails({})); + dispatch(_push('/data/manage')); + } + ); + }; + const pushToConnect = () => { + dispatch(_push('/data/manage/connect')); + }; + setTimeout(() => { + verifyProjectHealthAndConnectDataSource( + connectEnvVarDataSource, + pushToConnect + ); + }, 7000); + }) + .catch(e => { + console.error(e); + if (isSettingEnvVar) { + dispatch( + setDBConnectionDetails({ + dbURL, + dbName, + }) + ); + } else { + dispatch( + setDBConnectionDetails({ + envVar: createdEnvVar, + dbName, + }) + ); + } + dispatch(_push('/data/manage/connect')); + }); + } + }, [state['getting-config']]); + + let statusText = 'Creating database'; + if (isSettingEnvVar) { + statusText = 'Setting database URL in env vars'; + } + if (isConnectingDataSource) { + statusText = 'Connecting to Hasura'; + } + + return ( +
+
{ + if (loading) { + return; + } + start(session); + }} + > + +
+
+

+