+
+ {/* 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};