console/pro-console: add read replicas support

Co-authored-by: Aleksandra Sikora <9019397+beerose@users.noreply.github.com>
GitOrigin-RevId: 5e49ad67852b53bd4ba4fd195bdc538706902fd1
This commit is contained in:
Sameer Kolhar 2021-03-24 16:07:38 +05:30 committed by hasura-bot
parent 130e27e755
commit fe1c5afb01
16 changed files with 930 additions and 355 deletions

View File

@ -25,7 +25,7 @@ export const expandConnectionSettingsform = () => {
};
export const failsOnEmptyFormSubmission = () => {
cy.getBySel('save-database').click();
cy.getBySel('connect-database-btn').click();
cy.get('.notification-error').should('be.visible');
};
@ -36,7 +36,7 @@ export const addsNewPostgresDatabaseWithUrl = () => {
cy.getBySel('max-connections').type('50');
cy.getBySel('idle-timeout').type('180');
cy.getBySel('retries').type('1');
cy.getBySel('save-database').click();
cy.getBySel('connect-database-btn').click();
cy.get('.notification-success', { timeout: 10000 })
.should('be.visible')
.and('contain', 'Database added successfully!');
@ -56,7 +56,7 @@ export const addsNewPgDBWithConParams = () => {
cy.getBySel('password').type(config.password);
}
cy.getBySel('database-name').type(config.dbName);
cy.getBySel('save-database').click();
cy.getBySel('connect-database-btn').click();
cy.get('.notification-success', { timeout: 10000 })
.should('be.visible')
.and('contain', 'Database added successfully!');
@ -70,7 +70,7 @@ export const addsNewPgDBWithEnvVar = () => {
cy.getBySel('database-display-name').type('testDB3');
cy.getBySel('database-type').select('postgres');
cy.getBySel('database-url-env').type('HASURA_GRAPHQL_DATABASE_URL');
cy.getBySel('save-database').click();
cy.getBySel('connect-database-btn').click();
cy.get('.notification-success', { timeout: 10000 })
.should('be.visible')
.and('contain', 'Database added successfully!');
@ -82,7 +82,7 @@ export const failDuplicateNameDb = () => {
cy.get('button').contains('Connect Database').click();
cy.getBySel('database-display-name').type('testDB1');
cy.getBySel('database-url').type(dbUrl);
cy.getBySel('save-database').click();
cy.getBySel('connect-database-btn').click();
cy.get('.notification-error')
.should('be.visible')
.and('contain', 'Add data source failed')

View File

@ -4,7 +4,7 @@
"description": "Console for Hasura GraphQL Engine",
"author": "Hasura (https://github.com/hasura/graphql-engine)",
"license": "Apache 2.0",
"version": "2.0.1-5",
"version": "2.0.1-7",
"repository": {
"type": "git",
"url": "https://github.com/hasura/graphql-engine"
@ -224,4 +224,4 @@
"engines": {
"node": ">=8.9.1"
}
}
}

View File

@ -1490,7 +1490,6 @@ code {
.connect_db_radios {
display: flex;
width: 70%;
align-items: center;
height: auto;
justify-content: flex-start;
@ -1507,7 +1506,7 @@ code {
}
.connect_form_layout {
width: 50%;
width: 100%;
padding: 8px 0;
display: flex;
flex-direction: column;
@ -1552,7 +1551,7 @@ code {
justify-content: space-between;
}
.connnection_settings_form_input_layout {
.connection_settings_form_input_layout {
display: flex;
flex-direction: column;
align-items: center;
@ -1566,6 +1565,10 @@ code {
width: 65%;
}
.connect_form_width {
width: 50%;
}
/* container height subtracting top header and bottom scroll bar */
$mainContainerHeight: calc(100vh - 50px - 20px);

View File

@ -3,13 +3,13 @@ import styles from '../../Common/Common.scss';
interface LabeledInputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
labelInBold?: boolean;
boldlabel?: boolean;
}
export const LabeledInput: React.FC<LabeledInputProps> = props => (
<>
<label className={props.labelInBold ? '' : styles.connect_db_input_label}>
{props?.labelInBold ? <b>{props.label}</b> : props.label}
<label className={props.boldlabel ? '' : styles.connect_db_input_label}>
{props?.boldlabel ? <b>{props.label}</b> : props.label}
</label>
<input
type="text"

View File

@ -0,0 +1,325 @@
import React, { ChangeEvent, Dispatch, useState } from 'react';
import { ConnectDBActions, ConnectDBState, connectionTypes } from './state';
import { LabeledInput } from '../../../Common/LabeledInput';
import { Driver } from '../../../../dataSources';
import styles from './DataSources.scss';
type ConnectDatabaseFormProps = {
// Connect DB State Props
connectionDBState: ConnectDBState;
connectionDBStateDispatch: Dispatch<ConnectDBActions>;
// Connection Type Props - for the Radio buttons
updateConnectionTypeRadio: (e: ChangeEvent<HTMLInputElement>) => void;
connectionTypeState: string;
// Other Props
isreadreplica?: boolean;
title?: string;
};
export const connectionRadios = [
{
value: connectionTypes.CONNECTION_PARAMS,
title: 'Connection Parameters',
disableOnEdit: true,
},
{
value: connectionTypes.DATABASE_URL,
title: 'Database URL',
disableOnEdit: false,
},
{
value: connectionTypes.ENV_VAR,
title: 'Environment Variable',
disableOnEdit: true,
},
];
const dbTypePlaceholders: Record<Driver, string> = {
postgres: 'postgresql://username:password@hostname:5432/database',
mssql:
'Driver={ODBC Driver 17 for SQL Server};Server=serveraddress;Database=dbname;Uid=username;Pwd=password;',
mysql: 'MySQL connection string',
};
const defaultTitle = 'Connect Database Via';
const ConnectDatabaseForm: React.FC<ConnectDatabaseFormProps> = ({
connectionDBState,
connectionDBStateDispatch,
updateConnectionTypeRadio,
connectionTypeState,
isreadreplica = false,
title,
}) => {
const [currentConnectionParamState, toggleConnectionParamState] = useState(
false
);
const toggleConnectionParams = (value: boolean) => () => {
toggleConnectionParamState(value);
};
return (
<>
<h4 className={`${styles.remove_pad_bottom} ${styles.connect_db_header}`}>
{title ?? defaultTitle}
</h4>
<div
className={styles.connect_db_radios}
onChange={updateConnectionTypeRadio}
>
{connectionRadios.map(radioBtn => (
<label
key={`label-${radioBtn.title}`}
className={styles.connect_db_radio_label}
>
<input
type="radio"
value={radioBtn.value}
name={
!isreadreplica
? 'connection-type'
: 'connection-type-read-replica'
}
checked={connectionTypeState.includes(radioBtn.value)}
defaultChecked={
connectionTypeState === connectionTypes.DATABASE_URL
}
// disabled={
// isEditState === radioBtn.disableOnEdit && isEditState
// }
/>
{radioBtn.title}
</label>
))}
</div>
<div className={styles.connect_form_layout}>
{!isreadreplica && (
<>
<LabeledInput
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_DISPLAY_NAME',
data: e.target.value,
})
}
value={connectionDBState.displayName}
label="Database Display Name"
placeholder="database name"
data-test="database-display-name"
/>
<label
key="Data Source Driver"
className={styles.connect_db_input_label}
>
Data Source Driver
</label>
<select
key="connect-db-type"
value={connectionDBState.dbType}
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_DB_DRIVER',
data: e.target.value as Driver,
})
}
className={`form-control ${styles.connect_db_input_pad}`}
data-test="database-type"
>
<option key="postgres" value="postgres">
Postgres
</option>
<option key="mssql" value="mssql">
MS Server
</option>
</select>
</>
)}
{connectionTypeState.includes(connectionTypes.DATABASE_URL) ||
(connectionTypeState.includes(connectionTypes.CONNECTION_PARAMS) &&
connectionDBState.dbType === 'mssql') ? (
<LabeledInput
label="Database URL"
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_DB_URL',
data: e.target.value,
})
}
value={connectionDBState.databaseURLState.dbURL}
placeholder={dbTypePlaceholders[connectionDBState.dbType]}
data-test="database-url"
// disabled={isEditState}
/>
) : null}
{connectionTypeState.includes(connectionTypes.ENV_VAR) ? (
<LabeledInput
label="Environment Variable"
placeholder="HASURA_GRAPHQL_DB_URL_FROM_ENV"
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_DB_URL_ENV_VAR',
data: e.target.value,
})
}
value={connectionDBState.envVarURLState.envVarURL}
data-test="database-url-env"
/>
) : null}
{connectionTypeState.includes(connectionTypes.CONNECTION_PARAMS) &&
connectionDBState.dbType !== 'mssql' ? (
<>
<LabeledInput
label="Host"
placeholder="localhost"
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_DB_HOST',
data: e.target.value,
})
}
value={connectionDBState.connectionParamState.host}
data-test="host"
/>
<LabeledInput
label="Port"
placeholder="5432"
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_DB_PORT',
data: e.target.value,
})
}
value={connectionDBState.connectionParamState.port}
data-test="port"
/>
<LabeledInput
label="Username"
placeholder="postgres_user"
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_DB_USERNAME',
data: e.target.value,
})
}
value={connectionDBState.connectionParamState.username}
data-test="username"
/>
<LabeledInput
label="Password"
key="connect-db-password"
type="password"
placeholder="postgrespassword"
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_DB_PASSWORD',
data: e.target.value,
})
}
value={connectionDBState.connectionParamState.password}
data-test="password"
/>
<LabeledInput
key="connect-db-database-name"
label="Database Name"
placeholder="postgres"
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_DB_DATABASE_NAME',
data: e.target.value,
})
}
value={connectionDBState.connectionParamState.database}
data-test="database-name"
/>
</>
) : null}
<div className={styles.connection_settings_layout}>
<div className={styles.connection_settings_header}>
<a
href="#"
style={{ textDecoration: 'none' }}
onClick={toggleConnectionParams(!currentConnectionParamState)}
>
{currentConnectionParamState ? (
<i className="fa fa-caret-down" />
) : (
<i className="fa fa-caret-right" />
)}
{' '}
Connection Settings
</a>
</div>
{currentConnectionParamState ? (
<div className={styles.connection_settings_form}>
<div className={styles.connection_settings_form_input_layout}>
<LabeledInput
label="Max Connections"
type="number"
className={`form-control ${styles.connnection_settings_form_input}`}
placeholder="50"
value={
connectionDBState.connectionSettings?.max_connections ??
undefined
}
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_MAX_CONNECTIONS',
data: e.target.value,
})
}
min="0"
boldlabel
data-test="max-connections"
/>
</div>
<div className={styles.connection_settings_form_input_layout}>
<LabeledInput
label="Idle Timeout"
type="number"
className={`form-control ${styles.connnection_settings_form_input}`}
placeholder="180"
value={
connectionDBState.connectionSettings?.idle_timeout ??
undefined
}
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_IDLE_TIMEOUT',
data: e.target.value,
})
}
min="0"
boldlabel
data-test="idle-timeout"
/>
</div>
<div className={styles.connection_settings_form_input_layout}>
<LabeledInput
label="Retries"
type="number"
className={`form-control ${styles.connnection_settings_form_input}`}
placeholder="1"
value={
connectionDBState.connectionSettings?.retries ?? undefined
}
onChange={e =>
connectionDBStateDispatch({
type: 'UPDATE_RETRIES',
data: e.target.value,
})
}
min="0"
boldlabel
data-test="retries"
/>
</div>
</div>
) : null}
</div>
</div>
</>
);
};
export default ConnectDatabaseForm;

