diff --git a/web/packages/design/src/Icon/Icons.story.tsx b/web/packages/design/src/Icon/Icons.story.tsx index 71ee6cb18730d..74bfa6cac318b 100644 --- a/web/packages/design/src/Icon/Icons.story.tsx +++ b/web/packages/design/src/Icon/Icons.story.tsx @@ -56,6 +56,8 @@ export const Icons = () => ( + + diff --git a/web/packages/design/src/Icon/Icons/Broadcast.tsx b/web/packages/design/src/Icon/Icons/Broadcast.tsx new file mode 100644 index 0000000000000..86934a91464cf --- /dev/null +++ b/web/packages/design/src/Icon/Icons/Broadcast.tsx @@ -0,0 +1,72 @@ +/** + * Teleport + * Copyright (C) 2023 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 . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function Broadcast({ size = 24, color, ...otherProps }: IconProps) { + return ( + + + + + + + + ); +} diff --git a/web/packages/design/src/Icon/Icons/BroadcastSlash.tsx b/web/packages/design/src/Icon/Icons/BroadcastSlash.tsx new file mode 100644 index 0000000000000..9ee535f661831 --- /dev/null +++ b/web/packages/design/src/Icon/Icons/BroadcastSlash.tsx @@ -0,0 +1,71 @@ +/** + * Teleport + * Copyright (C) 2023 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 . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function BroadcastSlash({ size = 24, color, ...otherProps }: IconProps) { + return ( + + + + + + + ); +} diff --git a/web/packages/design/src/Icon/assets/Broadcast.svg b/web/packages/design/src/Icon/assets/Broadcast.svg new file mode 100644 index 0000000000000..b0bb8cc3ee87e --- /dev/null +++ b/web/packages/design/src/Icon/assets/Broadcast.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/packages/design/src/Icon/assets/BroadcastSlash.svg b/web/packages/design/src/Icon/assets/BroadcastSlash.svg new file mode 100644 index 0000000000000..ccdf23d928caa --- /dev/null +++ b/web/packages/design/src/Icon/assets/BroadcastSlash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/packages/design/src/Icon/index.ts b/web/packages/design/src/Icon/index.ts index 7c057234bdede..780c576aedffe 100644 --- a/web/packages/design/src/Icon/index.ts +++ b/web/packages/design/src/Icon/index.ts @@ -44,6 +44,8 @@ export { ArrowsIn } from './Icons/ArrowsIn'; export { ArrowsOut } from './Icons/ArrowsOut'; export { BookOpenText } from './Icons/BookOpenText'; export { Bots } from './Icons/Bots'; +export { Broadcast } from './Icons/Broadcast'; +export { BroadcastSlash } from './Icons/BroadcastSlash'; export { Bubble } from './Icons/Bubble'; export { CCAmex } from './Icons/CCAmex'; export { CCDiscover } from './Icons/CCDiscover'; diff --git a/web/packages/teleterm/src/services/config/appConfigSchema.ts b/web/packages/teleterm/src/services/config/appConfigSchema.ts index e02448182f051..b439535310dfd 100644 --- a/web/packages/teleterm/src/services/config/appConfigSchema.ts +++ b/web/packages/teleterm/src/services/config/appConfigSchema.ts @@ -122,6 +122,7 @@ export const createAppConfigSchema = (platform: Platform) => { .boolean() .default(false) .describe('Disables SSH connection resumption.'), + 'feature.vnet': z.boolean().default(false).describe('Shows UI for VNet.'), }); }; diff --git a/web/packages/teleterm/src/ui/App.tsx b/web/packages/teleterm/src/ui/App.tsx index b7db8f4c1d4b5..bab0ca2bea22b 100644 --- a/web/packages/teleterm/src/ui/App.tsx +++ b/web/packages/teleterm/src/ui/App.tsx @@ -27,6 +27,7 @@ import { StyledApp } from './components/App'; import AppContextProvider from './appContextProvider'; import AppContext from './appContext'; import { ThemeProvider } from './ThemeProvider'; +import { VnetContextProvider } from './Vnet/vnetContext'; export const App: React.FC<{ ctx: AppContext }> = ({ ctx }) => { return ( @@ -34,9 +35,11 @@ export const App: React.FC<{ ctx: AppContext }> = ({ ctx }) => { - - - + + + + + diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx index cd0fa40841b28..326bf21bbfbfb 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.story.tsx @@ -27,6 +27,7 @@ import { } from 'teleterm/services/tshd/testHelpers'; import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { VnetContextProvider } from 'teleterm/ui/Vnet'; import { ConnectAppActionButton, @@ -40,6 +41,33 @@ export default { }; export function ActionButtons() { + const appContext = new MockAppContext(); + appContext.configService.set('feature.vnet', true); + prepareAppContext(appContext); + + return ( + + + + + + ); +} + +export function WithoutVnet() { + const appContext = new MockAppContext({ platform: 'win32' }); + prepareAppContext(appContext); + + return ( + + + + + + ); +} + +function Buttons() { return ( @@ -76,160 +104,94 @@ export function ActionButtons() { ); } -function TcpApp() { - const appContext = new MockAppContext(); - const testCluster = makeRootCluster(); +const testCluster = makeRootCluster(); +testCluster.loggedInUser.sshLogins = ['ec2-user']; + +function prepareAppContext(appContext: MockAppContext): void { appContext.workspacesService.setState(d => { d.rootClusterUri = testCluster.uri; }); appContext.clustersService.setState(d => { d.clusters.set(testCluster.uri, testCluster); }); + appContext.resourcesService.getDbUsers = async () => ['postgres-user']; +} +function TcpApp() { return ( - - - + ); } function HttpApp() { - const appContext = new MockAppContext(); - const testCluster = makeRootCluster(); - appContext.workspacesService.setState(d => { - d.rootClusterUri = testCluster.uri; - }); - appContext.clustersService.setState(d => { - d.clusters.set(testCluster.uri, testCluster); - }); - return ( - - - + ); } function AwsConsole() { - const appContext = new MockAppContext(); - const testCluster = makeRootCluster(); - appContext.workspacesService.setState(d => { - d.rootClusterUri = testCluster.uri; - }); - appContext.clustersService.setState(d => { - d.clusters.set(testCluster.uri, testCluster); - }); - return ( - - - + ); } function SamlApp() { - const appContext = new MockAppContext(); - const testCluster = makeRootCluster(); - appContext.workspacesService.setState(d => { - d.rootClusterUri = testCluster.uri; - }); - appContext.clustersService.setState(d => { - d.clusters.set(testCluster.uri, testCluster); - }); - return ( - - - + ); } function Server() { - const appContext = new MockAppContext(); - const testCluster = makeRootCluster(); - testCluster.loggedInUser.sshLogins = ['ec2-user']; - appContext.workspacesService.setState(d => { - d.rootClusterUri = testCluster.uri; - }); - appContext.clustersService.setState(d => { - d.clusters.set(testCluster.uri, testCluster); - }); - return ( - - - + ); } function Database() { - const appContext = new MockAppContext(); - const testCluster = makeRootCluster(); - appContext.workspacesService.setState(d => { - d.rootClusterUri = testCluster.uri; - }); - appContext.clustersService.setState(d => { - d.clusters.set(testCluster.uri, testCluster); - }); - appContext.resourcesService.getDbUsers = async () => ['postgres-user']; - return ( - - - + ); } function Kube() { - const appContext = new MockAppContext(); - const testCluster = makeRootCluster(); - appContext.workspacesService.setState(d => { - d.rootClusterUri = testCluster.uri; - }); - appContext.clustersService.setState(d => { - d.clusters.set(testCluster.uri, testCluster); - }); - return ( - - - + ); } diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx index 806eec1b523fc..7b64a4a19bd2f 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import React from 'react'; import { MenuLogin, MenuLoginProps } from 'shared/components/MenuLogin'; import { AwsLaunchButton } from 'shared/components/AwsLaunchButton'; import { ButtonBorder, ButtonWithMenu, MenuItem } from 'design'; @@ -46,6 +47,7 @@ import { getAwsAppLaunchUrl, getSamlAppSsoUrl, } from 'teleterm/services/tshd/app'; +import { useVnetContext } from 'teleterm/ui/Vnet'; export function ConnectServerActionButton(props: { server: Server; @@ -107,6 +109,7 @@ export function ConnectKubeActionButton(props: { export function ConnectAppActionButton(props: { app: App }): React.JSX.Element { const appContext = useAppContext(); + const { isSupported: isVnetSupported } = useVnetContext(); function connect(): void { connectToApp(appContext, props.app, { origin: 'resource_table' }); @@ -125,6 +128,7 @@ export function ConnectAppActionButton(props: { app: App }): React.JSX.Element { app={props.app} cluster={cluster} rootCluster={rootCluster} + isVnetSupported={isVnetSupported} onLaunchUrl={() => { captureAppLaunchInBrowser(appContext, props.app, { origin: 'resource_table', @@ -211,6 +215,7 @@ function AppButton(props: { rootCluster: Cluster; connect(): void; onLaunchUrl(): void; + isVnetSupported: boolean; }) { if (props.app.awsConsole) { return ( @@ -269,6 +274,21 @@ function AppButton(props: { ); } + // TCP app with VNet. + if (props.isVnetSupported) { + return ( + window.alert('TODO(ravicious): Open VNet')} + > + Connect to local port + + ); + } + + // TCP app without VNet. return ( Connect diff --git a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx index eceb061e970ed..ecd38f56e68e9 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx @@ -43,6 +43,7 @@ import { ConnectMyComputerContextProvider } from 'teleterm/ui/ConnectMyComputer' import * as docTypes from 'teleterm/ui/services/workspacesService/documentsService/types'; import * as tsh from 'teleterm/services/tshd/types'; import { makeDocumentCluster } from 'teleterm/ui/services/workspacesService/documentsService/testHelpers'; +import { VnetContextProvider } from 'teleterm/ui/Vnet'; import DocumentCluster from './DocumentCluster'; import { ResourcesContextProvider } from './resourcesContext'; @@ -335,15 +336,17 @@ function renderState({ return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx index ef73d35a4fcdb..127023e682579 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx @@ -16,19 +16,78 @@ * along with this program. If not, see . */ -import React from 'react'; +import { useLayoutEffect } from 'react'; import AppContextProvider from 'teleterm/ui/appContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { VnetContextProvider } from 'teleterm/ui/Vnet'; import { Connections } from './Connections'; export default { title: 'Teleterm/TopBar/Connections', + decorators: [ + Story => { + useOpenConnections(); + + return ; + }, + ], }; -export function ExpanderConnections() { +export function Story() { + const appContext = new MockAppContext(); + prepareAppContext(appContext); + + return ( + + + + + + ); +} + +export function JustVnet() { const appContext = new MockAppContext(); + prepareAppContext(appContext); + appContext.connectionTracker.getConnections = () => []; + + return ( + + + + + + ); +} + +export function WithoutVnet() { + const appContext = new MockAppContext({ platform: 'win32' }); + prepareAppContext(appContext); + + return ( + + + + + + ); +} + +export function EmptyWithoutVnet() { + const appContext = new MockAppContext({ platform: 'win32' }); + + return ( + + + + + + ); +} + +const prepareAppContext = (appContext: MockAppContext) => { appContext.connectionTracker.getConnections = () => { return [ { @@ -66,10 +125,15 @@ export function ExpanderConnections() { appContext.connectionTracker.disconnectItem = async () => {}; appContext.connectionTracker.removeItem = async () => {}; appContext.connectionTracker.useState = () => null; + appContext.configService.set('feature.vnet', true); +}; - return ( - - - - ); -} +const useOpenConnections = () => { + useLayoutEffect(() => { + const button = document.querySelector( + 'button[title~="connections"i]' + ) as HTMLButtonElement; + + button?.click(); + }); +}; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx index 3831b1c6994b0..deb74aee37475 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx @@ -16,14 +16,14 @@ * along with this program. If not, see . */ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import Popover from 'design/Popover'; -import styled from 'styled-components'; -import { Box } from 'design'; +import { Box, StepSlider } from 'design'; +import { StepComponentProps } from 'design/StepSlider'; import { useKeyboardShortcuts } from 'teleterm/ui/services/keyboardShortcuts'; - import { KeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; +import { VnetSliderStep, useVnetContext } from 'teleterm/ui/Vnet'; import { useConnections } from './useConnections'; import { ConnectionsIcon } from './ConnectionsIcon/ConnectionsIcon'; @@ -33,6 +33,9 @@ export function Connections() { const iconRef = useRef(); const [isPopoverOpened, setIsPopoverOpened] = useState(false); const connections = useConnections(); + const { status: vnetStatus } = useVnetContext(); + const isAnyConnectionActive = + connections.isAnyConnectionActive || vnetStatus === 'running'; const togglePopover = useCallback(() => { setIsPopoverOpened(wasOpened => { @@ -58,10 +61,35 @@ export function Connections() { connections.activateItem(id); } + // TODO(ravicious): Investigate the problem with height getting temporarily reduced when switching + // from a shorter step 1 to a taller step 2, particularly when there's an error rendered in step 2 + // that wasn't there on first render. + // + // It might have to do with how Popover calculates height or how StepSlider uses refs for height. + // + // We aim to replace the sliding animation with an expanding animation before the release, so it + // might not be worth the effort. + const sliderSteps = [ + (props: StepComponentProps) => ( + + + + + + ), + VnetSliderStep, + ]; + return ( <> @@ -71,21 +99,14 @@ export function Connections() { anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} onClose={() => setIsPopoverOpened(false)} > - - - - - + {/* + 324px matches the total width when the outer div inside Popover used to have 12px of + padding (so 24px on both sides) and ConnectionsFilterableList had 300px of width. + */} + + + ); } - -const Container = styled(Box)` - background: ${props => props.theme.colors.levels.elevated}; -`; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx index b6fa6ea04dd8f..8e259de97d9f0 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx @@ -75,7 +75,7 @@ export function ConnectionItem(props: ConnectionItemProps) { css={` flex-shrink: 0; `} - connected={props.item.connected} + status={props.item.connected ? 'on' : 'off'} /> = props => { - const { connected, ...styles } = props; - return ; +type Status = 'on' | 'off' | 'error'; + +export const ConnectionStatusIndicator = (props: { + status: Status; + [key: string]: any; +}) => { + const { status, ...styles } = props; + return ; }; -const StyledStatus = styled(Box)` +const StyledStatus = styled(Box)` width: 8px; height: 8px; border-radius: 50%; - ${props => { - const { $connected, theme } = props; - const backgroundColor = $connected - ? theme.colors.success.main - : theme.colors.grey[300]; + ${(props: { $status: Status; [key: string]: any }) => { + const { $status, theme } = props; + let backgroundColor: string; + + switch ($status) { + case 'on': + backgroundColor = theme.colors.success.main; + break; + case 'off': + backgroundColor = theme.colors.grey[300]; + break; + case 'error': + // TODO(ravicious): Don't depend on color alone, add an exclamation mark. + backgroundColor = theme.colors.error.main; + break; + default: + $status satisfies never; + } return { backgroundColor, }; }} `; - -type Props = { - connected: boolean; - [key: string]: any; -}; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionsFilterableList.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionsFilterableList.tsx index 6b262a7ff9ba8..c544fb8746742 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionsFilterableList.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionsFilterableList.tsx @@ -16,55 +16,62 @@ * along with this program. If not, see . */ -import React from 'react'; - -import { Box, Text } from 'design'; +import { Text } from 'design'; import { FilterableList } from 'teleterm/ui/components/FilterableList'; import { ExtendedTrackedConnection } from 'teleterm/ui/services/connectionTracker'; - import { useKeyboardArrowsNavigationStateUpdate } from 'teleterm/ui/components/KeyboardArrowsNavigation'; +import { VnetConnectionItem, useVnetContext } from 'teleterm/ui/Vnet'; import { ConnectionItem } from './ConnectionItem'; -interface ConnectionsFilterableListProps { +export function ConnectionsFilterableList(props: { items: ExtendedTrackedConnection[]; - onActivateItem(id: string): void; - onRemoveItem(id: string): void; - onDisconnectItem(id: string): void; -} - -export function ConnectionsFilterableList( - props: ConnectionsFilterableListProps -) { + slideToVnet(): void; +}) { const { setActiveIndex } = useKeyboardArrowsNavigationStateUpdate(); + const { isSupported: isVnetSupported } = useVnetContext(); + + if (!isVnetSupported && props.items.length === 0) { + return No Connections; + } // With VNet being supported, there's always at least one item to show – the VNet item. return ( - - {props.items.length ? ( - - items={props.items} - filterBy="title" - placeholder="Search Connections" - onFilterChange={value => - value.length ? setActiveIndex(0) : setActiveIndex(-1) - } - Node={({ item, index }) => ( - props.onActivateItem(item.id)} - onRemove={() => props.onRemoveItem(item.id)} - onDisconnect={() => props.onDisconnectItem(item.id)} - /> - )} + + items={props.items} + filterBy="title" + placeholder="Search Connections" + onFilterChange={value => + value.length ? setActiveIndex(0) : setActiveIndex(-1) + } + Node={({ item, index }) => ( + props.onActivateItem(item.id)} + onRemove={() => props.onRemoveItem(item.id)} + onDisconnect={() => props.onDisconnectItem(item.id)} + /> + )} + > + {/* + TODO(ravicious): Change the type of FilterableList above to something like + FilterableList and render a different component in Node + depending on the item type. This way VNet will have tighter integration with the connection + list, i.e. be searchable and selectable through keyboard. + + We don't want to put VNet into ExtendedTrackedConnection because these are two fundamentally + different things. + */} + {isVnetSupported && ( + - ) : ( - No Connections )} - + ); } diff --git a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx new file mode 100644 index 0000000000000..386effe90e529 --- /dev/null +++ b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx @@ -0,0 +1,239 @@ +/** + * Teleport + * Copyright (C) 2024 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 styled from 'styled-components'; +import { Text, ButtonIcon, Flex, rotate360 } from 'design'; +import * as icons from 'design/Icon'; +import { copyToClipboard } from 'design/utils/copyToClipboard'; + +import { ConnectionStatusIndicator } from 'teleterm/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator'; +import { ListItem, StaticListItem } from 'teleterm/ui/components/ListItem'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; + +import { useVnetContext } from './vnetContext'; + +/** + * VnetConnection is the VNet entry in Connections. Also used as the topmost item in VnetSliderStep. + */ +export const VnetConnectionItem = (props: { + onClick: () => void; + title: string; + showBackButton?: boolean; + showHelpButton?: boolean; +}) => { + const { status, start, stop, startAttempt, stopAttempt } = useVnetContext(); + const indicatorStatus = + startAttempt.status === 'error' || stopAttempt.status === 'error' + ? 'error' + : status === 'running' + ? 'on' + : 'off'; + + return ( + + {props.showBackButton ? ( + + ) : ( + + )} + +
+ + VNet + + + Virtual Network Emulation + +
+ + {/* Buttons to the right. Negative margin to match buttons of other connections. */} + + {props.showHelpButton && ( + { + // Don't trigger ListItem's onClick. + e.stopPropagation(); + }} + > + + + )} + + {startAttempt.status === 'processing' || + stopAttempt.status === 'processing' ? ( + + ) : ( + <> + {status === 'running' && ( + { + e.stopPropagation(); + stop(); + }} + > + + + )} + {status === 'stopped' && ( + { + e.stopPropagation(); + start(); + }} + > + + + )} + + )} + +
+
+ ); +}; + +/** + * AppConnectionItem is an individual connection to an app made through VNet, shown in + * VnetSliderStep. + */ +export const AppConnectionItem = (props: { + app: string; + status: 'on' | 'error' | 'off'; + // TODO(ravicious): Refactor the status type so that the error prop is available only if status is + // set to 'error'. + error?: string; +}) => { + const { notificationsService } = useAppContext(); + + const copy = async () => { + const content = [props.app, props.error].filter(Boolean).join(': '); + await copyToClipboard(content); + + notificationsService.notifyInfo( + props.error + ? `Copied error for ${props.app} to clipboard` + : `Copied ${props.app} to clipboard` + ); + }; + + return ( + props.theme.space[2]}px; + height: unset; + `} + > + + +
+ + {props.app} + + {props.error && ( + + {props.error} + + )} +
+ + {/* Button to the right. */} + + + +
+
+ ); +}; + +const ButtonIconOnHover = styled(ButtonIcon)` + ${StaticListItem}:not(:hover) & { + visibility: hidden; + // Disable transition so that the button shows up immediately on hover, but still retains the + // original transition value once visible. + transition: none; + } +`; diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx new file mode 100644 index 0000000000000..411b73e8fdf6a --- /dev/null +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx @@ -0,0 +1,89 @@ +/** + * Teleport + * Copyright (C) 2024 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 { StepComponentProps } from 'design/StepSlider'; +import { Box, Flex, Text } from 'design'; + +import { useVnetContext } from './vnetContext'; +import { VnetConnectionItem, AppConnectionItem } from './VnetConnectionItem'; + +/** + * VnetSliderStep is the second step of StepSlider used in TopBar/Connections. It is shown after + * selecting VnetConnectionItem from ConnectionsFilterableList. + */ +export const VnetSliderStep = (props: StepComponentProps) => { + const { status, startAttempt, stopAttempt } = useVnetContext(); + + return ( + // Padding needs to align with the padding of the previous slider step. + + + + {startAttempt.status === 'error' && ( + Could not start VNet: {startAttempt.statusText} + )} + {stopAttempt.status === 'error' && ( + Could not stop VNet: {stopAttempt.statusText} + )} + + {status === 'stopped' && ( + VNet automatically authenticates connections to TCP apps. + )} + + + {status === 'running' && ( + <> + + Proxying connections to .teleport-local.dev, .company.private + + + + + + + + + )} + + ); +}; + +const textSpacing = 1; + +const dnsError = `DNS query for "redis.teleport-local.dev" in custom DNS zone failed: no matching Teleport app and upstream nameserver did not respond`; diff --git a/web/packages/teleterm/src/ui/Vnet/index.ts b/web/packages/teleterm/src/ui/Vnet/index.ts new file mode 100644 index 0000000000000..439657d5c9af2 --- /dev/null +++ b/web/packages/teleterm/src/ui/Vnet/index.ts @@ -0,0 +1,21 @@ +/** + * Teleport + * Copyright (C) 2024 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 . + */ + +export * from './VnetSliderStep'; +export * from './vnetContext'; +export { VnetConnectionItem } from './VnetConnectionItem'; diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx new file mode 100644 index 0000000000000..6a581f69ae35e --- /dev/null +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx @@ -0,0 +1,106 @@ +/** + * Teleport + * Copyright (C) 2024 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 { + FC, + PropsWithChildren, + createContext, + useContext, + useState, + useCallback, + useMemo, +} from 'react'; +import { useAsync, Attempt } from 'shared/hooks/useAsync'; + +import { useAppContext } from 'teleterm/ui/appContextProvider'; + +/** + * VnetContext manages the VNet instance. + * + * There is a single VNet instance running for all workspaces. + */ +export type VnetContext = { + /** + * Describes whether the given OS can run VNet. + */ + isSupported: boolean; + status: VnetStatus; + start: () => void; + startAttempt: Attempt; + stop: () => void; + stopAttempt: Attempt; +}; + +export type VnetStatus = 'running' | 'stopped'; + +export const VnetContext = createContext(null); + +export const VnetContextProvider: FC = props => { + const [status, setStatus] = useState('stopped'); + const { vnet, mainProcessClient, configService } = useAppContext(); + + const isSupported = useMemo( + () => + mainProcessClient.getRuntimeSettings().platform === 'darwin' && + configService.get('feature.vnet').value, + [mainProcessClient, configService] + ); + + const [startAttempt, start] = useAsync( + useCallback(async () => { + // TODO(ravicious): If the osascript dialog was canceled, do not throw an error and instead + // just don't update status. Perhaps even revert back attempt status if possible. + // + // Reconsider this only once the VNet daemon gets added. + await vnet.start({}); + setStatus('running'); + }, [vnet]) + ); + + const [stopAttempt, stop] = useAsync( + useCallback(async () => { + await vnet.stop({}); + setStatus('stopped'); + }, [vnet]) + ); + + return ( + + {props.children} + + ); +}; + +export const useVnetContext = () => { + const context = useContext(VnetContext); + + if (!context) { + throw new Error('useVnetContext must be used within a VnetContextProvider'); + } + + return context; +}; diff --git a/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx b/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx index b8042727504ad..e4ea227eec265 100644 --- a/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx +++ b/web/packages/teleterm/src/ui/components/FilterableList/FilterableList.tsx @@ -32,7 +32,9 @@ interface FilterableListProps { const maxItemsToShow = 10; -export function FilterableList(props: FilterableListProps) { +export function FilterableList( + props: React.PropsWithChildren> +) { const { items } = props; const [searchValue, setSearchValue] = useState(); @@ -55,6 +57,7 @@ export function FilterableList(props: FilterableListProps) { autoFocus={true} /> + {props.children} {filteredItems.map((item, index) => ( {props.Node({ item, index })} ))} diff --git a/web/packages/teleterm/src/ui/components/ListItem.tsx b/web/packages/teleterm/src/ui/components/ListItem.tsx index 51e25b63219aa..194d3809ce559 100644 --- a/web/packages/teleterm/src/ui/components/ListItem.tsx +++ b/web/packages/teleterm/src/ui/components/ListItem.tsx @@ -18,13 +18,12 @@ import styled from 'styled-components'; -export const ListItem = styled.li` +export const StaticListItem = styled.li` white-space: nowrap; box-sizing: border-box; display: flex; align-items: center; justify-content: flex-start; - cursor: pointer; width: 100%; position: relative; font-size: 14px; @@ -36,7 +35,10 @@ export const ListItem = styled.li` background: inherit; border: none; border-radius: 4px; +`; +export const ListItem = styled(StaticListItem)` + cursor: pointer; background: ${props => props.isActive ? props.theme.colors.spotBackground[0] : null};