Add unified resources view to Connect (#33916)

* Extract resource action buttons

* Add a method to fetch unified resources

* Add unified resources view to Connect

* Adjust stories and tests

* Properly log error events

* Inline `actionButtons.tsx` props

* Remove `ClusterContext.connectKube`

* Revert database type formatting

* Set `req.setSortBy` only if sort is present

* Take `serverId` from `server.name`

* Keep all `Layout` styling in one place

* Use `Flex` element directly, instead of `as={Flex}`

* Use entire `params` object as a dependency

* Move mapping resources ot of the component

* Document changes to `window.addEventListener('error')`

* Fix lint issues

* Call `clear()` when changing params

* Do not specify height on the `Layout` component

* Fix tests depending on mocked intersection observer

* Run prettier
This commit is contained in:
Grzegorz Zdunek
2023-10-31 10:19:45 +00:00
committed by GitHub
parent f8655d3e63
commit 2bffedb7eb
16 changed files with 598 additions and 256 deletions
@@ -799,15 +799,17 @@ export default function createClient(
.setClusterUri(params.clusterUri)
.setLimit(params.limit)
.setKindsList(params.kindsList)
.setSortBy(
new api.SortBy()
.setField(params.sortBy.field)
.setIsDesc(params.sortBy.isDesc)
)
.setStartKey(params.startKey)
.setSearch(params.search)
.setQuery(params.query)
.setSearchAsRoles(params.searchAsRoles);
if (params.sortBy) {
req.setSortBy(
new api.SortBy()
.setField(params.sortBy.field)
.setIsDesc(params.sortBy.isDesc)
);
}
return new Promise<types.ListUnifiedResourcesResponse>(
(resolve, reject) => {
@@ -381,16 +381,16 @@ export type WaitForConnectMyComputerNodeJoinResponse =
export type ListUnifiedResourcesRequest =
apiService.ListUnifiedResourcesRequest.AsObject;
export type ListUnifiedResourcesResponse = {
resources: (
| { kind: 'server'; resource: Server }
| {
kind: 'database';
resource: Database;
}
| { kind: 'kube'; resource: Kube }
)[];
resources: UnifiedResourceResponse[];
nextKey: string;
};
export type UnifiedResourceResponse =
| { kind: 'server'; resource: Server }
| {
kind: 'database';
resource: Database;
}
| { kind: 'kube'; resource: Kube };
// Replaces object property with a new type
type Modify<T, R> = Omit<T, keyof R> & R;
@@ -20,21 +20,22 @@ import Table, {
StyledTableWrapper,
} from 'design/DataTable';
import { Danger } from 'design/Alert';
import { MenuLogin, MenuLoginProps } from 'shared/components/MenuLogin';
import { SearchPanel, SearchPagination } from 'shared/components/Search';
import {
formatDatabaseInfo,
DbType,
DbProtocol,
} from 'shared/services/databases';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { retryWithRelogin } from 'teleterm/ui/utils';
import { IAppContext } from 'teleterm/ui/types';
import { GatewayProtocol } from 'teleterm/services/tshd/types';
import { makeDatabase } from 'teleterm/ui/services/clusters';
import { DatabaseUri, routing } from 'teleterm/ui/uri';
import { routing } from 'teleterm/ui/uri';
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
import { DarkenWhileDisabled } from '../DarkenWhileDisabled';
import { getEmptyTableStatus, getEmptyTableText } from '../getEmptyTableText';
import { useClusterContext } from '../../clusterContext';
import { ConnectDatabaseActionButton } from '../../actionButtons';
import { useDatabases, State } from './useDatabases';
export default function Container() {
@@ -44,7 +45,6 @@ export default function Container() {
function DatabaseList(props: State) {
const {
connect,
fetchAttempt,
agentFilter,
pageCount,
@@ -55,7 +55,7 @@ function DatabaseList(props: State) {
onAgentLabelClick,
updateSearch,
} = props;
const dbs = fetchAttempt.data?.agentsList.map(makeDatabase) || [];
const dbs = fetchAttempt.data?.agentsList || [];
const disabled = fetchAttempt.status === 'processing';
const loggedInUser = useWorkspaceLoggedInUser();
const { clusterUri } = useClusterContext();
@@ -95,7 +95,7 @@ function DatabaseList(props: State) {
isSortable: true,
},
{
key: 'description',
key: 'desc',
headerText: 'Description',
isSortable: true,
},
@@ -103,25 +103,31 @@ function DatabaseList(props: State) {
key: 'type',
headerText: 'Type',
isSortable: true,
render: ({ type, protocol }) => (
<Cell>
{
formatDatabaseInfo(type as DbType, protocol as DbProtocol)
.title
}
</Cell>
),
},
{
key: 'labels',
key: 'labelsList',
headerText: 'Labels',
render: ({ labels }) => (
render: ({ labelsList }) => (
<ClickableLabelCell
labels={labels}
labels={labelsList}
onClick={onAgentLabelClick}
/>
),
},
{
altKey: 'connect-btn',
render: db => (
<ConnectButton
dbUri={db.uri}
protocol={db.protocol as GatewayProtocol}
onConnect={dbUser => connect(db, dbUser)}
/>
render: database => (
<Cell align="right">
<ConnectDatabaseActionButton database={database} />
</Cell>
),
},
]}
@@ -135,70 +141,3 @@ function DatabaseList(props: State) {
</>
);
}
function ConnectButton({
dbUri,
protocol,
onConnect,
}: {
dbUri: DatabaseUri;
protocol: GatewayProtocol;
onConnect: (dbUser: string) => void;
}) {
const appContext = useAppContext();
return (
<Cell align="right">
<MenuLogin
{...getMenuLoginOptions(protocol)}
width="195px"
getLoginItems={() => getDatabaseUsers(appContext, dbUri)}
onSelect={(_, user) => {
onConnect(user);
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
anchorOrigin={{
vertical: 'center',
horizontal: 'right',
}}
/>
</Cell>
);
}
function getMenuLoginOptions(
protocol: GatewayProtocol
): Pick<MenuLoginProps, 'placeholder' | 'required'> {
if (protocol === 'redis') {
return {
placeholder: 'Enter username (optional)',
required: false,
};
}
return {
placeholder: 'Enter username',
required: true,
};
}
async function getDatabaseUsers(appContext: IAppContext, dbUri: DatabaseUri) {
try {
const dbUsers = await retryWithRelogin(appContext, dbUri, () =>
appContext.resourcesService.getDbUsers(dbUri)
);
return dbUsers.map(user => ({ login: user, url: '' }));
} catch (e) {
// Emitting a warning instead of an error here because fetching those username suggestions is
// not the most important part of the app.
appContext.notificationsService.notifyWarning({
title: 'Could not fetch database usernames',
description: e.message,
});
throw e;
}
}
@@ -16,8 +16,6 @@ limitations under the License.
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { Database, GetResourcesParams } from 'teleterm/services/tshd/types';
import { makeDatabase } from 'teleterm/ui/services/clusters';
import { connectToDatabase } from 'teleterm/ui/services/workspacesService';
import { useServerSideResources } from '../useServerSideResources';
@@ -31,18 +29,8 @@ export function useDatabases() {
appContext.resourcesService.fetchDatabases(params)
);
function connect(db: ReturnType<typeof makeDatabase>, dbUser: string): void {
const { uri, name, protocol } = db;
connectToDatabase(
appContext,
{ uri, name, protocol, dbUser },
{ origin: 'resource_table' }
);
}
return {
fetchAttempt,
connect,
...serverSideResources,
};
}
@@ -20,11 +20,9 @@ import Table, {
ClickableLabelCell,
StyledTableWrapper,
} from 'design/DataTable';
import { ButtonBorder } from 'design';
import { Danger } from 'design/Alert';
import { SearchPanel, SearchPagination } from 'shared/components/Search';
import { makeKube } from 'teleterm/ui/services/clusters';
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
import { routing } from 'teleterm/ui/uri';
@@ -32,6 +30,8 @@ import { DarkenWhileDisabled } from '../DarkenWhileDisabled';
import { getEmptyTableStatus, getEmptyTableText } from '../getEmptyTableText';
import { useClusterContext } from '../../clusterContext';
import { ConnectKubeActionButton } from '../../actionButtons';
import { useKubes, State } from './useKubes';
export default function Container() {
@@ -41,7 +41,6 @@ export default function Container() {
function KubeList(props: State) {
const {
connect,
fetchAttempt,
agentFilter,
pageCount,
@@ -52,7 +51,7 @@ function KubeList(props: State) {
onAgentLabelClick,
updateSearch,
} = props;
const kubes = fetchAttempt.data?.agentsList.map(makeKube) || [];
const kubes = fetchAttempt.data?.agentsList || [];
const disabled = fetchAttempt.status === 'processing';
const loggedInUser = useWorkspaceLoggedInUser();
const { clusterUri } = useClusterContext();
@@ -89,18 +88,22 @@ function KubeList(props: State) {
isSortable: true,
},
{
key: 'labels',
key: 'labelsList',
headerText: 'Labels',
render: ({ labels }) => (
render: ({ labelsList }) => (
<ClickableLabelCell
labels={labels}
labels={labelsList}
onClick={onAgentLabelClick}
/>
),
},
{
altKey: 'connect-btn',
render: kube => renderConnectButtonCell(kube.uri, connect),
render: kube => (
<Cell align="right">
<ConnectKubeActionButton kube={kube} />
</Cell>
),
},
]}
customSort={customSort}
@@ -113,21 +116,3 @@ function KubeList(props: State) {
</>
);
}
export const renderConnectButtonCell = (
uri: string,
connect: (kubeUri: string) => void
) => {
return (
<Cell align="right">
<ButtonBorder
size="small"
onClick={() => {
connect(uri);
}}
>
Connect
</ButtonBorder>
</Cell>
);
};
@@ -16,14 +16,11 @@ limitations under the License.
import { Kube, GetResourcesParams } from 'teleterm/services/tshd/types';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { useClusterContext } from 'teleterm/ui/DocumentCluster/clusterContext';
import { KubeUri } from 'teleterm/ui/uri';
import { useServerSideResources } from '../useServerSideResources';
export function useKubes() {
const appContext = useAppContext();
const ctx = useClusterContext();
const { fetchAttempt, ...serversideResources } = useServerSideResources<Kube>(
{ fieldName: 'name', dir: 'ASC' }, // default sort
(params: GetResourcesParams) =>
@@ -31,8 +28,6 @@ export function useKubes() {
);
return {
connect: (kubeUri: KubeUri) =>
ctx.connectKube(kubeUri, { origin: 'resource_table' }),
fetchAttempt,
...serversideResources,
};
@@ -22,18 +22,19 @@ import Table, {
import { ButtonPrimary } from 'design';
import { Danger } from 'design/Alert';
import * as icons from 'design/Icon';
import { MenuLogin } from 'shared/components/MenuLogin';
import { SearchPanel, SearchPagination } from 'shared/components/Search';
import { makeServer } from 'teleterm/ui/services/clusters';
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
import { useWorkspaceContext } from 'teleterm/ui/Documents';
import { useConnectMyComputerContext } from 'teleterm/ui/ConnectMyComputer';
import { Server } from 'teleterm/services/tshd/types';
import { DarkenWhileDisabled } from '../DarkenWhileDisabled';
import { getEmptyTableStatus, getEmptyTableText } from '../getEmptyTableText';
import { useClusterContext } from '../../clusterContext';
import { ConnectServerActionButton } from '../../actionButtons';
import { useServers, State } from './useServers';
export default function Container() {
@@ -43,8 +44,6 @@ export default function Container() {
function ServerList(props: State) {
const {
getSshLogins,
connect,
fetchAttempt,
agentFilter,
pageCount,
@@ -61,7 +60,7 @@ function ServerList(props: State) {
const { canUse: hasPermissionsForConnectMyComputer, agentCompatibility } =
useConnectMyComputerContext();
const servers = fetchAttempt.data?.agentsList.map(makeServer) || [];
const servers = fetchAttempt.data?.agentsList || [];
const disabled = fetchAttempt.status === 'processing';
const isRootCluster = clusterUri === rootClusterUri;
const canAddResources = isRootCluster && loggedInUser?.acl?.tokens.create;
@@ -128,22 +127,22 @@ function ServerList(props: State) {
render: renderAddressCell,
},
{
key: 'labels',
key: 'labelsList',
headerText: 'Labels',
render: ({ labels }) => (
render: ({ labelsList }) => (
<ClickableLabelCell
labels={labels}
labels={labelsList}
onClick={onAgentLabelClick}
/>
),
},
{
altKey: 'connect-btn',
render: server =>
renderConnectCell(
() => getSshLogins(server.uri),
login => connect(server, login)
),
render: server => (
<Cell align="right">
<ConnectServerActionButton server={server} />
</Cell>
),
},
]}
customSort={customSort}
@@ -159,29 +158,7 @@ function ServerList(props: State) {
);
}
const renderConnectCell = (
getSshLogins: () => string[],
onConnect: (login: string) => void
) => {
return (
<Cell align="right">
<MenuLogin
getLoginItems={() => getSshLogins().map(login => ({ login, url: '' }))}
onSelect={(e, login) => onConnect(login)}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
anchorOrigin={{
vertical: 'center',
horizontal: 'right',
}}
/>
</Cell>
);
};
const renderAddressCell = ({ addr, tunnel }: ReturnType<typeof makeServer>) => (
const renderAddressCell = ({ addr, tunnel }: Server) => (
<Cell>
{tunnel && (
<span
@@ -15,13 +15,9 @@ limitations under the License.
*/
import { Server, GetResourcesParams } from 'teleterm/services/tshd/types';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import { makeServer } from 'teleterm/ui/services/clusters';
import { connectToServer } from 'teleterm/ui/services/workspacesService';
import { useServerSideResources } from '../useServerSideResources';
import type * as uri from 'teleterm/ui/uri';
export function useServers() {
const appContext = useAppContext();
@@ -32,26 +28,8 @@ export function useServers() {
appContext.resourcesService.fetchServers(params)
);
function getSshLogins(serverUri: uri.ServerUri): string[] {
const cluster = appContext.clustersService.findClusterByResource(serverUri);
return cluster?.loggedInUser?.sshLoginsList || [];
}
function connect(server: ReturnType<typeof makeServer>, login: string): void {
const { uri, hostname } = server;
connectToServer(
appContext,
{ uri, hostname, login },
{
origin: 'resource_table',
}
);
}
return {
fetchAttempt,
getSshLogins,
connect,
...serversideResources,
};
}
@@ -28,6 +28,8 @@ import {
makeLoggedInUser,
makeRootCluster,
makeServer,
makeDatabase,
makeKube,
} from 'teleterm/services/tshd/testHelpers';
import { ResourcesService } from 'teleterm/ui/services/resources';
@@ -83,10 +85,10 @@ export const OnlineEmptyResourcesAndCanAddResourcesAndConnectComputer = () => {
state,
doc: rootClusterDoc,
platform: 'darwin',
fetchServersPromise: Promise.resolve({
agentsList: [],
listUnifiedResourcesPromise: Promise.resolve({
resources: [],
totalCount: 0,
startKey: '',
nextKey: '',
}),
});
};
@@ -118,10 +120,10 @@ export const OnlineEmptyResourcesAndCanAddResourcesButCannotConnectComputer =
state,
doc: rootClusterDoc,
platform: 'win32',
fetchServersPromise: Promise.resolve({
agentsList: [],
listUnifiedResourcesPromise: Promise.resolve({
resources: [],
totalCount: 0,
startKey: '',
nextKey: '',
}),
});
};
@@ -150,10 +152,10 @@ export const OnlineEmptyResourcesAndCannotAddResources = () => {
return renderState({
state,
doc: rootClusterDoc,
fetchServersPromise: Promise.resolve({
agentsList: [],
listUnifiedResourcesPromise: Promise.resolve({
resources: [],
totalCount: 0,
startKey: '',
nextKey: '',
}),
});
};
@@ -167,21 +169,21 @@ export const OnlineLoadingResources = () => {
})
);
let rejectPromise: () => void;
let rejectPromise: (error: Error) => void;
const promiseRejectedOnUnmount = new Promise<any>((resolve, reject) => {
rejectPromise = reject;
});
useEffect(() => {
return () => {
rejectPromise();
rejectPromise(new Error('Aborted'));
};
}, [rejectPromise]);
return renderState({
state,
doc: rootClusterDoc,
fetchServersPromise: promiseRejectedOnUnmount,
listUnifiedResourcesPromise: promiseRejectedOnUnmount,
});
};
@@ -197,17 +199,25 @@ export const OnlineLoadedResources = () => {
return renderState({
state,
doc: rootClusterDoc,
fetchServersPromise: Promise.resolve({
agentsList: [
makeServer(),
makeServer({
uri: '/clusters/foo/servers/1234',
hostname: 'bar',
tunnel: true,
}),
listUnifiedResourcesPromise: Promise.resolve({
resources: [
{
kind: 'server',
resource: makeServer(),
},
{
kind: 'server',
resource: makeServer({
uri: '/clusters/foo/servers/1234',
hostname: 'bar',
tunnel: true,
}),
},
{ kind: 'database', resource: makeDatabase() },
{ kind: 'kube', resource: makeKube() },
],
totalCount: 2,
startKey: '',
totalCount: 4,
nextKey: '',
}),
});
};
@@ -224,7 +234,7 @@ export const OnlineErrorLoadingResources = () => {
return renderState({
state,
doc: rootClusterDoc,
fetchServersPromise: Promise.reject(
listUnifiedResourcesPromise: Promise.reject(
new Error('Whoops, something went wrong, sorry!')
),
});
@@ -257,12 +267,14 @@ export const Notfound = () => {
function renderState({
state,
doc,
fetchServersPromise,
listUnifiedResourcesPromise,
platform = 'darwin',
}: {
state: ClustersServiceState;
doc: docTypes.DocumentCluster;
fetchServersPromise?: ReturnType<ResourcesService['fetchServers']>;
listUnifiedResourcesPromise?: ReturnType<
ResourcesService['listUnifiedResources']
>;
platform?: NodeJS.Platform;
userType?: tsh.UserType;
}) {
@@ -280,8 +292,9 @@ function renderState({
};
});
appContext.resourcesService.fetchServers = () =>
fetchServersPromise || Promise.reject('No fetchServersPromise passed');
appContext.resourcesService.listUnifiedResources = () =>
listUnifiedResourcesPromise ||
Promise.reject('No fetchServersPromise passed');
return (
<AppContextProvider value={appContext}>
@@ -15,7 +15,9 @@
*/
import React from 'react';
import { act } from '@testing-library/react';
import { render, screen } from 'design/utils/testing';
import { mockIntersectionObserver } from 'jsdom-testing-mocks';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
@@ -29,7 +31,9 @@ import { ConnectMyComputerContextProvider } from 'teleterm/ui/ConnectMyComputer'
import { ResourcesContextProvider } from './resourcesContext';
import DocumentCluster from '.';
import DocumentCluster from './DocumentCluster';
const mio = mockIntersectionObserver();
it('displays a button for Connect My Computer in the empty state if the user can use Connect My Computer', async () => {
const doc = {
@@ -74,12 +78,12 @@ it('displays a button for Connect My Computer in the empty state if the user can
});
const emptyResponse = {
agentsList: [],
resources: [],
totalCount: 0,
startKey: '',
nextKey: '',
};
jest
.spyOn(appContext.resourcesService, 'fetchServers')
.spyOn(appContext.resourcesService, 'listUnifiedResources')
.mockResolvedValue(emptyResponse);
render(
@@ -94,6 +98,8 @@ it('displays a button for Connect My Computer in the empty state if the user can
</MockAppContextProvider>
);
act(mio.enterAll);
await expect(
screen.findByRole('button', { name: 'Connect My Computer' })
).resolves.toBeInTheDocument();
@@ -142,12 +148,12 @@ it('does not display a button for Connect My Computer in the empty state if the
});
const emptyResponse = {
agentsList: [],
resources: [],
totalCount: 0,
startKey: '',
nextKey: '',
};
jest
.spyOn(appContext.resourcesService, 'fetchServers')
.spyOn(appContext.resourcesService, 'listUnifiedResources')
.mockResolvedValue(emptyResponse);
render(
@@ -162,8 +168,10 @@ it('does not display a button for Connect My Computer in the empty state if the
</MockAppContextProvider>
);
act(mio.enterAll);
await expect(
screen.findByText('No servers found.')
screen.findByText('No Resources Found')
).resolves.toBeInTheDocument();
expect(
@@ -28,7 +28,7 @@ import ClusterCtx, {
ClusterContextProvider,
useClusterContext,
} from './clusterContext';
import ClusterResources from './ClusterResources';
import { UnifiedResources } from './UnifiedResources';
export default function Container(props: DocumentProps) {
const { clusterUri } = props.doc;
@@ -82,8 +82,8 @@ export function Cluster() {
}
return (
<Layout mx="auto" px={5} pt={2} height="100%">
<ClusterResources />
<Layout>
<UnifiedResources clusterUri={clusterCtx.clusterUri} />
</Layout>
);
}
@@ -138,14 +138,8 @@ type DocumentProps = {
doc: types.DocumentCluster;
};
const Layout = styled(Box)`
const Layout = styled(Box).attrs({ mx: 'auto', px: 5, pt: 4 })`
flex-direction: column;
display: flex;
flex: 1;
max-width: 1248px;
::after {
content: ' ';
padding-bottom: 24px;
}
`;
@@ -0,0 +1,280 @@
/**
* Copyright 2023 Gravitational, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
UnifiedResources as SharedUnifiedResources,
useUnifiedResourcesFetch,
UnifiedResourcesQueryParams,
SharedUnifiedResource,
} from 'shared/components/UnifiedResources';
import {
DbProtocol,
formatDatabaseInfo,
DbType,
} from 'shared/services/databases';
import { Flex, ButtonPrimary, Text } from 'design';
import * as icons from 'design/Icon';
import Image from 'design/Image';
import stack from 'design/assets/resources/stack.png';
import SearchPanel from 'teleport/UnifiedResources/SearchPanel';
import { UnifiedResourceResponse } from 'teleterm/services/tshd/types';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import * as uri from 'teleterm/ui/uri';
import { useWorkspaceContext } from 'teleterm/ui/Documents';
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
import { useConnectMyComputerContext } from 'teleterm/ui/ConnectMyComputer';
import { retryWithRelogin } from 'teleterm/ui/utils';
import {
ConnectServerActionButton,
ConnectKubeActionButton,
ConnectDatabaseActionButton,
} from './actionButtons';
import { useResourcesContext } from './resourcesContext';
interface UnifiedResourcesProps {
clusterUri: uri.ClusterUri;
}
export function UnifiedResources(props: UnifiedResourcesProps) {
const appContext = useAppContext();
const { onResourcesRefreshRequest } = useResourcesContext();
const [params, setParams] = useState<UnifiedResourcesQueryParams>({
sort: { fieldName: 'name', dir: 'ASC' },
});
const { documentsService, rootClusterUri } = useWorkspaceContext();
const loggedInUser = useWorkspaceLoggedInUser();
const { canUse: hasPermissionsForConnectMyComputer, agentCompatibility } =
useConnectMyComputerContext();
const isRootCluster = props.clusterUri === rootClusterUri;
const canAddResources = isRootCluster && loggedInUser?.acl?.tokens.create;
const canUseConnectMyComputer =
isRootCluster &&
hasPermissionsForConnectMyComputer &&
agentCompatibility === 'compatible';
const { fetch, resources, attempt, clear } = useUnifiedResourcesFetch({
fetchFunc: useCallback(
async (paginationParams, signal) => {
const response = await retryWithRelogin(
appContext,
props.clusterUri,
() =>
appContext.resourcesService.listUnifiedResources(
{
clusterUri: props.clusterUri,
searchAsRoles: false,
sortBy: {
isDesc: params.sort.dir === 'DESC',
field: params.sort.fieldName,
},
search: params.search,
kindsList: params.kinds,
query: params.query,
pinnedOnly: params.pinnedOnly,
startKey: paginationParams.startKey,
limit: paginationParams.limit,
},
signal
)
);
return {
startKey: response.nextKey,
agents: response.resources,
totalCount: response.resources.length,
};
},
[appContext, params, props.clusterUri]
),
});
useEffect(() => {
const { cleanup } = onResourcesRefreshRequest(() => {
clear();
fetch({ force: true });
});
return cleanup;
}, [onResourcesRefreshRequest, fetch, clear]);
function onParamsChange(newParams: UnifiedResourcesQueryParams): void {
clear();
setParams(newParams);
}
return (
<SharedUnifiedResources
params={params}
setParams={onParamsChange}
updateUnifiedResourcesPreferences={() => alert('Not implemented')}
onLabelClick={() => alert('Not implemented')}
pinning={{ kind: 'hidden' }}
resources={resources.map(mapToSharedResource)}
resourcesFetchAttempt={attempt}
fetchResources={fetch}
availableKinds={['db', 'kube_cluster', 'node']}
Header={pinAllButton => (
<Flex alignItems="center" justifyContent="space-between">
{/*temporary search panel*/}
<SearchPanel
params={params}
pathname={''}
replaceHistory={() => undefined}
setParams={onParamsChange}
/>
{pinAllButton}
</Flex>
)}
NoResources={
<NoResources
canCreate={canAddResources}
canUseConnectMyComputer={canUseConnectMyComputer}
onConnectMyComputerCtaClick={() => {
documentsService.openConnectMyComputerDocument({ rootClusterUri });
}}
/>
}
/>
);
}
const mapToSharedResource = (
resource: UnifiedResourceResponse
): SharedUnifiedResource => {
switch (resource.kind) {
case 'server': {
const { resource: server } = resource;
return {
resource: {
kind: 'node' as const,
labels: server.labelsList,
id: server.name,
hostname: server.hostname,
addr: server.addr,
tunnel: server.tunnel,
},
ui: {
ActionButton: <ConnectServerActionButton server={server} />,
},
};
}
case 'database': {
const { resource: database } = resource;
return {
resource: {
kind: 'db' as const,
labels: database.labelsList,
description: database.desc,
name: database.name,
type: formatDatabaseInfo(
database.type as DbType,
database.protocol as DbProtocol
).title,
protocol: database.protocol as DbProtocol,
},
ui: {
ActionButton: <ConnectDatabaseActionButton database={database} />,
},
};
}
case 'kube': {
const { resource: kube } = resource;
return {
resource: {
kind: 'kube_cluster' as const,
labels: kube.labelsList,
name: kube.name,
},
ui: {
ActionButton: <ConnectKubeActionButton kube={kube} />,
},
};
}
}
};
function NoResources(props: {
canCreate: boolean;
canUseConnectMyComputer: boolean;
onConnectMyComputerCtaClick(): void;
}) {
let $content: React.ReactElement;
if (!props.canCreate) {
$content = (
<>
<Text typography="h3" mb="2" fontWeight={600}>
No Resources Found
</Text>
<Text>
Either there are no resources in the cluster, or your roles don't
grant you access.
</Text>
</>
);
} else {
$content = (
<>
<Image src={stack} ml="auto" mr="auto" mb={4} height="100px" />
<Text typography="h3" mb={2} fontWeight={600}>
Add your first resource to Teleport
</Text>
<Text color="text.slightlyMuted">
{props.canUseConnectMyComputer
? 'You can add it in the Teleport Web UI or by connecting your computer to the cluster.'
: 'Connect SSH servers, Kubernetes clusters, Databases and more from Teleport Web UI.'}
</Text>
{props.canUseConnectMyComputer && (
<ButtonPrimary
type="button"
mt={3}
gap={2}
onClick={props.onConnectMyComputerCtaClick}
>
<icons.Laptop size={'medium'} />
Connect My Computer
</ButtonPrimary>
)}
</>
);
}
return (
<Flex
maxWidth={600}
p={8}
pt={5}
width="100%"
mx="auto"
flexDirection="column"
alignItems="center"
justifyContent="center"
>
{$content}
</Flex>
);
}
@@ -0,0 +1,164 @@
/**
* Copyright 2023 Gravitational, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { MenuLogin, MenuLoginProps } from 'shared/components/MenuLogin';
import { ButtonBorder } from 'design';
import {
connectToServer,
connectToDatabase,
connectToKube,
} from 'teleterm/ui/services/workspacesService';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import {
Server,
Kube,
GatewayProtocol,
Database,
} from 'teleterm/services/tshd/types';
import { DatabaseUri } from 'teleterm/ui/uri';
import { IAppContext } from 'teleterm/ui/types';
import { retryWithRelogin } from 'teleterm/ui/utils';
export function ConnectServerActionButton(props: {
server: Server;
}): React.JSX.Element {
const ctx = useAppContext();
function getSshLogins(): string[] {
const cluster = ctx.clustersService.findClusterByResource(props.server.uri);
return cluster?.loggedInUser?.sshLoginsList || [];
}
function connect(login: string): void {
const { uri, hostname } = props.server;
connectToServer(
ctx,
{ uri, hostname, login },
{
origin: 'resource_table',
}
);
}
return (
<MenuLogin
getLoginItems={() => getSshLogins().map(login => ({ login, url: '' }))}
onSelect={(e, login) => connect(login)}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
anchorOrigin={{
vertical: 'center',
horizontal: 'right',
}}
/>
);
}
export function ConnectKubeActionButton(props: {
kube: Kube;
}): React.JSX.Element {
const appContext = useAppContext();
function connect(): void {
connectToKube(
appContext,
{ uri: props.kube.uri },
{ origin: 'resource_table' }
);
}
return (
<ButtonBorder size="small" onClick={connect}>
Connect
</ButtonBorder>
);
}
export function ConnectDatabaseActionButton(props: {
database: Database;
}): React.JSX.Element {
const appContext = useAppContext();
function connect(dbUser: string): void {
const { uri, name, protocol } = props.database;
connectToDatabase(
appContext,
{ uri, name, protocol, dbUser },
{ origin: 'resource_table' }
);
}
return (
<MenuLogin
{...getDatabaseMenuLoginOptions(
props.database.protocol as GatewayProtocol
)}
width="195px"
getLoginItems={() => getDatabaseUsers(appContext, props.database.uri)}
onSelect={(_, user) => {
connect(user);
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
anchorOrigin={{
vertical: 'center',
horizontal: 'right',
}}
/>
);
}
function getDatabaseMenuLoginOptions(
protocol: GatewayProtocol
): Pick<MenuLoginProps, 'placeholder' | 'required'> {
if (protocol === 'redis') {
return {
placeholder: 'Enter username (optional)',
required: false,
};
}
return {
placeholder: 'Enter username',
required: true,
};
}
async function getDatabaseUsers(appContext: IAppContext, dbUri: DatabaseUri) {
try {
const dbUsers = await retryWithRelogin(appContext, dbUri, () =>
appContext.resourcesService.getDbUsers(dbUri)
);
return dbUsers.map(user => ({ login: user, url: '' }));
} catch (e) {
// Emitting a warning instead of an error here because fetching those username suggestions is
// not the most important part of the app.
appContext.notificationsService.notifyWarning({
title: 'Could not fetch database usernames',
description: e.message,
});
throw e;
}
}
@@ -19,11 +19,7 @@ import { matchPath } from 'react-router';
import { useStore, Store } from 'shared/libs/stores';
import { IAppContext } from 'teleterm/ui/types';
import { ClusterUri, DocumentUri, KubeUri, routing } from 'teleterm/ui/uri';
import {
connectToKube,
DocumentOrigin,
} from 'teleterm/ui/services/workspacesService';
import { ClusterUri, DocumentUri, routing } from 'teleterm/ui/uri';
import type * as tsh from 'teleterm/services/tshd/types';
@@ -75,10 +71,6 @@ class ClusterContext extends Store<State> {
});
};
connectKube = (kubeUri: KubeUri, params: { origin: DocumentOrigin }) => {
connectToKube(this.appCtx, { uri: kubeUri }, { origin: params.origin });
};
refresh = () => {
const rootCluster = this.appCtx.clustersService.findRootClusterByResource(
this.clusterUri
+6 -2
View File
@@ -32,8 +32,12 @@ async function boot(): Promise<void> {
const appContext = new AppContext(globals);
window.addEventListener('error', event => {
console.error(event.error.stack);
logger.error(event.error.stack);
// The event object is a `ErrorEvent` instance if it was generated from
// a user interface element, or an `Event` instance otherwise.
// https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event#event_type
const message = event.error ? event.error.stack : event.message;
console.error(message);
logger.error(message);
});
window.addEventListener('unhandledrejection', event => {
@@ -125,6 +125,29 @@ export class ResourcesService {
return Promise.allSettled(promises);
}
listUnifiedResources(
params: types.ListUnifiedResourcesRequest,
abortSignal: AbortSignal
) {
const tshAbortSignal = {
aborted: false,
addEventListener: (cb: (...args: any[]) => void) => {
abortSignal.addEventListener('abort', cb);
},
removeEventListener: (cb: (...args: any[]) => void) => {
abortSignal.removeEventListener('abort', cb);
},
};
abortSignal.addEventListener(
'abort',
() => {
tshAbortSignal.aborted = true;
},
{ once: true }
);
return this.tshClient.listUnifiedResources(params, tshAbortSignal);
}
}
export class AmbiguousHostnameError extends Error {