From 2f89a2575d8a69680b80e371f729444c25f1c44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Cie=C5=9Blak?= Date: Wed, 15 Jan 2025 16:31:35 +0100 Subject: [PATCH] Add field for changing target port if app gateway fails to start (#50982) * Make OfflineGateway use uncontrolled forms * Pass form fields to OfflineGateway from outside This will enable each callsite to establish it's own rules as to which fields are required. This will be useful once we add required target subresource name for TCP apps. * Add tests for submitting form through OfflineGateway * Make sure reconnect and formSchema operate on the same type * Add comment for Terminal input --- web/.storybook/preview.tsx | 3 + web/packages/teleterm/src/logger.ts | 16 ++ .../DocumentGateway/DocumentGateway.story.tsx | 7 +- .../DocumentGateway/DocumentGateway.test.tsx | 78 ++++++++ .../ui/DocumentGateway/DocumentGateway.tsx | 27 ++- .../src/ui/DocumentGateway/useGateway.ts | 104 ++++++----- .../DocumentGatewayApp.test.tsx | 170 ++++++++++++++++++ .../DocumentGatewayApp/DocumentGatewayApp.tsx | 49 ++++- .../DocumentGatewayKube.test.tsx | 84 +++++++++ .../DocumentGatewayKube.tsx | 4 +- .../src/ui/components/OfflineGateway.tsx | 97 +++++++--- .../teleterm/src/ui/fixtures/mocks.ts | 18 ++ .../documentsService/types.ts | 2 + 13 files changed, 576 insertions(+), 83 deletions(-) create mode 100644 web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.test.tsx create mode 100644 web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.test.tsx create mode 100644 web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.test.tsx diff --git a/web/.storybook/preview.tsx b/web/.storybook/preview.tsx index 052e1d98600..c31358bef15 100644 --- a/web/.storybook/preview.tsx +++ b/web/.storybook/preview.tsx @@ -26,6 +26,7 @@ import { Theme } from '../packages/design/src/theme/themes/types'; import { ConfiguredThemeProvider } from '../packages/design/src/ThemeProvider'; import history from '../packages/teleport/src/services/history/history'; import { UserContextProvider } from '../packages/teleport/src/User'; +import Logger, { ConsoleService } from '../packages/teleterm/src/logger'; import { StaticThemeProvider as TeletermThemeProvider } from '../packages/teleterm/src/ui/ThemeProvider'; import { darkTheme as teletermDarkTheme, @@ -36,6 +37,8 @@ initialize(); history.init(); +Logger.init(new ConsoleService()); + interface ThemeDecoratorProps { theme: string; title: string; diff --git a/web/packages/teleterm/src/logger.ts b/web/packages/teleterm/src/logger.ts index e215653fa13..c4eae14b6d4 100644 --- a/web/packages/teleterm/src/logger.ts +++ b/web/packages/teleterm/src/logger.ts @@ -69,3 +69,19 @@ export class NullService implements LoggerService { } /* eslint-enable @typescript-eslint/no-unused-vars */ } + +export class ConsoleService implements LoggerService { + createLogger(loggerName: string): types.Logger { + return { + warn(...args: any[]) { + console.warn(loggerName, ...args); + }, + info(...args: any[]) { + console.info(loggerName, ...args); + }, + error(...args: any[]) { + console.error(loggerName, ...args); + }, + }; + } +} diff --git a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx index 239e75dc51c..6be35eb892d 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx @@ -28,6 +28,10 @@ import { import { makeDatabaseGateway } from 'teleterm/services/tshd/testHelpers'; import { OfflineGateway } from '../components/OfflineGateway'; +import { + formSchema, + makeRenderFormControlsFromDefaultPort, +} from './DocumentGateway'; import { OnlineDocumentGateway } from './OnlineDocumentGateway'; type StoryProps = { @@ -99,9 +103,10 @@ export function Story(props: StoryProps) { const offlineGatewayProps: ComponentProps = { connectAttempt: makeEmptyAttempt(), reconnect: () => {}, - gatewayPort: { isSupported: true, defaultPort: '1337' }, targetName: gateway.targetName, gatewayKind: 'database', + formSchema, + renderFormControls: makeRenderFormControlsFromDefaultPort('1337'), }; if (props.connectAttempt === 'error') { diff --git a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.test.tsx b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.test.tsx new file mode 100644 index 00000000000..2ded4c2d9b6 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.test.tsx @@ -0,0 +1,78 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'design/utils/testing'; + +import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; +import { + makeDatabaseGateway, + makeRootCluster, +} from 'teleterm/services/tshd/testHelpers'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import type * as docs from 'teleterm/ui/services/workspacesService'; +import { DatabaseUri } from 'teleterm/ui/uri'; + +import { MockWorkspaceContextProvider } from '../fixtures/MockWorkspaceContextProvider'; +import { DocumentGateway } from './DocumentGateway'; + +test('it allows reconnecting when the gateway fails to be created', async () => { + const user = userEvent.setup(); + + const appContext = new MockAppContext(); + const cluster = makeRootCluster(); + const gateway = makeDatabaseGateway(); + const doc: docs.DocumentGateway = { + uri: '/docs/1', + kind: 'doc.gateway', + targetName: gateway.targetName, + targetUri: gateway.targetUri as DatabaseUri, + targetUser: gateway.targetUser, + targetSubresourceName: gateway.targetSubresourceName, + gatewayUri: gateway.uri, + origin: 'resource_table', + title: '', + status: '', + }; + appContext.addRootClusterWithDoc(cluster, doc); + + jest + .spyOn(appContext.tshd, 'createGateway') + .mockReturnValueOnce( + new MockedUnaryCall(undefined, new Error('Something went wrong')) + ) + .mockReturnValueOnce(new MockedUnaryCall(gateway)); + + render( + + + + + + ); + + expect( + await screen.findByText('Could not establish the connection') + ).toBeInTheDocument(); + + await user.click(screen.getByText('Reconnect')); + + expect(await screen.findByText('Close Connection')).toBeInTheDocument(); +}); diff --git a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx index 6c0a3585bb9..cb884bff8a9 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.tsx @@ -16,11 +16,15 @@ * along with this program. If not, see . */ +import { useMemo } from 'react'; +import { z } from 'zod'; + import { getCliCommandArgv0 } from 'teleterm/services/tshd/gateway'; import Document from 'teleterm/ui/Document'; import * as types from 'teleterm/ui/services/workspacesService'; -import { OfflineGateway } from '../components/OfflineGateway'; +import { PortFieldInput } from '../components/FieldInputs'; +import { FormFields, OfflineGateway } from '../components/OfflineGateway'; import { useWorkspaceContext } from '../Documents'; import { OnlineDocumentGateway } from './OnlineDocumentGateway'; import { useGateway } from './useGateway'; @@ -63,15 +67,21 @@ export function DocumentGateway(props: { documentsService.setLocation(cliDoc.uri); }; + const renderFormControls = useMemo( + () => makeRenderFormControlsFromDefaultPort(defaultPort), + [defaultPort] + ); + if (!connected) { return ( ); @@ -92,3 +102,16 @@ export function DocumentGateway(props: { ); } + +export const formSchema = z.object({ [FormFields.LocalPort]: z.string() }); + +export const makeRenderFormControlsFromDefaultPort = + (defaultPort: string) => (isProcessing: boolean) => ( + + ); diff --git a/web/packages/teleterm/src/ui/DocumentGateway/useGateway.ts b/web/packages/teleterm/src/ui/DocumentGateway/useGateway.ts index 743667ebfa6..8efac1231fa 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/useGateway.ts +++ b/web/packages/teleterm/src/ui/DocumentGateway/useGateway.ts @@ -30,7 +30,7 @@ import { retryWithRelogin } from 'teleterm/ui/utils'; export function useGateway(doc: DocumentGateway) { const ctx = useAppContext(); - const { clustersService } = ctx; + const { clustersService, usageService } = ctx; const { documentsService } = useWorkspaceContext(); // The port to show as default in the input field in case creating a gateway fails. // This is typically the case if someone reopens the app and the port of the gateway is already @@ -46,51 +46,63 @@ export function useGateway(doc: DocumentGateway) { ); const connected = !!gateway; - const [connectAttempt, createGateway] = useAsync(async (port: string) => { - documentsService.update(doc.uri, { status: 'connecting' }); - let gw: Gateway; + const [connectAttempt, createGateway] = useAsync( + useCallback( + async (args: { localPort?: string; targetSubresourceName?: string }) => { + documentsService.update(doc.uri, { status: 'connecting' }); + let gw: Gateway; - try { - gw = await retryWithRelogin(ctx, doc.targetUri, () => - clustersService.createGateway({ - targetUri: doc.targetUri, - localPort: port, - targetUser: doc.targetUser, - targetSubresourceName: doc.targetSubresourceName, - }) - ); - } catch (error) { - documentsService.update(doc.uri, { status: 'error' }); - throw error; - } - documentsService.update(doc.uri, { - gatewayUri: gw.uri, - // Set the port on doc to match the one returned from the daemon. Teleterm doesn't let the - // user provide a port for the gateway, so instead we have to let the daemon use a random - // one. - // - // Setting it here makes it so that on app restart, Teleterm will restart the proxy with the - // same port number. - port: gw.localPort, - status: 'connected', - }); - if (isDatabaseUri(doc.targetUri)) { - ctx.usageService.captureProtocolUse({ - uri: doc.targetUri, - protocol: 'db', - origin: doc.origin, - accessThrough: 'local_proxy', - }); - } - if (isAppUri(doc.targetUri)) { - ctx.usageService.captureProtocolUse({ - uri: doc.targetUri, - protocol: 'app', - origin: doc.origin, - accessThrough: 'local_proxy', - }); - } - }); + try { + gw = await retryWithRelogin(ctx, doc.targetUri, () => + clustersService.createGateway({ + targetUri: doc.targetUri, + localPort: args.localPort, + targetUser: doc.targetUser, + targetSubresourceName: + args.targetSubresourceName || doc.targetSubresourceName, + }) + ); + } catch (error) { + documentsService.update(doc.uri, { status: 'error' }); + throw error; + } + documentsService.update(doc.uri, { + gatewayUri: gw.uri, + // Set the port on doc to match the one returned from the daemon. By default, + // createGateway is called with an empty localPort, so the daemon creates a listener on a + // random port. + // + // Setting it here makes it so that on app restart, Teleterm will restart the proxy with the + // same port number. + // + // Alternatively, if createGateway was called from OfflineGateway, this will persist in + // the doc the local port chosen by the user. + port: gw.localPort, + // targetSubresourceName needs to be updated here in case the createGateway function was + // called from OfflineGateway. + targetSubresourceName: gw.targetSubresourceName, + status: 'connected', + }); + if (isDatabaseUri(doc.targetUri)) { + usageService.captureProtocolUse({ + uri: doc.targetUri, + protocol: 'db', + origin: doc.origin, + accessThrough: 'local_proxy', + }); + } + if (isAppUri(doc.targetUri)) { + usageService.captureProtocolUse({ + uri: doc.targetUri, + protocol: 'app', + origin: doc.origin, + accessThrough: 'local_proxy', + }); + } + }, + [clustersService, ctx, doc, documentsService, usageService] + ) + ); const [disconnectAttempt, disconnect] = useAsync(async () => { await clustersService.removeGateway(doc.gatewayUri); @@ -146,7 +158,7 @@ export function useGateway(doc: DocumentGateway) { // to open DocumentGateway while the gateway is already running. In that scenario, we must // not attempt to create a gateway. if (!gateway && connectAttempt.status === '') { - createGateway(doc.port); + createGateway({ localPort: doc.port }); } }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.test.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.test.tsx new file mode 100644 index 00000000000..91e06096623 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.test.tsx @@ -0,0 +1,170 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'design/utils/testing'; +import { Gateway } from 'gen-proto-ts/teleport/lib/teleterm/v1/gateway_pb'; + +import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; +import { + makeAppGateway, + makeRootCluster, +} from 'teleterm/services/tshd/testHelpers'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; +import type * as docs from 'teleterm/ui/services/workspacesService'; +import { AppUri } from 'teleterm/ui/uri'; + +import { DocumentGatewayApp } from './DocumentGatewayApp'; + +beforeEach(() => { + jest.restoreAllMocks(); +}); + +describe('reconnecting when the gateway fails to be created', () => { + const tests: Array<{ + name: string; + gateway: Gateway; + }> = [ + { + name: 'web app', + gateway: makeAppGateway({ + protocol: 'HTTP', + targetSubresourceName: undefined, + }), + }, + { + name: 'single-port TCP app', + gateway: makeAppGateway({ + protocol: 'TCP', + targetSubresourceName: undefined, + }), + }, + { + name: 'multi-port TCP app', + gateway: makeAppGateway({ + protocol: 'TCP', + targetSubresourceName: '1337', + }), + }, + ]; + + test.each(tests)('$name', async ({ gateway }) => { + const user = userEvent.setup(); + + const appContext = new MockAppContext(); + const cluster = makeRootCluster(); + const doc: docs.DocumentGateway = { + uri: '/docs/1', + kind: 'doc.gateway', + targetName: gateway.targetName, + targetUri: gateway.targetUri as AppUri, + targetUser: gateway.targetUser, + targetSubresourceName: gateway.targetSubresourceName, + gatewayUri: gateway.uri, + origin: 'resource_table', + title: '', + status: '', + }; + appContext.addRootClusterWithDoc(cluster, doc); + + jest + .spyOn(appContext.tshd, 'createGateway') + .mockReturnValueOnce( + new MockedUnaryCall(undefined, new Error('Something went wrong')) + ) + .mockReturnValueOnce(new MockedUnaryCall(gateway)); + + render( + + + + + + ); + + expect( + await screen.findByText('Could not establish the connection') + ).toBeInTheDocument(); + + await user.click(screen.getByText('Reconnect')); + + expect(await screen.findByText('Close Connection')).toBeInTheDocument(); + }); + + it('allows changing the target port for multi-port TCP apps', async () => { + const user = userEvent.setup(); + + const appContext = new MockAppContext(); + const cluster = makeRootCluster(); + const gateway = makeAppGateway({ + protocol: 'TCP', + targetSubresourceName: '1337', + }); + const doc: docs.DocumentGateway = { + uri: '/docs/1', + kind: 'doc.gateway', + targetName: gateway.targetName, + targetUri: gateway.targetUri as AppUri, + targetUser: gateway.targetUser, + targetSubresourceName: '1337', + gatewayUri: gateway.uri, + origin: 'resource_table', + title: '', + status: '', + }; + appContext.addRootClusterWithDoc(cluster, doc); + + jest + .spyOn(appContext.tshd, 'createGateway') + .mockReturnValueOnce( + new MockedUnaryCall(undefined, new Error('Something went wrong')) + ) + .mockImplementationOnce( + async req => new MockedUnaryCall({ ...gateway, ...req }) + ); + + render( + + + + + + ); + + expect( + await screen.findByText('Could not establish the connection') + ).toBeInTheDocument(); + + const targetPortInput = screen.getByLabelText('Target Port'); + await user.clear(targetPortInput); + await user.type(targetPortInput, '4242'); + await user.click(screen.getByText('Reconnect')); + + expect(await screen.findByText('Close Connection')).toBeInTheDocument(); + expect(screen.getByLabelText('Target Port')).toHaveValue(4242); + + expect(appContext.tshd.createGateway).toHaveBeenLastCalledWith( + expect.objectContaining({ + targetSubresourceName: '4242', + }) + ); + }); +}); diff --git a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.tsx b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.tsx index 24db9f673be..32f22e90e2a 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayApp/DocumentGatewayApp.tsx @@ -15,10 +15,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import { ComponentProps } from 'react'; +import { z } from 'zod'; + import Document from 'teleterm/ui/Document'; import { DocumentGateway } from 'teleterm/ui/services/workspacesService'; -import { OfflineGateway } from '../components/OfflineGateway'; +import { PortFieldInput } from '../components/FieldInputs'; +import { FormFields, OfflineGateway } from '../components/OfflineGateway'; import { useGateway } from '../DocumentGateway/useGateway'; import { AppGateway } from './AppGateway'; @@ -29,6 +33,7 @@ export function DocumentGatewayApp(props: { const { doc } = props; const { gateway, + defaultPort, changePort: changeLocalPort, changePortAttempt: changeLocalPortAttempt, connected, @@ -40,6 +45,19 @@ export function DocumentGatewayApp(props: { changeTargetSubresourceNameAttempt: changeTargetPortAttempt, } = useGateway(doc); + const isMultiPort = !!doc.targetSubresourceName; + // TypeScript doesn't seem to be able to properly infer a simpler construct such as + // + // isMultiPort ? multiPortSchema : singlePortSchema + // + // This code would always infer formSchema to be that of the simpler type (singlePortSchema), so + // any errors in multiPortSchema would not be caught. + let formSchema: ComponentProps['formSchema'] = + singlePortSchema; + if (isMultiPort) { + formSchema = multiPortSchema; + } + return ( {!connected ? ( @@ -47,9 +65,29 @@ export function DocumentGatewayApp(props: { connectAttempt={connectAttempt} gatewayKind="app" targetName={doc.targetName} - gatewayPort={{ isSupported: true, defaultPort: doc.port }} reconnect={reconnect} - portFieldLabel="Local Port (optional)" + formSchema={formSchema} + renderFormControls={(isProcessing: boolean) => ( + <> + + {isMultiPort && ( + + )} + + )} /> ) : ( ); } + +const singlePortSchema = z.object({ [FormFields.LocalPort]: z.string() }); +const multiPortSchema = singlePortSchema.extend({ + [FormFields.TargetSubresourceName]: z.string(), +}); diff --git a/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.test.tsx b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.test.tsx new file mode 100644 index 00000000000..a673c316a3f --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.test.tsx @@ -0,0 +1,84 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'jest-canvas-mock'; + +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'design/utils/testing'; + +import Logger, { NullService } from 'teleterm/logger'; +import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; +import { + makeKubeGateway, + makeRootCluster, +} from 'teleterm/services/tshd/testHelpers'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import type * as docs from 'teleterm/ui/services/workspacesService'; +import { KubeUri, routing } from 'teleterm/ui/uri'; + +import { MockWorkspaceContextProvider } from '../fixtures/MockWorkspaceContextProvider'; +import { DocumentGatewayKube } from './DocumentGatewayKube'; + +beforeAll(() => { + Logger.init(new NullService()); +}); + +test('it allows reconnecting when the gateway fails to be created', async () => { + const user = userEvent.setup(); + + const appContext = new MockAppContext(); + const cluster = makeRootCluster(); + const gateway = makeKubeGateway(); + const doc: docs.DocumentGatewayKube = { + uri: '/docs/1', + kind: 'doc.gateway_kube', + targetUri: gateway.targetUri as KubeUri, + rootClusterId: routing.parseClusterUri(cluster.uri).params['rootClusterId'], + leafClusterId: undefined, + origin: 'resource_table', + title: '', + status: '', + }; + appContext.addRootClusterWithDoc(cluster, doc); + + jest + .spyOn(appContext.tshd, 'createGateway') + .mockReturnValueOnce( + new MockedUnaryCall(undefined, new Error('Something went wrong')) + ) + .mockReturnValueOnce(new MockedUnaryCall(gateway)); + + render( + + + + + + ); + + expect( + await screen.findByText('Could not establish the connection') + ).toBeInTheDocument(); + + await user.click(screen.getByText('Reconnect')); + + // Verify that the gateway was created by checking if the terminal was rendered. + // "Terminal input" is an ARIA label added by xterm.js. + expect(await screen.findByLabelText('Terminal input')).toBeInTheDocument(); +}); diff --git a/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx index 8d75b876e38..f6c98a14504 100644 --- a/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx +++ b/web/packages/teleterm/src/ui/DocumentGatewayKube/DocumentGatewayKube.tsx @@ -28,7 +28,7 @@ import * as types from 'teleterm/ui/services/workspacesService'; import { routing } from 'teleterm/ui/uri'; import { retryWithRelogin } from 'teleterm/ui/utils'; -import { OfflineGateway } from '../components/OfflineGateway'; +import { emptyFormSchema, OfflineGateway } from '../components/OfflineGateway'; /** * DocumentGatewayKube creates a terminal session that presets KUBECONFIG env @@ -96,8 +96,8 @@ export const DocumentGatewayKube = (props: { connectAttempt={connectAttempt} targetName={params.kubeId} gatewayKind="kube" + formSchema={emptyFormSchema} reconnect={createGateway} - gatewayPort={{ isSupported: false }} /> ); diff --git a/web/packages/teleterm/src/ui/components/OfflineGateway.tsx b/web/packages/teleterm/src/ui/components/OfflineGateway.tsx index 2dbc1027565..1518154d5c2 100644 --- a/web/packages/teleterm/src/ui/components/OfflineGateway.tsx +++ b/web/packages/teleterm/src/ui/components/OfflineGateway.tsx @@ -16,41 +16,72 @@ * along with this program. If not, see . */ -import { useState } from 'react'; +import { FormEvent, ReactNode, useState } from 'react'; +import { z } from 'zod'; import { ButtonPrimary, Flex, H2, Text } from 'design'; import * as Alerts from 'design/Alert'; import Validation from 'shared/components/Validation'; import { Attempt } from 'shared/hooks/useAsync'; -import { PortFieldInput } from './FieldInputs'; +import { useLogger } from 'teleterm/ui/hooks/useLogger'; -export function OfflineGateway(props: { +export function OfflineGateway< + FormFieldsT extends Partial>, +>(props: { connectAttempt: Attempt; - /** Setting `isSupported` to false hides the port input. */ - gatewayPort: - | { isSupported: true; defaultPort: string } - | { isSupported: false }; - reconnect(port?: string): void; + reconnect(args: FormFieldsT): void; /** Gateway target displayed in the UI, for example, 'cockroachdb'. */ targetName: string; /** Gateway kind displayed in the UI, for example, 'database'. */ gatewayKind: string; - portFieldLabel?: string; + /** + * Each callsite is expected to pass its own formSchema that parses form data from controls passed + * through renderFormControls. If the callsite doesn't pass any form data, it's expected to use + * emptyFormSchema. We cannot do params.formSchema || emptyFormSchema, as that would mess with + * type inference. + */ + formSchema: z.ZodType; + /** + * renderFormControls allows each consumer to provide its own form fields with specific HTML form + * validation rules. The form fields are read through FormData – names on the inputs must match + * names available through the FormFields enum. + */ + renderFormControls?: (isProcessing: boolean) => ReactNode; }) { - const portFieldLabel = props.portFieldLabel || 'Port (optional)'; - const defaultPort = props.gatewayPort.isSupported - ? props.gatewayPort.defaultPort - : undefined; + const logger = useLogger('OfflineGateway'); + const { reconnect } = props; - const [port, setPort] = useState(defaultPort); const [reconnectRequested, setReconnectRequested] = useState(false); + const [parseError, setParseError] = useState(''); const isProcessing = props.connectAttempt.status === 'processing'; const statusDescription = isProcessing ? 'being set up…' : 'offline.'; const shouldShowReconnectControls = props.connectAttempt.status === 'error' || reconnectRequested; + const submitForm = (event: FormEvent) => { + event.preventDefault(); + setReconnectRequested(true); + setParseError(''); + + const formData = new FormData(event.currentTarget); + const parseResult = props.formSchema.safeParse( + Object.fromEntries(formData.entries()) + ); + + // Explicitly compare to false to make type inference work since strictNullChecks are off. + if (parseResult.success === false) { + // There's no need to show validation errors in the UI, since they come from a programmer + // error, not user inputting actually invalid data. + logger.error('Could not parse form', parseResult.error); + setParseError(`Could not submit form. See logs for more details.`); + return; + } + + reconnect(parseResult.data); + }; + return ( )} + {!!parseError && ( + + Form validation error + + )} { - e.preventDefault(); - setReconnectRequested(true); - props.reconnect(props.gatewayPort.isSupported ? port : undefined); - }} + onSubmit={submitForm} alignItems="flex-end" flexWrap="wrap" justifyContent="space-between" @@ -87,16 +119,11 @@ export function OfflineGateway(props: { > {shouldShowReconnectControls && ( <> - {props.gatewayPort.isSupported && ( - - setPort(e.target.value)} - /> - + {props.renderFormControls && ( + // Form controls are expected to use HTML validation instead of our Validation, but + // PortFieldInput is written in a way where it expects the context provided by + // Validation to be present, no matter whether it's used or not. + {props.renderFormControls(isProcessing)} )} Reconnect @@ -107,3 +134,15 @@ export function OfflineGateway(props: { ); } + +export enum FormFields { + LocalPort = 'localPort', + TargetSubresourceName = 'targetSubresourceName', +} +type FormFieldNames = `${FormFields}`; + +/** + * emptyFormSchema is useful in situations where the callsite that uses OfflineGateway has no form + * fields to show. + */ +export const emptyFormSchema = z.object({}); diff --git a/web/packages/teleterm/src/ui/fixtures/mocks.ts b/web/packages/teleterm/src/ui/fixtures/mocks.ts index e2c6d9d0a69..fad0cd858d5 100644 --- a/web/packages/teleterm/src/ui/fixtures/mocks.ts +++ b/web/packages/teleterm/src/ui/fixtures/mocks.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; + import { MockMainProcessClient } from 'teleterm/mainProcess/fixtures/mocks'; import { MockPtyServiceClient } from 'teleterm/services/pty/fixtures/mocks'; import { @@ -24,6 +26,7 @@ import { } from 'teleterm/services/tshd/fixtures/mocks'; import { RuntimeSettings } from 'teleterm/types'; import AppContext from 'teleterm/ui/appContext'; +import { Document } from 'teleterm/ui/services/workspacesService'; export class MockAppContext extends AppContext { constructor(runtimeSettings?: Partial) { @@ -41,4 +44,19 @@ export class MockAppContext extends AppContext { getPathForFile: () => '', }); } + + addRootClusterWithDoc(cluster: Cluster, doc: Document) { + this.clustersService.setState(draftState => { + draftState.clusters.set(cluster.uri, cluster); + }); + this.workspacesService.setState(draftState => { + draftState.rootClusterUri = cluster.uri; + draftState.workspaces[cluster.uri] = { + documents: [doc], + location: doc.uri, + localClusterUri: cluster.uri, + accessRequests: undefined, + }; + }); + } } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index e975d8e268a..c767a8efbdd 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -112,6 +112,8 @@ export interface DocumentGateway extends DocumentBase { /** * targetSubresourceName contains database name for db gateways and target port for TCP app * gateways. + * A DocumentGateway created for a multi-port TCP app is expected to always have this field + * present. */ targetSubresourceName: string | undefined; port?: string;