View File

@ -1,4 +1,11 @@
import React, { ChangeEvent, FormEvent } from 'react';
/* eslint-disable no-underscore-dangle */
import React, {
ChangeEvent,
FormEvent,
useReducer,
useState,
useEffect,
} from 'react';
import { connect, ConnectedProps } from 'react-redux';
import Tabbed from './TabbedDataSourceConnection';
@ -7,65 +14,52 @@ import { mapDispatchToPropsEmpty } from '../../../Common/utils/reactUtils';
import Button from '../../../Common/Button';
import { showErrorNotification } from '../../Common/Notification';
import _push from '../push';
import styles from '../../../Common/Common.scss';
import {
connectDataSource,
connectDBReducer,
connectionTypes,
getDefaultState,
readReplicaReducer,
defaultState,
makeReadReplicaConnectionObject,
} from './state';
import { getDatasourceURL, getErrorMessageFromMissingFields } from './utils';
import { LabeledInput } from '../../../Common/LabeledInput';
import { Driver } from '../../../../dataSources';
import ConnectDatabaseForm from './ConnectDBForm';
import ReadReplicaForm from './ReadReplicaForm';
import styles from './DataSources.scss';
interface ConnectDatabaseProps extends InjectedProps {}
const connectionRadioName = 'connection-type';
const dbTypePlaceholders: Record<Driver, string> = {
postgres: 'postgresql://username:password@hostname:5432/database',
mssql:
'Driver={ODBC Driver 17 for SQL Server};Server=serveraddress;Database=dbname;Uid=username;Pwd=password;',
mysql: 'MySQL connection string',
};
const connectionRadios = [
{
value: connectionTypes.CONNECTION_PARAMS,
title: 'Connection Parameters',
disableOnEdit: true,
},
{
value: connectionTypes.DATABASE_URL,
title: 'Database URL',
disableOnEdit: false,
},
{
value: connectionTypes.ENV_VAR,
title: 'Environment Variable',
disableOnEdit: true,
},
];
const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
const { dispatch } = props;
const [connectDBInputState, connectDBDispatch] = React.useReducer(
const [connectDBInputState, connectDBDispatch] = useReducer(
connectDBReducer,
getDefaultState(props)
);
const [connectionType, changeConnectionType] = React.useState(
const [connectionType, changeConnectionType] = useState(
props.dbConnection.envVar
? connectionTypes.ENV_VAR
: connectionTypes.DATABASE_URL
);
const [openConnectionSettings, changeConnectionsParamState] = React.useState(
false
);
const [loading, setLoading] = React.useState(false);
const { sources = [], pathname = '' } = props;
const [loading, setLoading] = useState(false);
const [readReplicasState, readReplicaDispatch] = useReducer(
readReplicaReducer,
[]
);
const [
connectDBStateForReadReplica,
connectDBReadReplicaDispatch,
] = useReducer(connectDBReducer, defaultState);
const [readReplicaConnectionType, updateReadReplicaConnectionType] = useState(
connectionTypes.DATABASE_URL
);
const { sources = [], pathname = '' } = props;
const isEditState =
pathname.includes('edit') || pathname.indexOf('edit') !== -1;
const paths = pathname.split('/');
@ -73,7 +67,8 @@ const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
const currentSourceInfo = sources.find(
source => source.name === editSourceName
);
React.useEffect(() => {
useEffect(() => {
if (isEditState && currentSourceInfo) {
connectDBDispatch({
type: 'INIT',
@ -91,9 +86,8 @@ const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
}
}, [isEditState, currentSourceInfo]);
const onChangeConnectionType = (e: ChangeEvent<HTMLInputElement>) => {
const onChangeConnectionType = (e: ChangeEvent<HTMLInputElement>) =>
changeConnectionType(e.target.value);
};
const resetState = () => {
connectDBDispatch({
@ -124,6 +118,11 @@ const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
// TODO: server to provide API
}
// TODO: check if permitted, if not pass undefined
const read_replicas = readReplicasState.map(replica =>
makeReadReplicaConnectionObject(replica)
);
if (
connectionType === connectionTypes.DATABASE_URL ||
(connectionType === connectionTypes.CONNECTION_PARAMS &&
@ -144,7 +143,8 @@ const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
dispatch,
connectionTypes.DATABASE_URL,
connectDBInputState,
onSuccessConnectDBCb
onSuccessConnectDBCb,
read_replicas
)
.then(() => setLoading(false))
.catch(() => setLoading(false));
@ -165,9 +165,10 @@ const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
setLoading(true);
connectDataSource(
dispatch,
connectionType,
connectionTypes.ENV_VAR,
connectDBInputState,
onSuccessConnectDBCb
onSuccessConnectDBCb,
read_replicas
)
.then(() => setLoading(false))
.catch(() => setLoading(false));
@ -198,292 +199,95 @@ const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
setLoading(true);
connectDataSource(
dispatch,
connectionType,
connectionTypes.CONNECTION_PARAMS,
connectDBInputState,
onSuccessConnectDBCb
onSuccessConnectDBCb,
read_replicas
)
.then(() => setLoading(false))
.catch(() => setLoading(false));
};
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onConnectDatabase();
};
const updateRadioForReadReplica = (e: ChangeEvent<HTMLInputElement>) =>
updateReadReplicaConnectionType(e.target.value);
const onClickAddReadReplica = () => {
connectDBReadReplicaDispatch({
type: 'RESET_INPUT_STATE',
});
updateReadReplicaConnectionType(connectionTypes.DATABASE_URL);
const indexForName = readReplicasState.length;
connectDBReadReplicaDispatch({
type: 'UPDATE_DISPLAY_NAME',
data: `read-replica-${indexForName}`,
});
};
const onClickCancelOnReadReplicaForm = () =>
connectDBReadReplicaDispatch({
type: 'RESET_INPUT_STATE',
});
const onClickSaveReadReplicaForm = () => {
readReplicaDispatch({
type: 'ADD_READ_REPLICA',
data: {
...connectDBStateForReadReplica,
chosenConnectionType: readReplicaConnectionType,
},
});
connectDBReadReplicaDispatch({
type: 'RESET_INPUT_STATE',
});
};
return (
<Tabbed tabName="connect">
<form onSubmit={onSubmit} className={styles.connect_db_content}>
<h4
className={`${styles.remove_pad_bottom} ${styles.connect_db_header}`}
>
Connect Database Via
</h4>
<div
className={styles.connect_db_radios}
onChange={onChangeConnectionType}
>
{connectionRadios.map(
(radioBtn: {
value: string;
title: string;
disableOnEdit: boolean;
}) => (
<label
key={`label-${radioBtn.title}`}
className={styles.connect_db_radio_label}
>
<input
type="radio"
value={radioBtn.value}
name={connectionRadioName}
checked={connectionType === radioBtn.value}
disabled={
isEditState === radioBtn.disableOnEdit && isEditState
}
/>
{radioBtn.title}
</label>
)
<form
onSubmit={onSubmit}
className={`${styles.connect_db_content} ${styles.connect_form_width}`}
>
<ConnectDatabaseForm
connectionDBState={connectDBInputState}
connectionDBStateDispatch={connectDBDispatch}
connectionTypeState={connectionType}
updateConnectionTypeRadio={onChangeConnectionType}
/>
{/* Should be rendered only on Pro and Cloud Console */}
{connectDBInputState.dbType !== 'mssql' &&
(window.__env.consoleId || window.__env.userRole) && (
<ReadReplicaForm
readReplicaState={readReplicasState}
readReplicaDispatch={readReplicaDispatch}
connectDBState={connectDBStateForReadReplica}
connectDBStateDispatch={connectDBReadReplicaDispatch}
readReplicaConnectionType={readReplicaConnectionType}
updateReadReplicaConnectionType={updateRadioForReadReplica}
onClickAddReadReplicaCb={onClickAddReadReplica}
onClickCancelOnReadReplicaCb={onClickCancelOnReadReplicaForm}
onClickSaveReadReplicaCb={onClickSaveReadReplicaForm}
/>
)}
</div>
<div className={styles.connect_form_layout}>
<LabeledInput
onChange={e =>
connectDBDispatch({
type: 'UPDATE_DISPLAY_NAME',
data: e.target.value,
})
}
value={connectDBInputState.displayName}
label="Database Display Name"
placeholder="database name"
data-test="database-display-name"
/>
<label
key="Data Source Driver"
className={styles.connect_db_input_label}
<div className={styles.add_button_layout}>
<Button
size="large"
color="yellow"
type="submit"
style={{
width: '70%',
...(loading && { cursor: 'progress' }),
}}
disabled={loading}
data-test="connect-database-btn"
>
Data Source Driver
</label>
<select
key="connect-db-type"
value={connectDBInputState.dbType}
onChange={e =>
connectDBDispatch({
type: 'UPDATE_DB_DRIVER',
data: e.target.value as Driver,
})
}
className={`form-control ${styles.connect_db_input_pad}`}
data-test="database-type"
>
<option key="postgres" value="postgres">
Postgres
</option>
<option key="mssql" value="mssql">
MS Server
</option>
</select>
{connectionType === connectionTypes.DATABASE_URL ||
(connectionType === connectionTypes.CONNECTION_PARAMS &&
connectDBInputState.dbType === 'mssql') ? (
<LabeledInput
label="Database URL"
onChange={e =>
connectDBDispatch({
type: 'UPDATE_DB_URL',
data: e.target.value,
})
}
value={connectDBInputState.databaseURLState.dbURL}
placeholder={dbTypePlaceholders[connectDBInputState.dbType]}
disabled={isEditState}
data-test="database-url"
/>
) : null}
{connectionType === connectionTypes.ENV_VAR ? (
<LabeledInput
label="Environment Variable"
placeholder="HASURA_GRAPHQL_DB_URL_FROM_ENV"
onChange={e =>
connectDBDispatch({
type: 'UPDATE_DB_URL_ENV_VAR',
data: e.target.value,
})
}
value={connectDBInputState.envVarURLState.envVarURL}
data-test="database-url-env"
/>
) : null}
{connectionType === connectionTypes.CONNECTION_PARAMS &&
connectDBInputState.dbType !== 'mssql' ? (
<>
<LabeledInput
label="Host"
placeholder="localhost"
onChange={e =>
connectDBDispatch({
type: 'UPDATE_DB_HOST',
data: e.target.value,
})
}
value={connectDBInputState.connectionParamState.host}
data-test="host"
/>
<LabeledInput
label="Port"
placeholder="5432"
onChange={e =>
connectDBDispatch({
type: 'UPDATE_DB_PORT',
data: e.target.value,
})
}
value={connectDBInputState.connectionParamState.port}
data-test="port"
/>
<LabeledInput
label="Username"
placeholder="postgres_user"
onChange={e =>
connectDBDispatch({
type: 'UPDATE_DB_USERNAME',
data: e.target.value,
})
}
value={connectDBInputState.connectionParamState.username}
data-test="username"
/>
<LabeledInput
label="Password"
key="connect-db-password"
type="password"
placeholder="postgrespassword"
onChange={e =>
connectDBDispatch({
type: 'UPDATE_DB_PASSWORD',
data: e.target.value,
})
}
value={connectDBInputState.connectionParamState.password}
data-test="password"
/>
<LabeledInput
key="connect-db-database-name"
label="Database Name"
placeholder="postgres"
onChange={e =>
connectDBDispatch({
type: 'UPDATE_DB_DATABASE_NAME',
data: e.target.value,
})
}
value={connectDBInputState.connectionParamState.database}
data-test="database-name"
/>
</>
) : null}
<div className={styles.connection_settings_layout}>
<div className={styles.connection_settings_header}>
<a
href="#"
style={{ textDecoration: 'none' }}
onClick={() =>
changeConnectionsParamState(!openConnectionSettings)
}
>
{openConnectionSettings ? (
<i className="fa fa-caret-down" />
) : (
<i className="fa fa-caret-right" />
)}
{' '}
Connection Settings
</a>
</div>
{openConnectionSettings ? (
<div className={styles.connection_settings_form}>
<div className={styles.connnection_settings_form_input_layout}>
<LabeledInput
label="Max Connections"
type="number"
className={`form-control ${styles.connnection_settings_form_input}`}
placeholder="50"
value={
connectDBInputState.connectionSettings?.max_connections ??
undefined
}
onChange={e =>
connectDBDispatch({
type: 'UPDATE_MAX_CONNECTIONS',
data: e.target.value,
})
}
min="0"
labelInBold
data-test="max-connections"
/>
</div>
<div className={styles.connnection_settings_form_input_layout}>
<LabeledInput
label="Idle Timeout"
type="number"
className={`form-control ${styles.connnection_settings_form_input}`}
placeholder="180"
value={
connectDBInputState.connectionSettings?.idle_timeout ??
undefined
}
onChange={e =>
connectDBDispatch({
type: 'UPDATE_IDLE_TIMEOUT',
data: e.target.value,
})
}
min="0"
labelInBold
data-test="idle-timeout"
/>
</div>
<div className={styles.connnection_settings_form_input_layout}>
<LabeledInput
label="Retries"
type="number"
className={`form-control ${styles.connnection_settings_form_input}`}
placeholder="1"
value={
connectDBInputState.connectionSettings?.retries ??
undefined
}
onChange={e =>
connectDBDispatch({
type: 'UPDATE_RETRIES',
data: e.target.value,
})
}
min="0"
labelInBold
data-test="retries"
/>
</div>
</div>
) : null}
</div>
<div className={styles.add_button_layout}>
<Button
size="large"
color="yellow"
type="submit"
style={{
width: '70%',
...(loading && { cursor: 'progress' }),
}}
disabled={loading}
data-test="save-database"
>
{!isEditState ? 'Connect Database' : 'Edit Connection'}
</Button>
</div>
{!isEditState ? 'Connect Database' : 'Edit Connection'}
</Button>
</div>
</form>
</Tabbed>

View File

@ -11,3 +11,46 @@
font-size: 12px;
color: white;
}
.read_replicas_heading {
font-weight: 600;
font-size: 15px;
}
// hr doesn't work well within flexbox and it's a known issue
// See https://dev.to/alvaromontoro/the-disappearing-line-a-css-mystery-3e35
.line_width {
width: 100%;
}
.add_button_styles {
width: 140px;
}
.read_replica_add_form {
width: 100%;
padding: 18px 12px;
background-color: white;
border: 1px solid rgba(207, 205, 205, 0.6);
}
.read_replica_action_bar {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.read_replica_list_item {
width: 125%;
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: space-between;
}
.remove_replica_btn {
margin-right: 8px;
color: rgb(214, 29, 29);
}

View File

@ -1,7 +1,7 @@
import { DataSource } from '../../../../metadata/types';
import { Driver } from '../../../../dataSources';
const parseURI = (url: string) => {
export const parseURI = (url: string) => {
try {
const pattern = /^(?:([^:/?#\s]+):\/{2})?(?:([^@/?#\s]+)@)?([^/?#\s]+)?(?:\/([^?#\s]*))?(?:[?]([^#\s]+))?\S*$/;
const matches = url.match(pattern);

View File

@ -0,0 +1,278 @@
import React, { useState, Dispatch, ChangeEvent } from 'react';
import {
ReadReplicaState,
ReadReplicaActions,
ConnectDBState,
ConnectDBActions,
ExtendedConnectDBState,
connectionTypes,
} from './state';
import ConnectDatabaseForm from './ConnectDBForm';
import Button from '../../../Common/Button';
import ToolTip from '../../../Common/Tooltip/Tooltip';
import {
makeConnectionStringFromConnectionParams,
parseURI,
} from './ManageDBUtils';
import styles from './DataSources.scss';
const checkIfFieldsAreEmpty = (
currentReadReplicaConnectionType: string,
currentReadReplicaState: ConnectDBState
) => {
// split into 3 different conditions for better readability
if (
currentReadReplicaConnectionType === connectionTypes.DATABASE_URL &&
!currentReadReplicaState?.databaseURLState?.dbURL
) {
return true;
}
if (
currentReadReplicaConnectionType === connectionTypes.ENV_VAR &&
!currentReadReplicaState?.envVarURLState?.envVarURL
) {
return true;
}
if (
currentReadReplicaConnectionType === connectionTypes.CONNECTION_PARAMS &&
!currentReadReplicaState?.connectionParamState?.database &&
!currentReadReplicaState?.connectionParamState?.host &&
!currentReadReplicaState?.connectionParamState?.port &&
!currentReadReplicaState?.connectionParamState?.username
) {
return true;
}
return false;
};
type ReadReplicaProps = {
readReplicaState: ReadReplicaState;
readReplicaDispatch: Dispatch<ReadReplicaActions>;
connectDBState: ConnectDBState;
connectDBStateDispatch: Dispatch<ConnectDBActions>;
readReplicaConnectionType: string;
updateReadReplicaConnectionType: (e: ChangeEvent<HTMLInputElement>) => void;
onClickAddReadReplicaCb?: () => void;
onClickCancelOnReadReplicaCb?: () => void;
onClickSaveReadReplicaCb?: () => void;
};
interface FormProps
extends Pick<
ReadReplicaProps,
| 'connectDBState'
| 'connectDBStateDispatch'
| 'readReplicaConnectionType'
| 'updateReadReplicaConnectionType'
> {
onClickCancel: () => void;
onClickSave: () => void;
}
const Form: React.FC<FormProps> = ({
connectDBState,
connectDBStateDispatch,
onClickCancel,
onClickSave,
readReplicaConnectionType,
updateReadReplicaConnectionType,
}) => {
const areFieldsEmpty = checkIfFieldsAreEmpty(
readReplicaConnectionType,
connectDBState
);
return (
<div className={styles.read_replica_add_form}>
<ConnectDatabaseForm
connectionDBState={connectDBState}
connectionDBStateDispatch={connectDBStateDispatch}
updateConnectionTypeRadio={updateReadReplicaConnectionType}
connectionTypeState={readReplicaConnectionType}
isreadreplica
title="Connect Read Replica via"
/>
<div className={styles.read_replica_action_bar}>
<Button size="sm" color="white" onClick={onClickCancel}>
Cancel
</Button>
<Button
size="sm"
color="yellow"
onClick={onClickSave}
disabled={areFieldsEmpty}
>
Add Read Replica
</Button>
</div>
</div>
);
};
type ReadReplicaListItemProps = {
currentState: ExtendedConnectDBState;
onClickRemove: () => void;
};
const ReadReplicaListItem: React.FC<ReadReplicaListItemProps> = ({
currentState,
onClickRemove,
}) => {
const [showUrl, setShowUrl] = useState(false);
const connectionType = currentState.chosenConnectionType;
const isFromEnvVar = connectionType === connectionTypes.ENV_VAR;
let connectionString = '';
if (!isFromEnvVar) {
if (connectionType === connectionTypes.DATABASE_URL) {
connectionString = currentState?.databaseURLState?.dbURL?.trim() ?? '';
} else {
connectionString = makeConnectionStringFromConnectionParams({
dbType: 'postgres',
...currentState.connectionParamState,
});
}
}
const host = isFromEnvVar ? '' : parseURI(connectionString)?.host ?? '';
return (
<div
className={styles.read_replica_list_item}
key={`read-replica-item-${currentState.displayName}`}
>
<Button
color="white"
size="xs"
onClick={onClickRemove}
className={styles.remove_replica_btn}
>
Remove
</Button>
<p>{isFromEnvVar ? currentState.envVarURLState.envVarURL : host}</p>
{/* The connection string is redundant if it's provided via ENV VAR */}
{!isFromEnvVar && (
<span
className={`${styles.db_large_string_break_words} ${styles.add_pad_top_10}`}
>
{showUrl ? (
connectionString
) : (
<span
className={styles.show_connection_string}
onClick={() => setShowUrl(true)}
>
<i
className={`${styles.showAdminSecret} fa fa-eye`}
aria-hidden="true"
/>
<p style={{ marginLeft: 6 }}>Show Connection String</p>
</span>
)}
{showUrl && (
<ToolTip
id="connection-string-hide"
placement="right"
message="Hide connection string"
>
<i
className={`${styles.closeHeader} fa fa-times`}
aria-hidden="true"
onClick={() => setShowUrl(false)}
style={{ paddingLeft: 10 }}
/>
</ToolTip>
)}
</span>
)}
</div>
);
};
const ReadReplicaForm: React.FC<ReadReplicaProps> = ({
connectDBState,
connectDBStateDispatch,
readReplicaState,
readReplicaDispatch,
onClickAddReadReplicaCb,
onClickCancelOnReadReplicaCb,
onClickSaveReadReplicaCb,
readReplicaConnectionType,
updateReadReplicaConnectionType,
}) => {
const [isReadReplicaButtonClicked, updateClickState] = useState(false);
const onClickAddReadReplica = () => {
updateClickState(true);
if (onClickAddReadReplicaCb) {
onClickAddReadReplicaCb();
}
};
const onClickCancelOnReadReplica = () => {
updateClickState(false);
if (onClickCancelOnReadReplicaCb) {
onClickCancelOnReadReplicaCb();
}
};
const onClickSaveReadReplica = () => {
updateClickState(false);
if (onClickSaveReadReplicaCb) {
onClickSaveReadReplicaCb();
}
};
const onClickRemoveReadReplica = (replicaDBName: string) => () =>
readReplicaDispatch({
type: 'REMOVE_READ_REPLICA',
data: replicaDBName,
});
return (
<>
<hr className={styles.line_width} />
<div className={styles.flexColumn}>
<h5 className={styles.read_replicas_heading}>Read Replicas</h5>
<p>
Hasura Cloud can load balance queries and subscriptions across read
replicas while sending all mutations and metadata API calls to the
master.&nbsp;
<a
href="https://hasura.io/docs/latest/graphql/cloud/read-replicas.html"
target="_blank"
rel="noopener noreferrer"
>
<i>(Read More)</i>
</a>
</p>
{readReplicaState.map(stateVar => (
<ReadReplicaListItem
currentState={stateVar}
onClickRemove={onClickRemoveReadReplica(stateVar.displayName)}
/>
))}
{!isReadReplicaButtonClicked ? (
<Button
onClick={onClickAddReadReplica}
className={styles.add_button_styles}
>
Add Read Replica
</Button>
) : (
<Form
connectDBState={connectDBState}
connectDBStateDispatch={connectDBStateDispatch}
onClickCancel={onClickCancelOnReadReplica}
onClickSave={onClickSaveReadReplica}
readReplicaConnectionType={readReplicaConnectionType}
updateReadReplicaConnectionType={updateReadReplicaConnectionType}
/>
)}
</div>
<hr className={styles.line_width} />
</>
);
};
export default ReadReplicaForm;

View File

@ -2,6 +2,7 @@ import { Driver } from '../../../../dataSources';
import { makeConnectionStringFromConnectionParams } from './ManageDBUtils';
import { addDataSource } from '../../../../metadata/actions';
import { Dispatch } from '../../../../types';
import { SourceConnectionInfo } from '../../../../metadata/types';
export const connectionTypes = {
DATABASE_URL: 'DATABASE_URL',
@ -84,7 +85,8 @@ export const connectDataSource = (
dispatch: Dispatch,
typeConnection: string,
currentState: ConnectDBState,
cb: () => void
cb: () => void,
replicas?: Omit<SourceConnectionInfo, 'connection_string'>[]
) => {
let databaseURL:
| string
@ -108,7 +110,8 @@ export const connectDataSource = (
connection_pool_settings: currentState.connectionSettings,
},
},
cb
cb,
replicas
)
);
};
@ -254,3 +257,81 @@ export const connectDBReducer = (
return state;
}
};
export interface ExtendedConnectDBState extends ConnectDBState {
chosenConnectionType: string;
}
const defaultReadReplicasState: ExtendedConnectDBState[] = [];
export type ReadReplicaState = ExtendedConnectDBState[];
export interface AddReadReplicaToState {
type: 'ADD_READ_REPLICA';
data: ExtendedConnectDBState;
}
export interface RemoveReadReplicaFromState {
type: 'REMOVE_READ_REPLICA';
// the default name is set using index `read-replica-${index}`
// we can use that to simplify removal
data: string;
}
export interface ResetReadReplicaState {
type: 'RESET_READ_REPLICA_STATE';
}
export type ReadReplicaActions =
| AddReadReplicaToState
| RemoveReadReplicaFromState
| ResetReadReplicaState;
export const readReplicaReducer = (
state: ReadReplicaState,
action: ReadReplicaActions
): ReadReplicaState => {
switch (action.type) {
case 'ADD_READ_REPLICA':
return [...state, action.data];
case 'REMOVE_READ_REPLICA':
return state.filter(st => st.displayName !== action.data);
case 'RESET_READ_REPLICA_STATE':
return defaultReadReplicasState;
default:
return state;
}
};
export const makeReadReplicaConnectionObject = (
stateVal: ExtendedConnectDBState
) => {
let database_url;
if (stateVal.chosenConnectionType === connectionTypes.DATABASE_URL) {
database_url = stateVal.databaseURLState?.dbURL?.trim() ?? '';
} else if (stateVal.chosenConnectionType === connectionTypes.ENV_VAR) {
database_url = {
from_env: stateVal.envVarURLState?.envVarURL?.trim() ?? '',
};
} else {
database_url = makeConnectionStringFromConnectionParams({
dbType: 'postgres',
...stateVal.connectionParamState,
});
}
const pool_settings: any = {};
if (stateVal.connectionSettings.max_connections) {
pool_settings.max_connections = stateVal.connectionSettings.max_connections;
}
if (stateVal.connectionSettings.idle_timeout) {
pool_settings.idle_timeout = stateVal.connectionSettings.idle_timeout;
}
if (stateVal.connectionSettings.retries) {
pool_settings.retries = stateVal.connectionSettings.retries;
}
return {
database_url,
pool_settings,
};
};

View File

@ -18,7 +18,6 @@ import ToolTip from '../../../Common/Tooltip/Tooltip';
import { getConfirmation } from '../../../Common/utils/jsUtils';
import { mapDispatchToPropsEmpty } from '../../../Common/utils/reactUtils';
import _push from '../push';
import { getHostFromConnectionString } from '../DataSources/ManageDBUtils';
import { isInconsistentSource } from '../utils';
const driverToLabel: Record<Driver, string> = {
@ -96,15 +95,19 @@ const DatabaseListItem: React.FC<DatabaseListItemProps> = ({
>
{removing ? 'Removing...' : 'Remove'}
</Button>
<div
className={`${styles.displayFlexContainer} ${styles.add_pad_left} ${styles.add_pad_top_10}`}
>
<b>{dataSource.name}</b>&nbsp;
<p>({driverToLabel[dataSource.driver]})</p>
<div className={styles.flexColumn}>
<div
className={`${styles.displayFlexContainer} ${styles.add_pad_left} ${styles.add_pad_top_10}`}
>
<b>{dataSource.name}</b>&nbsp;
<p>({driverToLabel[dataSource.driver]})</p>
{!!dataSource?.read_replicas?.length && (
<div className={styles.replica_badge}>
{dataSource.read_replicas.length} Replicas
</div>
)}
</div>
</div>
<p className={`${styles.add_pad_top_10} ${styles.add_pad_left}`}>
{getHostFromConnectionString(dataSource)}
</p>
{isInconsistentDataSource && (
<ToolTip
id={`inconsistent-source-${dataSource.name}`}
@ -142,7 +145,7 @@ const DatabaseListItem: React.FC<DatabaseListItemProps> = ({
{showUrl && (
<ToolTip
id="connection-string-hide"
placement="right"
placement="top"
message="Hide connection string"
>
<i

View File

@ -52,3 +52,18 @@
color: #c02020;
font-size: 15px;
}
.replica_badge {
display: flex;
align-items: center;
background-color: #ccc;
width: auto;
border-radius: 10px;
height: 20px;
padding: 8px;
font-size: 13px;
color: black;
margin: 0px 4px;
font-weight: 600;
letter-spacing: 0.3px;
}

View File

@ -1,6 +1,11 @@
import requestAction from '../utils/requestAction';
import Endpoints, { globalCookiePolicy } from '../Endpoints';
import { HasuraMetadataV2, HasuraMetadataV3, RestEndpointEntry } from './types';
import {
HasuraMetadataV2,
HasuraMetadataV3,
RestEndpointEntry,
SourceConnectionInfo,
} from './types';
import {
showSuccessNotification,
showErrorNotification,
@ -227,6 +232,7 @@ export const exportMetadata = (
export const addDataSource = (
data: AddDataSourceRequest['data'],
successCb: () => void,
replicas?: Omit<SourceConnectionInfo, 'connection_string'>[],
skipNotification = false
): Thunk<Promise<void | ReduxState>, MetadataActions> => (
dispatch,
@ -234,7 +240,7 @@ export const addDataSource = (
) => {
const { dataHeaders } = getState().tables;
const query = addSource(data.driver, data.payload);
const query = addSource(data.driver, data.payload, replicas);
const options = {
method: 'POST',
@ -350,6 +356,7 @@ export const editDataSource = (
);
onSuccessCb();
},
[],
true
)
).catch(err => {

View File

@ -361,6 +361,7 @@ export const getDataSources = createSelector(getMetadata, metadata => {
max_connections: 50,
},
driver: source.kind || 'postgres',
read_replicas: source.configuration?.read_replicas ?? undefined,
});
});
return sources;

View File

@ -1,4 +1,5 @@
import { Driver } from '../dataSources';
import { SourceConnectionInfo } from './types';
export const addSource = (
driver: Driver,
@ -10,7 +11,9 @@ export const addSource = (
idle_timeout?: number;
retries?: number;
};
}
},
// supported only for PG sources at the moment
replicas?: Omit<SourceConnectionInfo, 'connection_string'>[]
) => {
if (driver === 'mssql') {
return {
@ -36,6 +39,7 @@ export const addSource = (
database_url: payload.dbUrl,
pool_settings: payload.connection_pool_settings,
},
read_replicas: replicas?.length ? replicas : null,
},
},
};

View File

@ -10,6 +10,7 @@ export type DataSource = {
idle_timeout?: number;
retries?: number;
};
read_replicas?: Omit<SourceConnectionInfo, 'connection_string'>[];
};
// GENERATED
@ -865,6 +866,22 @@ export interface RestEndpointEntry {
// #endregion REST ENDPOINT
// /////////////////////////////
/**
* Docs for type: https://hasura.io/docs/latest/graphql/core/api-reference/syntax-defs.html#pgsourceconnectioninfo
*/
export interface SourceConnectionInfo {
// used for SQL Server
connection_string: string;
// used for Postgres
database_url: string | { from_env: string };
pool_settings: {
max_connections: number;
idle_timeout: number;
retries: number;
};
}
/**
* Type used in exported 'metadata.json' and replace metadata endpoint
* https://hasura.io/docs/latest/graphql/core/api-reference/schema-metadata-api/manage-metadata.html#replace-metadata
@ -886,17 +903,11 @@ export interface HasuraMetadataV2 {
export interface MetadataDataSource {
name: string;
kind?: 'postgres' | 'mysql';
kind?: 'postgres' | 'mysql' | 'mssql';
configuration?: {
connection_info?: {
connection_string?: string;
database_url?: string | { from_env: string };
pool_settings?: {
max_connections: number;
idle_timeout: number;
retries: number;
};
};
connection_info?: SourceConnectionInfo;
// pro-only feature
read_replicas?: SourceConnectionInfo[];
};
tables: TableEntry[];
functions?: Array<{