Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display full list of DNS zones in VNet panel #43195

Merged
merged 16 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions web/packages/shared/utils/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* 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 { useRef, useEffect } from 'react';

/** Resolves after a given duration. */
export function wait(ms: number, abortSignal?: AbortSignal): Promise<void> {
Expand Down Expand Up @@ -51,3 +52,23 @@ export function waitForever(abortSignal: AbortSignal): Promise<never> {
abortSignal.addEventListener('abort', abort, { once: true });
});
}

/**
* usePromiseRejectedOnUnmount is useful when writing stories for loading states.
*/
export const usePromiseRejectedOnUnmount = () => {
const abortControllerRef = useRef(new AbortController());

useEffect(() => {
return () => {
abortControllerRef.current.abort();
};
});
ravicious marked this conversation as resolved.
Show resolved Hide resolved

const promiseRef = useRef<Promise<unknown>>();
ravicious marked this conversation as resolved.
Show resolved Hide resolved
if (!promiseRef.current) {
promiseRef.current = waitForever(abortControllerRef.current.signal);
}

return promiseRef.current;
};
1 change: 1 addition & 0 deletions web/packages/teleterm/src/services/tshd/fixtures/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,5 @@ export class MockTshClient implements TshdClient {
export class MockVnetClient implements VnetClient {
start = () => new MockedUnaryCall({});
stop = () => new MockedUnaryCall({});
listDNSZones = () => new MockedUnaryCall({ dnsZones: [] });
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,37 +150,6 @@ export function VnetError() {
);
}

export function VnetUnexpectedShutdown() {
const appContext = new MockAppContext();
prepareAppContext(appContext);

appContext.statePersistenceService.putState({
...appContext.statePersistenceService.getState(),
vnet: { autoStart: true },
});
appContext.workspacesService.setState(draft => {
draft.isInitialized = true;
});
appContext.vnet.start = () => {
setTimeout(() => {
appContext.unexpectedVnetShutdownListener({
error: 'lorem ipsum dolor sit amet',
});
}, 0);
return new MockedUnaryCall({});
};

return (
<AppContextProvider value={appContext}>
<ConnectionsContextProvider>
<VnetContextProvider>
<Connections />
</VnetContextProvider>
</ConnectionsContextProvider>
</AppContextProvider>
);
}

export function WithScroll() {
const appContext = new MockAppContext();
prepareAppContext(appContext);
Expand Down
169 changes: 169 additions & 0 deletions web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
import { Box } from 'design';

import { usePromiseRejectedOnUnmount } from 'shared/utils/wait';

import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient';

import { VnetContextProvider } from './vnetContext';
import { VnetSliderStep } from './VnetSliderStep';

export default {
title: 'Teleterm/Vnet/VnetSliderStep',
decorators: [
Story => {
return (
<Box width={324} bg="levels.elevated">
<Story />
</Box>
);
},
],
};

const dnsZones = ['teleport.example.com', 'company.test'];

export function Running() {
const appContext = new MockAppContext();
appContext.statePersistenceService.putState({
...appContext.statePersistenceService.getState(),
vnet: { autoStart: true },
});
appContext.workspacesService.setState(draft => {
draft.isInitialized = true;
});
appContext.vnet.listDNSZones = () => new MockedUnaryCall({ dnsZones });

return (
<MockAppContextProvider appContext={appContext}>
<VnetContextProvider>
<Component />
</VnetContextProvider>
</MockAppContextProvider>
);
}

export function UpdatingDnsZones() {
const appContext = new MockAppContext();
appContext.statePersistenceService.putState({
...appContext.statePersistenceService.getState(),
vnet: { autoStart: true },
});
appContext.workspacesService.setState(draft => {
draft.isInitialized = true;
});
const promise = usePromiseRejectedOnUnmount();
appContext.vnet.listDNSZones = async () => {
await promise;
return new MockedUnaryCall({ dnsZones: [] });
};
ravicious marked this conversation as resolved.
Show resolved Hide resolved

return (
<MockAppContextProvider appContext={appContext}>
<VnetContextProvider>
<Component />
</VnetContextProvider>
</MockAppContextProvider>
);
}

export function DnsZonesError() {
ravicious marked this conversation as resolved.
Show resolved Hide resolved
const appContext = new MockAppContext();
appContext.statePersistenceService.putState({
...appContext.statePersistenceService.getState(),
vnet: { autoStart: true },
});
appContext.workspacesService.setState(draft => {
draft.isInitialized = true;
});
appContext.vnet.listDNSZones = () =>
new MockedUnaryCall(undefined, new Error('something went wrong'));

return (
<MockAppContextProvider appContext={appContext}>
<VnetContextProvider>
<Component />
</VnetContextProvider>
</MockAppContextProvider>
);
}

export function StartError() {
const appContext = new MockAppContext();
appContext.statePersistenceService.putState({
...appContext.statePersistenceService.getState(),
vnet: { autoStart: true },
});
appContext.workspacesService.setState(draft => {
draft.isInitialized = true;
});
appContext.vnet.start = () =>
new MockedUnaryCall(undefined, new Error('something went wrong'));

return (
<MockAppContextProvider appContext={appContext}>
<VnetContextProvider>
<Component />
</VnetContextProvider>
</MockAppContextProvider>
);
}

export function UnexpectedShutdown() {
const appContext = new MockAppContext();

appContext.statePersistenceService.putState({
...appContext.statePersistenceService.getState(),
vnet: { autoStart: true },
});
appContext.workspacesService.setState(draft => {
draft.isInitialized = true;
});
appContext.vnet.start = () => {
setTimeout(() => {
appContext.unexpectedVnetShutdownListener({
error: 'lorem ipsum dolor sit amet',
});
}, 0);
return new MockedUnaryCall({});
};

return (
<MockAppContextProvider appContext={appContext}>
<VnetContextProvider>
<Component />
</VnetContextProvider>
</MockAppContextProvider>
);
}

const Component = () => (
<VnetSliderStep
refCallback={noop}
next={noop}
prev={noop}
hasTransitionEnded
stepIndex={1}
flowLength={2}
/>
);

const noop = () => {};
103 changes: 74 additions & 29 deletions web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { PropsWithChildren, useCallback } from 'react';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { StepComponentProps } from 'design/StepSlider';
import { Box, Flex, Text } from 'design';
import { Box, ButtonSecondary, Flex, Text } from 'design';
import { mergeRefs } from 'shared/libs/mergeRefs';
import { useRefAutoFocus } from 'shared/hooks';
import * as whatwg from 'whatwg-url';

import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector';
import { ConnectionStatusIndicator } from 'teleterm/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator';

import { useVnetContext } from './vnetContext';
Expand All @@ -39,16 +37,6 @@ export const VnetSliderStep = (props: StepComponentProps) => {
const autoFocusRef = useRefAutoFocus<HTMLElement>({
shouldFocus: visible,
});
const clusters = useStoreSelector(
'clustersService',
useCallback(state => state.clusters, [])
);
const rootClusters = [...clusters.values()].filter(
cluster => !cluster.leaf && cluster.connected
);
const rootProxyHostnames = rootClusters.map(
cluster => new whatwg.URL(`https://${cluster.proxyHost}`).hostname
);

return (
// Padding needs to align with the padding of the previous slider step.
Expand Down Expand Up @@ -102,21 +90,7 @@ export const VnetSliderStep = (props: StepComponentProps) => {
))}
</Flex>

{status.value === 'running' &&
(rootClusters.length === 0 ? (
<Text p={textSpacing}>
No clusters connected yet, VNet is not proxying any connections.
</Text>
) : (
<>
{/* TODO(ravicious): Add leaf clusters and custom DNS zones when support for them
lands in VNet. */}
<Text p={textSpacing}>
<ConnectionStatusIndicator status="on" inline mr={1} /> Proxying
TCP connections to {rootProxyHostnames.join(', ')}
</Text>
</>
))}
{status.value === 'running' && <DnsZones />}
</Box>
);
};
Expand All @@ -129,3 +103,74 @@ const ErrorText = (props: PropsWithChildren) => (
{props.children}
</Text>
);

