[feature branch] EE Lite Trials

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8208
Co-authored-by: awjchen <13142944+awjchen@users.noreply.github.com>
Co-authored-by: Vijay Prasanna <11921040+vijayprasanna13@users.noreply.github.com>
Co-authored-by: Toan Nguyen  <1615675+hgiasac@users.noreply.github.com>
Co-authored-by: Abhijeet Khangarot <26903230+abhi40308@users.noreply.github.com>
Co-authored-by: Solomon <24038+solomon-b@users.noreply.github.com>
Co-authored-by: gneeri <10553562+gneeri@users.noreply.github.com>
GitOrigin-RevId: 454ee0dea636da77e43810edb2f427137027956c
This commit is contained in:
Rishichandra Wawhal 2023-04-05 14:27:19 +05:30 committed by hasura-bot
parent f2b76b7da7
commit c6d65508b2
320 changed files with 12275 additions and 3296 deletions

View File

@ -91,6 +91,7 @@ constraints: any.Cabal ==3.6.3.0,
any.cryptohash-md5 ==0.11.101.0,
any.cryptohash-sha1 ==0.11.101.0,
any.cryptonite ==0.30,
any.cryptostore ==0.3.0.0,
any.data-binary-ieee754 ==0.4.4,
any.data-bword ==0.1.0.2,
any.data-checked ==0.3,
@ -197,6 +198,7 @@ constraints: any.Cabal ==3.6.3.0,
any.iso8601-time ==0.1.5,
any.isomorphism-class ==0.1.0.7,
any.jose ==0.9,
any.jwt ==0.11.0,
any.kan-extensions ==5.2.5,
any.keys ==3.12.3,
any.kriti-lang ==0.3.3,
@ -335,6 +337,7 @@ constraints: any.Cabal ==3.6.3.0,
any.tasty-bench ==0.3.2,
any.template-haskell ==2.18.0.0,
any.template-haskell-compat-v0208 ==0.1.9.1,
any.temporary ==1.3,
any.terminal-size ==0.3.3,
any.terminfo ==0.4.1.5,
any.text ==1.2.5.0,

View File

