mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +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 * from './CustomFieldNames';
|
||||
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 };
|
||||
type ValidJson = Record<string, any>;
|
||||
|
||||
export interface PostgresConfiguration {
|
||||
connection_info: {
|
||||
database_url:
|
||||
| string
|
||||
| FromEnv
|
||||
| {
|
||||
username: string;
|
||||
password?: string;
|
||||
database: string;
|
||||
host: string;
|
||||
port: string;
|
||||
};
|
||||
pool_settings?: {
|
||||
max_connections?: number;
|
||||
total_max_connections?: number;
|
||||
idle_timeout?: number;
|
||||
retries?: number;
|
||||
pool_timeout?: 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;
|
||||
};
|
||||
interface PostgresConnectionInfo {
|
||||
database_url:
|
||||
| string
|
||||
| FromEnv
|
||||
| {
|
||||
username: string;
|
||||
password?: string;
|
||||
database: string;
|
||||
host: string;
|
||||
port: string;
|
||||
};
|
||||
pool_settings?: {
|
||||
max_connections?: number;
|
||||
total_max_connections?: number;
|
||||
idle_timeout?: number;
|
||||
retries?: number;
|
||||
pool_timeout?: 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;
|
||||
};
|
||||
}
|
||||
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)
|
||||
*/
|
||||
|
@ -1157,11 +1157,14 @@ type GraphQLCustomizationMetadata = {
|
||||
};
|
||||
|
||||
// Used for Dynamic Connection Routing
|
||||
type ConnectionSet = {
|
||||
export type ConnectionSet = {
|
||||
connection_info: SourceConnectionInfo;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SourceConnectionTemplate = {
|
||||
template: string | null;
|
||||
};
|
||||
export interface MetadataDataSource {
|
||||
name: string;
|
||||
kind:
|
||||
@ -1177,6 +1180,7 @@ export interface MetadataDataSource {
|
||||
extensions_schema?: string;
|
||||
// pro-only feature
|
||||
read_replicas?: SourceConnectionInfo[];
|
||||
connection_template?: SourceConnectionTemplate;
|
||||
connection_set?: ConnectionSet[];
|
||||
service_account?: BigQueryServiceAccount;
|
||||
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 queryCollectionMetadataHandlers } from '../features/QueryCollections';
|
||||
import { metadataHandlers as openTelemetryMetadataHandlers } from '../features/OpenTelemetry';
|
||||
import { metadataHandlers as dataMetadataHandlers } from '../features/Data';
|
||||
|
||||
import { TMigration } from '../features/MetadataAPI/hooks/useMetadataMigration';
|
||||
|
||||
@ -32,6 +33,7 @@ const metadataHandlers: Partial<Record<allowedMetadataTypes, MetadataReducer>> =
|
||||
...queryCollectionMetadataHandlers,
|
||||
...adhocEventMetadataHandlers,
|
||||
...openTelemetryMetadataHandlers,
|
||||
...dataMetadataHandlers,
|
||||
};
|
||||
|
||||
export const metadataReducer: MetadataReducer = (state, action) => {
|
||||
|
@ -5,6 +5,7 @@ import type { Metadata } from '../features/hasura-metadata-types';
|
||||
import { allowListInitialData } from '../features/AllowLists';
|
||||
import { queryCollectionInitialData } from '../features/QueryCollections';
|
||||
import { openTelemetryInitialData } from '../features/OpenTelemetry';
|
||||
import { dataInitialData } from '../features/Data';
|
||||
|
||||
import { rest } from 'msw';
|
||||
import { metadataReducer } from './actions';
|
||||
@ -18,6 +19,7 @@ export const createDefaultInitialData = (): Metadata => ({
|
||||
...allowListInitialData,
|
||||
...queryCollectionInitialData,
|
||||
...openTelemetryInitialData,
|
||||
...dataInitialData,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -8,7 +8,8 @@ export type BadgeColor =
|
||||
| 'indigo'
|
||||
| 'gray'
|
||||
| 'blue'
|
||||
| 'purple';
|
||||
| 'purple'
|
||||
| 'light-gray';
|
||||
interface BadgeProps extends React.ComponentProps<'span'> {
|
||||
/**
|
||||
* The color of the basge
|
||||
@ -24,6 +25,7 @@ const badgeClassnames: Record<BadgeColor, string> = {
|
||||
indigo: 'bg-indigo-100 text-indigo-800',
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
purple: 'bg-purple-100 text-purple-800',
|
||||
'light-gray': 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export const Badge: React.FC<React.PropsWithChildren<BadgeProps>> = ({
|
||||
|
Loading…
Reference in New Issue
Block a user