/**
* DnsZones displays the list of currently proxied DNS zones, as understood by the VNet admin
* process. The list is cached in the context and updated when the VNet panel gets opened.
*
* As for 95% of users the list will never change during the lifespan of VNet, the VNet panel always
* optimistically displays previously fetched results while fetching new list.
*/
const DnsZones = () => {
const { listDNSZones, listDNSZonesAttempt } = useVnetContext();
const dnsZonesRefreshRequestedRef = useRef(false);

useEffect(function refreshListOnOpen() {
if (!dnsZonesRefreshRequestedRef.current) {
dnsZonesRefreshRequestedRef.current = true;
listDNSZones();
}
}, []);

if (listDNSZonesAttempt.status === 'error') {
return (
<Text p={textSpacing}>
<ConnectionStatusIndicator status="warning" inline mr={2} />
VNet is working, but Teleport Connect could not fetch DNS zones:{' '}
{listDNSZonesAttempt.statusText}
<ButtonSecondary
ml={2}
size="small"
type="button"
onClick={listDNSZones}
>
Retry
</ButtonSecondary>
</Text>
);
}

if (
listDNSZonesAttempt.status === '' ||
(listDNSZonesAttempt.status === 'processing' && !listDNSZonesAttempt.data)
) {
return (
<Text p={textSpacing}>
<ConnectionStatusIndicator status="processing" inline mr={2} />
Updating list of DNS zones.
ravicious marked this conversation as resolved.
Show resolved Hide resolved
</Text>
);
}

const dnsZones = listDNSZonesAttempt.data;

return (
<Text p={textSpacing}>
<ConnectionStatusIndicator
status={listDNSZonesAttempt.status === 'success' ? 'on' : 'processing'}
title={
listDNSZonesAttempt.status === 'processing'
? 'Updating list of DNS zones'
: undefined
}
inline
mr={2}
/>
{dnsZones.length === 0 ? (
<>No clusters connected yet, VNet is not proxying any connections.</>
) : (
<>Proxying TCP connections to {dnsZones.join(', ')}</>
)}
</Text>
);
};
Loading