mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
add dynamic db routing storybook component
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8152 Co-authored-by: Varun Choudhary <68095256+Varun-Choudhary@users.noreply.github.com> GitOrigin-RevId: 89a15b336d85301308e18e82754f10f4ed856563
This commit is contained in:
parent
061c6aa0f9
commit
0dc3073559
@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
import { Collapsible } from '../../../../../../new-components/Collapsible';
|
||||||
|
import { Dialog } from '../../../../../../new-components/Dialog';
|
||||||
|
import { schema } from '../../schema';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InputField,
|
||||||
|
useConsoleForm,
|
||||||
|
} from '../../../../../../new-components/Form';
|
||||||
|
import { areSSLSettingsEnabled } from '../../utils/helpers';
|
||||||
|
import { DatabaseUrl } from '../DatabaseUrl';
|
||||||
|
import { IsolationLevel } from '../IsolationLevel';
|
||||||
|
import { PoolSettings } from '../PoolSettings';
|
||||||
|
import { SslSettings } from '../SslSettings';
|
||||||
|
import { UsePreparedStatements } from '../UsePreparedStatements';
|
||||||
|
|
||||||
|
interface ConnectPostgresModalProps {
|
||||||
|
alreadyUseNames?: string[];
|
||||||
|
defaultValues?: z.infer<typeof schema>;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (values: z.infer<typeof schema>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectPostgresModal = (props: ConnectPostgresModalProps) => {
|
||||||
|
const { onClose, onSubmit, defaultValues, alreadyUseNames } = props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
Form,
|
||||||
|
methods: { setError, formState, trigger, getValues },
|
||||||
|
} = useConsoleForm({
|
||||||
|
schema,
|
||||||
|
options: {
|
||||||
|
defaultValues: defaultValues ?? {
|
||||||
|
configuration: {
|
||||||
|
connectionInfo: {
|
||||||
|
databaseUrl: {
|
||||||
|
connectionType: 'databaseUrl',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
onSubmit={values => {
|
||||||
|
if (alreadyUseNames?.includes(values.name)) {
|
||||||
|
setError('name', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'This name is already in use',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onSubmit(values);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
size="lg"
|
||||||
|
hasBackdrop
|
||||||
|
title={defaultValues ? 'Edit connection' : 'Add connection'}
|
||||||
|
description={`${
|
||||||
|
defaultValues ? 'Edit connection' : 'Add connections'
|
||||||
|
} which will be available to be referenced in your dynamic connection template.`}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className="px-6 mb-8">
|
||||||
|
<InputField
|
||||||
|
name="name"
|
||||||
|
label="Connection name"
|
||||||
|
placeholder="Connection name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DatabaseUrl
|
||||||
|
name="configuration.connectionInfo.databaseUrl"
|
||||||
|
hideOptions={[]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-sm">
|
||||||
|
<Collapsible
|
||||||
|
triggerChildren={
|
||||||
|
<div className="font-semibold text-muted">
|
||||||
|
Advanced Settings
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PoolSettings
|
||||||
|
name={`configuration.connectionInfo.poolSettings`}
|
||||||
|
/>
|
||||||
|
<IsolationLevel name={`configuration.connectionInfo`} />
|
||||||
|
<UsePreparedStatements name={`configuration.connectionInfo`} />
|
||||||
|
{areSSLSettingsEnabled() && (
|
||||||
|
<Collapsible
|
||||||
|
triggerChildren={
|
||||||
|
<div className="font-semibold text-muted">
|
||||||
|
SSL Certificates Settings
|
||||||
|
<span className="px-1.5 italic font-light">
|
||||||
|
(Certificates will be loaded from{' '}
|
||||||
|
<a href="https://hasura.io/docs/latest/graphql/cloud/projects/create.html#existing-database">
|
||||||
|
environment variables
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SslSettings
|
||||||
|
name={`configuration.connectionInfo.sslSettings`}
|
||||||
|
/>
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer
|
||||||
|
callToDeny="Cancel"
|
||||||
|
callToAction="Submit"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</Dialog>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,48 @@
|
|||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import { userEvent, waitFor, within } from '@storybook/testing-library';
|
||||||
|
import { handlers } from '../../../../../../mocks/metadata.mock';
|
||||||
|
import { ReactQueryDecorator } from '../../../../../../storybook/decorators/react-query';
|
||||||
|
import { DynamicDBRouting } from './DynamicDBRouting';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: DynamicDBRouting,
|
||||||
|
decorators: [ReactQueryDecorator()],
|
||||||
|
parameters: {
|
||||||
|
msw: handlers({ delay: 500 }),
|
||||||
|
},
|
||||||
|
} as ComponentMeta<typeof DynamicDBRouting>;
|
||||||
|
|
||||||
|
export const Default: ComponentStory<typeof DynamicDBRouting> = () => (
|
||||||
|
<DynamicDBRouting sourceName="default" />
|
||||||
|
);
|
||||||
|
|
||||||
|
Default.play = async ({ args, canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(canvas.getByLabelText('Database Tenancy')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// click on Database Tenancy
|
||||||
|
const radioTenancy = canvas.getByLabelText('Database Tenancy');
|
||||||
|
userEvent.click(radioTenancy);
|
||||||
|
|
||||||
|
// click on "Add Connection"
|
||||||
|
const buttonAddConnection = canvas.getByText('Add Connection');
|
||||||
|
userEvent.click(buttonAddConnection);
|
||||||
|
|
||||||
|
// write "test" in the input text with testid "name"
|
||||||
|
const inputName = canvas.getByTestId('name');
|
||||||
|
userEvent.type(inputName, 'test');
|
||||||
|
|
||||||
|
// write "test" in the input text with testid "configuration.connectionInfo.databaseUrl.url"
|
||||||
|
const inputDatabaseUrl = canvas.getByTestId(
|
||||||
|
'configuration.connectionInfo.databaseUrl.url'
|
||||||
|
);
|
||||||
|
userEvent.type(inputDatabaseUrl, 'test');
|
||||||
|
|
||||||
|
// click on submit
|
||||||
|
const buttonSubmit = canvas.getByText('Submit');
|
||||||
|
userEvent.click(buttonSubmit);
|
||||||
|
};
|
@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
import { SimpleForm } from '../../../../../../new-components/Form';
|
||||||
|
import { ConnectPostgresModal } from './ConnectPostgresModal';
|
||||||
|
import { DynamicDBRoutingForm } from './DynamicDBRoutingForm';
|
||||||
|
import { generatePostgresRequestPayload } from '../../utils/generateRequests';
|
||||||
|
import { adaptPostgresConnectionInfo } from '../../utils/adaptResponse';
|
||||||
|
import { useDynamicDbRouting } from './hooks/useDynamicDbRouting';
|
||||||
|
import { ConnectionSet } from '../../../../../../metadata/types';
|
||||||
|
import { PostgresConfiguration } from '../../../../../hasura-metadata-types';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
connection_template: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DynamicDBRoutingProps {
|
||||||
|
sourceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DynamicDBRouting = (props: DynamicDBRoutingProps) => {
|
||||||
|
const {
|
||||||
|
connectionTemplate,
|
||||||
|
connectionSet,
|
||||||
|
addConnection,
|
||||||
|
removeConnection,
|
||||||
|
updateConnection,
|
||||||
|
updateConnectionTemplate,
|
||||||
|
isLoading,
|
||||||
|
isMetadaLoading,
|
||||||
|
} = useDynamicDbRouting({
|
||||||
|
sourceName: props.sourceName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||||
|
const [editingConnectionSetMember, setEditingConnectionSetMember] =
|
||||||
|
React.useState<string>();
|
||||||
|
|
||||||
|
const connectionSetMembers = connectionSet.map(connection => {
|
||||||
|
const { name, connection_info } = connection;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
configuration: {
|
||||||
|
connectionInfo: adaptPostgresConnectionInfo(
|
||||||
|
connection_info as PostgresConfiguration['connection_info']
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isMetadaLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('template', connectionTemplate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isModalOpen && (
|
||||||
|
<ConnectPostgresModal
|
||||||
|
alreadyUseNames={connectionSetMembers.map(
|
||||||
|
connection => connection.name
|
||||||
|
)}
|
||||||
|
onSubmit={values => {
|
||||||
|
const payload = {
|
||||||
|
name: values.name,
|
||||||
|
connection_info: generatePostgresRequestPayload({
|
||||||
|
driver: 'postgres',
|
||||||
|
values,
|
||||||
|
}).details.configuration.connection_info,
|
||||||
|
} as ConnectionSet;
|
||||||
|
addConnection(payload);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editingConnectionSetMember && (
|
||||||
|
<ConnectPostgresModal
|
||||||
|
alreadyUseNames={connectionSetMembers.map(
|
||||||
|
connection => connection.name
|
||||||
|
)}
|
||||||
|
defaultValues={connectionSetMembers.find(
|
||||||
|
connection => connection.name === editingConnectionSetMember
|
||||||
|
)}
|
||||||
|
onSubmit={values => {
|
||||||
|
updateConnection(editingConnectionSetMember, {
|
||||||
|
name: values.name,
|
||||||
|
connection_info: generatePostgresRequestPayload({
|
||||||
|
driver: 'postgres',
|
||||||
|
values,
|
||||||
|
}).details.configuration.connection_info,
|
||||||
|
} as ConnectionSet);
|
||||||
|
|
||||||
|
setEditingConnectionSetMember(undefined);
|
||||||
|
}}
|
||||||
|
onClose={() => setEditingConnectionSetMember(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SimpleForm
|
||||||
|
onSubmit={values => {
|
||||||
|
updateConnectionTemplate(values.connection_template);
|
||||||
|
}}
|
||||||
|
schema={schema}
|
||||||
|
options={{
|
||||||
|
defaultValues: {
|
||||||
|
connection_template: connectionTemplate ?? undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicDBRoutingForm
|
||||||
|
connectionSetMembers={connectionSetMembers}
|
||||||
|
onAddConnection={() => setIsModalOpen(true)}
|
||||||
|
onEditConnection={connectionName => {
|
||||||
|
setEditingConnectionSetMember(connectionName);
|
||||||
|
}}
|
||||||
|
onRemoveConnection={connectionName => {
|
||||||
|
removeConnection(connectionName);
|
||||||
|
}}
|
||||||
|
isLoading={isLoading}
|
||||||
|
connectionTemplate={connectionTemplate}
|
||||||
|
/>
|
||||||
|
</SimpleForm>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,251 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { FaExclamationCircle, FaPlusCircle } from 'react-icons/fa';
|
||||||
|
import z from 'zod';
|
||||||
|
import { Badge } from '../../../../../../new-components/Badge';
|
||||||
|
import { Button } from '../../../../../../new-components/Button';
|
||||||
|
import { CardedTable } from '../../../../../../new-components/CardedTable';
|
||||||
|
import { CardRadioGroup } from '../../../../../../new-components/CardRadioGroup';
|
||||||
|
import { schema as postgresSchema } from '../../schema';
|
||||||
|
import { CodeEditorField } from '../../../../../../new-components/Form';
|
||||||
|
import { LearnMoreLink } from '../../../../../../new-components/LearnMoreLink';
|
||||||
|
import { IconTooltip } from '../../../../../../new-components/Tooltip';
|
||||||
|
|
||||||
|
const editorOptions = {
|
||||||
|
minLines: 33,
|
||||||
|
maxLines: 33,
|
||||||
|
showLineNumbers: true,
|
||||||
|
useSoftTabs: true,
|
||||||
|
showPrintMargin: false,
|
||||||
|
showGutter: true,
|
||||||
|
wrap: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const templates = {
|
||||||
|
disabled: {
|
||||||
|
value: 'disabled',
|
||||||
|
title: 'Disabled',
|
||||||
|
body: 'Use default Hasura connection routing.',
|
||||||
|
template: '',
|
||||||
|
isSelected: (connectionTemplate?: string | null) => !connectionTemplate,
|
||||||
|
},
|
||||||
|
tenancy: {
|
||||||
|
value: 'tenancy',
|
||||||
|
title: 'Database Tenancy',
|
||||||
|
body: 'Tenancy template using a x-hasura-tenant variable to connect to a named database.',
|
||||||
|
template: `{{ if ($.request.session.x-hasura-tenant-id == "my_tenant_1")}}
|
||||||
|
{{$.connection_set.my_tenant_1_connection}}
|
||||||
|
{{ elif ($.request.session.x-hasura-tenant-id == "my_tenant_2")}}
|
||||||
|
{{$.connection_set.my_tenant_2_connection}}
|
||||||
|
{{ else }}
|
||||||
|
{{$.default}}
|
||||||
|
{{ end }}`,
|
||||||
|
isSelected: (connectionTemplate?: string | null) =>
|
||||||
|
connectionTemplate?.includes('x-hasura-tenant-id'),
|
||||||
|
},
|
||||||
|
'no-stale-reads': {
|
||||||
|
value: 'no-stale-reads',
|
||||||
|
title: 'Read Replicas - No Stale Reads',
|
||||||
|
body: 'No stale reads template using the primary for reads on mutations and replicas for all other reads.',
|
||||||
|
template: `{{ if (($.request.query.operation_type == "query")
|
||||||
|
|| ($.request.query.operation_type == "subscription"))
|
||||||
|
&& ($.request.headers.x-query-read-no-stale == "true") }}
|
||||||
|
{{$.primary}}
|
||||||
|
{{ else }}
|
||||||
|
{{$.default}}
|
||||||
|
{{ end }}`,
|
||||||
|
|
||||||
|
isSelected: (connectionTemplate?: string | null) =>
|
||||||
|
connectionTemplate?.includes('x-query-read-no-stale'),
|
||||||
|
},
|
||||||
|
sharding: {
|
||||||
|
value: 'sharding',
|
||||||
|
title: 'Different credentials',
|
||||||
|
body: 'Route specific queries to specific databases in a distributed database system model.',
|
||||||
|
template: `{{ if ($.request.session.x-hasura-role == "manager")}}
|
||||||
|
{{$.connection_set.manager_connection}}
|
||||||
|
{{ elif ($.request.session.x-hasura-role == "employee")}}
|
||||||
|
{{$.connection_set.employee_connection}}
|
||||||
|
{{ else }}
|
||||||
|
{{$.default}}
|
||||||
|
{{ end }}`,
|
||||||
|
isSelected: (connectionTemplate?: string | null) =>
|
||||||
|
connectionTemplate?.includes('x-hasura-role'),
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
value: 'custom',
|
||||||
|
title: 'Custom Template',
|
||||||
|
body: 'Write a custom connection template using Kriti templating.',
|
||||||
|
template: `{{ if ()}}
|
||||||
|
{{$.}}
|
||||||
|
{{ elif ()}}
|
||||||
|
{{$.}}
|
||||||
|
{{ else }}
|
||||||
|
{{$.default}}
|
||||||
|
{{ end }}`,
|
||||||
|
isSelected: (connectionTemplate?: string | null) => !!connectionTemplate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DynamicDBRoutingFormProps {
|
||||||
|
connectionSetMembers: z.infer<typeof postgresSchema>[];
|
||||||
|
onAddConnection: () => void;
|
||||||
|
onRemoveConnection: (name: string) => void;
|
||||||
|
onEditConnection: (name: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
connectionTemplate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DynamicDBRoutingForm = (props: DynamicDBRoutingFormProps) => {
|
||||||
|
const {
|
||||||
|
connectionSetMembers,
|
||||||
|
onAddConnection,
|
||||||
|
onRemoveConnection,
|
||||||
|
onEditConnection,
|
||||||
|
isLoading,
|
||||||
|
connectionTemplate,
|
||||||
|
} = props;
|
||||||
|
const { setValue } = useFormContext();
|
||||||
|
const [template, setTemplate] = React.useState<keyof typeof templates>(
|
||||||
|
(Object.entries(templates).find(([_, template]) =>
|
||||||
|
template.isSelected(connectionTemplate)
|
||||||
|
)?.[0] as keyof typeof templates) || 'disabled'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2">
|
||||||
|
{template !== 'disabled' && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center rounded bg-gray-200 border border-gray-300 py-sm px-sm mb-md`}
|
||||||
|
>
|
||||||
|
<FaExclamationCircle className="fill-current self-start h-md text-muted" />
|
||||||
|
<div className="ml-xs">
|
||||||
|
<strong>Dynamic Database Routing Precedence</strong>
|
||||||
|
<p>
|
||||||
|
{' '}
|
||||||
|
Dynamic database routing takes precedence over read replicas.
|
||||||
|
You may use both read replica routing and default database
|
||||||
|
routing in your connection template.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="block flex items-center text-gray-600 font-semibold">
|
||||||
|
<label htmlFor="connection_template" className="font-semibold">
|
||||||
|
Connection Template
|
||||||
|
</label>
|
||||||
|
<IconTooltip message="Connection templates to route GraphQL requests based on different request parameters such as session variables, headers and tenant IDs." />
|
||||||
|
<LearnMoreLink
|
||||||
|
href="https://hasura.io/docs/latest/databases/connect-db/dynamic-db-connection/#connection-template"
|
||||||
|
className="font-normal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
Database connection template to define dynamic connection routing.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<CardRadioGroup
|
||||||
|
value={template}
|
||||||
|
orientation="vertical"
|
||||||
|
onChange={value => {
|
||||||
|
setTemplate(value as keyof typeof templates);
|
||||||
|
setValue(
|
||||||
|
'connection_template',
|
||||||
|
templates[value as keyof typeof templates]?.template
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
items={Object.values(templates).map(template => ({
|
||||||
|
value: template.value,
|
||||||
|
title: template.title,
|
||||||
|
body: template.body,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div data-testid="template-editor">
|
||||||
|
<CodeEditorField
|
||||||
|
disabled={template === 'disabled'}
|
||||||
|
noErrorPlaceholder
|
||||||
|
name="connection_template"
|
||||||
|
editorOptions={editorOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 mt-8 flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<div className="block flex items-center text-gray-600 font-semibold">
|
||||||
|
<label htmlFor="template" className="font-semibold">
|
||||||
|
Available Connections for Templating
|
||||||
|
</label>
|
||||||
|
<IconTooltip message="Available database connections which can be referenced in your dynamic connection template." />
|
||||||
|
<LearnMoreLink
|
||||||
|
href="https://hasura.io/docs/latest/databases/connect-db/dynamic-db-connection/#connection-set"
|
||||||
|
text="(Learn More)"
|
||||||
|
className="font-normal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted">
|
||||||
|
Available connections which can be referenced in your dynamic
|
||||||
|
connection template.{' '}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onAddConnection}
|
||||||
|
icon={<FaPlusCircle />}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Add Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardedTable
|
||||||
|
showActionCell
|
||||||
|
columns={['Connection']}
|
||||||
|
data={[
|
||||||
|
[
|
||||||
|
'{{$.default}}',
|
||||||
|
<Badge color="light-gray">Default Routing Behavior</Badge>,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'{{$.primary}}',
|
||||||
|
<Badge color="light-gray">The Database Primary</Badge>,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'{{$.read_replicas}}',
|
||||||
|
<Badge color="light-gray">Read Replica Routing</Badge>,
|
||||||
|
],
|
||||||
|
...connectionSetMembers.map(connection => [
|
||||||
|
`{{$.${connection.name}}}`,
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
disabled={isLoading}
|
||||||
|
className="mr-2"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEditConnection(connection.name)}
|
||||||
|
>
|
||||||
|
Edit Connection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={isLoading}
|
||||||
|
mode="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRemoveConnection(connection.name)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</>,
|
||||||
|
]),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" mode="primary">
|
||||||
|
Update Database Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,134 @@
|
|||||||
|
import { ConnectionSet } from '../../../../../../../metadata/types';
|
||||||
|
import {
|
||||||
|
useMetadata,
|
||||||
|
useMetadataMigration,
|
||||||
|
} from '../../../../../../MetadataAPI';
|
||||||
|
|
||||||
|
export const useDynamicDbRouting = ({ sourceName }: { sourceName: string }) => {
|
||||||
|
const { data, isLoading: isMetadaLoading } = useMetadata();
|
||||||
|
|
||||||
|
const { mutate, isLoading } = useMetadataMigration({});
|
||||||
|
|
||||||
|
const source = data?.metadata?.sources.find(
|
||||||
|
source => source.name === sourceName
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectionTemplate =
|
||||||
|
source?.configuration?.connection_template?.template || null;
|
||||||
|
const connectionSet = source?.configuration?.connection_set || [];
|
||||||
|
|
||||||
|
const addConnection = async (
|
||||||
|
connection: ConnectionSet,
|
||||||
|
options?: Parameters<typeof mutate>[1]
|
||||||
|
): Promise<void> => {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
...(data?.resource_version && {
|
||||||
|
resource_version: data.resource_version,
|
||||||
|
}),
|
||||||
|
type: 'pg_update_source',
|
||||||
|
args: {
|
||||||
|
...source,
|
||||||
|
configuration: {
|
||||||
|
...source?.configuration,
|
||||||
|
connection_set: [...connectionSet, connection],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeConnection = async (
|
||||||
|
connectionName: string,
|
||||||
|
options?: Parameters<typeof mutate>[1]
|
||||||
|
): Promise<void> => {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
...(data?.resource_version && {
|
||||||
|
resource_version: data.resource_version,
|
||||||
|
}),
|
||||||
|
type: 'pg_update_source',
|
||||||
|
args: {
|
||||||
|
...source,
|
||||||
|
configuration: {
|
||||||
|
...source?.configuration,
|
||||||
|
connection_set:
|
||||||
|
connectionSet.length === 1
|
||||||
|
? null
|
||||||
|
: connectionSet.filter(c => c.name !== connectionName),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConnection = async (
|
||||||
|
connectionName: string,
|
||||||
|
connection: ConnectionSet,
|
||||||
|
options?: Parameters<typeof mutate>[1]
|
||||||
|
): Promise<void> => {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
...(data?.resource_version && {
|
||||||
|
resource_version: data.resource_version,
|
||||||
|
}),
|
||||||
|
type: 'pg_update_source',
|
||||||
|
args: {
|
||||||
|
...source,
|
||||||
|
configuration: {
|
||||||
|
...source?.configuration,
|
||||||
|
connection_set: connectionSet.map(c =>
|
||||||
|
c.name === connectionName ? connection : c
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConnectionTemplate = async (
|
||||||
|
connectionTemplate?: string | null,
|
||||||
|
options?: Parameters<typeof mutate>[1]
|
||||||
|
): Promise<void> => {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
...(data?.resource_version && {
|
||||||
|
resource_version: data.resource_version,
|
||||||
|
}),
|
||||||
|
type: 'pg_update_source',
|
||||||
|
args: {
|
||||||
|
...source,
|
||||||
|
configuration: {
|
||||||
|
...source?.configuration,
|
||||||
|
connection_template: connectionTemplate
|
||||||
|
? { template: connectionTemplate }
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionTemplate,
|
||||||
|
connectionSet,
|
||||||
|
addConnection,
|
||||||
|
removeConnection,
|
||||||
|
updateConnection,
|
||||||
|
updateConnectionTemplate,
|
||||||
|
isLoading,
|
||||||
|
isMetadaLoading,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './DynamicDBRouting';
|
@ -6,3 +6,4 @@ export { useTrackTable } from './TrackTables/hooks/useTrackTable';
|
|||||||
export { useMetadataSource } from './TrackTables/hooks/useMetadataSource';
|
export { useMetadataSource } from './TrackTables/hooks/useMetadataSource';
|
||||||
export * from './CustomFieldNames';
|
export * from './CustomFieldNames';
|
||||||
export * from '../../utils/getDataRoute';
|
export * from '../../utils/getDataRoute';
|
||||||
|
export * from './mocks/metadata.mocks';
|
||||||
|
@ -0,0 +1,61 @@
|
|||||||
|
import produce from 'immer';
|
||||||
|
|
||||||
|
import { allowedMetadataTypes } from '../../MetadataAPI';
|
||||||
|
import { Metadata } from '../../hasura-metadata-types';
|
||||||
|
import { MetadataReducer } from '../../../mocks/actions';
|
||||||
|
|
||||||
|
export const dataInitialData: Partial<Metadata['metadata']> = {
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
kind: 'postgres',
|
||||||
|
tables: [],
|
||||||
|
configuration: {
|
||||||
|
connection_info: {
|
||||||
|
database_url: {
|
||||||
|
from_env: 'HASURA_GRAPHQL_DATABASE_URL',
|
||||||
|
},
|
||||||
|
isolation_level: 'read-committed',
|
||||||
|
pool_settings: {
|
||||||
|
connection_lifetime: 600,
|
||||||
|
idle_timeout: 180,
|
||||||
|
max_connections: 50,
|
||||||
|
retries: 1,
|
||||||
|
},
|
||||||
|
use_prepared_statements: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metadataHandlers: Partial<
|
||||||
|
Record<allowedMetadataTypes, MetadataReducer>
|
||||||
|
> = {
|
||||||
|
pg_update_source: (state, action) => {
|
||||||
|
const { name, configuration } = action.args as {
|
||||||
|
name: string;
|
||||||
|
configuration: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const existingSource = state.metadata.sources.find(s => s.name === name);
|
||||||
|
if (!existingSource) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: {
|
||||||
|
path: '$.args.name',
|
||||||
|
error: `source with name "${name}" does not exist`,
|
||||||
|
code: 'not-exists',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return produce(state, draft => {
|
||||||
|
const source = draft.metadata.sources.find(s => s.name === name);
|
||||||
|
if (source?.configuration) {
|
||||||
|
source.configuration = {
|
||||||
|
...source?.configuration,
|
||||||
|
...configuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
@ -1,39 +1,54 @@
|
|||||||
export type FromEnv = { from_env: string };
|
export type FromEnv = { from_env: string };
|
||||||
type ValidJson = Record<string, any>;
|
type ValidJson = Record<string, any>;
|
||||||
|
|
||||||
export interface PostgresConfiguration {
|
interface PostgresConnectionInfo {
|
||||||
connection_info: {
|
database_url:
|
||||||
database_url:
|
| string
|
||||||
| string
|
| FromEnv
|
||||||
| FromEnv
|
| {
|
||||||
| {
|
username: string;
|
||||||
username: string;
|
password?: string;
|
||||||
password?: string;
|
database: string;
|
||||||
database: string;
|
host: string;
|
||||||
host: string;
|
port: string;
|
||||||
port: string;
|
};
|
||||||
};
|
pool_settings?: {
|
||||||
pool_settings?: {
|
max_connections?: number;
|
||||||
max_connections?: number;
|
total_max_connections?: number;
|
||||||
total_max_connections?: number;
|
idle_timeout?: number;
|
||||||
idle_timeout?: number;
|
retries?: number;
|
||||||
retries?: number;
|
pool_timeout?: number;
|
||||||
pool_timeout?: number;
|
connection_lifetime?: number;
|
||||||
connection_lifetime?: number;
|
|
||||||
};
|
|
||||||
use_prepared_statements?: boolean;
|
|
||||||
/**
|
|
||||||
* The transaction isolation level in which the queries made to the source will be run with (default: read-committed).
|
|
||||||
*/
|
|
||||||
isolation_level?: 'read-committed' | 'repeatable-read' | 'serializable';
|
|
||||||
ssl_configuration?: {
|
|
||||||
sslmode: string;
|
|
||||||
sslrootcert: FromEnv;
|
|
||||||
sslcert: FromEnv;
|
|
||||||
sslkey: FromEnv;
|
|
||||||
sslpassword: FromEnv;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
use_prepared_statements?: boolean;
|
||||||
|
/**
|
||||||
|
* The transaction isolation level in which the queries made to the source will be run with (default: read-committed).
|
||||||
|
*/
|
||||||
|
isolation_level?: 'read-committed' | 'repeatable-read' | 'serializable';
|
||||||
|
ssl_configuration?: {
|
||||||
|
sslmode: string;
|
||||||
|
sslrootcert: FromEnv;
|
||||||
|
sslcert: FromEnv;
|
||||||
|
sslkey: FromEnv;
|
||||||
|
sslpassword: FromEnv;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface PostgresConfiguration {
|
||||||
|
connection_info: PostgresConnectionInfo;
|
||||||
|
/**
|
||||||
|
* Kriti template to resolve connection info at runtime
|
||||||
|
*/
|
||||||
|
connection_template?: {
|
||||||
|
template: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List of connection sets to use in connection template
|
||||||
|
*/
|
||||||
|
connection_set?: {
|
||||||
|
name: string;
|
||||||
|
connection_info: PostgresConnectionInfo;
|
||||||
|
}[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional list of read replica configuration (supported only in cloud/enterprise versions)
|
* Optional list of read replica configuration (supported only in cloud/enterprise versions)
|
||||||
*/
|
*/
|
||||||
|
@ -1157,11 +1157,14 @@ type GraphQLCustomizationMetadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Used for Dynamic Connection Routing
|
// Used for Dynamic Connection Routing
|
||||||
type ConnectionSet = {
|
export type ConnectionSet = {
|
||||||
connection_info: SourceConnectionInfo;
|
connection_info: SourceConnectionInfo;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SourceConnectionTemplate = {
|
||||||
|
template: string | null;
|
||||||
|
};
|
||||||
export interface MetadataDataSource {
|
export interface MetadataDataSource {
|
||||||
name: string;
|
name: string;
|
||||||
kind:
|
kind:
|
||||||
@ -1177,6 +1180,7 @@ export interface MetadataDataSource {
|
|||||||
extensions_schema?: string;
|
extensions_schema?: string;
|
||||||
// pro-only feature
|
// pro-only feature
|
||||||
read_replicas?: SourceConnectionInfo[];
|
read_replicas?: SourceConnectionInfo[];
|
||||||
|
connection_template?: SourceConnectionTemplate;
|
||||||
connection_set?: ConnectionSet[];
|
connection_set?: ConnectionSet[];
|
||||||
service_account?: BigQueryServiceAccount;
|
service_account?: BigQueryServiceAccount;
|
||||||
global_select_limit?: number;
|
global_select_limit?: number;
|
||||||
|
@ -5,6 +5,7 @@ import { metadataHandlers as allowListMetadataHandlers } from '../features/Allow
|
|||||||
import { metadataHandlers as adhocEventMetadataHandlers } from '../features/AdhocEvents';
|
import { metadataHandlers as adhocEventMetadataHandlers } from '../features/AdhocEvents';
|
||||||
import { metadataHandlers as queryCollectionMetadataHandlers } from '../features/QueryCollections';
|
import { metadataHandlers as queryCollectionMetadataHandlers } from '../features/QueryCollections';
|
||||||
import { metadataHandlers as openTelemetryMetadataHandlers } from '../features/OpenTelemetry';
|
import { metadataHandlers as openTelemetryMetadataHandlers } from '../features/OpenTelemetry';
|
||||||
|
import { metadataHandlers as dataMetadataHandlers } from '../features/Data';
|
||||||
|
|
||||||
import { TMigration } from '../features/MetadataAPI/hooks/useMetadataMigration';
|
import { TMigration } from '../features/MetadataAPI/hooks/useMetadataMigration';
|
||||||
|
|
||||||
@ -32,6 +33,7 @@ const metadataHandlers: Partial<Record<allowedMetadataTypes, MetadataReducer>> =
|
|||||||
...queryCollectionMetadataHandlers,
|
...queryCollectionMetadataHandlers,
|
||||||
...adhocEventMetadataHandlers,
|
...adhocEventMetadataHandlers,
|
||||||
...openTelemetryMetadataHandlers,
|
...openTelemetryMetadataHandlers,
|
||||||
|
...dataMetadataHandlers,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadataReducer: MetadataReducer = (state, action) => {
|
export const metadataReducer: MetadataReducer = (state, action) => {
|
||||||
|
@ -5,6 +5,7 @@ import type { Metadata } from '../features/hasura-metadata-types';
|
|||||||
import { allowListInitialData } from '../features/AllowLists';
|
import { allowListInitialData } from '../features/AllowLists';
|
||||||
import { queryCollectionInitialData } from '../features/QueryCollections';
|
import { queryCollectionInitialData } from '../features/QueryCollections';
|
||||||
import { openTelemetryInitialData } from '../features/OpenTelemetry';
|
import { openTelemetryInitialData } from '../features/OpenTelemetry';
|
||||||
|
import { dataInitialData } from '../features/Data';
|
||||||
|
|
||||||
import { rest } from 'msw';
|
import { rest } from 'msw';
|
||||||
import { metadataReducer } from './actions';
|
import { metadataReducer } from './actions';
|
||||||
@ -18,6 +19,7 @@ export const createDefaultInitialData = (): Metadata => ({
|
|||||||
...allowListInitialData,
|
...allowListInitialData,
|
||||||
...queryCollectionInitialData,
|
...queryCollectionInitialData,
|
||||||
...openTelemetryInitialData,
|
...openTelemetryInitialData,
|
||||||
|
...dataInitialData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -8,7 +8,8 @@ export type BadgeColor =
|
|||||||
| 'indigo'
|
| 'indigo'
|
||||||
| 'gray'
|
| 'gray'
|
||||||
| 'blue'
|
| 'blue'
|
||||||
| 'purple';
|
| 'purple'
|
||||||
|
| 'light-gray';
|
||||||
interface BadgeProps extends React.ComponentProps<'span'> {
|
interface BadgeProps extends React.ComponentProps<'span'> {
|
||||||
/**
|
/**
|
||||||
* The color of the basge
|
* The color of the basge
|
||||||
@ -24,6 +25,7 @@ const badgeClassnames: Record<BadgeColor, string> = {
|
|||||||
indigo: 'bg-indigo-100 text-indigo-800',
|
indigo: 'bg-indigo-100 text-indigo-800',
|
||||||
blue: 'bg-blue-100 text-blue-800',
|
blue: 'bg-blue-100 text-blue-800',
|
||||||
purple: 'bg-purple-100 text-purple-800',
|
purple: 'bg-purple-100 text-purple-800',
|
||||||
|
'light-gray': 'bg-gray-100 text-gray-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Badge: React.FC<React.PropsWithChildren<BadgeProps>> = ({
|
export const Badge: React.FC<React.PropsWithChildren<BadgeProps>> = ({
|
||||||
|
Loading…
Reference in New Issue
Block a user