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;