diff --git a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx index 66a67ec182f7a..6dc32f865740e 100644 --- a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx +++ b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx @@ -54,7 +54,7 @@ export const MenuLoginWithActionMenu = ({ inputType, }: { /** Button text for main menu button. */ - buttonText: string; + buttonText?: string; /** * Handles select or click in main menu items. * If isExternalUrl item returned by getLoginItems is true, a button with tag is rendered diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx index 2e34a07ba7c2b..8a9a2759ed2d2 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx @@ -35,6 +35,7 @@ import { MenuLogin, MenuLoginProps, } from 'shared/components/MenuLogin'; +import { MenuLoginWithActionMenu } from 'shared/components/MenuLoginWithActionMenu'; import { formatPortRange, @@ -57,12 +58,25 @@ import { import { IAppContext } from 'teleterm/ui/types'; import { DatabaseUri, routing } from 'teleterm/ui/uri'; import { retryWithRelogin } from 'teleterm/ui/utils'; -import { useVnetAppLauncher, useVnetContext } from 'teleterm/ui/Vnet'; +import { useVnetContext, useVnetLauncher } from 'teleterm/ui/Vnet'; export function ConnectServerActionButton(props: { server: Server; }): React.JSX.Element { const ctx = useAppContext(); + const { isSupported: isVnetSupported } = useVnetContext(); + const { launchVnet } = useVnetLauncher(); + + function connectWithVnet(): void { + const hostname = props.server.hostname; + const cluster = ctx.clustersService.findClusterByResource(props.server.uri); + const clusterName = cluster?.name || ''; + const addr = `${hostname}.${clusterName}`; + launchVnet({ + addrToCopy: addr, + resourceUri: props.server.uri, + }); + } function getSshLogins(): string[] { const cluster = ctx.clustersService.findClusterByResource(props.server.uri); @@ -80,21 +94,28 @@ export function ConnectServerActionButton(props: { ); } + const commonProps = { + inputType: MenuInputType.FILTER, + textTransform: 'none', + getLoginItems: () => getSshLogins().map(login => ({ login, url: '' })), + onSelect: (e, login) => connect(login), + transformOrigin: { + vertical: 'top', + horizontal: 'right', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'right', + }, + }; + + if (!isVnetSupported) { + return ; + } return ( - getSshLogins().map(login => ({ login, url: '' }))} - onSelect={(e, login) => connect(login)} - transformOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'right', - }} - /> + + Connect with VNet + ); } @@ -121,13 +142,13 @@ export function ConnectKubeActionButton(props: { export function ConnectAppActionButton(props: { app: App }): React.JSX.Element { const appContext = useAppContext(); const { isSupported: isVnetSupported } = useVnetContext(); - const { launchVnet } = useVnetAppLauncher(); + const { launchVnet } = useVnetLauncher(); function connectWithVnet(targetPort?: number): void { void launchVnet({ addrToCopy: appToAddrToCopy(props.app, targetPort), resourceUri: props.app.uri, - isMultiPort: !!props.app.tcpPorts.length, + isMultiPortApp: !!props.app.tcpPorts.length, }); } diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx index e7c7d8245c569..2da3ec26a9d90 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx @@ -52,7 +52,9 @@ import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvi import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; import { makeDocumentCluster } from 'teleterm/ui/services/workspacesService/documentsService/testHelpers'; +import { ConnectionsContextProvider } from 'teleterm/ui/TopBar/Connections/connectionsContext'; import * as uri from 'teleterm/ui/uri'; +import { VnetContextProvider } from 'teleterm/ui/Vnet'; const mio = mockIntersectionObserver(); @@ -318,12 +320,16 @@ test.each([ - - + + + + + + @@ -398,7 +404,9 @@ it('passes props with stable identity to ', async () => { - {children} + + {children} + diff --git a/web/packages/teleterm/src/ui/Search/actions.tsx b/web/packages/teleterm/src/ui/Search/actions.tsx index 559aa71b28f47..f8bd045aec7dd 100644 --- a/web/packages/teleterm/src/ui/Search/actions.tsx +++ b/web/packages/teleterm/src/ui/Search/actions.tsx @@ -31,7 +31,7 @@ import { ResourceRequest } from 'teleterm/ui/services/workspacesService/accessRe import { IAppContext } from 'teleterm/ui/types'; import { routing } from 'teleterm/ui/uri'; import { assertUnreachable, retryWithRelogin } from 'teleterm/ui/utils'; -import { VnetAppLauncher } from 'teleterm/ui/Vnet'; +import { VnetLauncher } from 'teleterm/ui/Vnet'; export interface SimpleAction { type: 'simple-action'; @@ -73,7 +73,7 @@ export type SearchAction = SimpleAction | ParametrizedAction; export function mapToAction( ctx: IAppContext, - launchVnet: VnetAppLauncher, + launchVnet: VnetLauncher, searchContext: SearchContext, result: SearchResult ): SearchAction { diff --git a/web/packages/teleterm/src/ui/Search/pickers/useActionAttempts.ts b/web/packages/teleterm/src/ui/Search/pickers/useActionAttempts.ts index 0b31754da7115..de73c428cce77 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/useActionAttempts.ts +++ b/web/packages/teleterm/src/ui/Search/pickers/useActionAttempts.ts @@ -38,7 +38,7 @@ import { } from 'teleterm/ui/Search/useSearch'; import { routing } from 'teleterm/ui/uri'; import { isRetryable } from 'teleterm/ui/utils/retryWithRelogin'; -import { useVnetAppLauncher, useVnetContext } from 'teleterm/ui/Vnet'; +import { useVnetContext, useVnetLauncher } from 'teleterm/ui/Vnet'; import { useDisplayResults } from './useDisplayResults'; @@ -48,7 +48,7 @@ export function useActionAttempts() { const searchContext = useSearchContext(); const { inputValue, filters, pauseUserInteraction } = searchContext; const { isSupported: isVnetSupported } = useVnetContext(); - const vnetLauncher = useVnetAppLauncher(); + const vnetLauncher = useVnetLauncher(); const launchVnet = isVnetSupported ? vnetLauncher.launchVnet : undefined; const [resourceSearchAttempt, runResourceSearch, setResourceSearchAttempt] = diff --git a/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx b/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx index eb6bd8ecc0db7..878ae406aa808 100644 --- a/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx +++ b/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx @@ -31,7 +31,7 @@ import type * as docTypes from 'teleterm/ui/services/workspacesService'; import { routing } from 'teleterm/ui/uri'; import appAccessPng from './app-access.png'; -import { useVnetAppLauncher } from './useVnetAppLauncher'; +import { useVnetLauncher } from './useVnetLauncher'; import { useVnetContext } from './vnetContext'; export function DocumentVnetInfo(props: { @@ -46,7 +46,7 @@ export function DocumentVnetInfo(props: { stopAttempt, status, } = useVnetContext(); - const { launchVnetWithoutFirstTimeCheck } = useVnetAppLauncher(); + const { launchVnetWithoutFirstTimeCheck } = useVnetLauncher(); const userAtHost = useMemo(() => { const { hostname, username } = mainProcessClient.getRuntimeSettings(); return `${username}@${hostname}`; @@ -55,13 +55,10 @@ export function DocumentVnetInfo(props: { const proxyHostname = routing.parseClusterName(rootClusterUri); const startVnet = async () => { - await launchVnetWithoutFirstTimeCheck({ - addrToCopy: doc.app?.targetAddress, - isMultiPort: doc.app?.isMultiPort, - }); - // Remove targetAddress so that subsequent launches of VNet from this specific doc won't copy - // the stale app address to the clipboard. - documentsService.update(doc.uri, { app: undefined }); + await launchVnetWithoutFirstTimeCheck(doc.launcherArgs); + // Remove launcherArgs so that subsequent launches of VNet from this + // specific doc won't copy the stale address to the clipboard. + documentsService.update(doc.uri, { launcherArgs: undefined }); }; return ( diff --git a/web/packages/teleterm/src/ui/Vnet/index.ts b/web/packages/teleterm/src/ui/Vnet/index.ts index 84e6538a4bc67..0199a9148e28a 100644 --- a/web/packages/teleterm/src/ui/Vnet/index.ts +++ b/web/packages/teleterm/src/ui/Vnet/index.ts @@ -19,4 +19,4 @@ export * from './VnetSliderStep'; export * from './vnetContext'; export { VnetConnectionItem } from './VnetConnectionItem'; -export * from './useVnetAppLauncher'; +export * from './useVnetLauncher'; diff --git a/web/packages/teleterm/src/ui/Vnet/useVnetAppLauncher.tsx b/web/packages/teleterm/src/ui/Vnet/useVnetLauncher.tsx similarity index 69% rename from web/packages/teleterm/src/ui/Vnet/useVnetAppLauncher.tsx rename to web/packages/teleterm/src/ui/Vnet/useVnetLauncher.tsx index dd18aaeeddecc..9ede335521b95 100644 --- a/web/packages/teleterm/src/ui/Vnet/useVnetAppLauncher.tsx +++ b/web/packages/teleterm/src/ui/Vnet/useVnetLauncher.tsx @@ -19,48 +19,31 @@ import { useCallback, useMemo } from 'react'; import { useAppContext } from 'teleterm/ui/appContextProvider'; +import { VnetLauncherArgs } from 'teleterm/ui/services/workspacesService/documentsService/types'; import { useConnectionsContext } from 'teleterm/ui/TopBar/Connections/connectionsContext'; -import { ResourceUri, routing } from 'teleterm/ui/uri'; +import { routing } from 'teleterm/ui/uri'; import { useVnetContext } from './vnetContext'; -export type VnetAppLauncher = (args: VnetAppLauncherArgs) => Promise; +export type VnetLauncher = (args: VnetLauncherArgs) => Promise; -// NOTE: Almost every field added to VnetAppLauncherArgs will need to be added to DocumentVnetInfo. -// -// This is because during the first launch of VNet through useVnetAppLauncher, the act of launching -// VNet is split into two parts. When a user clicks the "Connect" button next to a TCP app or opens -// one from the search bar, a DocumentVnetInfo is opened first. Then the user can start VNet from -// there, which should carry out the launch of that particular app. Hence the need to copy some -// arguments from the app to the doc. -type VnetAppLauncherArgs = { - addrToCopy: string | undefined; - /** - * resourceUri lets the VNet launcher establish which workspace to open the info doc in if - * there's a need to do it. - */ - resourceUri: ResourceUri; - isMultiPort: boolean; -}; - -export const useVnetAppLauncher = (): { +export const useVnetLauncher = (): { /** * launchVnet is a function that manages VNet start when: * * - The user clicks "Connect" next to a TCP app or selects one of the ports from the menu. * - The user selects a TCP app through the search bar. + * - The user clicks "Connect with VNet" on an SSH server. * * If the user is yet to start VNet, it opens the info doc. If they already started it in the past, - * it starts VNet and then copies the address of the app to the clipboard. + * it starts VNet and then copies the address of the resource to the clipboard. */ - launchVnet: VnetAppLauncher; + launchVnet: VnetLauncher; /** * launchVnetWithoutFirstTimeCheck never opens the info doc, it starts VNet and then copies the - * address of the app to the clipboard. + * address of the resource to the clipboard. */ - launchVnetWithoutFirstTimeCheck: ( - args: Pick - ) => Promise; + launchVnetWithoutFirstTimeCheck: (args?: VnetLauncherArgs) => Promise; } => { const { notificationsService, workspacesService } = useAppContext(); const { start, status, startAttempt, hasEverStarted } = useVnetContext(); @@ -82,8 +65,8 @@ export const useVnetAppLauncher = (): { }, [status.value, startAttempt.status, open, start]); const openInfoDoc = useCallback( - async ({ addrToCopy, resourceUri, isMultiPort }: VnetAppLauncherArgs) => { - const rootClusterUri = routing.ensureRootClusterUri(resourceUri); + async (args: VnetLauncherArgs) => { + const rootClusterUri = routing.ensureRootClusterUri(args.resourceUri); // Since VNet app launcher might be called from the search bar, we have to account for the // user being in a different workspace than the selected app. const { isAtDesiredWorkspace } = @@ -105,35 +88,39 @@ export const useVnetAppLauncher = (): { // Update targetAddress so that clicking "Start VNet" from the info doc is going to copy that // address to clipboard. docsService.update(docUri, { - app: { targetAddress: addrToCopy, isMultiPort }, + launcherArgs: args, }); }, [workspacesService] ); const launchVnetAndCopyAddr = useCallback( - async ({ - addrToCopy, - isMultiPort, - }: Pick) => { + async (args?: VnetLauncherArgs) => { if (!(await launchVnet())) { return; } - - if (!addrToCopy) { + if (!args) { + // args are optional, if unset don't copy anything to the clipboard. return; } - - const copiedToClipboard = copyAddrToClipboard(addrToCopy); - notificationsService.notifyInfo( - [ - `Connect via VNet by using ${addrToCopy}`, - copiedToClipboard && '(copied to clipboard)', - !isMultiPort && 'and any port', - ] - .filter(Boolean) - .join(' ') + '.' - ); + const { addrToCopy, isMultiPortApp, resourceUri } = args; + const isApp = !!routing.parseAppUri(resourceUri)?.params?.appId; + const isServer = !!routing.parseServerUri(resourceUri)?.params?.serverId; + let msgParts = []; + if (isApp) { + msgParts.push(`Connect via VNet by using ${addrToCopy}`); + } else if (isServer) { + msgParts.push(`Connect with any SSH client to ${addrToCopy}`); + } else { + msgParts.push(`Connect via VNet to ${addrToCopy}`); + } + if (copyAddrToClipboard(addrToCopy)) { + msgParts.push('(copied to clipboard)'); + } + if (isApp && !isMultiPortApp) { + msgParts.push('and any port'); + } + notificationsService.notifyInfo(msgParts.join(' ') + '.'); }, [launchVnet, notificationsService] ); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts index 2602b80b0763b..8343dca0cc6fe 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts @@ -27,7 +27,7 @@ import { import { appToAddrToCopy } from 'teleterm/services/vnet/app'; import { IAppContext } from 'teleterm/ui/types'; import { AppUri, routing } from 'teleterm/ui/uri'; -import { VnetAppLauncher } from 'teleterm/ui/Vnet'; +import { VnetLauncher } from 'teleterm/ui/Vnet'; import { DocumentOrigin } from './types'; @@ -46,7 +46,7 @@ export async function connectToApp( * launchVnet is supposed to be provided if VNet is supported. If so, connectToApp is going to use * this function when targeting a TCP app. Otherwise it'll create an app gateway. */ - launchVnet: null | VnetAppLauncher, + launchVnet: null | VnetLauncher, target: App, telemetry: { origin: DocumentOrigin }, options?: { @@ -111,7 +111,7 @@ export async function connectToApp( await launchVnet({ addrToCopy: appToAddrToCopy(target), resourceUri: target.uri, - isMultiPort: !!target.tcpPorts.length, + isMultiPortApp: !!target.tcpPorts.length, }); return; } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index 9848ffda21d54..5c586e828f31a 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -51,6 +51,7 @@ import { DocumentTshNode, DocumentVnetDiagReport, DocumentVnetInfo, + VnetLauncherArgs, WebSessionRequest, } from './types'; @@ -243,10 +244,7 @@ export class DocumentsService { createVnetInfoDocument(opts: { rootClusterUri: RootClusterUri; - app?: { - targetAddress: string; - isMultiPort: boolean; - }; + launcherArgs?: VnetLauncherArgs; }): DocumentVnetInfo { const uri = routing.getDocUri({ docId: unique() }); @@ -255,7 +253,7 @@ export class DocumentsService { kind: 'doc.vnet_info', title: 'VNet', rootClusterUri: opts.rootClusterUri, - app: opts.app, + launcherArgs: opts.launcherArgs, }; } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts index a5958b04853a2..a0ae1bc3412c9 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts @@ -222,7 +222,7 @@ export function makeDocumentVnetInfo( uri: '/docs/vnet-info', title: 'VNet', rootClusterUri, - app: undefined, + launcherArgs: undefined, ...props, }; } 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 77b6e4b4f8b74..79a46587fbd65 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -231,30 +231,35 @@ export interface DocumentVnetInfo extends DocumentBase { // the document fields, hence why rootClusterUri is defined here. rootClusterUri: uri.RootClusterUri; /** - * Details of the app if the doc was opened by selecting a specific TCP app. + * Details of the resource if the doc was opened by selecting a specific resource. * * This field is needed to facilitate a scenario where a first-time user clicks "Connect" next to - * a TCP app, which opens this doc. Once the user clicks "Start VNet" in the doc, Connect should - * continue the regular flow of connecting to a TCP app through VNet, which means it should copy - * the address of the app to the clipboard, hence this field. + * a TCP app or SSH server, which opens this doc. Once the user clicks "Start VNet" in the doc, + * Connect should continue the regular flow of connecting to a TCP app through VNet, which means + * it should copy the address of the resource to the clipboard, hence this field. * - * app is removed when restoring persisted state. Let's say the user opens the doc through the - * "Connect" button of a specific app. If they close the app and then reopen the docs, we don't + * launcherArgs is removed when restoring persisted state. Let's say the user opens the doc through + * the "Connect" button of a specific app. If they close the app and then reopen the doc, we don't * want the "Start VNet" button to copy the address of the app from the prev session. */ - app: - | { - /** - * The address that's going to be copied to the clipboard after user starts VNet for the - * first time through this document. - * - */ - targetAddress: string | undefined; - isMultiPort: boolean; - } - | undefined; + launcherArgs: VnetLauncherArgs | undefined; } +/** + * Details about a user-selected resource that prompted the VNet launch, so + * that addrToCopy can be copied to the clipboard after VNet launches with a + * helpful message displayed in a notification. + */ +export type VnetLauncherArgs = { + // The address that's going to be copied to the clipboard. + addrToCopy: string; + // The URI of the resource the user clicked. + resourceUri: uri.ResourceUri; + // True if the user clicked a multi-port TCP app, used to render a slightly + // different message in the (copied to clipboard) notification. + isMultiPortApp?: boolean; +}; + /** * Document to authorize a web session with device trust. * Unlike other documents, it is not persisted on disk. diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts index 2f0df51f058e4..c311aaad3b036 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts @@ -593,7 +593,7 @@ export class WorkspacesService extends ImmutableStore { if (d.kind === 'doc.vnet_info') { const documentVnetInfo: DocumentVnetInfo = { ...d, - app: undefined, + launcherArgs: undefined, }; return documentVnetInfo; }