@ -1,6 +1,6 @@
{
"name": "@hasura/dc-api-types",
"version": "0.28.0",
"version": "0.29.0",
"description": "Hasura GraphQL Engine Data Connector Agent API types",
"author": "Hasura (https://github.com/hasura/graphql-engine)",
"license": "Apache-2.0",

View File

@ -507,6 +507,9 @@
"explain": {
"$ref": "#/components/schemas/ExplainCapabilities"
},
"licensing": {
"$ref": "#/components/schemas/Licensing"
},
"metrics": {
"$ref": "#/components/schemas/MetricsCapabilities"
},
@ -703,6 +706,7 @@
"ExplainCapabilities": {},
"RawCapabilities": {},
"DatasetCapabilities": {},
"Licensing": {},
"ConfigSchemaResponse": {
"nullable": false,
"properties": {

View File

@ -56,6 +56,7 @@ export type { GraphQLType } from './models/GraphQLType';
export type { InsertCapabilities } from './models/InsertCapabilities';
export type { InsertFieldSchema } from './models/InsertFieldSchema';
export type { InsertMutationOperation } from './models/InsertMutationOperation';
export type { Licensing } from './models/Licensing';
export type { MetricsCapabilities } from './models/MetricsCapabilities';
export type { MutationCapabilities } from './models/MutationCapabilities';
export type { MutationOperation } from './models/MutationOperation';

View File

@ -6,6 +6,7 @@ import type { ComparisonCapabilities } from './ComparisonCapabilities';
import type { DataSchemaCapabilities } from './DataSchemaCapabilities';
import type { DatasetCapabilities } from './DatasetCapabilities';
import type { ExplainCapabilities } from './ExplainCapabilities';
import type { Licensing } from './Licensing';
import type { MetricsCapabilities } from './MetricsCapabilities';
import type { MutationCapabilities } from './MutationCapabilities';
import type { QueryCapabilities } from './QueryCapabilities';
@ -19,6 +20,7 @@ export type Capabilities = {
data_schema?: DataSchemaCapabilities;
datasets?: DatasetCapabilities;
explain?: ExplainCapabilities;
licensing?: Licensing;
metrics?: MetricsCapabilities;
mutations?: MutationCapabilities;
queries?: QueryCapabilities;

View File

@ -0,0 +1,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Licensing = {
};

View File

@ -24,7 +24,7 @@
},
"dc-api-types": {
"name": "@hasura/dc-api-types",
"version": "0.28.0",
"version": "0.29.0",
"license": "Apache-2.0",
"devDependencies": {
"@tsconfig/node16": "^1.0.3",
@ -2227,7 +2227,7 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.28.0",
"@hasura/dc-api-types": "0.29.0",
"fastify": "^4.13.0",
"mathjs": "^11.0.0",
"pino-pretty": "^8.0.0",
@ -2547,7 +2547,7 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.28.0",
"@hasura/dc-api-types": "0.29.0",
"fastify": "^4.13.0",
"fastify-metrics": "^9.2.1",
"nanoid": "^3.3.4",
@ -2868,7 +2868,7 @@
"version": "file:reference",
"requires": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.28.0",
"@hasura/dc-api-types": "0.29.0",
"@tsconfig/node16": "^1.0.3",
"@types/node": "^16.11.49",
"@types/xml2js": "^0.4.11",
@ -3080,7 +3080,7 @@
"version": "file:sqlite",
"requires": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.28.0",
"@hasura/dc-api-types": "0.29.0",
"@tsconfig/node16": "^1.0.3",
"@types/node": "^16.11.49",
"@types/sqlite3": "^3.1.8",

View File

@ -10,7 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.28.0",
"@hasura/dc-api-types": "0.29.0",
"fastify": "^4.13.0",
"mathjs": "^11.0.0",
"pino-pretty": "^8.0.0",
@ -52,7 +52,7 @@
"integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ=="
},
"node_modules/@hasura/dc-api-types": {
"version": "0.28.0",
"version": "0.29.0",
"license": "Apache-2.0",
"devDependencies": {
"@tsconfig/node16": "^1.0.3",

View File

@ -22,7 +22,7 @@
},
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.28.0",
"@hasura/dc-api-types": "0.29.0",
"fastify": "^4.13.0",
"mathjs": "^11.0.0",
"pino-pretty": "^8.0.0",

View File

@ -10,7 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.28.0",
"@hasura/dc-api-types": "0.29.0",
"fastify": "^4.13.0",
"fastify-metrics": "^9.2.1",
"nanoid": "^3.3.4",
@ -57,7 +57,7 @@
"integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ=="
},
"node_modules/@hasura/dc-api-types": {
"version": "0.28.0",
"version": "0.29.0",
"license": "Apache-2.0",
"devDependencies": {
"@tsconfig/node16": "^1.0.3",

View File

@ -22,7 +22,7 @@
},
"dependencies": {
"@fastify/cors": "^8.1.0",
"@hasura/dc-api-types": "0.28.0",
"@hasura/dc-api-types": "0.29.0",
"fastify-metrics": "^9.2.1",
"fastify": "^4.13.0",
"nanoid": "^3.3.4",

View File

@ -13,5 +13,6 @@
// adding this setting per this discussion on github:
// https://github.com/nrwl/nx/issues/9465#issuecomment-1080093295
"typescript.preferences.importModuleSpecifier": "project-relative"
"typescript.preferences.importModuleSpecifier": "project-relative",
"git.ignoreLimitWarning": true
}

View File

@ -1,7 +1,7 @@
import type { Metadata } from '@hasura/console-legacy-ce';
import { readMetadata } from '../../../utils/checkMetadataPayload';
describe('Dynamic Db Routing', () => {
xdescribe('Dynamic Db Routing', () => {
before(() => {
cy.visit('/data/manage/connect/');

View File

@ -69,10 +69,17 @@ export {
addUserProperties,
programmaticallyTraceError,
REDACT_EVERYTHING,
InitializeTelemetry,
} from './lib/features/Analytics';
export { CloudOnboarding } from './lib/features/CloudOnboarding';
export { prefetchSurveysData } from './lib/features/Surveys';
export { prefetchOnboardingData } from './lib/features/CloudOnboarding/OnboardingWizard';
export {
prefetchEELicenseInfo,
NavbarButton as EntepriseNavbarButton,
WithEELiteAccess,
useEELiteAccess,
} from './lib/features/EETrial';
export { default as PageNotFound } from './lib/components/Error/PageNotFound';
export * from './lib/new-components/Button/';
export * from './lib/new-components/Tooltip/';
@ -130,6 +137,10 @@ export {
export { ReactQueryProvider, reactQueryClient } from './lib/lib/reactQuery';
export { PrometheusSettings } from './lib/features/Prometheus';
export { QueryResponseCaching } from './lib/features/QueryResponseCaching';
export { MultipleAdminSecretsPage } from './lib/features/EETrial';
export { MultipleJWTSecretsPage } from './lib/features/EETrial';
export { SingleSignOnPage } from './lib/features/EETrial';
export { OpenTelemetryFeature } from './lib/features/OpenTelemetry';

View File

@ -13,6 +13,8 @@ export const getEndpoints = (globals: typeof consoleGlobals) => {
graphQLUrl: `${baseUrl}/v1/graphql`,
relayURL: `${baseUrl}/v1beta1/relay`,
query: `${baseUrl}/v2/query`,
entitlement: `${baseUrl}/v1/entitlement`,
license: `${baseUrl}/v1/entitlement/license`,
metadata: `${baseUrl}/v1/metadata`,
// metadata: `${baseUrl}/v1/query`,
queryV2: `${baseUrl}/v2/query`,
@ -30,6 +32,8 @@ export const getEndpoints = (globals: typeof consoleGlobals) => {
globals.luxDataHost
}/v1/graphql`,
prometheusUrl: `${baseUrl}/v1/metrics`,
registerEETrial: `https://licensing.pro.hasura.io/v1/graphql`,
// registerEETrial: `http://licensing.lux-dev.hasura.me/v1/graphql`,
};
return endpoints;

View File

@ -1561,8 +1561,6 @@ code {
margin-bottom: 2px;
align-self: flex-end;
padding-top: 8px;
border-color: rgba(0, 0, 0, 0.1);
border-top-width: 1px;
position: sticky;
bottom: 0;
background-color: #f8fafb;

View File

@ -16,6 +16,7 @@ import { verifyLogin } from './Actions';
import { CLI_CONSOLE_MODE } from '../../constants';
import { getAdminSecret } from '../Services/ApiExplorer/ApiRequest/utils';
import { ConnectInjectedProps } from '../../types';
import { isProConsole } from '../../utils/proConsole';
import hasuraLogo from './black-logo.svg';
import hasuraEELogo from './black-logo-ee.svg';
@ -149,12 +150,11 @@ const Login: React.FC<ConnectInjectedProps> = ({ dispatch, children }) => {
);
};
const showLogo =
globals.consoleType === 'pro' || globals.consoleType === 'pro-lite' ? (
<img className="flex w-36 mx-auto" src={hasuraEELogo} alt="Hasura EE" />
) : (
<img src={hasuraLogo} alt="Hasura" />
);
const showLogo = isProConsole(globals) ? (
<img className="flex w-36 mx-auto" src={hasuraEELogo} alt="Hasura EE" />
) : (
<img src={hasuraLogo} alt="Hasura" />
);
return (
<div className="flex justify-center items-center min-h-screen bg-gray-100">

View File

@ -13,7 +13,7 @@ class Container extends React.Component {
render() {
const { children } = this.props;
const currentLocation = location.pathname;
const currentLocation = window.location.pathname;
const sidebarContent = (
<ul className="bootstrap-jail">
@ -26,7 +26,11 @@ class Container extends React.Component {
<Link className={styles.linkBorder} to={appPrefix + '/manage'}>
Manage
</Link>
<LeftSidebar appPrefix={appPrefix} {...this.props} />
<LeftSidebar
appPrefix={appPrefix}
{...this.props}
allowOpenApiImport
/>
</li>
<li
role="presentation"

View File

@ -8,7 +8,6 @@ import { appPrefix, pageTitle } from '../constants';
import globals from '../../../../Globals';
import { Button } from '../../../../new-components/Button';
import TopicDescription from '../../Common/Landing/TopicDescription';
import { isImportFromOpenAPIEnabled } from '../../../../utils';
import { FaEdit, FaFileImport } from 'react-icons/fa';
import { Badge } from '../../../../new-components/Badge';
@ -65,29 +64,25 @@ class Landing extends React.Component {
<div className={'flex'}>
<h2 className="font-bold text-3xl pr-3">Actions</h2>
{getAddBtn()}
{isImportFromOpenAPIEnabled(window.__env) && (
<Analytics
name="action-tab-btn-import-action-from-openapi"
passHtmlAttributesToChildren
<Analytics
name="action-tab-btn-import-action-from-openapi"
passHtmlAttributesToChildren
>
<Button
icon={<FaFileImport />}
className="ml-2"
onClick={() => {
dispatch(
push(`${globals.urlPrefix}${appPrefix}/manage/add-oas`)
);
}}
>
<Button
icon={<FaFileImport />}
className="ml-2"
onClick={() => {
dispatch(
push(
`${globals.urlPrefix}${appPrefix}/manage/add-oas`
)
);
}}
>
Import from OpenAPI
<Badge className="ml-2 font-xs" color="purple">
New
</Badge>
</Button>
</Analytics>
)}
Import from OpenAPI
<Badge className="ml-2 font-xs" color="purple">
New
</Badge>
</Button>
</Analytics>
</div>
<hr className="mt-5 mb-5" />
{getIntroSection()}

View File

@ -6,7 +6,6 @@ import { browserHistory, Link } from 'react-router';
import LeftSubSidebar from '../../../Common/Layout/LeftSubSidebar/LeftSubSidebar';
import styles from '../../../Common/Layout/LeftSubSidebar/LeftSubSidebar.module.scss';
import { isProConsole } from '../../../../utils';
import { Badge } from '../../../../new-components/Badge';
import globals from '../../../../Globals';
@ -15,6 +14,7 @@ const LeftSidebar = ({
common: { currentAction },
actions,
readOnlyMode,
allowOpenApiImport,
}) => {
const [searchText, setSearchText] = React.useState('');
@ -109,7 +109,7 @@ const LeftSidebar = ({
addTestString={'actions-sidebar-add-table'}
childListTestString={'actions-table-links'}
addBtn={
isProConsole(window.__env) ? (
allowOpenApiImport ? (
<div
className={`col-xs-4 text-center ${styles.padd_left_remove} ${styles.sidebarCreateTable}`}
>

View File

@ -13,9 +13,11 @@ import {
AllowListSidebar,
AllowListPermissions,
} from '../../../features/AllowLists';
import { EETrialCard, useEELiteAccess } from '../../../features/EETrial';
import PageContainer from '../../Common/Layout/PageContainer/PageContainer';
import { isProConsole } from '../../../utils/proConsole';
import globals from '../../../Globals';
interface AllowListDetailProps {
params: {
@ -33,7 +35,7 @@ export const pushUrl = (name: string, section: string) => {
export const AllowListDetail: React.FC<AllowListDetailProps> = props => {
const { name, section } = props.params;
const { access: eeLiteAccess } = useEELiteAccess(globals);
const {
data: queryCollections,
isLoading,
@ -54,6 +56,11 @@ export const AllowListDetail: React.FC<AllowListDetailProps> = props => {
pushUrl(queryCollections[0].name, section ?? 'operations');
}
const isFeatureActive = isProConsole(globals) || eeLiteAccess === 'active';
const isFeatureSupported =
isProConsole(globals) || eeLiteAccess !== 'forbidden';
const isEELiteContext = eeLiteAccess !== 'forbidden';
return (
<Analytics name="AllowList" {...REDACT_EVERYTHING}>
<div className="flex flex-auto overflow-y-hidden h-[calc(100vh-35.49px-54px)]">
@ -90,7 +97,7 @@ export const AllowListDetail: React.FC<AllowListDetailProps> = props => {
/>
</div>
)}
{isProConsole(window.__env) ? (
{isFeatureSupported ? (
<Tabs
value={section}
onValueChange={value => {
@ -111,7 +118,22 @@ export const AllowListDetail: React.FC<AllowListDetailProps> = props => {
label: 'Permissions',
content: (
<div className="p-4">
<AllowListPermissions collectionName={name} />
{isFeatureActive ? (
<AllowListPermissions collectionName={name} />
) : (
isEELiteContext && (
<div className="max-w-3xl">
<EETrialCard
id="allow-list-role-based-permission"
cardTitle="Looking to add role based permissions to your Allow List?"
cardText="Get production-ready today with a 30-day free trial of Hasura EE, no credit card required."
buttonType="default"
eeAccess={eeLiteAccess}
horizontal
/>
</div>
)
)}
</div>
),
},

View File

@ -25,6 +25,7 @@ import {
import { showErrorNotification } from '../../Common/Notification';
import ToolTip from '../../../Common/Tooltip/Tooltip';
import { getActionsCreateRoute } from '../../../Common/utils/routesUtils';
import { getQueryResponseCachingRoute } from '../../../../utils/routeUtils';
import { getConfirmation } from '../../../Common/utils/jsUtils';
import {
setActionDefinition,
@ -33,7 +34,9 @@ import {
} from '../../Actions/Add/reducer';
import { getGraphQLEndpoint } from '../utils';
import snippets from './snippets';
import { canAccessCacheButton } from '../../../../utils/permissions';
import globals from '../../../../Globals';
import { WithEELiteAccess } from '../../../../features/EETrial';
import { isProConsole } from '../../../../utils/proConsole';
import './GraphiQL.css';
import _push from '../../Data/push';
@ -188,10 +191,18 @@ class GraphiQLWrapper extends Component {
const _toggleCacheDirective = () => {
trackGraphiQlToolbarButtonClick('Cache');
const editor = graphiqlContext.getQueryEditor();
const operationString = editor.getValue();
const cacheToggledOperationString = toggleCacheDirective(operationString);
editor.setValue(cacheToggledOperationString);
try {
const editor = graphiqlContext.getQueryEditor();
const operationString = editor.getValue();
const cacheToggledOperationString =
toggleCacheDirective(operationString);
editor.setValue(cacheToggledOperationString);
} catch {
// throw a generic error
throw new Error(
'Caching directives can only be added to valid GraphQL queries.'
);
}
};
const renderGraphiqlFooter = responseTime &&
@ -237,7 +248,7 @@ class GraphiQLWrapper extends Component {
}
// get toolbar buttons
const getGraphiqlButtons = () => {
const getGraphiqlButtons = eeLiteAccess => {
const routeToREST = createRouteToREST(graphiqlProps);
const buttons = [
@ -262,8 +273,18 @@ class GraphiQLWrapper extends Component {
{
label: 'Cache',
title: 'Cache the response of this query',
onClick: _toggleCacheDirective,
hide: !canAccessCacheButton(),
onClick: () => {
if (eeLiteAccess === 'active' || isProConsole(globals)) {
// toggle cache directive only if it is cloud/ee-classic/ee-lite-active
_toggleCacheDirective();
} else {
// send to the query-response-caching page if the EE lite trial isn't active
if (eeLiteAccess !== 'forbidden') {
dispatch(_push(getQueryResponseCachingRoute()));
}
}
},
hide: !isProConsole(globals) && eeLiteAccess === 'forbidden',
},
{
label: 'Code Exporter',
@ -306,7 +327,11 @@ class GraphiQLWrapper extends Component {
>
<GraphiQL.Logo>GraphiQL</GraphiQL.Logo>
<GraphiQL.Toolbar>
{getGraphiqlButtons()}
<WithEELiteAccess globals={globals}>
{({ access: eeLiteAccess }) => {
return getGraphiqlButtons(eeLiteAccess);
}}
</WithEELiteAccess>
<AnalyzeButton
operations={graphiqlContext && graphiqlContext.state.operations}
analyzeFetcher={analyzeFetcherInstance}

View File

@ -3,6 +3,7 @@ import Helmet from 'react-helmet';
import CommonTabLayout from '../../../Common/Layout/CommonTabLayout/CommonTabLayout';
import { RightContainer } from '../../../Common/Layout/RightContainer';
import styles from '../../../Common/Common.module.scss';
import { ApiSecurityTabEELiteWrapper } from '../../../../features/EETrial';
const appPrefix = `/api`;
@ -32,21 +33,23 @@ export const SecurityTabs: React.FC<{ tabName: keyof typeof tabs }> = ({
return (
<RightContainer>
<Helmet title={`${tabs[tabName].display_text} - Hasura`} />
<div
className={`${styles.view_stitch_schema_wrapper} ${styles.addWrapper}`}
>
<CommonTabLayout
appPrefix={appPrefix}
currentTab={tabName}
heading={tabs[tabName].display_text}
tabsInfo={tabs}
breadCrumbs={breadCrumbs}
baseUrl={`${appPrefix}/security`}
showLoader={false}
testPrefix="security-features-tabs"
/>
<div className={styles.add_pad_top}>{children}</div>
</div>
<ApiSecurityTabEELiteWrapper>
<div
className={`${styles.view_stitch_schema_wrapper} ${styles.addWrapper}`}
>
<CommonTabLayout
appPrefix={appPrefix}
currentTab={tabName}
heading={tabs[tabName].display_text}
tabsInfo={tabs}
breadCrumbs={breadCrumbs}
baseUrl={`${appPrefix}/security`}
showLoader={false}
testPrefix="security-features-tabs"
/>
<div className={styles.add_pad_top}>{children}</div>
</div>
</ApiSecurityTabEELiteWrapper>
</RightContainer>
);
};

View File

@ -1,12 +1,16 @@
import React from 'react';
import { Link, RouteComponentProps } from 'react-router';
import { canAccessSecuritySettings } from '../../../utils/permissions';
import { isProConsole } from '../../../utils/proConsole';
import { useEELiteAccess } from '../../../features/EETrial';
import globals from '../../../Globals';
type TopNavProps = {
location: RouteComponentProps<unknown, unknown>['location'];
};
const TopNav: React.FC<TopNavProps> = ({ location }) => {
const { access: eeLiteAccess } = useEELiteAccess(globals);
const sectionsData = [
[
{
@ -32,7 +36,7 @@ const TopNav: React.FC<TopNavProps> = ({ location }) => {
],
];
if (canAccessSecuritySettings()) {
if (isProConsole(globals) || eeLiteAccess !== 'forbidden') {
sectionsData[1].push({
key: 'security',
link: '/api/security/api_limits',

View File

@ -1,41 +1,41 @@
import React from 'react';
import { Route, IndexRedirect } from 'react-router';
import { IndexRedirect, Route } from 'react-router';
import globals from '../../../Globals';
import { SERVER_CONSOLE_MODE } from '../../../constants';
import globals from '../../../Globals';
import {
schemaConnector,
rawSQLConnector,
addExistingTableViewConnector,
addTableConnector,
ConnectedCreateDataSourcePage,
ConnectedDatabaseManagePage,
dataPageConnector,
FunctionPermissions,
functionWrapperConnector,
migrationsConnector,
ModifyCustomFunction,
modifyViewConnector,
permissionsConnector,
permissionsSummaryConnector,
rawSQLConnector,
relationshipsConnector,
relationshipsViewConnector,
permissionsConnector,
dataPageConnector,
migrationsConnector,
functionWrapperConnector,
permissionsSummaryConnector,
ModifyCustomFunction,
FunctionPermissions,
ConnectedDatabaseManagePage,
ConnectedCreateDataSourcePage,
schemaConnector,
} from '.';
import { Connect } from '../../../features/ConnectDB';
import { ConnectUIContainer } from '../../../features/ConnectDBRedesign';
import { ConnectDatabaseRouteWrapper } from '../../../features/ConnectDBRedesign/ConnectDatabase.route';
import { ManageDatabaseContainer } from '../../../features/Data';
import { ManageTable } from '../../../features/Data/ManageTable';
import { setDriver } from '../../../dataSources';
import { exportMetadata } from '../../../metadata/actions';
import { getSourcesFromMetadata } from '../../../metadata/selector';
import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions';
import ConnectedDataSourceContainer from './DataSourceContainer';
import ConnectDatabase from './DataSources/ConnectDatabase';
import { setDriver } from '../../../dataSources';
import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions';
import { getSourcesFromMetadata } from '../../../metadata/selector';
import { ManageDatabaseContainer } from '../../../features/Data';
import { Connect } from '../../../features/ConnectDB';
import { TableBrowseRowsContainer } from './TableBrowseRows/TableBrowseRowsContainer';
import { TableEditItemContainer } from './TableEditItem/TableEditItemContainer';
import { TableInsertItemContainer } from './TableInsertItem/TableInsertItemContainer';
import { ModifyTableContainer } from './TableModify/ModifyTableContainer';
import { TableEditItemContainer } from './TableEditItem/TableEditItemContainer';
import { TableBrowseRowsContainer } from './TableBrowseRows/TableBrowseRowsContainer';
import { ManageTable } from '../../../features/Data/ManageTable';
const makeDataRouter = (
connect,
@ -56,6 +56,9 @@ const makeDataRouter = (
<Route path="v2">
<Route path="manage">
<Route path="connect" component={ConnectDatabaseRouteWrapper} />
<Route path="database/add" component={ConnectUIContainer} />
<Route path="database/edit" component={ConnectUIContainer} />
<Route path="table" component={ManageTable}>
<IndexRedirect to="modify" />
<Route path=":operation" component={ManageTable} />

View File

@ -7,8 +7,9 @@ import React, {
useEffect,
} from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { canAccessReadReplica } from '../../../../utils/permissions';
import { isPostgres } from '../../../../metadata/dataSource.utils';
import { useEELiteAccess } from '../../../../features/EETrial';
import globals from '../../../../Globals';
import Tabbed from './TabbedDataSourceConnection';
import { ReduxState } from '../../../../types';
@ -39,6 +40,7 @@ import { getSupportedDrivers } from '../../../../dataSources';
import { Tabs } from '../../../../new-components/Tabs';
import { DynamicDBRouting } from '../../../../features/ConnectDBRedesign/components/ConnectPostgresWidget/parts/DynamicDBRouting';
import { isDynamicDBRoutingEnabled } from '../../../../utils/proConsole';
import { canAccessReadReplica as isReadReplicaAccessible } from '../../../../utils';
type ConnectDatabaseProps = InjectedProps;
@ -50,6 +52,13 @@ const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
getDefaultState(props)
);
const { access: eeLiteAccess } = useEELiteAccess(globals);
// the first case is only for pro/cloud console. the second expression is for pro-lite
const canAccessReadReplica =
isReadReplicaAccessible(connectDBInputState.dbType) ||
(eeLiteAccess !== 'forbidden' && connectDBInputState.dbType !== 'bigquery');
const [connectionType, changeConnectionType] = useState(
props.dbConnection.envVar
? connectionTypes.ENV_VAR
@ -404,7 +413,7 @@ const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
{getSupportedDrivers('connectDbForm.read_replicas.edit').includes(
connectDBInputState.dbType
) &&
canAccessReadReplica(connectDBInputState.dbType) && (
canAccessReadReplica && (
<ReadReplicaForm
readReplicaState={readReplicasState}
readReplicaDispatch={readReplicaDispatch}
@ -464,7 +473,7 @@ const ConnectDatabase: React.FC<ConnectDatabaseProps> = props => {
{getSupportedDrivers('connectDbForm.read_replicas.create').includes(
connectDBInputState.dbType
) &&
canAccessReadReplica(connectDBInputState.dbType) && (
canAccessReadReplica && (
<ReadReplicaForm
readReplicaState={readReplicasState}
readReplicaDispatch={readReplicaDispatch}

View File

@ -0,0 +1,49 @@
import * as React from 'react';
import { Dispatch } from '../../../../../../types';
import { reactQueryClient } from '../../../../../../lib/reactQuery';
import { NeonBanner } from './components/Neon/NeonBanner';
import {
getNeonDBName,
transformNeonIntegrationStatusToNeonBannerProps,
} from './utils';
import { useNeonIntegration } from './useNeonIntegration';
import _push from '../../../push';
import { FETCH_NEON_PROJECTS_BY_PROJECTID_QUERYKEY } from './components/NeonDashboardLink';
type NeonConnectProps = {
allDatabases: string[];
dispatch: Dispatch;
connectDbUrl?: string;
};
// This component deals with Neon DB creation on connect DB page
export function NeonConnect({
dispatch,
allDatabases,
connectDbUrl = '/data/manage/connect',
}: NeonConnectProps) {
// success callback
const pushToDataSource = (dataSourceName: string) => {
// on success, refetch queries to show neon dashboard link in connect database page,
// overriding the stale time
reactQueryClient.refetchQueries(FETCH_NEON_PROJECTS_BY_PROJECTID_QUERYKEY);
dispatch(_push(`/data/${dataSourceName}/schema/public`));
};
const pushToConnectDBPage = () => {
dispatch(_push(connectDbUrl));
};
const neonIntegrationStatus = useNeonIntegration(
getNeonDBName(allDatabases),
pushToDataSource,
pushToConnectDBPage,
dispatch,
'data-manage-create'
);
const neonBannerProps = transformNeonIntegrationStatusToNeonBannerProps(
neonIntegrationStatus
);
return <NeonBanner {...neonBannerProps} />;
}

View File

@ -1,44 +1,2 @@
import * as React from 'react';
import { Dispatch } from '../../../../../../types';
import { reactQueryClient } from '../../../../../../lib/reactQuery';
import { NeonBanner } from './components/Neon/NeonBanner';
import {
getNeonDBName,
transformNeonIntegrationStatusToNeonBannerProps,
} from './utils';
import { useNeonIntegration } from './useNeonIntegration';
import _push from '../../../push';
import { FETCH_NEON_PROJECTS_BY_PROJECTID_QUERYKEY } from './components/NeonDashboardLink';
// This component deals with Neon DB creation on connect DB page
export function Neon(props: { allDatabases: string[]; dispatch: Dispatch }) {
const { dispatch, allDatabases } = props;
// success callback
const pushToDatasource = (dataSourceName: string) => {
// on success, refetch queries to show neon dashboard link in connect database page,
// overriding the stale time
reactQueryClient.refetchQueries(FETCH_NEON_PROJECTS_BY_PROJECTID_QUERYKEY);
dispatch(_push(`/data/${dataSourceName}/schema/public`));
};
const pushToConnectDBPage = () => {
dispatch(_push(`/data/manage/connect`));
};
const neonIntegrationStatus = useNeonIntegration(
getNeonDBName(allDatabases),
pushToDatasource,
pushToConnectDBPage,
dispatch,
'data-manage-create'
);
const neonBannerProps = transformNeonIntegrationStatusToNeonBannerProps(
neonIntegrationStatus
);
return <NeonBanner {...neonBannerProps} />;
}
export { NeonConnect } from './NeonConnect';
export { useNeonIntegration } from './useNeonIntegration';

View File

@ -12,7 +12,7 @@ import { mapDispatchToPropsEmpty } from '../../../../Common/utils/reactUtils';
import Tabbed from '../TabbedDataSourceConnection';
import { NotFoundError } from '../../../../Error/PageNotFound';
import { getDataSources } from '../../../../../metadata/selector';
import { Neon } from './Neon';
import { NeonConnect } from './Neon';
type Props = InjectedProps;
@ -28,7 +28,7 @@ const CreateDataSource: React.FC<Props> = ({ dispatch, allDataSources }) => {
<div className={styles.connect_db_content}>
<div className={`${styles.container} mb-md`}>
<div className="w-full mb-md">
<Neon
<NeonConnect
allDatabases={allDataSources.map(d => d.name)}
dispatch={dispatch}
/>
@ -52,3 +52,4 @@ const connector = connect(mapStateToProps, mapDispatchToPropsEmpty);
type InjectedProps = ConnectedProps<typeof connector>;
const ConnectedCreateDataSourcePage = connector(CreateDataSource);
export default ConnectedCreateDataSourcePage;
export { NeonConnect };

View File

@ -110,7 +110,7 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
<Analytics name="EditDataSource" {...REDACT_EVERYTHING}>
<form
onSubmit={onSubmit}
className={`${styles.connect_db_content} ${styles.connect_form_width}`}
className={`${styles.connect_db_content} max-w-screen-md`}
>
<div className="max-w-xl">
{!isReadReplica && (
@ -154,12 +154,13 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
<ConnectDatabaseForm isEditState={isEditState} {...props} />
{children}
<div className={styles.add_button_layout}>
<div>
<Analytics
name="data-tab-connect-db-button"
passHtmlAttributesToChildren
>
<Button
className="mt-sm"
size="lg"
mode="primary"
type="submit"

View File

@ -17,9 +17,13 @@ import {
makeConnectionStringFromConnectionParams,
parseURI,
} from './ManageDBUtils';
import { useEELiteAccess } from '../../../../features/EETrial';
import styles from './DataSources.module.scss';
import { LearnMoreLink } from '../../../../new-components/LearnMoreLink';
import { EETrialCard } from '../../../../features/EETrial/components/EETrialCard/EETrialCard';
import globals from '../../../../Globals';
import { isProConsole } from '../../../../utils';
const checkIfFieldsAreEmpty = (
currentReadReplicaConnectionType: string,
@ -198,6 +202,7 @@ const ReadReplicaForm: React.FC<ReadReplicaProps> = ({
readReplicaConnectionType,
updateReadReplicaConnectionType,
}) => {
const { access: eeLiteAccess } = useEELiteAccess(globals);
const [isReadReplicaButtonClicked, updateClickState] = useState(false);
const onClickAddReadReplica = () => {
@ -229,52 +234,80 @@ const ReadReplicaForm: React.FC<ReadReplicaProps> = ({
return (
<>
<Analytics name="EditDataSource" {...REDACT_EVERYTHING}>
<Collapse title="Read Replicas">
<Collapse.Content>
<div className={`${styles.flexColumn} my-1.5`}>
<p>
Hasura can load balance queries and subscriptions across read
replicas while sending all mutations and metadata API calls to
the master.
<LearnMoreLink href="https://hasura.io/docs/latest/graphql/cloud/read-replicas.html" />
</p>
{readReplicaState.map((stateVar, index) => (
<ReadReplicaListItem
currentState={stateVar}
onClickRemove={onClickRemoveReadReplica(stateVar.displayName)}
key={index}
/>
))}
{!isReadReplicaButtonClicked ? (
<span className="py-1.5">
<Button
size="sm"
onClick={onClickAddReadReplica}
className={styles.add_button_styles}
>
Add Read Replica
</Button>
</span>
) : (
<Form
connectDBState={connectDBState}
connectDBStateDispatch={connectDBStateDispatch}
onClickCancel={onClickCancelOnReadReplica}
onClickSave={onClickSaveReadReplica}
readReplicaConnectionType={readReplicaConnectionType}
updateReadReplicaConnectionType={
updateReadReplicaConnectionType
}
/>
)}
</div>
</Collapse.Content>
</Collapse>
</Analytics>
<Analytics name="EditDataSource" {...REDACT_EVERYTHING}>
<hr className={styles.line_width} />
</Analytics>
{
// eslint-disable-next-line no-underscore-dangle
eeLiteAccess !== 'forbidden' || isProConsole(globals) ? (
<Analytics name="EditDataSource" {...REDACT_EVERYTHING}>
<Collapse
title="Read Replicas"
defaultOpen={eeLiteAccess !== 'active'}
>
<Collapse.Content>
<div className={`${styles.flexColumn} my-1.5`}>
<p>
Hasura can load balance queries and subscriptions across
read replicas while sending all mutations and metadata API
calls to the master.
<LearnMoreLink href="https://hasura.io/docs/latest/graphql/cloud/read-replicas.html" />
</p>
{readReplicaState.map((stateVar, index) => (
<ReadReplicaListItem
currentState={stateVar}
onClickRemove={onClickRemoveReadReplica(
stateVar.displayName
)}
key={index}
/>
))}
{eeLiteAccess === 'eligible' ||
eeLiteAccess === 'expired' ||
eeLiteAccess === 'deactivated' ? (
<EETrialCard
cardTitle="Improve performance and handle increased traffic with read replicas"
id="read-replicas-legacy"
cardText={
<span>
Scale your database by offloading read queries to
read-only replicas, allowing for better performance
and availability for users.
</span>
}
buttonLabel="Enable Enterprise"
buttonType="default"
eeAccess={eeLiteAccess}
horizontal
/>
) : null}
{(isProConsole(globals) || eeLiteAccess === 'active') &&
(!isReadReplicaButtonClicked ? (
<span className="py-1.5">
<Button
size="sm"
onClick={onClickAddReadReplica}
className={styles.add_button_styles}
>
Add Read Replica
</Button>
</span>
) : (
<Form
connectDBState={connectDBState}
connectDBStateDispatch={connectDBStateDispatch}
onClickCancel={onClickCancelOnReadReplica}
onClickSave={onClickSaveReadReplica}
readReplicaConnectionType={readReplicaConnectionType}
updateReadReplicaConnectionType={
updateReadReplicaConnectionType
}
/>
))}
</div>
</Collapse.Content>
</Collapse>
</Analytics>
) : null
}
<Analytics name="EditDataSource" children={null} {...REDACT_EVERYTHING} />
</>
);
};

View File

@ -59,8 +59,14 @@ import {
useVPCBannerVisibility,
} from './utils';
import { NeonDashboardLink } from '../DataSources/CreateDataSource/Neon/components/NeonDashboardLink';
import { getRoute } from '../../../../utils/getDataRoute';
import {
availableFeatureFlagIds,
useIsFeatureFlagEnabled,
} from '../../../../features/FeatureFlags';
import { Collapsible } from '../../../../new-components/Collapsible';
import { IconTooltip } from '../../../../new-components/Tooltip';
import { ListConnectedDatabases } from '../../../../features/ConnectDBRedesign';
const KNOW_MORE_PROJECT_REGION_UPDATE =
'https://hasura.io/docs/latest/projects/regions/#changing-region-of-an-existing-project';
@ -271,8 +277,15 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
dataHeaders,
sourcesFromMetadata,
}) => {
const { enabled: isConnectDBRedesignEnabled, isLoading } =
useIsFeatureFlagEnabled(availableFeatureFlagIds.connectDBRedesign);
useEffect(() => {
if (sourcesFromMetadata.length === 0 && !autoRedirectedToConnectPage) {
if (
sourcesFromMetadata.length === 0 &&
!autoRedirectedToConnectPage &&
!isLoading
) {
/**
* Because the getDataSources() doesn't list the GDC sources, the Data tab will redirect to the /connect page
* thinking that are no sources available in Hasura, even if there are GDC sources connected to it. Modifying getDataSources()
@ -280,10 +293,21 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
* So a quick workaround is to check from the actual metadata if any sources are present -
* Combined with checks between getDataSources() and metadata -> we know the remaining sources are GDC sources. In such a case redirect to the manage db route
*/
dispatch(_push('/data/manage/connect'));
if (isConnectDBRedesignEnabled)
dispatch(_push('/data/v2/manage/connect'));
else {
dispatch(_push('/data/manage/connect'));
}
autoRedirectedToConnectPage = true;
}
}, [location, dataSources, dispatch]);
}, [
location,
dataSources,
dispatch,
sourcesFromMetadata.length,
isConnectDBRedesignEnabled,
isLoading,
]);
const { show: shouldShowVPCBanner, dismiss: dismissVPCBanner } =
useVPCBannerVisibility();
@ -323,7 +347,9 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
};
const onClickConnectDB = () => {
dispatch(_push('/data/manage/connect'));
isConnectDBRedesignEnabled
? dispatch(_push(getRoute().connectDatabase()))
: dispatch(_push('/data/manage/connect'));
};
const pushRoute = (route: string) => {
@ -416,6 +442,7 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
data-test="manage-database-section"
>
<BreadCrumb breadCrumbs={crumbs} />
<div className={styles.padd_top}>
<div className={`${styles.display_flex} manage-db-header`}>
<h2
@ -436,157 +463,169 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
<VPCBanner className="mt-md" onClose={dismissVPCBanner} />
)}
</div>
<div className={styles.manage_db_content}>
<hr className="my-md" />
<div className="overflow-x-auto border border-gray-300 rounded">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<th className="px-sm py-xs max-w-xs text-left text-sm bg-gray-50 font-semibold text-gray-600 uppercase tracking-wider" />
<th className="px-sm py-xs text-left text-sm bg-gray-50 font-semibold text-gray-600 uppercase tracking-wider">
Database
</th>
<th className="px-sm py-xs text-left text-sm bg-gray-50 font-semibold text-gray-600 uppercase tracking-wider">
Connection String
</th>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sourcesFromMetadata.length ? (
sourcesFromMetadata.map(source => {
if (nativeDrivers.includes(source.kind)) {
const data = dataSources.find(
s => s.name === source.name
);
if (!data) return null;
<hr className="mt-sm" />
return (
<DatabaseListItem
key={data.name}
dataSource={data}
inconsistentObjects={inconsistentObjects}
pushRoute={pushRoute}
onEdit={onEdit}
onReload={onReload}
onRemove={onRemove}
dispatch={dispatch}
dataHeaders={dataHeaders}
dbLatencyData={
isCloudConsole(globals)
? getSourceInfoFromLatencyData(
data.name,
latencyCheckData
)
: undefined
}
/>
);
}
return (
<GDCDatabaseListItem
dataSource={{
name: source.name,
kind: source.kind,
}}
inconsistentObjects={inconsistentObjects}
dispatch={dispatch}
/>
);
})
) : (
<td colSpan={3} className="text-center px-sm py-xs">
You don&apos;t have any data sources connected, please
connect one to continue.
</td>
)}
</tbody>
</table>
{isConnectDBRedesignEnabled ? (
<div className="mt-sm">
<ListConnectedDatabases />
</div>
</div>
) : (
<>
<div className={styles.manage_db_content}>
<div className="overflow-x-auto border border-gray-300 rounded">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<th className="px-sm py-xs max-w-xs text-left text-sm bg-gray-50 font-semibold text-gray-600 uppercase tracking-wider" />
<th className="px-sm py-xs text-left text-sm bg-gray-50 font-semibold text-gray-600 uppercase tracking-wider">
Database
</th>
<th className="px-sm py-xs text-left text-sm bg-gray-50 font-semibold text-gray-600 uppercase tracking-wider">
Connection String
</th>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sourcesFromMetadata.length ? (
sourcesFromMetadata.map(source => {
if (nativeDrivers.includes(source.kind)) {
const data = dataSources.find(
s => s.name === source.name
);
if (!data) return null;
{showCheckLatencyButton ? (
<Button
size="md"
className="mt-xs mr-xs"
icon={<FaHourglassHalf />}
onClick={checkDatabaseLatency}
isLoading={queryResponse.isLoading}
loadingText="Measuring Latencies..."
>
Check Database Latency
</Button>
) : null}
{showAccelerateProjectSection ? (
<div className="mt-xs">
<IndicatorCard
status="negative"
headline="Accelerate your Hasura Project"
>
<div className="flex items-center flex-row">
<span>
Databases marked with Elevated Latency indicate that it
took us over 200 ms for this Hasura project to communicate
with your database. These conditions generally happen when
databases and projects are in geographically distant
regions. This can cause API and subsequently application
performance issues. We want your GraphQL APIs to be{' '}
<b>lightning fast</b>, therefore we recommend that you
either deploy your Hasura project in the same region as your
database or select a database instance that&apos;s closer to
where you&apos;ve deployed Hasura.
<LearnMoreLink href={KNOW_MORE_PROJECT_REGION_UPDATE} />
</span>
<div className="flex items-center flex-row ml-xs">
<Button
className="mr-xs"
onClick={checkDatabaseLatency}
isLoading={queryResponse.isLoading}
loadingText="Measuring Latencies..."
icon={<FaRedoAlt />}
>
Re-check Database Latency
</Button>
<Button
className="mr-xs"
onClick={openUpdateProjectRegionPage}
icon={<FaExternalLinkAlt />}
>
Update Project Region
</Button>
</div>
return (
<DatabaseListItem
key={data.name}
dataSource={data}
inconsistentObjects={inconsistentObjects}
pushRoute={pushRoute}
onEdit={onEdit}
onReload={onReload}
onRemove={onRemove}
dispatch={dispatch}
dataHeaders={dataHeaders}
dbLatencyData={
isCloudConsole(globals)
? getSourceInfoFromLatencyData(
data.name,
latencyCheckData
)
: undefined
}
/>
);
}
return (
<GDCDatabaseListItem
dataSource={{
name: source.name,
kind: source.kind,
}}
inconsistentObjects={inconsistentObjects}
dispatch={dispatch}
/>
);
})
) : (
<td colSpan={3} className="text-center px-sm py-xs">
You don&apos;t have any data sources connected, please
connect one to continue.
</td>
)}
</tbody>
</table>
</div>
</IndicatorCard>
</div>
) : null}
{showErrorIndicator ? (
<div className="mt-xs">
<IndicatorCard
status="negative"
headline="Houston, we've got a problem here!"
showIcon
>
<div className="flex items-center flex-row">
<span>
There was an error in fetching the latest latency data.
<pre className="w-1/2">{queryResponse.data}</pre>
</span>
<div className="flex items-center flex-row ml-xs">
<Button
className="mr-xs"
onClick={checkDatabaseLatency}
isLoading={queryResponse.isLoading}
loadingText="Measuring Latencies..."
>
Re-check Database Latency
</Button>
<Button onClick={() => setLatencyButtonVisibility(true)}>
Close
</Button>
</div>
</div>
{showCheckLatencyButton ? (
<Button
size="md"
className="mt-xs mr-xs"
icon={<FaHourglassHalf />}
onClick={checkDatabaseLatency}
isLoading={queryResponse.isLoading}
loadingText="Measuring Latencies..."
>
Check Database Latency
</Button>
) : null}
{showAccelerateProjectSection ? (
<div className="mt-xs">
<IndicatorCard
status="negative"
headline="Accelerate your Hasura Project"
>
<div className="flex items-center flex-row">
<span>
Databases marked with Elevated Latency indicate that
it took us over 200 ms for this Hasura project to
communicate with your database. These conditions
generally happen when databases and projects are in
geographically distant regions. This can cause API and
subsequently application performance issues. We want
your GraphQL APIs to be <b>lightning fast</b>, therefore
we recommend that you either deploy your Hasura project
in the same region as your database or select a database
instance that&apos;s closer to where you&apos;ve
deployed Hasura.
<LearnMoreLink href={KNOW_MORE_PROJECT_REGION_UPDATE} />
</span>
<div className="flex items-center flex-row ml-xs">
<Button
className="mr-xs"
onClick={checkDatabaseLatency}
isLoading={queryResponse.isLoading}
loadingText="Measuring Latencies..."
icon={<FaRedoAlt />}
>
Re-check Database Latency
</Button>
<Button
className="mr-xs"
onClick={openUpdateProjectRegionPage}
icon={<FaExternalLinkAlt />}
>
Update Project Region
</Button>
</div>
</div>
</IndicatorCard>
</div>
</IndicatorCard>
</div>
) : null}
<NeonDashboardLink className="mt-lg" />
) : null}
{showErrorIndicator ? (
<div className="mt-xs">
<IndicatorCard
status="negative"
headline="Houston, we've got a problem here!"
showIcon
>
<div className="flex items-center flex-row">
<span>
There was an error in fetching the latest latency data.
<pre className="w-1/2">{queryResponse.data}</pre>
</span>
<div className="flex items-center flex-row ml-xs">
<Button
className="mr-xs"
onClick={checkDatabaseLatency}
isLoading={queryResponse.isLoading}
loadingText="Measuring Latencies..."
>
Re-check Database Latency
</Button>
<Button
onClick={() => setLatencyButtonVisibility(true)}
>
Close
</Button>
</div>
</div>
</IndicatorCard>
</div>
) : null}
<NeonDashboardLink className="mt-lg" />
</>
)}
<hr className="my-md" />
<div className="mt-4">

View File

@ -45,6 +45,8 @@ import { isEmpty } from '../../../../Common/utils/jsUtils';
import requestAction from '../../../../../utils/requestAction';
import Endpoints from '../../../../../Endpoints';
import { Button } from '../../../../../new-components/Button';
import { useEELiteAccess } from '../../../../../features/EETrial';
import globals from '../../../../../Globals';
import { MapStateToProps } from '../../../../../types';
import { useEventTrigger } from '../state';
import { Header } from '../../../../Common/Headers/Headers';
@ -84,6 +86,13 @@ const Add: React.FC<Props> = props => {
const [databaseInfo, setDatabaseInfo] = useState<DatabaseInfo>({});
const { access: eeLiteAccess } = useEELiteAccess(globals);
const autoCleanupSupport =
isProConsole(globals) || eeLiteAccess === 'active'
? 'active'
: eeLiteAccess;
useEffect(() => {
const driver = getSourceDriver(dataSourcesList, source);
setState.operationColumns([]);
@ -303,7 +312,7 @@ const Add: React.FC<Props> = props => {
const newState = { ...state };
/* don't cleanup_config if console type is oss */
if (!isProConsole(window.__env)) {
if (autoCleanupSupport === 'active') {
delete newState?.cleanupConfig;
}
@ -413,6 +422,7 @@ const Add: React.FC<Props> = props => {
handleHeadersChange={handleHeadersChange}
handleToggleAllColumn={setState.toggleAllColumnChecked}
handleAutoCleanupChange={handleAutoCleanupChange}
autoCleanupSupport={autoCleanupSupport}
/>
<ConfigureTransformation
transformationType="event"

View File

@ -1,7 +1,6 @@
/* eslint-disable no-underscore-dangle */
import React from 'react';
import { Collapsible } from '../../../../../new-components/Collapsible';
import { isProConsole } from '../../../../../utils/proConsole';
import { useSchemas } from '../../../Data/TableInsertItem/hooks/useSchemas';
import { LocalEventTriggerState } from '../state';
import Headers, { Header } from '../../../../Common/Headers/Headers';
@ -22,6 +21,7 @@ import FormLabel from './FormLabel';
import { inputStyles, heading } from '../../constants';
import { AutoCleanupForm } from '../Common/AutoCleanupForm';
import { FaShieldAlt } from 'react-icons/fa';
import { EELiteAccessStatus } from '../../../../../features/EETrial';
type CreateETFormProps = {
state: LocalEventTriggerState;
@ -40,6 +40,7 @@ type CreateETFormProps = {
handleHeadersChange: (h: Header[]) => void;
handleToggleAllColumn: () => void;
handleAutoCleanupChange: (config: EventTriggerAutoCleanup) => void;
autoCleanupSupport: EELiteAccessStatus;
};
const CreateETForm: React.FC<CreateETFormProps> = props => {
@ -70,6 +71,7 @@ const CreateETForm: React.FC<CreateETFormProps> = props => {
handleHeadersChange,
handleToggleAllColumn,
handleAutoCleanupChange,
autoCleanupSupport,
} = props;
const supportedDrivers = getSupportedDrivers('events.triggers.add');
@ -222,7 +224,7 @@ const CreateETForm: React.FC<CreateETFormProps> = props => {
<br />
</div>
<hr className="my-md" />
{isProConsole(window.__env) && (
{autoCleanupSupport !== 'forbidden' && (
<>
<div className="mb-md">
<div className="mb-md cursor-pointer">

View File

@ -5,8 +5,8 @@ import { Collapsible } from '../../../../../new-components/Collapsible';
import { DropdownButton } from '../../../../../new-components/DropdownButton';
import { InputSection } from '../../../../../new-components/InputSetionWithoutForm';
import { Switch } from '../../../../../new-components/Switch';
import React from 'react';
import { EventTriggerAutoCleanup } from '../../types';
import { ETAutoCleanupWrapper } from '../../../../../features/EETrial';
interface AutoCleanupFormProps {
cleanupConfig?: EventTriggerAutoCleanup;
@ -32,230 +32,229 @@ export const AutoCleanupForm = (props: AutoCleanupFormProps) => {
: !cleanupConfig?.paused;
return (
<div className="w-1/2">
<Analytics
name="open-event-log-auto-cleanup"
passHtmlAttributesToChildren
>
<Collapsible
triggerChildren={
<h2 className="text-lg font-semibold mb-xs flex items-center mb-0">
Auto-cleanup Event Logs
<div className="flex items-center">
<Tooltip
side="top"
tooltipContentChildren={
isCleanupConfigSet &&
<Analytics name="open-event-log-auto-cleanup" passHtmlAttributesToChildren>
<Collapsible
triggerChildren={
<h2 className="text-lg font-semibold mb-xs flex items-center mb-0">
Auto-cleanup Event Logs
<div className="flex items-center">
<Tooltip
side="top"
tooltipContentChildren={
isCleanupConfigSet &&
!(
cleanupConfig?.paused &&
Object.keys(cleanupConfig).length === 1
)
? 'Auto-cleanup has been configured. After clearing/resetting, save changes to remove the configuration.'
: 'Auto-cleanup is currently not configured'
}
className="h-full flex items-center"
>
{' '}
{isCleanupConfigSet &&
!(
cleanupConfig?.paused &&
Object.keys(cleanupConfig).length === 1
) ? (
<FaCircle className="ml-xs fill-sky-600 text-xs" />
) : (
<FaCircle className="ml-xs fill-slate-400 text-xs" />
)}
<Analytics
name="event-auto-cleanup-clear-reset-btn"
passHtmlAttributesToChildren
>
<span
className="text-sky-500 ml-xs font-thin text-sm"
onClick={() => onChange({})}
>
{isCleanupConfigSet &&
!(
cleanupConfig?.paused &&
Object.keys(cleanupConfig).length === 1
)
? 'Auto-cleanup has been configured. After clearing/resetting, save changes to remove the configuration.'
: 'Auto-cleanup is currently not configured'
}
className="h-full flex items-center"
>
{' '}
{isCleanupConfigSet &&
!(
cleanupConfig?.paused &&
Object.keys(cleanupConfig).length === 1
) ? (
<FaCircle className="ml-xs fill-sky-600 text-xs" />
) : (
<FaCircle className="ml-xs fill-slate-400 text-xs" />
)}
<Analytics
name="event-auto-cleanup-clear-reset-btn"
passHtmlAttributesToChildren
>
<span
className="text-sky-500 ml-xs font-thin text-sm"
onClick={() => onChange({})}
>
{isCleanupConfigSet &&
!(
cleanupConfig?.paused &&
Object.keys(cleanupConfig).length === 1
)
? 'Clear / Reset'
: ''}
</span>
</Analytics>
</Tooltip>
</div>
</h2>
}
defaultOpen={!!cleanupConfig}
>
<div className="flex items-center mb-sm">
<Tooltip
side="right"
tooltipContentChildren={
isCleanupConfigSet
? 'When not enabled, event log cleanup is paused. To completely remove event log cleanup configuration use Clear/Reset button'
: 'When not enabled, event log cleanup is paused'
}
className="flex items-center ml-0"
>
<Switch
checked={cleanupConfig?.paused === false}
onCheckedChange={() => {
onChange({
...cleanupConfig,
paused: cleanupConfig?.paused === false ? true : false,
});
}}
/>
<span className="ml-xs cursor-pointer">
Enable event log cleanup
</span>
</Tooltip>
</div>
{
<div>
<div className="flex items-center mb-sm">
<Tooltip
side="right"
tooltipContentChildren={
isDisable
? 'Enable event log cleanup to configure'
: 'Enabling this will clear event invocation logs along with event logs'
}
className="flex items-center ml-0"
>
<Switch
checked={cleanupConfig?.clean_invocation_logs}
disabled={isDisable}
onCheckedChange={() => {
onChange({
...cleanupConfig,
clean_invocation_logs:
!cleanupConfig?.clean_invocation_logs,
});
}}
/>
<span className="ml-xs cursor-pointer">
Clean invocation logs with event logs
? 'Clear / Reset'
: ''}
</span>
</Tooltip>
</div>
<InputSection
label="Clear logs older than (hours)"
disabled={isDisable}
tooltip={
isDisable
? `Enable event log cleanup to configure. Clear event logs older than (in hours)`
: `Clear event logs older than (in hours)`
}
placeholder="168"
required
value={cleanupConfig?.clear_older_than?.toString() ?? ''}
onChange={value => {
onChange({
...cleanupConfig,
clear_older_than: value ? parseInt(value, 10) : undefined,
});
}}
/>
<InputSection
label="Cleanup Frequency"
disabled={isDisable}
tooltip={
isDisable
? `Enable event log cleanup to configure. Cron expression at which the cleanup should be invoked.`
: `Cron expression at which the cleanup should be invoked.`
}
placeholder="0 0 * * *"
required
value={cleanupConfig?.schedule?.toString() ?? ''}
onChange={value => {
onChange({
...cleanupConfig,
schedule: value,
});
}}
/>
<div className="my-sm">
<DropdownButton
disabled={isDisable}
items={[
crons.map(cron => (
<div
key={cron.value}
onClick={() => {
onChange({
...cleanupConfig,
schedule: cron.value,
});
}}
className="py-xs cursor-pointer mx-1 px-xs py-1 rounded hover:bg-gray-100"
>
<p className="mb-0 font-semibold whitespace-nowrap">
{cron.label}
</p>
<p className="mb-0">{cron.value}</p>
</div>
)),
]}
>
<span className="font-bold">Frequent Frequencies</span>
</DropdownButton>
</div>
<Analytics
name="open-adv-setting-event-log-cleanup"
passHtmlAttributesToChildren
>
<Collapsible
triggerChildren={
<h2 className="text-lg font-semibold mb-xs flex items-center mb-0">
Advanced Settings
</h2>
}
>
<InputSection
label="Timeout (seconds)"
disabled={isDisable}
tooltip={
isDisable
? `Enable event log cleanup to configure. Timeout for the query (in seconds, default: 60)`
: `Timeout for the query (in seconds, default: 60)`
}
placeholder="60"
value={cleanupConfig?.timeout?.toString() ?? ''}
onChange={value => {
onChange({
...cleanupConfig,
timeout: value ? parseInt(value, 10) : undefined,
});
}}
/>
<InputSection
label="Batch Size"
disabled={isDisable}
tooltip={
isDisable
? `Enable event log cleanup to configure. Number of event trigger logs to delete in a batch (default: 10,000)`
: `Number of event trigger logs to delete in a batch (default: 10,000)`
}
placeholder="10000"
value={cleanupConfig?.batch_size?.toString() ?? ''}
onChange={value => {
onChange({
...cleanupConfig,
batch_size: value ? parseInt(value, 10) : undefined,
});
}}
/>
</Collapsible>
</Analytics>
</Analytics>
</Tooltip>
</div>
}
</Collapsible>
</Analytics>
</div>
</h2>
}
defaultOpen
>
<ETAutoCleanupWrapper>
<div className="w-1/2">
<div className="flex items-center mb-sm">
<Tooltip
side="right"
tooltipContentChildren={
isCleanupConfigSet
? 'When not enabled, event log cleanup is paused. To completely remove event log cleanup configuration use Clear/Reset button'
: 'When not enabled, event log cleanup is paused'
}
className="flex items-center ml-0"
>
<Switch
checked={cleanupConfig?.paused === false}
onCheckedChange={() => {
onChange({
...cleanupConfig,
paused: cleanupConfig?.paused === false ? true : false,
});
}}
/>
<span className="ml-xs cursor-pointer">
Enable event log cleanup
</span>
</Tooltip>
</div>
{
<div>
<div className="flex items-center mb-sm">
<Tooltip
side="right"
tooltipContentChildren={
isDisable
? 'Enable event log cleanup to configure'
: 'Enabling this will clear event invocation logs along with event logs'
}
className="flex items-center ml-0"
>
<Switch
checked={cleanupConfig?.clean_invocation_logs}
disabled={isDisable}
onCheckedChange={() => {
onChange({
...cleanupConfig,
clean_invocation_logs:
!cleanupConfig?.clean_invocation_logs,
});
}}
/>
<span className="ml-xs cursor-pointer">
Clean invocation logs with event logs
</span>
</Tooltip>
</div>
<InputSection
label="Clear logs older than (hours)"
disabled={isDisable}
tooltip={
isDisable
? `Enable event log cleanup to configure. Clear event logs older than (in hours)`
: `Clear event logs older than (in hours)`
}
placeholder="168"
required
value={cleanupConfig?.clear_older_than?.toString() ?? ''}
onChange={value => {
onChange({
...cleanupConfig,
clear_older_than: value ? parseInt(value, 10) : undefined,
});
}}
/>
<InputSection
label="Cleanup Frequency"
disabled={isDisable}
tooltip={
isDisable
? `Enable event log cleanup to configure. Cron expression at which the cleanup should be invoked.`
: `Cron expression at which the cleanup should be invoked.`
}
placeholder="0 0 * * *"
required
value={cleanupConfig?.schedule?.toString() ?? ''}
onChange={value => {
onChange({
...cleanupConfig,
schedule: value,
});
}}
/>
<div className="my-sm">
<DropdownButton
disabled={isDisable}
items={[
crons.map(cron => (
<div
key={cron.value}
onClick={() => {
onChange({
...cleanupConfig,
schedule: cron.value,
});
}}
className="py-xs cursor-pointer mx-1 px-xs py-1 rounded hover:bg-gray-100"
>
<p className="mb-0 font-semibold whitespace-nowrap">
{cron.label}
</p>
<p className="mb-0">{cron.value}</p>
</div>
)),
]}
>
<span className="font-bold">Frequent Frequencies</span>
</DropdownButton>
</div>
<Analytics
name="open-adv-setting-event-log-cleanup"
passHtmlAttributesToChildren
>
<Collapsible
triggerChildren={
<h2 className="text-lg font-semibold mb-xs flex items-center mb-0">
Advanced Settings
</h2>
}
>
<InputSection
label="Timeout (seconds)"
disabled={isDisable}
tooltip={
isDisable
? `Enable event log cleanup to configure. Timeout for the query (in seconds, default: 60)`
: `Timeout for the query (in seconds, default: 60)`
}
placeholder="60"
value={cleanupConfig?.timeout?.toString() ?? ''}
onChange={value => {
onChange({
...cleanupConfig,
timeout: value ? parseInt(value, 10) : undefined,
});
}}
/>
<InputSection
label="Batch Size"
disabled={isDisable}
tooltip={
isDisable
? `Enable event log cleanup to configure. Number of event trigger logs to delete in a batch (default: 10,000)`
: `Number of event trigger logs to delete in a batch (default: 10,000)`
}
placeholder="10000"
value={cleanupConfig?.batch_size?.toString() ?? ''}
onChange={value => {
onChange({
...cleanupConfig,
batch_size: value ? parseInt(value, 10) : undefined,
});
}}
/>
</Collapsible>
</Analytics>
</div>
}
</div>
</ETAutoCleanupWrapper>
</Collapsible>
</Analytics>
);
};

View File

@ -38,14 +38,16 @@ import {
import ConfigureTransformation from '../../../../Common/ConfigureTransformation/ConfigureTransformation';
import requestAction from '../../../../../utils/requestAction';
import Endpoints from '../../../../../Endpoints';
import { useEELiteAccess } from '../../../../../features/EETrial';
import {
getValidateTransformOptions,
parseValidateApiData,
getTransformState,
} from '../../../../Common/ConfigureTransformation/utils';
import { showErrorNotification } from '../../../Common/Notification';
} from '../../../../../components/Common/ConfigureTransformation/utils';
import { showErrorNotification } from '../../../../../components/Services/Common/Notification';
import { Button } from '../../../../../new-components/Button';
import { isProConsole } from '../../../../../utils/proConsole';
import globals from '../../../../../Globals';
import { getSourceDriver } from '../../../Data/utils';
import { mapDispatchToPropsEmpty } from '../../../../Common/utils/reactUtils';
import { getEventRequestSampleInput } from '../utils';
@ -89,6 +91,12 @@ const Modify: React.FC<Props> = props => {
getEventRequestTransformDefaultState()
);
const { access: eeLiteAccess } = useEELiteAccess(globals);
const autoCleanupSupport =
isProConsole(globals) || eeLiteAccess === 'active'
? 'active'
: eeLiteAccess;
useEffect(() => {
if (currentTrigger) {
const driver = getSourceDriver(dataSourcesList, currentTrigger.source);
@ -412,7 +420,7 @@ const Modify: React.FC<Props> = props => {
save={saveWrapper('retry_conf')}
/>
<hr className="my-md" />
{isProConsole(window.__env) && (
{autoCleanupSupport !== 'forbidden' && (
<div className="mb-md">
<AutoCleanupForm
onChange={setState.cleanupConfig}

View File

@ -0,0 +1,71 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query';
import { eeLicenseInfo } from '../../../../features/EETrial/mocks/http';
import { About } from './About';
export default {
title: 'components/Services/Settings/About',
parameters: {
Benefits: {
source: { type: 'code' },
},
},
component: About,
decorators: [ReactQueryDecorator()],
} as ComponentMeta<typeof About>;
export const LoadingServerVersion: ComponentStory<typeof About> = args => (
<div className="flex justify-center">
<About serverVersion="" consoleAssetVersion="9acd324" />
</div>
);
export const WithoutEnterpriseAccess: ComponentStory<typeof About> = args => (
<div className="flex justify-center">
<About serverVersion="v2.17.0" consoleAssetVersion="9acd324" />
</div>
);
export const WithoutEnterpriseLicense: ComponentStory<typeof About> = args => (
<div className="flex justify-center">
<About serverVersion="v2.17.0" consoleAssetVersion="9acd324" />
</div>
);
WithoutEnterpriseLicense.parameters = {
msw: [eeLicenseInfo.none],
consoleType: 'pro-lite',
};
export const DeactivatedEnterpriseLicense: ComponentStory<
typeof About
> = args => (
<div className="flex justify-center">
<About serverVersion="v2.17.0" consoleAssetVersion="9acd324" />
</div>
);
DeactivatedEnterpriseLicense.parameters = {
msw: [eeLicenseInfo.deactivated],
consoleType: 'pro-lite',
};
export const ExpiredEnterpriseLicense: ComponentStory<typeof About> = args => (
<div className="flex justify-center">
<About serverVersion="v2.17.0" consoleAssetVersion="9acd324" />
</div>
);
ExpiredEnterpriseLicense.parameters = {
msw: [eeLicenseInfo.expired],
consoleType: 'pro-lite',
};
export const ActiveEnterpriseLicense: ComponentStory<typeof About> = args => (
<div className="flex justify-center">
<About serverVersion="v2.17.0" consoleAssetVersion="9acd324" />
</div>
);
ActiveEnterpriseLicense.parameters = {
msw: [eeLicenseInfo.active],
consoleType: 'pro-lite',
};

View File

@ -1,71 +1,48 @@
import React, { Component } from 'react';
import React from 'react';
import { Connect } from 'react-redux';
import Helmet from 'react-helmet';
import { FaSpinner } from 'react-icons/fa';
import { Analytics, REDACT_EVERYTHING } from '../../../../features/Analytics';
import { EELicenseInfo } from './EELicenseInfo';
import { LabelValue } from './LabelValue';
import globals from '../../../../Globals';
import { ReduxState } from '../../../../types';
import { ReduxState, ConnectInjectedProps } from '../../../../types';
export const About: React.VFC<StateProps> = props => {
const { serverVersion, consoleAssetVersion } = props;
type AboutState = {
consoleAssetVersion?: string;
};
const spinner = <FaSpinner className="animate-spin" />;
class About extends Component<ConnectInjectedProps & StateProps> {
// had to add this here as the state type is not being read properly if added above.
override state: AboutState = {
consoleAssetVersion: globals.consoleAssetVersion,
};
override render() {
const { consoleAssetVersion } = this.state;
const { serverVersion } = this.props;
const spinner = <FaSpinner className="animate-spin" />;
const getServerVersionSection = () => {
return (
<div>
<b>Current server version: </b>
<span className="ml-sm font-light">{serverVersion || spinner}</span>
</div>
);
};
const getConsoleAssetVersionSection = () => {
return (
<div>
<b>Console asset version: </b>
<span className="ml-sm font-light">
{consoleAssetVersion || 'NA'}
</span>
</div>
);
};
return (
<Analytics name="About" {...REDACT_EVERYTHING}>
<div className="clear-both pl-sm pt-md mb-sm bootstrap-jail">
<div className="text-base font-bold">
<Helmet title="About | Hasura" />
<h2 className="text-xl font-bold">About</h2>
<div className="mt-sm">{getServerVersionSection()}</div>
<div className="mt-sm">{getConsoleAssetVersionSection()}</div>
return (
<Analytics name="About" {...REDACT_EVERYTHING}>
<div className="clear-both pl-md pt-md mb-sm bootstrap-jail">
<div className="text-base font-bold">
<Helmet title="About | Hasura" />
<h2 className="text-xl font-bold mb-md">About</h2>
<div className="mb-md">
<LabelValue
label={'Current Server Version'}
value={serverVersion || spinner}
/>
</div>
<div className="mb-md">
<LabelValue
label={'Console asset version'}
value={consoleAssetVersion || 'NA'}
/>
</div>
<EELicenseInfo className="mb-md" />
</div>
</Analytics>
);
}
}
</div>
</Analytics>
);
};
const mapStateToProps = (state: ReduxState) => {
return {
dataHeaders: state.tables.dataHeaders,
serverVersion: state.main.serverVersion,
source: state.tables.currentDataSource,
latestStableServerVersion: state.main.latestStableServerVersion,
consoleAssetVersion: globals.consoleAssetVersion,
};
};

View File

@ -0,0 +1,110 @@
import * as React from 'react';
import { Button } from '../../../../new-components/Button';
import { FaExternalLinkAlt } from 'react-icons/fa';
import moment from 'moment';
import { LabelValue } from './LabelValue';
import {
useEELiteAccess,
EELiteAccess,
EE_TRIAL_CONTACT_US_URL,
EETrialCard,
} from '../../../../features/EETrial';
import globals from '../../../../Globals';
export const EECTAButton: React.VFC<{
text: string;
className?: string;
}> = props => {
const { className, text } = props;
return (
<a
href={EE_TRIAL_CONTACT_US_URL}
target="_blank"
rel="noopener noreferrer"
className={className}
>
<Button
icon={<FaExternalLinkAlt className="text-sm" />}
iconPosition="end"
className="font-weight-700 text-md"
>
{text}
</Button>
</a>
);
};
export const EELicenseInfo: React.VFC<{ className?: string }> = props => {
const { className } = props;
const eeLite = useEELiteAccess(globals);
if (eeLite.access === 'forbidden') {
return null;
}
return (
<div className={className}>
<EELicenseInfoUI info={eeLite} />
</div>
);
};
export const EELicenseInfoUI: React.VFC<{
info: EELiteAccess;
}> = props => {
const { info } = props;
switch (info.access) {
case 'eligible': {
return (
<div className="max-w-3xl">
<div className="mb-xs">
<LabelValue
label="Enterprise Edition"
value={
<EETrialCard
cardTitle="Activate your free Hasura Enterprise trial license"
className="mt-xs"
cardText="Unlock extra observability, security, and performance features for your Hasura instance."
eeAccess={info.access}
horizontal
id="settings-about-ee"
/>
}
/>
</div>
</div>
);
}
case 'active':
case 'expired':
const expiryDate = moment(info.license.expiry_at);
return (
<div>
<div className="mb-xs">
<LabelValue
label="Enterprise Edition Expiry Date"
value={`${expiryDate.format(
'D MMMM, YYYY'
)} (${expiryDate.fromNow()})`}
/>
</div>
<EECTAButton
text={info.access === 'active' ? 'Get in touch' : 'Renew License'}
className="mt-md"
/>
</div>
);
case 'deactivated':
return (
<div>
<div className="mb-xs">
<LabelValue label="Enterprise Edition" value={`Deactivated`} />
</div>
<EECTAButton text="Get in touch" />
</div>
);
case 'loading':
default:
return null;
}
};

View File

@ -0,0 +1,14 @@
import * as React from 'react';
export const LabelValue: React.VFC<{
label: React.ReactNode;
value: React.ReactNode;
}> = props => {
const { label, value } = props;
return (
<div className="flex flex-col">
<b className="text-muted">{label}: </b>
<span className="text-muted font-normal">{value}</span>
</div>
);
};

View File

@ -3,6 +3,8 @@ import { RouteComponentProps } from 'react-router';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { rest, DelayMode } from 'msw';
import { QueryClient, QueryClientProvider } from 'react-query';
import { eeLicenseInfo } from '../../../features/EETrial/mocks/http';
import Sidebar, { Metadata } from './Sidebar';
import { HasuraMetadataV3 } from '../../../metadata/types';
@ -153,7 +155,7 @@ export const MetadataOk: ComponentStory<typeof Sidebar> = args => {
MetadataOk.storyName = '💠 Demo Metadata Ok';
MetadataOk.args = generateArgs();
MetadataOk.parameters = {
msw: mockHandlers({}),
msw: [...mockHandlers({}), eeLicenseInfo.active],
};
export const MetadataKo: ComponentStory<typeof Sidebar> = args => {
@ -162,7 +164,7 @@ export const MetadataKo: ComponentStory<typeof Sidebar> = args => {
MetadataKo.storyName = '💠 Demo Metadata Ko';
MetadataKo.args = generateArgs(false);
MetadataKo.parameters = {
msw: mockHandlers({}),
msw: [...mockHandlers({}), eeLicenseInfo.active],
};
export const LogoutActive: ComponentStory<typeof Sidebar> = args => {
@ -171,7 +173,7 @@ export const LogoutActive: ComponentStory<typeof Sidebar> = args => {
LogoutActive.storyName = '💠 Demo Pro Logout Active';
LogoutActive.args = generateArgs();
LogoutActive.parameters = {
msw: mockHandlers({}),
msw: [...mockHandlers({}), eeLicenseInfo.active],
adminSecretSet: true,
};
@ -185,6 +187,19 @@ ProLiteLoading.parameters = {
consoleType: 'pro-lite',
};
export const ProLitePrometheusWithoutLicense: ComponentStory<
typeof Sidebar
> = args => {
return <Sidebar {...args} />;
};
ProLitePrometheusWithoutLicense.storyName =
'💠 Demo Pro Lite Prometheus Without License';
ProLitePrometheusWithoutLicense.args = generateArgs();
ProLitePrometheusWithoutLicense.parameters = {
msw: [...mockHandlers({ prometheusEnabled: true }), eeLicenseInfo.none],
consoleType: 'pro-lite',
};
export const ProLitePrometheusEnabled: ComponentStory<
typeof Sidebar
> = args => {
@ -193,7 +208,7 @@ export const ProLitePrometheusEnabled: ComponentStory<
ProLitePrometheusEnabled.storyName = '💠 Demo Pro Lite Prometheus Enabled';
ProLitePrometheusEnabled.args = generateArgs();
ProLitePrometheusEnabled.parameters = {
msw: mockHandlers({ prometheusEnabled: true }),
msw: [...mockHandlers({ prometheusEnabled: true }), eeLicenseInfo.active],
consoleType: 'pro-lite',
};
@ -205,7 +220,7 @@ export const ProLitePrometheusDisabled: ComponentStory<
ProLitePrometheusDisabled.storyName = '💠 Demo Pro Lite Prometheus Disabled';
ProLitePrometheusDisabled.args = generateArgs();
ProLitePrometheusDisabled.parameters = {
msw: mockHandlers({ prometheusEnabled: false }),
msw: [...mockHandlers({ prometheusEnabled: false }), eeLicenseInfo.active],
consoleType: 'pro-lite',
};
@ -215,7 +230,20 @@ export const ProLiteError: ComponentStory<typeof Sidebar> = args => {
ProLiteError.storyName = '💠 Demo Pro Lite Prometheus Error';
ProLiteError.args = generateArgs();
ProLiteError.parameters = {
msw: mockHandlers({ status: 500 }),
msw: [...mockHandlers({ status: 500 }), eeLicenseInfo.active],
consoleType: 'pro-lite',
};
export const ProLiteOpenTelemetryWithoutLicense: ComponentStory<
typeof Sidebar
> = args => {
return <Sidebar {...args} />;
};
ProLiteOpenTelemetryWithoutLicense.storyName =
'💠 Demo Pro Lite OpenTelemetry Without License';
ProLiteOpenTelemetryWithoutLicense.args = generateArgs();
ProLiteOpenTelemetryWithoutLicense.parameters = {
msw: [...mockHandlers({ openTelemetryEnabled: false }), eeLicenseInfo.none],
consoleType: 'pro-lite',
};
@ -228,7 +256,7 @@ ProLiteOpenTelemetryEnabled.storyName =
'💠 Demo Pro Lite OpenTelemetry Enabled';
ProLiteOpenTelemetryEnabled.args = generateArgs();
ProLiteOpenTelemetryEnabled.parameters = {
msw: mockHandlers({ openTelemetryEnabled: true }),
msw: [...mockHandlers({ openTelemetryEnabled: true }), eeLicenseInfo.active],
consoleType: 'pro-lite',
};
@ -241,6 +269,6 @@ ProLiteOpenTelemetryDisabled.storyName =
'💠 Demo Pro Lite OpenTelemetry Disabled';
ProLiteOpenTelemetryDisabled.args = generateArgs();
ProLiteOpenTelemetryDisabled.parameters = {
msw: mockHandlers({ openTelemetryEnabled: false }),
msw: [...mockHandlers({ openTelemetryEnabled: false }), eeLicenseInfo.active],
consoleType: 'pro-lite',
};

View File

@ -7,13 +7,15 @@ import LeftContainer from '../../Common/Layout/LeftContainer/LeftContainer';
import globals from '../../../Globals';
import { CLI_CONSOLE_MODE } from '../../../constants';
import { getAdminSecret } from '../ApiExplorer/ApiRequest/utils';
import { isProLiteConsole } from '../../../utils/proConsole';
import {
NavigationSidebar,
NavigationSidebarProps,
NavigationSidebarSection,
} from '../../../new-components/NavigationSidebar';
import { useEELiteAccess } from '../../../features/EETrial';
import { getQueryResponseCachingRoute } from '../../../utils/routeUtils';
export interface Metadata {
inconsistentObjects: Record<string, unknown>[];
inconsistentInheritedRoles: Record<string, unknown>[];
@ -32,10 +34,11 @@ type SectionDataKey =
| 'about';
const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
const eeLiteAccess = useEELiteAccess(globals);
const sectionsData: Partial<
Record<SectionDataKey, NavigationSidebarSection>
> = {};
sectionsData.metadata = {
key: 'metadata',
label: 'Metadata',
@ -105,7 +108,8 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
const { data: openTelemetry } = useMetadata(m => m.metadata.opentelemetry);
const { data: configData, isLoading, isError } = useServerConfig();
if (isProLiteConsole(window.__env)) {
if (eeLiteAccess.access !== 'forbidden') {
sectionsData.monitoring = {
key: 'monitoring',
label: 'Monitoring & observability',
@ -115,13 +119,16 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
sectionsData.monitoring.items.push({
key: 'prometheus-settings',
label: 'Prometheus Metrics',
status: isLoading
? 'loading'
: isError
? 'error'
: configData?.is_prometheus_metrics_enabled
? 'enabled'
: 'disabled',
status:
eeLiteAccess.access !== 'active'
? 'disabled'
: isLoading
? 'loading'
: isError
? 'error'
: configData?.is_prometheus_metrics_enabled
? 'enabled'
: 'disabled',
route: '/settings/prometheus-settings',
dataTestVal: 'prometheus-settings-link',
});
@ -129,16 +136,64 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
sectionsData.monitoring.items.push({
key: 'opentelemetry-settings',
label: 'OpenTelemetry Exporter (Beta)',
status: !openTelemetry
? 'none'
: openTelemetry.status === 'enabled'
? 'enabled'
: 'disabled',
status:
eeLiteAccess.access !== 'active'
? 'disabled'
: !openTelemetry
? 'none'
: openTelemetry.status === 'enabled'
? 'enabled'
: 'disabled',
route: '/settings/opentelemetry',
dataTestVal: 'opentelemetry-settings-link',
});
}
if (eeLiteAccess.access !== 'forbidden') {
sectionsData.security.items.push({
key: 'multiple-admin-secrets',
label: 'Multiple admin secrets',
route: '/settings/multiple-admin-secrets',
dataTestVal: 'multiple-admin-secrets',
});
sectionsData.security.items.push({
key: 'multiple-jwt-secrets',
label: 'Multiple jwt secrets',
route: '/settings/multiple-jwt-secrets',
dataTestVal: 'multiple-jwt-secrets',
});
// sectionsData.security.items.push({
// key: 'single-sign-on',
// label: 'Single Sign On',
// route: '/settings/single-sign-on',
// dataTestVal: 'single-sign-on',
// });
sectionsData.performance = {
key: 'performance',
label: 'Performance',
items: [
{
key: 'query-response-caching',
label: 'Query Response Caching',
// // TODO: Figure out the disabled/enabled logic
// status:
// licenseInfo?.status !== 'active'
// ? 'disabled'
// : isLoading
// ? 'loading'
// : isError
// ? 'error'
// : 'enabled',
route: getQueryResponseCachingRoute(),
dataTestVal: 'query-response-caching',
},
],
};
}
sectionsData.about = {
key: 'about',
label: 'About',

View File

@ -82,7 +82,7 @@ export const supportedFeatures: DeepRequired<SupportedFeaturesType> = {
ssl_certificates:
globals.consoleType === 'cloud' ||
globals.consoleType === 'pro' ||
globals.consoleType === 'pro-lite',
globals.consoleType === 'pro-lite', // TODO: Should be allowed only if the license is active
},
driver: {
name: 'cockroach',

View File

@ -765,7 +765,7 @@ export const supportedFeatures: DeepRequired<SupportedFeaturesType> = {
ssl_certificates:
globals.consoleType === 'cloud' ||
globals.consoleType === 'pro' ||
globals.consoleType === 'pro-lite',
globals.consoleType === 'pro-lite', // TODO: should be enabled only when license is active
},
};

View File

@ -13,8 +13,6 @@ import { generatedActionToHasuraAction } from '../OASGenerator/utils';
import { FaAngleRight, FaFileImport, FaHome } from 'react-icons/fa';
import { z } from 'zod';
import { useQueryClient } from 'react-query';
import { isImportFromOpenAPIEnabled } from '../../../../utils';
import { browserHistory } from 'react-router';
import { SimpleForm } from '../../../../new-components/Form';
import { OasGeneratorForm } from './OASGeneratorForm';
import React from 'react';
@ -111,11 +109,6 @@ export const OASGeneratorPage = () => {
}
};
if (!isImportFromOpenAPIEnabled(window.__env)) {
browserHistory.push('/actions');
return null;
}
return (
<div>
<div>

View File

@ -2,6 +2,9 @@ import debounce from 'lodash/debounce';
import React from 'react';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { useServerConfig } from '../../../../hooks';
import globals from '../../../../Globals';
import { isProConsole } from '../../../../utils/proConsole';
import { useEELiteAccess } from '../../../../features/EETrial';
import { LearnMoreLink } from '../../../../new-components/LearnMoreLink';
import { AllowListSidebarHeader } from './AllowListSidebarHeader';
import { QueryCollectionList } from './QueryCollectionList';
@ -24,6 +27,10 @@ export const AllowListSidebar: React.FC<AllowListSidebarProps> = props => {
const [search, setSearch] = React.useState('');
const debouncedSearch = React.useMemo(() => debounce(setSearch, 300), []);
const { access: eeLiteAccess } = useEELiteAccess(globals);
const allowQueryCollectionsCreation =
isProConsole(globals) || eeLiteAccess === 'active';
const { data: configData, isLoading: isConfigLoading } = useServerConfig();
const renderInstructions =
@ -32,7 +39,9 @@ export const AllowListSidebar: React.FC<AllowListSidebarProps> = props => {
return (
<div>
<AllowListSidebarHeader
onQueryCollectionCreate={onQueryCollectionCreate}
onQueryCollectionCreate={
allowQueryCollectionsCreation ? onQueryCollectionCreate : undefined
}
/>
<AllowListSidebarSearchForm
setSearch={(searchString: string) => debouncedSearch(searchString)}

View File

@ -1,13 +1,12 @@
/* eslint-disable no-underscore-dangle */
import React from 'react';
import { Button } from '../../../../new-components/Button';
import { isProConsole } from '../../../../utils/proConsole';
import { FaFolderPlus } from 'react-icons/fa';
import { QueryCollectionCreateDialog } from './QueryCollectionCreateDialog';
import { AllowListStatus } from './AllowListStatus';
interface AllowListSidebarHeaderProps {
onQueryCollectionCreate: (name: string) => void;
onQueryCollectionCreate?: (name: string) => void;
}
export const AllowListSidebarHeader = (props: AllowListSidebarHeaderProps) => {
@ -17,7 +16,11 @@ export const AllowListSidebarHeader = (props: AllowListSidebarHeaderProps) => {
<div className="pb-4">
{isCreateModalOpen && (
<QueryCollectionCreateDialog
onCreate={onQueryCollectionCreate}
onCreate={name => {
if (onQueryCollectionCreate) {
onQueryCollectionCreate(name);
}
}}
onClose={() => setIsCreateModalOpen(false)}
/>
)}
@ -30,7 +33,7 @@ export const AllowListSidebarHeader = (props: AllowListSidebarHeaderProps) => {
<AllowListStatus />
</div>
</div>
{isProConsole(window.__env) && (
{onQueryCollectionCreate && (
<div className="mt-2 2xl:mt-0 2xl:ml-auto">
<Button
icon={<FaFolderPlus />}

View File

@ -2,6 +2,8 @@ import type { RedactOptions } from './heap/types';
import type { HtmlAnalyticsAttributes, HtmlNameAttributes } from '../types';
import { getRedactAttributes } from './heap/getRedactAttributes';
export const DATA_ANALYTICS_ATTRIBUTE = 'data-analytics-name';
export type AnalyticsOptions = RedactOptions & {
/**
* @deprecated It is meant for the old components that already had a `data-trackid` attribute
@ -17,7 +19,7 @@ export function getAnalyticsAttributes(
name: string,
options?: AnalyticsOptions
): HtmlAnalyticsAttributes {
let htmlAttributes: HtmlNameAttributes = { 'data-analytics-name': name };
let htmlAttributes: HtmlNameAttributes = { [DATA_ANALYTICS_ATTRIBUTE]: name };
if (!options) return htmlAttributes;

View File

@ -0,0 +1,16 @@
import * as React from 'react';
import {
useSetupTelemetryEventListeners,
UserEventTracker,
} from '../htmlEvents';
// This component exists so that the logic of the `useSetupTelemetryEventListeners` hook can be run in class components
// This is important because the entrypoint of both CE and EE console is `Main.js`, which is a class component
export const InitializeTelemetry = (props: {
tracker: UserEventTracker;
skip: boolean;
}) => {
const { tracker, skip } = props;
useSetupTelemetryEventListeners(tracker, skip);
return null;
};

View File

@ -0,0 +1,237 @@
import * as React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Analytics } from '../../../Analytics';
import { setupTelemetryEventListeners } from './htmlEvents';
let mockTracker = jest.fn();
beforeEach(() => {
mockTracker = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
const useSetupTelemetryEventListeners = () => {
React.useEffect(() => {
const listenersCleaner = setupTelemetryEventListeners(mockTracker);
return listenersCleaner;
}, []);
};
test('tracks button clicks with analytics attribute correctly', async () => {
const TestComponent = () => {
useSetupTelemetryEventListeners();
return (
<Analytics name="button-component">
<button onClick={() => null} data-testid="click-me">
Click Me
</button>
</Analytics>
);
};
await render(<TestComponent />);
fireEvent.click(screen.getByTestId('click-me'));
expect(mockTracker).toHaveBeenLastCalledWith('button-component', 'click');
});
test('tracks nested button clicks with analytics attribute correctly', async () => {
const TestComponent = () => {
useSetupTelemetryEventListeners();
return (
<Analytics name="nested-button-component">
<button onClick={() => null}>
<div>
<span data-testid="click-me" onClick={() => null}>
Click me
</span>
</div>
</button>
</Analytics>
);
};
await render(<TestComponent />);
fireEvent.click(screen.getByTestId('click-me'));
expect(mockTracker).toHaveBeenLastCalledWith(
'nested-button-component',
'click'
);
});
test('does not track button clicks without analytics attribute', async () => {
const TestComponent = () => {
useSetupTelemetryEventListeners();
return (
<button onClick={() => null} data-testid="click-me">
Click Me
</button>
);
};
await render(<TestComponent />);
fireEvent.click(screen.getByTestId('click-me'));
expect(mockTracker).toHaveBeenCalledTimes(0);
});
test('tracks onchange with of input[type=text] with analytics attribute', async () => {
const TestComponent = () => {
useSetupTelemetryEventListeners();
const [value, setValue] = React.useState('');
return (
<Analytics name="text-component">
<input
onChange={e => setValue(e.target.value)}
value={value}
type="text"
data-testid="type-here"
/>
</Analytics>
);
};
await render(<TestComponent />);
fireEvent.change(screen.getByTestId('type-here'), {
target: { value: 'text' },
});
expect(mockTracker).toHaveBeenLastCalledWith('text-component', 'change');
});
test('tracks onchange with of a nested input[type=text] with analytics attribute', async () => {
const TestComponent = () => {
useSetupTelemetryEventListeners();
const [value, setValue] = React.useState('');
return (
<Analytics name="text-component">
<div>
<input
onChange={e => setValue(e.target.value)}
value={value}
type="text"
data-testid="type-here"
/>
</div>
</Analytics>
);
};
await render(<TestComponent />);
fireEvent.change(screen.getByTestId('type-here'), {
target: { value: 'text' },
});
expect(mockTracker).toHaveBeenLastCalledWith('text-component', 'change');
});
test('does not track onchange with of an input[type=text] if analytics attribute absent', async () => {
const TestComponent = () => {
useSetupTelemetryEventListeners();
const [value, setValue] = React.useState('');
return (
<div>
<input
onChange={e => setValue(e.target.value)}
value={value}
type="text"
data-testid="type-here"
/>
</div>
);
};
await render(<TestComponent />);
fireEvent.change(screen.getByTestId('type-here'), {
target: { value: 'text' },
});
expect(mockTracker).not.toHaveBeenCalled();
});
test('tracks onchange with of input[type=radio] with analytics attribute', async () => {
const TestComponent = () => {
useSetupTelemetryEventListeners();
const [checked, setChecked] = React.useState(false);
return (
<Analytics name="radio-component">
<input
type="radio"
onChange={() => setChecked(c => !c)}
checked={checked}
data-testid="toggle-radio"
/>
</Analytics>
);
};
await render(<TestComponent />);
fireEvent.change(screen.getByTestId('toggle-radio'), {
target: { value: 'text' },
});
expect(mockTracker).toHaveBeenLastCalledWith('radio-component', 'change');
});
test('tracks onchange with of input[type=checkbox] with analytics attribute', async () => {
const TestComponent = () => {
useSetupTelemetryEventListeners();
const [checked, setChecked] = React.useState(false);
return (
<Analytics name="checkbox-component">
<input
type="checkbox"
onChange={() => setChecked(c => !c)}
checked={checked}
data-testid="toggle-checkbox"
/>
</Analytics>
);
};
await render(<TestComponent />);
fireEvent.change(screen.getByTestId('toggle-checkbox'), {
target: { value: 'text' },
});
expect(mockTracker).toHaveBeenLastCalledWith('checkbox-component', 'change');
});
test('tracks onchange with of <select> with analytics attribute', async () => {
const TestComponent = () => {
const [value, setValue] = React.useState('');
useSetupTelemetryEventListeners();
return (
<Analytics name="select-component">
<select
onChange={e => setValue(e.target.value)}
value={value}
data-testid="select-option"
>
<option value="value1">First</option>
<option value="value2">Second</option>
</select>
</Analytics>
);
};
await render(<TestComponent />);
fireEvent.change(screen.getByTestId('select-option'), {
target: { value: 'value2' },
});
expect(mockTracker).toHaveBeenLastCalledWith('select-component', 'change');
});

View File

@ -0,0 +1,77 @@
/**
* This file contains the core Analytics utils for reusing this Analytics module for telemetry
* It uses the common attributes exposed by the Analytics component and useGetAnalyticsAttributes hook and sets up events listeners to
* track events that have the analytics attributes. Currently, telemetry is only set up for ee-lite, but it can be extended to CE later.
*/
import * as React from 'react';
// import { sendTelemetryEvent, HTMLUserEvent } from '../../../../telemetry'
import { DATA_ANALYTICS_ATTRIBUTE } from '../getAnalyticsAttributes';
type UserEvent = 'click' | 'change';
export type UserEventTracker = (id: string, kind: UserEvent) => void;
// This function accepts the event identifier, constructs the telemetry payload and sends it
const trackEvent = (
target: HTMLElement,
kind: UserEvent,
tracker: UserEventTracker
) => {
// if the target or one of its ancestors has the data-analytics attribute, track the event with the attribute value and event kind
if (target && `closest` in target) {
const matchingTarget = target.closest(`[${DATA_ANALYTICS_ATTRIBUTE}]`);
const analyticsAttributeValue = matchingTarget?.getAttribute
? matchingTarget.getAttribute(DATA_ANALYTICS_ATTRIBUTE)
: null;
if (analyticsAttributeValue) {
tracker(analyticsAttributeValue, kind);
}
}
};
const generateOnClickHandler =
(tracker: UserEventTracker) => (e: MouseEvent) => {
const eventTarget = e.target;
if (eventTarget) {
trackEvent(eventTarget as HTMLElement, 'click', tracker);
}
};
const generateOnChangeHandler = (tracker: UserEventTracker) => (e: Event) => {
const eventTarget = e.target;
if (eventTarget) {
trackEvent(eventTarget as HTMLElement, 'change', tracker);
}
};
// this function sets up event listeners on the document so that click/change events can be filtered and sent as telemetry events
// it also returns a cleaner so that the event listeners can be removed whenever needed
// the tracker is parameterised so
// a. the telemetry target can be changed if need be
// b. to avoid the depedency loop between telemetr <> Analytics
// c. to make the code testable
export const setupTelemetryEventListeners = (tracker: UserEventTracker) => {
const handleOnClick = generateOnClickHandler(tracker);
const handleOnChange = generateOnChangeHandler(tracker);
document.addEventListener('click', handleOnClick);
document.addEventListener('change', handleOnChange);
return () => {
document.removeEventListener('click', handleOnClick);
document.removeEventListener('change', handleOnChange);
};
};
// a hook that sets up telemetry event listeners on component mount
export const useSetupTelemetryEventListeners = (
tracker: UserEventTracker,
skip: boolean
) => {
React.useEffect(() => {
if (!skip) {
const cleaner = setupTelemetryEventListeners(tracker);
return cleaner;
}
return () => null;
}, [skip, tracker]);
};

View File

@ -11,6 +11,7 @@ export { programmaticallyTraceError } from './core/programmaticallyTraceError';
// REACT UTILITIES
export { Analytics } from './components/Analytics';
export { InitializeTelemetry } from './core/telemetry/components/InitializeTelemetry';
export { useGetAnalyticsAttributes } from './hooks/useGetAnalyticsAttributes';
// CUSTOM EVENTS

View File

@ -82,37 +82,42 @@ export const EditConnection = () => {
if (!schema) return <>Could not find schema</>;
return (
<Form onSubmit={submit} className="p-0 pl-sm">
<div className="max-w-5xl">
<InputField type="text" name="name" label="Database Display Name" />
<Select
options={[{ label: driver || '', value: driver }]}
name="driver"
label="Data Source Driver"
disabled
/>
<div className="max-w-xl">
<Configuration name="configuration" />
</div>
<div className="mt-4">
<CustomizationForm />
</div>
<div className="mt-4">
<Button type="submit" mode="primary" isLoading={submitIsLoading}>
Edit Connection
</Button>
</div>
{!!Object(formState.errors)?.keys?.length && (
<div className="mt-6 max-w-xl">
<IndicatorCard status="negative">
Error submitting form, see error messages above
</IndicatorCard>
</div>
)}
<div>
<div className="text-xl text-gray-600 font-semibold p-4">
Edit {name} database connection
</div>
</Form>
<Form onSubmit={submit} className="p-0 pl-sm">
<div className="max-w-5xl">
<InputField type="text" name="name" label="Database Display Name" />
<Select
options={[{ label: driver || '', value: driver }]}
name="driver"
label="Data Source Driver"
disabled
/>
<div className="max-w-xl">
<Configuration name="configuration" />
</div>
<div className="mt-4">
<CustomizationForm />
</div>
<div className="mt-4">
<Button type="submit" mode="primary" isLoading={submitIsLoading}>
Edit Connection
</Button>
</div>
{!!Object(formState.errors)?.keys?.length && (
<div className="mt-6 max-w-xl">
<IndicatorCard status="negative">
Error submitting form, see error messages above
</IndicatorCard>
</div>
)}
</div>
</Form>
</div>
);
};

View File

@ -1,14 +1,31 @@
import { DataSource } from '../../DataSource';
import { useHttpClient } from '../../Network';
import React from 'react';
import { useQuery } from 'react-query';
import { DataSource, DriverInfo } from '../../DataSource';
import { useHttpClient } from '../../Network';
export const useAvailableDrivers = () => {
// default options should return pretty much `list_source_kinds` response
export const useAvailableDrivers = ({
onFirstSuccess,
}: {
onFirstSuccess?: (drivers: DriverInfo[]) => void;
} = {}) => {
const httpClient = useHttpClient();
const firstSuccess = React.useRef(true);
return useQuery({
queryKey: ['get_available_drivers'],
queryFn: async () => {
const drivers = await DataSource(httpClient).driver.getAllSourceKinds();
return drivers.filter(driver => driver.release !== 'disabled');
const unfilteredDrivers = await DataSource(
httpClient
).driver.getAllSourceKinds();
return unfilteredDrivers.filter(driver => driver.release !== 'disabled');
},
onSuccess: drivers => {
if (firstSuccess.current === true) {
onFirstSuccess?.(drivers);
firstSuccess.current = false;
}
},
});
};

View File

@ -0,0 +1,12 @@
import { ConnectDatabaseV2 } from './ConnectDatabase';
import { useEnvironmentState } from './hooks/useEnvironmentState';
/**
*
* This is a wrapper component intended to be used directly as a route
*
*/
export const ConnectDatabaseRouteWrapper = () => {
const env = useEnvironmentState();
return <ConnectDatabaseV2 {...env} />;
};

View File

@ -1,37 +1,327 @@
import { hasuraToast } from '../../new-components/Toasts';
import { useArgs } from '@storybook/client-api';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { ConnectDatabase } from './ConnectDatabase';
import globals from '../../Globals';
import { ReactQueryDecorator } from '../../storybook/decorators/react-query';
import { isCloudConsole } from '../../utils';
import { ConnectDatabaseV2 } from './ConnectDatabase';
import { useEnvironmentState } from './hooks';
import { handlers } from './mocks/handlers.mock';
export default {
component: ConnectDatabase,
argTypes: {
onEnableEnterpriseTrial: { action: 'Enable Enterprise Clicked' },
onContactSales: { action: 'Contact Sales Clicked' },
component: ConnectDatabaseV2,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers({ dcAgentsAdded: true }),
},
} as ComponentMeta<typeof ConnectDatabase>;
} as ComponentMeta<typeof ConnectDatabaseV2>;
export const Primary: ComponentStory<typeof ConnectDatabase> = args => {
const [, updateArgs] = useArgs();
const Template: ComponentStory<typeof ConnectDatabaseV2> = args => {
return <ConnectDatabaseV2 {...args} />;
};
Template.args = {
eeLicenseInfo: 'eligible',
consoleType: 'pro-lite',
};
/**
*
* This Story attempts to get oss/cloud/license info from environment.
*
* DC Agents are mocked as available
*
* TODO: Add mocks for licensing api calls.
*
* The new Storybook Console Mode drop down can be used to interact with this version
*
*/
export const FromEnvironment: ComponentStory<typeof ConnectDatabaseV2> = () => {
const env = useEnvironmentState();
const cloud = isCloudConsole(globals);
return (
<ConnectDatabase
{...args}
onEnableEnterpriseTrial={() => {
hasuraToast({
message:
'Missing EE Trial Forms here. Setting ee trial prop to active as a temporary measure.',
title: 'Sign Up Not Implemented',
toastOptions: {
duration: 3000,
},
});
updateArgs({ ...args, eeState: 'active' });
}}
/>
<div>
<div className="my-3">
This component attempts to read Console Type, and EE License Info from
the environment
</div>
<div>isCloud: {cloud.toString()}</div>
<div>Console Type: {globals.consoleType}</div>
<div>Tenant Id: {globals.hasuraCloudTenantId}</div>
<ConnectDatabaseV2 {...env} />
</div>
);
};
FromEnvironment.storyName = '💠 Using Environment (DC Agents Available)';
Primary.args = {
eeState: 'inactive',
initialDb: 'snowflake',
/**
*
* This Story attempts to get oss/cloud/license info from environment.
*
* DC Agents are mocked as NOT available
*
*
* The new Storybook Console Mode drop down can be used to interact with this version
*
*/
export const FromEnvironment2 = FromEnvironment.bind({});
FromEnvironment2.storyName = '💠 Using Environment (DC Agents NOT Available)';
FromEnvironment2.parameters = {
msw: handlers({ dcAgentsAdded: false }),
};
/**
*
* Playground
*
* Mock DC Agents are NOT added in this version
*
*/
export const Playground = Template.bind({});
Playground.storyName = '💠 Playground (DC Agents NOT Available)';
Playground.parameters = {
msw: handlers({ dcAgentsAdded: false }),
};
Playground.args = Template.args;
/**
*
* Playground 2
*
*
* Mock DC Agents are added in this version
*
*
*/
export const Playground2 = Template.bind({});
Playground2.storyName = '💠 Playground (DC Agents Available)';
Playground2.args = Template.args;
/**
* TODO:
*
* Re-write old tests to work with refactored component....
*
*
*/
// const defaultArgs: ConnectDatabaseProps = {
// environmentState: {
// eeLicenseInfo: 'active',
// isCloud: false,
// isOss: false,
// isPro: false,
// },
// };
// export const No_Enterprise_Drivers = Template.bind({});
// No_Enterprise_Drivers.storyName = 'No Enterprise Drivers (OSS)';
// No_Enterprise_Drivers.args = {
// ...defaultArgs,
// };
// No_Enterprise_Drivers.play = async ({ canvasElement }) => {
// const c = within(canvasElement);
// await waitFor(() => c.findByTestId('fancy-radio-postgres'));
// await expect(
// c.queryByTestId('fancy-radio-snowflake')
// ).not.toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-athena')).not.toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-sqlite')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-mysqlgdc')).toBeInTheDocument();
// };
// export const License_Inactive = Template.bind({});
// License_Inactive.args = {
// ...defaultArgs,
// showEnterpriseDrivers: true,
// licenseState: 'forbidden',
// };
// License_Inactive.play = async ({ canvasElement }) => {
// const c = within(canvasElement);
// await waitFor(() => c.findByTestId('fancy-radio-postgres'));
// await expect(c.queryByTestId('fancy-radio-snowflake')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-athena')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-sqlite')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-mysqlgdc')).toBeInTheDocument();
// await userEvent.click(await c.findByTestId('fancy-label-snowflake'));
// await expect(
// await c.findByTestId('license-inactive-card')
// ).toBeInTheDocument();
// await expect(
// c.queryByTestId('connect-existing-button')
// ).not.toBeInTheDocument();
// };
// export const License_Active_GDC_N = Template.bind({});
// License_Active_GDC_N.storyName = 'License Active: DC Agents Not Added';
// License_Active_GDC_N.args = {
// ...defaultArgs,
// showEnterpriseDrivers: true,
// licenseState: 'active',
// initialDriverName: 'snowflake',
// };
// License_Active_GDC_N.play = async ({ canvasElement }) => {
// const c = within(canvasElement);
// await waitFor(() => c.findByTestId('fancy-radio-postgres'));
// await expect(c.queryByTestId('fancy-radio-snowflake')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-athena')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-sqlite')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-mysqlgdc')).toBeInTheDocument();
// await userEvent.click(await c.findByTestId('fancy-label-snowflake'));
// await expect(
// await c.findByTestId('setup-data-connector-card')
// ).toBeInTheDocument();
// await expect(
// c.queryByTestId('connect-existing-button')
// ).not.toBeInTheDocument();
// };
// export const License_Active_GDC_Y = Template.bind({});
// License_Active_GDC_Y.parameters = {
// msw: handlers({ dcAgentsAdded: true }),
// };
// License_Active_GDC_Y.storyName = 'License Active: DC Agents Added';
// License_Active_GDC_Y.args = {
// ...defaultArgs,
// showEnterpriseDrivers: true,
// licenseState: 'active',
// initialDriverName: 'snowflake',
// };
// License_Active_GDC_Y.play = async ({ canvasElement }) => {
// const c = within(canvasElement);
// await waitFor(() => c.findByTestId('fancy-radio-postgres'));
// await expect(c.queryByTestId('fancy-radio-snowflake')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-athena')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-sqlite')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-mysqlgdc')).toBeInTheDocument();
// await userEvent.click(await c.findByTestId('fancy-label-snowflake'));
// await expect(
// c.queryByTestId('setup-data-connector-card')
// ).not.toBeInTheDocument();
// await expect(c.queryByTestId('connect-existing-button')).toBeInTheDocument();
// };
// export const License_Expired = Template.bind({});
// License_Expired.args = {
// ...defaultArgs,
// showEnterpriseDrivers: true,
// licenseState: 'expired',
// initialDriverName: 'snowflake',
// };
// License_Expired.play = async ({ canvasElement }) => {
// const c = within(canvasElement);
// await waitFor(() => c.findByTestId('fancy-radio-postgres'));
// await expect(c.queryByTestId('fancy-radio-snowflake')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-athena')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-sqlite')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-mysqlgdc')).toBeInTheDocument();
// await userEvent.click(await c.findByTestId('fancy-label-snowflake'));
// await expect(
// await c.findByTestId('license-expired-card')
// ).toBeInTheDocument();
// await expect(
// await c.queryByTestId('connect-existing-button')
// ).not.toBeInTheDocument();
// };
// export const License_Deactivated = Template.bind({});
// License_Deactivated.args = {
// ...defaultArgs,
// showEnterpriseDrivers: true,
// licenseState: 'deactivated',
// initialDriverName: 'snowflake',
// };
// License_Deactivated.play = License_Expired.play;
// export const Cloud_GDC_Y = Template.bind({});
// Cloud_GDC_Y.storyName = 'Cloud: DC Agents Available';
// Cloud_GDC_Y.parameters = {
// msw: handlers({ dcAgentsAdded: true }),
// };
// Cloud_GDC_Y.args = {
// ...defaultArgs,
// dataConnectorHostType: 'cloud',
// showEnterpriseDrivers: true,
// initialDriverName: 'snowflake',
// };
// Cloud_GDC_Y.play = async ({ canvasElement }) => {
// const c = within(canvasElement);
// await waitFor(() => c.findByTestId('fancy-radio-postgres'));
// await expect(c.queryByTestId('fancy-radio-snowflake')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-athena')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-sqlite')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-mysqlgdc')).toBeInTheDocument();
// await userEvent.click(await c.findByTestId('fancy-label-snowflake'));
// await expect(
// await c.queryByTestId('cloud-driver-not-available')
// ).not.toBeInTheDocument();
// await expect(
// await c.queryByTestId('connect-existing-button')
// ).toBeInTheDocument();
// };
// export const Cloud_GDC_N = Template.bind({});
// Cloud_GDC_N.storyName = 'Cloud: DC Agents Not Available';
// Cloud_GDC_N.parameters = {
// msw: handlers({ dcAgentsAdded: false }),
// };
// Cloud_GDC_N.args = {
// ...defaultArgs,
// dataConnectorHostType: 'cloud',
// showEnterpriseDrivers: true,
// initialDriverName: 'snowflake',
// };
// Cloud_GDC_N.play = async ({ canvasElement }) => {
// const c = within(canvasElement);
// await waitFor(() => c.findByTestId('fancy-radio-postgres'));
// await expect(c.queryByTestId('fancy-radio-snowflake')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-athena')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-sqlite')).toBeInTheDocument();
// await expect(c.queryByTestId('fancy-radio-mysqlgdc')).toBeInTheDocument();
// await userEvent.click(await c.findByTestId('fancy-label-snowflake'));
// await expect(
// await c.findByTestId('cloud-driver-not-available')
// ).toBeInTheDocument();
// await expect(
// await c.queryByTestId('connect-existing-button')
// ).not.toBeInTheDocument();
// };
// export const Neon_Connect = Template.bind({});
// Neon_Connect.args = {
// ...defaultArgs,
// allowNeonConnect: true,
// };
// Neon_Connect.play = async ({ canvasElement }) => {
// const c = within(canvasElement);
// await waitFor(() => c.findByTestId('fancy-radio-postgres'));
// await userEvent.click(await c.findByTestId('fancy-label-postgres'));
// await expect(await c.findByTestId('neon-connect')).toBeInTheDocument();
// await expect(
// await c.findByTestId('connect-existing-button')
// ).toBeInTheDocument();
// };

View File

@ -1,23 +1,74 @@
import {
SelectDatabase,
SelectDatabaseProps,
} from './components/SelectDatabase/SelectDatabase';
import React from 'react';
import { DriverInfo } from '../DataSource';
import { EELiteAccess } from '../EETrial';
import { ConnectDatabaseWrapper, FancyRadioCards } from './components';
import { ConnectDbBody } from './ConnectDbBody';
import { DEFAULT_DRIVER } from './constants';
import { useDatabaseConnectDrivers } from './hooks/useConnectDatabaseDrivers';
import { DbConnectConsoleType } from './types';
export type ConnectDatabaseProps = {
/**
*
* Can be used to set initial selected database. Will default to Postgres if not set.
*
*/
initialDriverName?: string;
/**
*
* Used to drive the rendering of body content after the radio cards
*
*/
consoleType: DbConnectConsoleType;
/**
*
* Possible license statuses that are relevant to ProLite
*
*/
eeLicenseInfo: EELiteAccess['access'];
};
export const ConnectDatabaseV2 = (props: ConnectDatabaseProps) => {
const { initialDriverName, eeLicenseInfo, consoleType } = props;
const [selectedDriver, setSelectedDriver] =
React.useState<DriverInfo>(DEFAULT_DRIVER);
const { cardData, allDrivers, availableDrivers } = useDatabaseConnectDrivers({
showEnterpriseDrivers: consoleType !== 'oss',
onFirstSuccess: () =>
setSelectedDriver(
currentDriver =>
allDrivers.find(
d =>
d.name === initialDriverName &&
(d.enterprise === false || consoleType !== 'oss')
) || currentDriver
),
});
const isDriverAvailable = (availableDrivers ?? []).some(
d => d.name === selectedDriver.name
);
export const ConnectDatabase = (props: SelectDatabaseProps) => {
return (
<div className="flex flex-col items-center">
<div className="py-lg border-b border-slate-300 w-full flex justify-center">
<div className="max-w-3xl w-full">
<div className="text-xl font-bold">Connect Your First Database</div>
<div className="text-muted">
Connect your first database to access your database objects in your
GraphQL API.
</div>
</div>
</div>
<div className="max-w-3xl py-lg w-full">
<SelectDatabase {...props} />
</div>
</div>
<ConnectDatabaseWrapper>
<FancyRadioCards
items={cardData}
value={selectedDriver?.name}
onChange={val => {
setSelectedDriver(
prev => allDrivers?.find(d => d.name === val) || prev
);
}}
/>
<ConnectDbBody
consoleType={consoleType}
selectedDriver={selectedDriver}
eeLicenseInfo={eeLicenseInfo}
isDriverAvailable={isDriverAvailable}
/>
</ConnectDatabaseWrapper>
);
};

View File

@ -0,0 +1,44 @@
import { DriverInfo } from '../../DataSource';
import { EELiteAccess } from '../../EETrial';
import { DbConnectConsoleType } from '../types';
import { Cloud, Oss, Pro, ProLite } from './parts';
type ConnectDbBodyProps = {
consoleType: DbConnectConsoleType;
selectedDriver: DriverInfo;
isDriverAvailable: boolean;
eeLicenseInfo: EELiteAccess['access'];
};
export const ConnectDbBody = ({
consoleType,
selectedDriver,
isDriverAvailable,
eeLicenseInfo,
}: ConnectDbBodyProps) => {
switch (consoleType) {
case 'oss':
return <Oss selectedDriver={selectedDriver} />;
case 'pro-lite':
return (
<ProLite
selectedDriver={selectedDriver}
eeLicenseInfo={eeLicenseInfo}
isDriverAvailable={isDriverAvailable}
/>
);
case 'pro':
return (
<Pro
selectedDriver={selectedDriver}
isDriverAvailable={isDriverAvailable}
/>
);
case 'cloud':
return (
<Cloud
selectedDriver={selectedDriver}
isDriverAvailable={isDriverAvailable}
/>
);
}
};

View File

@ -0,0 +1 @@
export { ConnectDbBody } from './ConnectDbBody';

View File

@ -0,0 +1,52 @@
import { NeonConnect } from '../../../../components/Services/Data/DataSources/CreateDataSource/Neon';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { useAppDispatch } from '../../../../storeHooks';
import { DriverInfo } from '../../../DataSource';
import { useMetadata } from '../../../hasura-metadata-api';
import { ConnectButton } from '../../components/ConnectButton';
import { DEFAULT_DRIVER } from '../../constants';
export const Cloud = ({
selectedDriver,
isDriverAvailable,
}: {
selectedDriver: DriverInfo;
isDriverAvailable: boolean;
}) => {
const { data: sourceNames } = useMetadata(m =>
m?.metadata.sources.map(s => s.name)
);
const dispatch = useAppDispatch();
const selectedDriverName = selectedDriver?.name ?? DEFAULT_DRIVER.name;
return (
<>
{selectedDriver?.name === 'postgres' && (
<div className="mt-3" data-testid="neon-connect">
<NeonConnect
allDatabases={sourceNames ?? []}
dispatch={dispatch}
connectDbUrl={'data/v2/manage/connect'}
/>
</div>
)}
{!isDriverAvailable ? (
<div className="mt-3" data-testid="cloud-driver-not-available">
<IndicatorCard
status="negative"
headline="Cannot find the corresponding driver info"
>
The response from<code>list_source_kinds</code>did not return your
selected driver. Please verify if the data connector agent is
reachable from your Hasura instance.
</IndicatorCard>
</div>
) : (
<ConnectButton driverName={selectedDriverName} />
)}
</>
);
};

View File

@ -0,0 +1,6 @@
import { DriverInfo } from '../../../DataSource';
import { ConnectButton } from '../../components/ConnectButton';
export const Oss = ({ selectedDriver }: { selectedDriver: DriverInfo }) => (
<ConnectButton driverName={selectedDriver?.name} />
);

View File

@ -0,0 +1,28 @@
import { DriverInfo } from '../../../DataSource';
import { SetupConnector } from '../../components';
import { ConnectButton } from '../../components/ConnectButton';
import { usePushRoute } from '../../hooks';
export const Pro = ({
selectedDriver,
isDriverAvailable,
}: {
selectedDriver: DriverInfo;
isDriverAvailable: boolean;
}) => {
const pushRoute = usePushRoute();
return isDriverAvailable ? (
<ConnectButton driverName={selectedDriver?.name} />
) : (
<div className="mt-3" data-testid="setup-connector">
<SetupConnector
selectedDriver={selectedDriver}
onSetupSuccess={() => {
pushRoute(
`/data/v2/manage/database/add?driver=${selectedDriver?.name}`
);
}}
/>
</div>
);
};

View File

@ -0,0 +1,85 @@
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import { DriverInfo } from '../../../DataSource';
import { EELiteAccess, EETrialCard } from '../../../EETrial';
import { SetupConnector } from '../../components';
import { ConnectButton } from '../../components/ConnectButton';
import { eeCardContentMap } from '../../constants';
import { usePushRoute } from '../../hooks';
import { indefiniteArticle } from '../../utils';
export const ProLite = ({
selectedDriver,
isDriverAvailable,
eeLicenseInfo,
}: {
selectedDriver: DriverInfo;
isDriverAvailable: boolean;
eeLicenseInfo: EELiteAccess['access'];
}) => {
const pushRoute = usePushRoute();
const dbWithArticle = `${indefiniteArticle(selectedDriver.displayName)} ${
selectedDriver.displayName
}`;
return (
<>
{selectedDriver?.enterprise &&
(() => {
switch (eeLicenseInfo) {
case 'active':
return !isDriverAvailable ? (
<div className="mt-3" data-testid="setup-connector">
<SetupConnector
selectedDriver={selectedDriver}
onSetupSuccess={() => {
pushRoute(
`/data/v2/manage/database/add?driver=${selectedDriver?.name}`
);
}}
/>
</div>
) : null;
case 'forbidden':
/**
*
* The only way "forbidden" happens here is if the licensing API is not reachable.
*
*/
return (
<div className="mt-3" data-testid="license-forbidden-api-error">
<IndicatorCard
status="negative"
headline="Error Loading License"
>
Unable to determine your Enterprise License status.
</IndicatorCard>
</div>
);
/**
* Being verbose for state clarity
*/
case 'loading':
case 'deactivated':
case 'expired':
case 'eligible':
return (
<div className="mt-3" data-testid="ee-trial-card">
<EETrialCard
eeAccess={eeLicenseInfo}
id={dbWithArticle.replace(' ', '-')}
horizontal
{...eeCardContentMap(dbWithArticle)[eeLicenseInfo]}
/>
</div>
);
}
})()}
{(!selectedDriver?.enterprise ||
(eeLicenseInfo === 'active' && isDriverAvailable)) && (
<ConnectButton driverName={selectedDriver?.name} />
)}
</>
);
};

View File

@ -0,0 +1,4 @@
export { Cloud } from './Cloud';
export { Oss } from './Oss';
export { Pro } from './Pro';
export { ProLite } from './ProLite';

View File

@ -1,23 +0,0 @@
// Button.stories.ts|tsx
import React from 'react';
import { ReactQueryDecorator } from '../../storybook/decorators/react-query';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { SelectDatabase } from '.';
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
component: SelectDatabase,
decorators: [ReactQueryDecorator()],
} as ComponentMeta<typeof SelectDatabase>;
export const Primary: ComponentStory<typeof SelectDatabase> = () => (
<div className="max-w-3xl">
Note: This container has a max width set. When rendering this component keep
width in mind to avoid it growing too large.
<SelectDatabase />
</div>
);

View File

@ -1,76 +0,0 @@
import { NeonBanner } from '../../components/Services/Data/DataSources/CreateDataSource/Neon/components/Neon/NeonBanner';
import { DatabaseKind } from './types';
import { Button } from '../../new-components/Button';
import React from 'react';
import DbConnectSVG from '../../graphics/database-connect.svg';
import { FancyRadioCards } from './components/FancyRadioCards';
import { databases } from './databases';
const enterpriseDbs: DatabaseKind[] = ['snowflake', 'athena'];
export const SelectDatabase: React.VFC = () => {
const [selectedDb, setSelectedDb] = React.useState<DatabaseKind>('snowflake');
return (
<div className="flex flex-col">
<img
src={DbConnectSVG}
className={`mb-md w-full`}
//
alt="Database Connection Diagram"
/>
<FancyRadioCards
items={databases}
value={selectedDb}
onChange={val => {
console.log('selected value', val);
setSelectedDb(val);
}}
/>
{selectedDb === 'postgres' && (
<div className="mt-3">
<NeonBanner
onClickConnect={() =>
window.alert('todo: implement Neon integration')
}
status={{ status: 'default' }}
buttonText="Create a Neon Database"
/>
</div>
)}
{enterpriseDbs.includes(selectedDb) && (
<div className="border border-gray-300 mt-3 shadow-md rounded bg-white p-6">
<div className="flex items-center">
<div className="flex flex-col w-3/4">
<div className="text-[21px]">
Looking to connect to{' '}
{selectedDb === 'snowflake' ? 'a Snowflake' : 'an Athena'}{' '}
database?
</div>
<div className="text-md text-gray-700">
Deploy data connectors to add data sources such as Snowflake,
Amazon Athena, and more to your GraphQL API.
</div>
</div>
<div className="flex w-1/4 justify-end">
<Button
//data-testid="onboarding-wizard-neon-connect-db-button"
mode={'primary'}
//isLoading={status.status === 'loading'}
//loadingText={buttonText}
size="md"
//icon={icon ? iconMap[icon] : undefined}
onClick={() => {}}
//disabled={isButtonDisabled}
>
<div className="text-black font-semibold text-md">
Enable Enterprise
</div>
</Button>
</div>
</div>
</div>
)}
<Button className="mt-6 self-end">Connect Existing Database</Button>
</div>
);
};

View File

@ -1,14 +1,15 @@
import { useEffect, useState } from 'react';
import { InputField, useConsoleForm } from '../../../../new-components/Form';
import { Button } from '../../../../new-components/Button';
import { useEffect } from 'react';
import { GraphQLCustomization } from '../GraphQLCustomization/GraphQLCustomization';
import { Configuration } from './parts/Configuration';
import { getDefaultValues, BigQueryConnectionSchema, schema } from './schema';
import { useManageDatabaseConnection } from '../../hooks/useManageDatabaseConnection';
import { hasuraToast } from '../../../../new-components/Toasts';
import { useMetadata } from '../../../hasura-metadata-api';
import { generatePostgresRequestPayload } from './utils/generateRequests';
import { useManageDatabaseConnection } from '../../hooks/useManageDatabaseConnection';
import { generateBigQueryRequestPayload } from './utils/generateRequests';
import { Collapsible } from '../../../../new-components/Collapsible';
import { Tabs } from '../../../../new-components/Tabs';
interface ConnectBigQueryWidgetProps {
dataSourceName?: string;
@ -23,6 +24,8 @@ export const ConnectBigQueryWidget = (props: ConnectBigQueryWidgetProps) => {
m.metadata.sources.find(source => source.name === dataSourceName)
);
const [tab, setTab] = useState('connectionDetails');
const { createConnection, editConnection, isLoading } =
useManageDatabaseConnection({
onSuccess: () => {
@ -43,7 +46,7 @@ export const ConnectBigQueryWidget = (props: ConnectBigQueryWidgetProps) => {
});
const handleSubmit = (formValues: BigQueryConnectionSchema) => {
const payload = generatePostgresRequestPayload({
const payload = generateBigQueryRequestPayload({
driver: 'bigquery',
values: formValues,
});
@ -80,37 +83,52 @@ export const ConnectBigQueryWidget = (props: ConnectBigQueryWidgetProps) => {
<div className="text-xl text-gray-600 font-semibold">
{isEditMode ? 'Edit BigQuery Connection' : 'Connect BigQuery Database'}
</div>
<Form onSubmit={handleSubmit}>
<InputField
name="name"
label="Database name"
placeholder="Database name"
/>
<Configuration name="configuration" />
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
GraphQL Customization
<Tabs
value={tab}
onValueChange={value => setTab(value)}
items={[
{
value: 'connectionDetails',
label: 'Connection Details',
content: (
<div className="mt-sm">
<Form onSubmit={handleSubmit}>
<InputField
name="name"
label="Database name"
placeholder="Database name"
/>
<Configuration name="configuration" />
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
GraphQL Customization
</div>
}
>
<GraphQLCustomization name="customization" />
</Collapsible>
</div>
<div className="flex justify-end">
<Button
type="submit"
mode="primary"
isLoading={isLoading}
loadingText="Saving"
>
{isEditMode ? 'Update Connection' : 'Connect Database'}
</Button>
</div>
</Form>
</div>
}
>
<GraphQLCustomization name="customization" />
</Collapsible>
</div>
<div className="flex justify-end">
<Button
type="submit"
mode="primary"
isLoading={isLoading}
loadingText="Saving"
>
{isEditMode ? 'Update Connection' : 'Connect Database'}
</Button>
</div>
</Form>
),
},
]}
/>
</div>
);
};

View File

@ -3,7 +3,7 @@ import { generateGraphQLCustomizationInfo } from '../../GraphQLCustomization/uti
import { BigQueryConnectionSchema } from '../schema';
import { cleanEmpty } from '../../ConnectPostgresWidget/utils/helpers';
export const generatePostgresRequestPayload = ({
export const generateBigQueryRequestPayload = ({
driver,
values,
}: {

View File

@ -0,0 +1,19 @@
import React from 'react';
import { Button } from '../../../new-components/Button';
import { usePushRoute } from '../hooks';
export const ConnectButton = ({ driverName }: { driverName: string }) => {
const pushRoute = usePushRoute();
return (
<Button
className="mt-6 self-end"
data-testid="connect-existing-button"
onClick={() =>
pushRoute(`/data/v2/manage/database/add?driver=${driverName}`)
}
>
Connect Existing Database
</Button>
);
};

View File

@ -0,0 +1,41 @@
import { useMetadata } from '../../hasura-metadata-api';
import React from 'react';
import DbConnectSVG from '../graphics/database-connect.svg';
export const ConnectDatabaseWrapper: React.FC = ({ children }) => {
const { data: metadataSources } = useMetadata(m => m.metadata.sources);
return (
<div className="flex flex-col items-center">
<div className="py-lg border-b border-slate-300 w-full flex justify-center">
<div className="max-w-3xl w-full">
<div className="text-xl font-bold">
{metadataSources?.length
? 'Connect Database'
: 'Connect Your First Database'}
</div>
{metadataSources?.length ? (
<div className="text-muted">
Connect a database to access your database objects in your GraphQL
API.
</div>
) : (
<div className="text-muted">
Connect your first database to access your database objects in
your GraphQL API.
</div>
)}
</div>
</div>
<div className="max-w-3xl py-lg w-full">
<div className="flex flex-col">
<img
src={DbConnectSVG}
className={`mb-md w-full`}
alt="Database Connection Diagram"
/>
{children}
</div>
</div>
</div>
);
};

View File

@ -18,6 +18,7 @@ import { generateGDCRequestPayload } from './utils/generateRequest';
import { hasuraToast } from '../../../../new-components/Toasts';
import { useManageDatabaseConnection } from '../../hooks/useManageDatabaseConnection';
import { capitaliseFirstLetter } from '../../../../components/Common/ConfigureTransformation/utils';
import { Collapsible } from '../../../../new-components/Collapsible';
interface ConnectGDCSourceWidgetProps {
driver: string;
@ -159,14 +160,21 @@ export const ConnectGDCSourceWidget = (props: ConnectGDCSourceWidgetProps) => {
schemaObject={data?.configSchemas.configSchema}
references={data?.configSchemas.otherSchemas}
/>
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
GraphQL Customization
</div>
}
>
<GraphQLCustomization name="customization" />
</Collapsible>
</div>
</div>
),
},
{
value: 'customization',
label: 'GraphQL Customization',
content: <GraphQLCustomization name="customization" />,
},
]}
/>
<div className="flex justify-end">

View File

@ -1,6 +1,6 @@
import { InputField, useConsoleForm } from '../../../../new-components/Form';
import { Button } from '../../../../new-components/Button';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { GraphQLCustomization } from '../GraphQLCustomization/GraphQLCustomization';
import { getDefaultValues, MssqlConnectionSchema, schema } from './schema';
import { ReadReplicas } from './parts/ReadReplicas';
@ -9,9 +9,10 @@ import { hasuraToast } from '../../../../new-components/Toasts';
import { useMetadata } from '../../../hasura-metadata-api';
import { generateMssqlRequestPayload } from './utils/generateRequests';
import { ConnectionString } from './parts/ConnectionString';
import { areReadReplicasEnabled } from '../ConnectPostgresWidget/utils/helpers';
import { Collapsible } from '../../../../new-components/Collapsible';
import { PoolSettings } from './parts/PoolSettings';
import { LimitedFeatureWrapper } from '../LimitedFeatureWrapper/LimitedFeatureWrapper';
import { Tabs } from '../../../../new-components/Tabs';
interface ConnectMssqlWidgetProps {
dataSourceName?: string;
@ -21,6 +22,7 @@ export const ConnectMssqlWidget = (props: ConnectMssqlWidgetProps) => {
const { dataSourceName } = props;
const isEditMode = !!dataSourceName;
const [tab, setTab] = useState('connectionDetails');
const { data: metadataSource } = useMetadata(m =>
m.metadata.sources.find(source => source.name === dataSourceName)
@ -82,59 +84,82 @@ export const ConnectMssqlWidget = (props: ConnectMssqlWidgetProps) => {
<div className="text-xl text-gray-600 font-semibold">
{isEditMode ? 'Edit MSSQL Connection' : 'Connect MSSQL Database'}
</div>
<Form onSubmit={handleSubmit}>
<InputField
name="name"
label="Database name"
placeholder="Database name"
/>
<ConnectionString name="configuration.connectionInfo.connectionString" />
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">Advanced Settings</div>
}
>
<PoolSettings name="configuration.connectionInfo.poolSettings" />
</Collapsible>
</div>
<Tabs
value={tab}
onValueChange={value => setTab(value)}
items={[
{
value: 'connectionDetails',
label: 'Connection Details',
content: (
<div className="mt-sm">
<Form onSubmit={handleSubmit}>
<InputField
name="name"
label="Database name"
placeholder="Database name"
/>
<ConnectionString name="configuration.connectionInfo.connectionString" />
{areReadReplicasEnabled() && (
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">Read Replicas</div>
}
>
<ReadReplicas name="configuration.readReplicas" />
</Collapsible>
</div>
)}
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
Advanced Settings
</div>
}
>
<PoolSettings name="configuration.connectionInfo.poolSettings" />
</Collapsible>
</div>
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
GraphQL Customization
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
GraphQL Customization
</div>
}
>
<GraphQLCustomization name="customization" />
</Collapsible>
</div>
<div className="mt-sm">
<LimitedFeatureWrapper
title="Looking to add Read Replicas?"
id="read-replicas"
description="Get production-ready today with a 30-day free trial of Hasura EE, no credit card required."
>
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
Read Replicas
</div>
}
>
<ReadReplicas name="configuration.readReplicas" />
</Collapsible>
</LimitedFeatureWrapper>
</div>
<div className="flex justify-end mt-sm">
<Button
type="submit"
mode="primary"
isLoading={isLoading}
loadingText="Saving"
>
{isEditMode ? 'Update Connection' : 'Connect Database'}
</Button>
</div>
</Form>
</div>
}
>
<GraphQLCustomization name="customization" />
</Collapsible>
</div>
<div className="flex justify-end">
<Button
type="submit"
mode="primary"
isLoading={isLoading}
loadingText="Saving"
>
{isEditMode ? 'Update Connection' : 'Connect Database'}
</Button>
</div>
</Form>
),
},
]}
/>
</div>
);
};

View File

@ -14,8 +14,12 @@ import { UsePreparedStatements } from './parts/UsePreparedStatements';
import { SslSettings } from './parts/SslSettings';
import { Collapsible } from '../../../../new-components/Collapsible';
import { ExtensionSchema } from './parts/ExtensionSchema';
import { areReadReplicasEnabled, areSSLSettingsEnabled } from './utils/helpers';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { LimitedFeatureWrapper } from '../LimitedFeatureWrapper/LimitedFeatureWrapper';
import { DynamicDBRouting } from './parts/DynamicDBRouting';
import { Tabs } from '../../../../new-components/Tabs';
import { isProConsole } from '../../../../utils';
import globals from '../../../../Globals';
interface ConnectPostgresWidgetProps {
dataSourceName?: string;
@ -27,6 +31,7 @@ interface ConnectPostgresWidgetProps {
export const ConnectPostgresWidget = (props: ConnectPostgresWidgetProps) => {
const { dataSourceName, overrideDriver, overrideDisplayName } = props;
const [tab, setTab] = useState('connectionDetails');
const isEditMode = !!dataSourceName;
@ -88,102 +93,150 @@ export const ConnectPostgresWidget = (props: ConnectPostgresWidgetProps) => {
const hiddenOptions =
overrideDriver === 'cockroach' ? ['connectionParams'] : [];
const dynamicDBRoutingTab =
dataSourceName && isEditMode && isProConsole(globals)
? [
{
value: 'dynamicDBRouting',
label: 'Dynamic DB Routing',
content: (
<div className="mt-sm">
<DynamicDBRouting sourceName={dataSourceName} />
</div>
),
},
]
: [];
return (
<div>
<>
<div className="text-xl text-gray-600 font-semibold">
{isEditMode
? `Edit ${overrideDisplayName ?? 'Postgres'} Connection`
: `Connect ${overrideDisplayName ?? 'Postgres'} Database`}
</div>
<Form onSubmit={handleSubmit}>
<InputField
name="name"
label="Database name"
placeholder="Database name"
/>
<Tabs
value={tab}
onValueChange={value => setTab(value)}
items={[
{
value: 'connectionDetails',
label: 'Connection Details',
content: (
<div className="mt-sm">
<Form onSubmit={handleSubmit}>
<InputField
name="name"
label="Database name"
placeholder="Database name"
/>
<div className="bg-white border border-hasGray-300 rounded-md shadow-sm overflow-hidden p-4">
<DatabaseUrl
name="configuration.connectionInfo.databaseUrl"
hideOptions={hiddenOptions}
/>
</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.isolationLevel`}
/>
<UsePreparedStatements
name={`configuration.connectionInfo.usePreparedStatements`}
/>
<ExtensionSchema name="configuration.extensionSchema" />
{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 className="bg-white border border-hasGray-300 rounded-md shadow-sm overflow-hidden p-4">
<DatabaseUrl
name="configuration.connectionInfo.databaseUrl"
hideOptions={hiddenOptions}
/>
</div>
}
>
<SslSettings
name={`configuration.connectionInfo.sslSettings`}
/>
</Collapsible>
)}
</Collapsible>
</div>
{areReadReplicasEnabled() && (
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">Read Replicas</div>
}
>
<ReadReplicas
name="configuration.readReplicas"
hideOptions={hiddenOptions}
/>
</Collapsible>
</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.isolationLevel`}
/>
<UsePreparedStatements
name={`configuration.connectionInfo.usePreparedStatements`}
/>
<ExtensionSchema name="configuration.extensionSchema" />
<LimitedFeatureWrapper
title="Looking to add SSL Settings?"
id="db-ssl-settings"
description="Get production-ready today with a 30-day free trial of Hasura EE, no credit card required."
>
<div className="mt-sm">
<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>
</div>
</LimitedFeatureWrapper>
</Collapsible>
</div>
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
GraphQL Customization
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
GraphQL Customization
</div>
}
>
<GraphQLCustomization name="customization" />
</Collapsible>
</div>
<div className="mt-sm">
<LimitedFeatureWrapper
id="read-replicas"
title="Improve performance and handle increased traffic with read replicas"
description="Scale your database by offloading read queries to
read-only replicas, allowing for better performance
and availability for users."
>
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
Read Replicas
</div>
}
>
<ReadReplicas
name="configuration.readReplicas"
hideOptions={hiddenOptions}
/>
</Collapsible>
</LimitedFeatureWrapper>
</div>
<div className="flex justify-end mt-sm">
<Button
type="submit"
mode="primary"
isLoading={isLoading}
loadingText="Saving"
>
{isEditMode ? 'Update Connection' : 'Connect Database'}
</Button>
</div>
</Form>
</div>
}
>
<GraphQLCustomization name="customization" />
</Collapsible>
</div>
<div className="flex justify-end mt-sm">
<Button
type="submit"
mode="primary"
isLoading={isLoading}
loadingText="Saving"
>
{isEditMode ? 'Update Connection' : 'Connect Database'}
</Button>
</div>
</Form>
</div>
),
},
...dynamicDBRoutingTab,
]}
/>
</>
);
};

View File

@ -136,7 +136,7 @@ export const DynamicDBRoutingForm = (props: DynamicDBRoutingFormProps) => {
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">
<div className="ml-xs max-w-2xl">
<strong>Dynamic Routing Precedence</strong>
<p>
{' '}

View File

@ -29,7 +29,7 @@ export const ReadReplicas = ({
>({
name,
});
const { watch, setValue } =
const { watch, setValue, trigger } =
useFormContext<Record<string, ConnectionInfoSchema[]>>();
const [mode, setMode] = useState<'idle' | 'add' | 'edit'>('idle');
@ -141,9 +141,14 @@ export const ReadReplicas = ({
</Collapsible>
</div>
<Button
onClick={() => {
setMode('idle');
setActiveRow(undefined);
onClick={async () => {
// validate the current open read replica state before closing.
const result = await trigger(`${name}.${activeRow}`);
if (result) {
setMode('idle');
setActiveRow(undefined);
}
}}
mode="primary"
className="my-2"

View File

@ -0,0 +1,97 @@
import BreadCrumb from '../../../../components/Common/Layout/BreadCrumb/BreadCrumb';
import { getRoute } from '../../../../utils/getDataRoute';
import { ConnectBigQueryWidget } from '../ConnectBigQueryWidget/ConnectBigQueryWidget';
import { ConnectGDCSourceWidget } from '../ConnectGDCSourceWidget/ConnectGDCSourceWidget';
import { ConnectMssqlWidget } from '../ConnectMssqlWidget/ConnectMssqlWidget';
import { ConnectPostgresWidget } from '../ConnectPostgresWidget/ConnectPostgresWidget';
const getEditDatasourceName = (): string | undefined => {
const urlParams = new URLSearchParams(window.location.search);
const database = urlParams.get('database');
return database ?? undefined;
};
const getDriverName = (): string | undefined => {
const urlParams = new URLSearchParams(window.location.search);
const driver = urlParams.get('driver');
return driver ?? undefined;
};
const ConnectDatabaseWrapper = () => {
const dataSourceName = getEditDatasourceName();
const driver = getDriverName();
if (!driver) return <div>Error. No driver found.</div>;
if (driver === 'postgres')
return <ConnectPostgresWidget dataSourceName={dataSourceName} />;
if (driver === 'citus')
return (
<ConnectPostgresWidget
dataSourceName={dataSourceName}
overrideDisplayName="Citus"
overrideDriver="citus"
/>
);
if (driver === 'alloy')
return (
<ConnectPostgresWidget
dataSourceName={dataSourceName}
overrideDisplayName="AlloyDB"
/>
);
if (driver === 'cockroach')
return (
<ConnectPostgresWidget
dataSourceName={dataSourceName}
overrideDisplayName="CockroachDB"
overrideDriver="cockroach"
/>
);
if (driver === 'bigquery')
return <ConnectBigQueryWidget dataSourceName={dataSourceName} />;
if (driver === 'mssql')
return <ConnectMssqlWidget dataSourceName={dataSourceName} />;
return (
<ConnectGDCSourceWidget dataSourceName={dataSourceName} driver={driver} />
);
};
export const ConnectUIContainer = () => {
const driver = getDriverName();
return (
<div className="p-4">
<BreadCrumb
breadCrumbs={[
{
url: '/data',
title: 'Data',
},
{
url: '/data/manage',
title: 'Manage',
},
{
url: '/data/v2/manage/connect',
title: 'Connect',
},
{
url: getRoute().connectDatabase(driver),
title: driver ?? '',
},
]}
/>
<ConnectDatabaseWrapper />
</div>
);
};

View File

@ -0,0 +1 @@
export { ConnectUIContainer } from './ConnectUIContainer';

View File

@ -8,7 +8,7 @@ export const DatabaseLogo: React.FC<{ title: string; image: string }> = ({
<div className="flex flex-col mt-2 items-center">
<img
src={image}
className="h-[16px] w-[16px] mb-2"
className="h-[24px] w-[24px] mb-2 object-contain"
alt={`${title} logo`}
/>
<div className="text-black text-base">{title}</div>

View File

@ -0,0 +1,56 @@
import { expect } from '@storybook/jest';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import React from 'react';
import { ReactQueryDecorator } from '../../../storybook/decorators/react-query';
import { handlers } from '../mocks/handlers.mock';
import { useDatabaseConnectDrivers } from '../hooks';
import { FancyRadioCards } from './FancyRadioCards';
import { Badge } from '../../../new-components/Badge';
export default {
component: FancyRadioCards,
parameters: {
msw: handlers(),
},
decorators: [ReactQueryDecorator()],
} as ComponentMeta<typeof FancyRadioCards>;
const Template: ComponentStory<typeof FancyRadioCards> = () => {
// using this hook as it has a handy card data return so the story has an example of icons + text
const { cardData } = useDatabaseConnectDrivers({
showEnterpriseDrivers: true,
});
const [value, setValue] = React.useState('postgres');
return (
<div>
<div className="mb-3">
Selected Value:
<Badge className="ml-2">
<span data-testid="value">{value}</span>
</Badge>
</div>
<FancyRadioCards value={value} items={cardData} onChange={setValue} />
</div>
);
};
export const Primary = Template.bind({});
Primary.play = async ({ canvasElement }) => {
const c = within(canvasElement);
// test click on label element
await userEvent.click(await c.findByTestId('fancy-label-snowflake'));
expect(c.getByTestId('value')).toHaveTextContent('snowflake');
// test click on radio button
await userEvent.click(await c.findByTestId('fancy-radio-mssql'));
expect(c.getByTestId('value')).toHaveTextContent('mssql');
};

View File

@ -1,10 +1,8 @@
import { DatabaseKind } from '../types';
import * as RadioGroup from '@radix-ui/react-radio-group';
import clsx from 'clsx';
import React from 'react';
import React, { VFC } from 'react';
const twRadioStyles = {
//root: `flex flex-row gap-3 flex-wrap w-full`,
root: `grid grid-cols-4 gap-3`,
itemContainer: {
default: `flex items-center border bg-white shadow-sm rounded border-gray-300 cursor-pointer relative flex-[0_0_160px] h-[88px]`,
@ -16,24 +14,25 @@ const twRadioStyles = {
label: `text-base whitespace-nowrap cursor-pointer flex-[1] h-full w-full flex justify-center items-center`,
};
export const FancyRadioCards: React.VFC<{
export const FancyRadioCards: VFC<{
value: string;
items: {
value: string;
content: React.ReactNode | string;
}[];
onChange: (value: DatabaseKind) => void;
onChange: (value: string) => void;
}> = ({ value, items, onChange }) => {
return (
<RadioGroup.Root
className={twRadioStyles.root}
defaultValue={value}
aria-label="View density"
value={value}
aria-label="Radio cards"
onValueChange={onChange}
>
{items.map((item, i) => {
return (
<div
key={item.value}
className={clsx(
twRadioStyles.itemContainer.default,
value === item.value && twRadioStyles.itemContainer.active
@ -42,12 +41,14 @@ export const FancyRadioCards: React.VFC<{
<RadioGroup.Item
className={twRadioStyles.radioButton}
value={item.value}
data-testid={`fancy-radio-${item.value}`}
id={`radio-item-${item.value}`}
>
<RadioGroup.Indicator className={twRadioStyles.indicator} />
</RadioGroup.Item>
<label
className={twRadioStyles.label}
data-testid={`fancy-label-${item.value}`}
htmlFor={`radio-item-${item.value}`}
>
{item.content}

View File

@ -1,7 +1,5 @@
export { GraphQLCustomization } from './GraphQLCustomization';
export { adaptGraphQLCustomization } from './utils/adaptResponse';
export { generateGraphQLCustomizationInfo } from './utils/generateRequest';
export {
GraphQLCustomizationSchema,
graphQLCustomizationSchema,
} from './schema';
export { graphQLCustomizationSchema } from './schema';
export type { GraphQLCustomizationSchema } from './schema';

View File

@ -17,4 +17,6 @@ export const graphQLCustomizationSchema = z.object({
namingConvention: z.string().optional(),
});
export type GraphQLCustomization = z.infer<typeof graphQLCustomizationSchema>;
export type GraphQLCustomizationSchema = z.infer<
typeof graphQLCustomizationSchema
>;

View File

@ -1,9 +1,9 @@
import { SourceCustomization } from '../../../../hasura-metadata-types';
import { GraphQLCustomization } from '../schema';
import { GraphQLCustomizationSchema } from '../schema';
export const adaptGraphQLCustomization = (
sourceCustomization: SourceCustomization
): GraphQLCustomization => {
): GraphQLCustomizationSchema => {
return {
rootFields: {
namespace: sourceCustomization.root_fields?.namespace,

View File

@ -1,7 +1,7 @@
import { GraphQLCustomization } from '../schema';
import { GraphQLCustomizationSchema } from '../schema';
export const generateGraphQLCustomizationInfo = (
values: GraphQLCustomization
values: GraphQLCustomizationSchema
) => {
return {
root_fields: {

View File

@ -0,0 +1,47 @@
import globals from '../../../../Globals';
import { isProConsole } from '../../../../utils';
import { EETrialCard, useEELiteAccess } from '../../../EETrial';
export const LimitedFeatureWrapper = ({
children,
title,
description,
id,
}: //
{
title: string;
description: string;
id: string;
children: React.ReactNode;
}) => {
const { access: eeLiteAccess } = useEELiteAccess(globals);
/**
* There are three cases here.
* 1. If it's OSS - do not show the children at all. (there is no point in using this wrapper for oss features)
* 2. If it's pro lite
* - show the "Try pro-lite" license form if license is not active.
* - show the children if license is active.
* 3. If it's cloud/pro just show the children
*
*/
// this will tell us if console is pro or cloud
const isPro = isProConsole(window.__env);
if (eeLiteAccess === 'active' || isPro) return <div>{children}</div>;
// this is to return nothing for oss
if (eeLiteAccess === 'forbidden') return null;
return (
<EETrialCard
id={id}
cardTitle={title}
cardText={description}
buttonType="default"
eeAccess={eeLiteAccess}
horizontal
/>
);
};

View File

@ -14,7 +14,6 @@ import { useMetadata } from '../../../MetadataAPI';
import _push from '../../../../components/Services/Data/push';
import { useReloadSource } from '../../hooks/useReloadSource';
import { useDropSource } from '../../hooks/useDropSource';
import { getRoute } from '../../../../utils/getDataRoute';
import { IndicatorCard } from '../../../../new-components/IndicatorCard';
import Skeleton from 'react-loading-skeleton';
import { useInconsistentSources } from '../../hooks/useInconsistentSources';
@ -129,12 +128,7 @@ export const ListConnectedDatabases = (props?: { className?: string }) => {
const columns = ['database', 'driver', '', ''];
const rowData = (databaseList ?? []).map((databaseItem, index) => [
<a
href={getRoute().database(databaseItem.dataSourceName)}
className="text-secondary"
>
{databaseItem.dataSourceName}
</a>,
<div>{databaseItem.dataSourceName}</div>,
databaseItem.driver,
isDatabaseVersionLoading || isInconsistentFetchCallLoading ? (
<Skeleton />
@ -178,7 +172,7 @@ export const ListConnectedDatabases = (props?: { className?: string }) => {
onClick={() => {
dispatch(
_push(
`/data/v2/database/edit?database=${databaseItem.dataSourceName}`
`/data/v2/manage/database/edit?driver=${databaseItem.driver}&database=${databaseItem.dataSourceName}`
)
);
}}

View File

@ -23,7 +23,7 @@ export const DisplayDetails = ({
<div className="flex justify-start">
<div
className={clsx(
'max-w-xs',
'max-w-2xl',
isExpanded
? 'whitespace-pre-line'
: 'overflow-hidden text-ellipsis whitespace-nowrap'

View File

@ -1,40 +0,0 @@
import { SelectDatabase } from '.';
import { hasuraToast } from '../../../../new-components/Toasts';
import { useArgs } from '@storybook/client-api';
import { ComponentMeta, ComponentStory } from '@storybook/react';
export default {
component: SelectDatabase,
argTypes: {
onEnableEnterpriseTrial: { action: 'Enable Enterprise Clicked' },
onContactSales: { action: 'Contact Sales Clicked' },
},
} as ComponentMeta<typeof SelectDatabase>;
export const Primary: ComponentStory<typeof SelectDatabase> = args => {
const [, updateArgs] = useArgs();
return (
<div className="max-w-3xl">
Note: This container has a max width set. When rendering this component
keep width in mind to avoid it growing too large.
<SelectDatabase
{...args}
onEnableEnterpriseTrial={() => {
hasuraToast({
message:
'Missing EE Trial Forms here. Setting ee trial prop to active as a temporary measure.',
title: 'Sign Up Not Implemented',
toastOptions: {
duration: 3000,
},
});
updateArgs({ ...args, eeState: 'active' });
}}
/>
</div>
);
};
Primary.args = {
eeState: 'inactive',
initialDb: 'snowflake',
};

View File

@ -1,67 +0,0 @@
import { Button } from '../../../../../new-components/Button';
import { InputField } from '../../../../../new-components/Form';
import clsx from 'clsx';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { FaRegCopy } from 'react-icons/fa';
const twStyles = {
alignToTopOfInput: `top-[32px]`,
inputHeight: `h-[36px]`,
copyConfirm: {
base: `select-none transition-opacity duration-300 font-bold bg-slate-100/50 rounded backdrop-blur-sm absolute left-0 w-full flex items-center justify-center`,
invisible: `opacity-0 pointer-events-none`,
visible: `opacity-100 pointer-events-auto`,
},
copyButton: `active:opacity-50 border-none bg-transparent absolute right-0 shadow-none bg-none`,
};
export const CopyableInputField: typeof InputField = props => {
const { watch } = useFormContext();
const fieldValue = watch(props.name);
// state to control visibility of copy confirmation
const [showCopiedConfirmation, setShowCopiedConfirmation] =
React.useState(false);
const copyTimer = React.useRef<NodeJS.Timeout>();
const handleCopyButton = () => {
// clear timer if already going...
if (copyTimer.current) {
clearTimeout(copyTimer.current);
}
// copy text to clipboard
navigator.clipboard.writeText(fieldValue);
// show confirmation
setShowCopiedConfirmation(true);
// hide after 1.5s
copyTimer.current = setTimeout(() => {
setShowCopiedConfirmation(false);
}, 1500);
};
return (
<div className="relative">
<InputField {...props} />
<Button
className={clsx(twStyles.copyButton, twStyles.alignToTopOfInput)}
icon={<FaRegCopy />}
onClick={handleCopyButton}
/>
<div
className={clsx(
twStyles.copyConfirm.base,
twStyles.alignToTopOfInput,
twStyles.inputHeight,
twStyles.copyConfirm.invisible,
showCopiedConfirmation && twStyles.copyConfirm.visible
)}
>
Copied!
</div>
</div>
);
};

View File

@ -1,17 +0,0 @@
import React from 'react';
export const DatabaseLogo: React.FC<{ title: string; image: string }> = ({
title,
image,
}) => {
return (
<div className="flex flex-col mt-2 items-center">
<img
src={image}
className="h-[16px] w-[16px] mb-2"
alt={`${title} logo`}
/>
<div className="text-black text-base">{title}</div>
</div>
);
};

View File

@ -1,100 +0,0 @@
import { CopyableInputField, InformationCard } from '.';
import { dbDisplayNames } from '../databases';
import { indefiniteArticle } from '../utils';
import { DatabaseKind } from '../../../types';
import { Button } from '../../../../../new-components/Button';
import { InputField, SimpleForm } from '../../../../../new-components/Form';
import { hasuraToast } from '../../../../../new-components/Toasts';
import React from 'react';
import { FaExternalLinkAlt } from 'react-icons/fa';
import { GrDocker } from 'react-icons/gr';
import { z } from 'zod';
export const EETrialActive: React.VFC<{ selectedDb: DatabaseKind }> = ({
selectedDb,
}) => {
const dbWithArticle = `${indefiniteArticle(selectedDb)} ${
dbDisplayNames[selectedDb]
}`;
return (
<InformationCard blueLeftBorder>
<div className="flex flex-col">
<div className="flex items-center pb-3 mb-3 border-b border-slate-300">
<div className="flex flex-col w-3/4">
<div className="font-bold">
{dbDisplayNames[selectedDb]} Connector Required
</div>
<div className="text-md text-gray-700">
{`The Hasura GraphQL Data Connector Service is required to connect to ${dbWithArticle} database.`}
</div>
</div>
<div className="flex w-1/4 justify-end">
<Button
icon={<FaExternalLinkAlt />}
iconPosition="end"
onClick={() => {
alert('need link to docs here');
}}
>
Deployment Methods
</Button>
</div>
</div>
<div>
<div className="font-bold text-muted flex items-center">
<GrDocker />
<div className="ml-1">Docker Initialization</div>
</div>
<SimpleForm
schema={z.object({
docker_command: z.string(),
agent_path: z.string(),
})}
options={{
defaultValues: {
docker_command:
'docker run -p 127.0.0.1:1234:1234 hasura/graphql-data-connector',
agent_path: 'http://host.docker.internal:1234',
},
}}
onSubmit={values => {
console.log(values);
}}
>
<CopyableInputField
className="mt-1"
label="Run GraphQL Data Connector Service"
tooltip="This is a really great tooltip for this field"
disabled={true}
name="docker_command"
learnMoreLink="https://hasura.io/docs"
/>
<InputField
name="agent_path"
label="Connect to GraphQL Data Connector URI"
tooltip="This is a really great tooltip for this field"
/>
<div className="flex justify-end w-full">
<Button
type="submit"
mode="primary"
onClick={() => {
hasuraToast({
title: 'Not Implemented',
message:
'This feature will be implemented once the dc_add_agent check is merged.',
toastOptions: {
duration: 3000,
},
});
}}
>
Validate And Connect Database
</Button>
</div>
</SimpleForm>
</div>
</div>
</InformationCard>
);
};

View File

@ -1,31 +0,0 @@
import { Button } from '../../../../../new-components/Button';
import React from 'react';
import { FiAlertTriangle } from 'react-icons/fi';
import { DatabaseKind } from '../../../types';
import { InformationCard } from './InformationCard';
export const EETrialExpired: React.VFC<{
onContactSales: () => void;
selectedDb: DatabaseKind;
}> = ({ onContactSales, selectedDb }) => {
return (
<InformationCard>
<div className="flex items-center">
<div className="flex flex-col w-3/4">
<div className="text-[21px] flex items-center gap-2">
<FiAlertTriangle color="rgb(220 38 38)" /> Enterprise Trial Expired
</div>
<div className="text-md text-gray-700">
With an Enterprise Edition license you can add data sources such as
Snowflake, Amazon Athena, and more to your GraphQL API.
</div>
</div>
<div className="flex w-1/4 justify-end">
<Button size="md" onClick={onContactSales}>
Contact Sales
</Button>
</div>
</div>
</InformationCard>
);
};

View File

@ -1,35 +0,0 @@
import { Button } from '../../../../../new-components/Button';
import React from 'react';
import { DatabaseKind } from '../../../types';
import { dbDisplayNames } from '../databases';
import { indefiniteArticle } from '../utils';
import { InformationCard } from './InformationCard';
export const EETrialInactive: React.VFC<{
onEnableEnterpriseTrial: () => void;
selectedDb: DatabaseKind;
}> = ({ onEnableEnterpriseTrial, selectedDb }) => {
const dbWithArticle = `${indefiniteArticle(selectedDb)} ${
dbDisplayNames[selectedDb]
}`;
return (
<InformationCard>
<div className="flex items-center">
<div className="flex flex-col w-3/4">
<div className="text-[21px]">
{`Looking to connect to ${dbWithArticle} database?`}
</div>
<div className="text-md text-gray-700">
Deploy data connectors to add data sources such as Snowflake, Amazon
Athena, and more to your GraphQL API.
</div>
</div>
<div className="flex w-1/4 justify-end">
<Button mode={'primary'} size="md" onClick={onEnableEnterpriseTrial}>
Enable Enterprise
</Button>
</div>
</div>
</InformationCard>
);
};

View File

@ -1,59 +0,0 @@
import * as RadioGroup from '@radix-ui/react-radio-group';
import clsx from 'clsx';
import React from 'react';
import { DatabaseKind } from '../../../types';
const twRadioStyles = {
root: `grid grid-cols-4 gap-3`,
itemContainer: {
default: `flex items-center border bg-white shadow-sm rounded border-gray-300 cursor-pointer relative flex-[0_0_160px] h-[88px]`,
active: `ring-2 ring-blue-300 border-blue-400`,
disabled: ` cursor-not-allowed bg-gray-200`,
},
radioButton: `bg-white w-[20px] h-[20px] rounded-full shadow-eq shadow-blue-900 hover:bg-blue-100 flex-[2] absolute top-0 left-0 m-3`,
indicator: `flex items-center justify-center w-full h-full relative after:content[''] after:block after:w-[10px] after:h-[10px] after:rounded-[50%] after:bg-blue-600`,
label: `text-base whitespace-nowrap cursor-pointer flex-[1] h-full w-full flex justify-center items-center`,
};
export const FancyRadioCards: React.VFC<{
value: string;
items: {
value: string;
content: React.ReactNode | string;
}[];
onChange: (value: DatabaseKind) => void;
}> = ({ value, items, onChange }) => {
return (
<RadioGroup.Root
className={twRadioStyles.root}
defaultValue={value}
aria-label="Radio cards"
onValueChange={onChange}
>
{items.map((item, i) => {
return (
<div
className={clsx(
twRadioStyles.itemContainer.default,
value === item.value && twRadioStyles.itemContainer.active
)}
>
<RadioGroup.Item
className={twRadioStyles.radioButton}
value={item.value}
id={`radio-item-${item.value}`}
>
<RadioGroup.Indicator className={twRadioStyles.indicator} />
</RadioGroup.Item>
<label
className={twRadioStyles.label}
htmlFor={`radio-item-${item.value}`}
>
{item.content}
</label>
</div>
);
})}
</RadioGroup.Root>
);
};

View File

@ -1,25 +0,0 @@
import clsx from 'clsx';
import React from 'react';
const twStyles = {
container: `border border-gray-300 mt-3 shadow-md rounded bg-white p-6`,
blueBorder: `border-l-4 border-l-[#297393]`,
};
export const InformationCard: React.FC<{
blueLeftBorder?: boolean;
className?: string;
innerContainerClassName?: string;
}> = ({ children, blueLeftBorder, className, innerContainerClassName }) => {
return (
<div
className={clsx(
twStyles.container,
blueLeftBorder && twStyles.blueBorder,
className
)}
>
{children}
</div>
);
};

View File

@ -1,7 +0,0 @@
export { CopyableInputField } from './CopyableInputField';
export { DatabaseLogo } from './DatabaseLogo';
export { EETrialActive } from './EETrialActive';
export { EETrialExpired } from './EETrialExpired';
export { EETrialInactive } from './EETrialInactive';
export { FancyRadioCards } from './FancyRadioCards';
export { InformationCard } from './InformationCard';

View File

@ -1,66 +0,0 @@
import React from 'react';
import { DatabaseLogo } from './components';
import postgresLogo from './graphics/db-logos/postgres.svg';
import googleLogo from './graphics/db-logos/google.svg';
import microsoftLogo from './graphics/db-logos/microsoft.svg';
import citusLogo from './graphics/db-logos/citus.svg';
import cockroachLogo from './graphics/db-logos/cockroach.svg';
import amazonLogo from './graphics/db-logos/amazon.svg';
import snowflakeLogo from './graphics/db-logos/snowflake.svg';
import { DatabaseKind } from '../../types';
export const dbDisplayNames: Record<DatabaseKind, string> = {
postgres: 'PostgresSQL',
citus: 'Citus',
cockroach: 'CockroachDB',
alloydb: 'AlloyDB',
mssql: 'MSSQL',
bigquery: 'BigQuery',
snowflake: 'Snowflake',
athena: 'Amazon Athena',
};
export const databases: { value: DatabaseKind; content: React.ReactNode }[] = [
{
value: 'postgres',
content: (
<DatabaseLogo title={dbDisplayNames.postgres} image={postgresLogo} />
),
},
{
value: 'citus',
content: <DatabaseLogo title={dbDisplayNames.citus} image={citusLogo} />,
},
{
value: 'cockroach',
content: (
<DatabaseLogo title={dbDisplayNames.cockroach} image={cockroachLogo} />
),
},
{
value: 'alloydb',
content: <DatabaseLogo title={dbDisplayNames.alloydb} image={googleLogo} />,
},
{
value: 'mssql',
content: (
<DatabaseLogo title={dbDisplayNames.mssql} image={microsoftLogo} />
),
},
{
value: 'bigquery',
content: (
<DatabaseLogo title={dbDisplayNames.bigquery} image={googleLogo} />
),
},
{
value: 'snowflake',
content: (
<DatabaseLogo title={dbDisplayNames.snowflake} image={snowflakeLogo} />
),
},
{
value: 'athena',
content: <DatabaseLogo title={dbDisplayNames.athena} image={amazonLogo} />,
},
];

View File

@ -1,9 +0,0 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect x="0.9375" width="16" height="16" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_44_21233" transform="scale(0.0625)"/>
</pattern>
<image id="image0_44_21233" width="16" height="16" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABdUlEQVQ4jc2TO04jQRCGv+rxjDDaFZiAERkSsNqQG7BH4ABIiARENjEiMBIHmAzYZM0NOALcYEMEC7KEQJqAh7HxGM90FwEv29gWZPuH1fV/XV1dJQxQvvdjUZAYQNGosHZy0C9PegP6e27eWhOLsNAVV448z0Wyevq3L0D/TI+7VhAjsjyoqhfSvhlpR7JSvXsD2J2fZUQjYGyo+V01VGJv/bgs9c3ZX2L0ICgVfK/I6Gfcj9eu2TjPMvHNUgFAHWOP1znGlySYMKHxP7QGgPxBqZ9liX3QEMBa1yh0JrhMw1Zi8YqSBCUvFPPy7BzqZ1nSvnEhEHZ6ugCvsqmGaStP/W+m3a450isboN3GoYDnaylmdVdML+3AFAAz9PQT+g8A37f/HYJsAbUv+GoIW1OV6uHbh9+Wp8e93IsFuka5edHTRGE/gKhU6RjlTjU25ubVuBiel+kVoMKRgWiyUu2/TL2635xZFGW3eWFbGKKwUu27zk/aOpGGPDxYiQAAAABJRU5ErkJggg=="/>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,9 +0,0 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect x="0.6875" width="16" height="16" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_44_21195" transform="scale(0.03125)"/>
</pattern>
<image id="image0_44_21195" width="32" height="32" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAE5UlEQVR42rVXa2yUVRDdbh87QkstaNMqz9m2KIVWo9TSat3t2HY02hASKkT9oZhoZov4iI9UoiBGbRSJ6A+MJoOloikhEBNMTKMI0ag8FB8oMYKoiIGgUq0EoVBzm1ny+fnttsWyyfzZvXfOmblnzr0bCo3QB0gygGQUkFwEJJcAyQwgmQYk4933oXPxAZIcICkDkluBZCWQdAPJbiD5EUh+AZKfgOQbIHkHSJ4EklogiYwE8DggaQGSLiA5ACR9QNI/hDgKJGuAZPrZAhcAyQIg+QhI/h4iaFC4rsSHA5wFJAwk7wHJCU8iR+IwkOwBkp1Ash1IvgaSQ0BychASu5xWhgJeCCTt1r5kG98HkmVA0gwk5UBSBCTnA0m+rXffzQOSTiA5koLAaSBZPhh4hQnLLd4PJM8DSU1O7cLRQ+xcNpDEgGRLChJfpNscs7NyLX4mEkuUhkIrMyq21IajHU0Xo3Icle9A5ftReREq34bKMVQej8qZvlwTbBL8BA6lAm8Eku+AZBPEE1Wh0PKMkrUN41B5Hip3ovJWVO5C5UdReQ4qV6FyGSpPQOWxfgKW043grz4C3weBX2OCeiz7qkV5pV3XuYStqLwTlb9C5WWofDkqwzAnKDfgKN72L5oKJBuhXuYXL27JxtVNjMofovJvqLwClTGFA55nInROmJFmkjZ4wN00tXoXOAW3R+KJ60vfaBiDyktRuQeVv0XlZlTO8iXMA5IbgeQlINkMJDtsOp4CkokpOrDVN4aTvFXMjcQTTdHOxiJUfh2VT6Hyp6h8ZUCy6gF9kBwLENZfQLIkYM9l5g/95hGt07rrcpI/TorEE/WTV93g1L0Rlfut8qqARLOB5IcAYCewp4FkFpAU+/aEgeRZz9q38m+5vTD6WlO1+zEzEkuUFbW1uMrXGHivU3wAeE0K8ONAclea82/2GNKXkbrW8mhHUw0qzw7lXL0wK3fOnaNQeTEq9xmBDlSO+JIAkKxPYShuasYGALvKbwKSfbZubySeuLaks7EYlV9A5ckDC1G5DpUPG7gTXiwgmbt2f07j60WetZlAUmqCTFa+G+olVvpmQwSVX0TltiS4q36dgbv4AJXHBBAoN1cMIuBEtc5uylYgWW3W7Sz81IAL1ktF5bbqMCo/gMqfo3I0ScDN+u8eAi+nOMe8FHaaLpzqn4B6uXDmgcoMVF5gnvLgmcSo/IoH3EV7GjertRfPYMBH7OExK+uKe8PRzsYsVL4blY9ahwu9BPb4CLw6iKVeCiQrnJpt9HqBpMeeYc6QlgLJTPdcs/z5Zmq9Vj3/KyEqH/MR2IXKRYOQCNu9X2ldcYAIJKN9uWeg8npUPmkT9jgqh/0ETvsIuIUP/2fhMD6uxah8Hyrv8+Rd67oRtLjHR6DfWnUPKucOA9QpfCIqCypvs6qT+brdVZ1q4ycBBFwcR+VN9gaYYuMa9uzLsvOdispzUXmV2XefL8+7qFySjnlbwCZvnEDl/ai82dropkbtznCVHvRVmwx3mW0443ZpCEyxW69/BONPVH4OlS8Y6vm5Nv8xAsBO0J/ZkeQMR7XZqPyQMT9bYKf4JSnFNkQSN6Py9hRnGhTOXD5G5UdQuRSVM/73/z6b4fnOES35XhPaQatyh11ebfYMLzgn/3xtrgvcQxSVp1tEUdk9z7NHCucflO7Q/CFSv7QAAAAASUVORK5CYII="/>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1,9 +0,0 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect x="0.3125" width="16" height="16" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0_44_21201" transform="scale(0.03125)"/>
</pattern>
<image id="image0_44_21201" width="32" height="32" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAAXNSR0IArs4c6QAAAKhQTFRFR3BMaDL/aTP/aDP/aTP/ajP/aTL/aDD/cDD/aTP/ajP/aDT/aTL/ajL/aTP/aDL/aDL/aDP/fU3/aDH/ajT/vaX/s5b/aTP/////2cz/9vL/xrL/7OX/j2b/s5n/ckD/mHP/vaX/tZn/qov/ooD/0L//jmb/q43/4tn/x7P/l3L/hVn/fEz/fE3/7Ob/e0z/vKX/s5j/0MD/vab/oH7/hln/4tj/qoz/OmmteQAAADh0Uk5TAGDfoM+f3yAQ78+A739fgJCQ76DP+vr////////////////////////////////////////////R1fvCAAABX0lEQVQ4y31T53rCMAx0ICEDKKND8iJh7+7x/m9Wm2EpoV/1I/l0Ovt0li1EiCxKihwgL5K7TNxGOnLFEEnaKGd9aES/vvwBbqLFNklbAP8xfH1srd1fSy+2qsaM4ddLtdkopS3A6kOhekbpwOLshvr72hotUVblFRicBLjuFHHKcy/SZvkEZ2ucMGDoToClC5z5jyUkz0RE2cHok4wqCYu4wgQr/9vhGztzEVNi8NP/VqgIiwWNyBW8edgq1kUuiDxX1jtwQmZOKCNMJTiPzim8a0YgCS29R+9UL5lEzHdwMzFQI8Tinnowl21AvjKbdFAWywtBVQG8E1loolTzM2GHdJRu4MNaE54gNVPg4z6qH09Y4LE2bjEI6VrttTyYWQC65ytPTpdGGvMd0s710nZCn0tEGTrs0LUODHh6hNt6jRGiqL++brPebT7glF/e3ij9431nUTvuuWKcRGz1L1LVS43OWNCRAAAAAElFTkSuQmCC"/>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Some files were not shown because too many files have changed in this diff Show More