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
This commit is contained in:
Rafał Cieślak
2025-01-15 15:31:35 +00:00
committed by GitHub
parent dd044f1c4f
commit 2f89a2575d
13 changed files with 576 additions and 83 deletions
+3
View File
@@ -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;
+16
View File
@@ -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);
},
};
}
}
@@ -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<typeof OfflineGateway> = {
connectAttempt: makeEmptyAttempt(),
reconnect: () => {},
gatewayPort: { isSupported: true, defaultPort: '1337' },
targetName: gateway.targetName,
gatewayKind: 'database',
formSchema,
renderFormControls: makeRenderFormControlsFromDefaultPort('1337'),
};
if (props.connectAttempt === 'error') {
@@ -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 <http://www.gnu.org/licenses/>.
*/
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(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<DocumentGateway visible doc={doc} />
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);
expect(
await screen.findByText('Could not establish the connection')
).toBeInTheDocument();
await user.click(screen.getByText('Reconnect'));
expect(await screen.findByText('Close Connection')).toBeInTheDocument();
});
@@ -16,11 +16,15 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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 (
<Document visible={visible}>
<OfflineGateway
connectAttempt={connectAttempt}
reconnect={reconnect}
gatewayPort={{ isSupported: true, defaultPort }}
targetName={doc.targetName}
gatewayKind="database"
formSchema={formSchema}
renderFormControls={renderFormControls}
/>
</Document>
);
@@ -92,3 +102,16 @@ export function DocumentGateway(props: {
</Document>
);
}
export const formSchema = z.object({ [FormFields.LocalPort]: z.string() });
export const makeRenderFormControlsFromDefaultPort =
(defaultPort: string) => (isProcessing: boolean) => (
<PortFieldInput
name={FormFields.LocalPort}
label="Port (optional)"
defaultValue={defaultPort}
mb={0}
readonly={isProcessing}
/>
);
@@ -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
@@ -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 <http://www.gnu.org/licenses/>.
*/
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(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<DocumentGatewayApp visible doc={doc} />
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);
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(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<DocumentGatewayApp visible doc={doc} />
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);
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',
})
);
});
});
@@ -15,10 +15,14 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<typeof OfflineGateway>['formSchema'] =
singlePortSchema;
if (isMultiPort) {
formSchema = multiPortSchema;
}
return (
<Document visible={props.visible}>
{!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) => (
<>
<PortFieldInput
name={FormFields.LocalPort}
label="Local Port (optional)"
defaultValue={defaultPort}
mb={0}
readonly={isProcessing}
/>
{isMultiPort && (
<PortFieldInput
name={FormFields.TargetSubresourceName}
label="Target Port"
defaultValue={doc.targetSubresourceName}
required
mb={0}
readonly={isProcessing}
/>
)}
</>
)}
/>
) : (
<AppGateway
@@ -65,3 +103,8 @@ export function DocumentGatewayApp(props: {
</Document>
);
}
const singlePortSchema = z.object({ [FormFields.LocalPort]: z.string() });
const multiPortSchema = singlePortSchema.extend({
[FormFields.TargetSubresourceName]: z.string(),
});
@@ -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 <http://www.gnu.org/licenses/>.
*/
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(
<MockAppContextProvider appContext={appContext}>
<MockWorkspaceContextProvider>
<DocumentGatewayKube visible doc={doc} />
</MockWorkspaceContextProvider>
</MockAppContextProvider>
);
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();
});
@@ -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 }}
/>
</Document>
);
@@ -16,41 +16,72 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<Record<FormFieldNames, string>>,
>(props: {
connectAttempt: Attempt<void>;
/** 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<FormFieldsT>;
/**
* 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<HTMLFormElement>) => {
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 (
<Flex
flexDirection="column"
@@ -72,13 +103,14 @@ export function OfflineGateway(props: {
Could not establish the connection
</Alerts.Danger>
)}
{!!parseError && (
<Alerts.Danger mt={2} mb={0} details={parseError}>
Form validation error
</Alerts.Danger>
)}
<Flex
as="form"
onSubmit={e => {
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 && (
<Validation>
<PortFieldInput
label={portFieldLabel}
value={port}
mb={0}
readonly={isProcessing}
onChange={e => setPort(e.target.value)}
/>
</Validation>
{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.
<Validation>{props.renderFormControls(isProcessing)}</Validation>
)}
<ButtonPrimary type="submit" disabled={isProcessing}>
Reconnect
@@ -107,3 +134,15 @@ export function OfflineGateway(props: {
</Flex>
);
}
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({});
@@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<RuntimeSettings>) {
@@ -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,
};
});
}
}
@@ -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;