diff --git a/web/packages/teleterm/src/services/tshd/createClient.ts b/web/packages/teleterm/src/services/tshd/createClient.ts index 43bb4d520bc..3747a07b79c 100644 --- a/web/packages/teleterm/src/services/tshd/createClient.ts +++ b/web/packages/teleterm/src/services/tshd/createClient.ts @@ -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( (resolve, reject) => { diff --git a/web/packages/teleterm/src/services/tshd/types.ts b/web/packages/teleterm/src/services/tshd/types.ts index ef2aecf8f2c..9624f565608 100644 --- a/web/packages/teleterm/src/services/tshd/types.ts +++ b/web/packages/teleterm/src/services/tshd/types.ts @@ -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 = Omit & R; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/Databases.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/Databases.tsx index f6dd98af978..7615eed27a4 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/Databases.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/Databases.tsx @@ -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 }) => ( + + { + formatDatabaseInfo(type as DbType, protocol as DbProtocol) + .title + } + + ), }, { - key: 'labels', + key: 'labelsList', headerText: 'Labels', - render: ({ labels }) => ( + render: ({ labelsList }) => ( ), }, { altKey: 'connect-btn', - render: db => ( - connect(db, dbUser)} - /> + render: database => ( + + + ), }, ]} @@ -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 ( - - getDatabaseUsers(appContext, dbUri)} - onSelect={(_, user) => { - onConnect(user); - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - anchorOrigin={{ - vertical: 'center', - horizontal: 'right', - }} - /> - - ); -} - -function getMenuLoginOptions( - protocol: GatewayProtocol -): Pick { - 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; - } -} diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/useDatabases.ts b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/useDatabases.ts index 973c252ddb0..08278a54214 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/useDatabases.ts +++ b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/useDatabases.ts @@ -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, dbUser: string): void { - const { uri, name, protocol } = db; - connectToDatabase( - appContext, - { uri, name, protocol, dbUser }, - { origin: 'resource_table' } - ); - } - return { fetchAttempt, - connect, ...serverSideResources, }; } diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Kubes/Kubes.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Kubes/Kubes.tsx index c624c169d2f..8b9b2ee07a1 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Kubes/Kubes.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Kubes/Kubes.tsx @@ -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 }) => ( ), }, { altKey: 'connect-btn', - render: kube => renderConnectButtonCell(kube.uri, connect), + render: kube => ( + + + + ), }, ]} customSort={customSort} @@ -113,21 +116,3 @@ function KubeList(props: State) { ); } - -export const renderConnectButtonCell = ( - uri: string, - connect: (kubeUri: string) => void -) => { - return ( - - { - connect(uri); - }} - > - Connect - - - ); -}; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Kubes/useKubes.ts b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Kubes/useKubes.ts index 21cca334ddb..d6c23cd31e2 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Kubes/useKubes.ts +++ b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Kubes/useKubes.ts @@ -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( { 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, }; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Servers/Servers.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Servers/Servers.tsx index f95a94aa6ae..005c4f8dd28 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Servers/Servers.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Servers/Servers.tsx @@ -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 }) => ( ), }, { altKey: 'connect-btn', - render: server => - renderConnectCell( - () => getSshLogins(server.uri), - login => connect(server, login) - ), + render: server => ( + + + + ), }, ]} customSort={customSort} @@ -159,29 +158,7 @@ function ServerList(props: State) { ); } -const renderConnectCell = ( - getSshLogins: () => string[], - onConnect: (login: string) => void -) => { - return ( - - getSshLogins().map(login => ({ login, url: '' }))} - onSelect={(e, login) => onConnect(login)} - transformOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - anchorOrigin={{ - vertical: 'center', - horizontal: 'right', - }} - /> - - ); -}; - -const renderAddressCell = ({ addr, tunnel }: ReturnType) => ( +const renderAddressCell = ({ addr, tunnel }: Server) => ( {tunnel && ( , login: string): void { - const { uri, hostname } = server; - connectToServer( - appContext, - { uri, hostname, login }, - { - origin: 'resource_table', - } - ); - } - return { fetchAttempt, - getSshLogins, - connect, ...serversideResources, }; } diff --git a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx index 065da37b4d8..9090ae061a4 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.story.tsx @@ -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((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; + 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 ( diff --git a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.test.tsx index 2b6cb52965e..a59bc643dd3 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.test.tsx @@ -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 ); + 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 ); + act(mio.enterAll); + await expect( - screen.findByText('No servers found.') + screen.findByText('No Resources Found') ).resolves.toBeInTheDocument(); expect( diff --git a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx index b92823c0d5b..9be3c5e30f6 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx @@ -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 ( - - + + ); } @@ -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; - } `; diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx new file mode 100644 index 00000000000..d5ff54ab39d --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx @@ -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({ + 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 ( + 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 => ( + + {/*temporary search panel*/} + undefined} + setParams={onParamsChange} + /> + {pinAllButton} + + )} + NoResources={ + { + 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: , + }, + }; + } + 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: , + }, + }; + } + case 'kube': { + const { resource: kube } = resource; + + return { + resource: { + kind: 'kube_cluster' as const, + labels: kube.labelsList, + name: kube.name, + }, + ui: { + ActionButton: , + }, + }; + } + } +}; + +function NoResources(props: { + canCreate: boolean; + canUseConnectMyComputer: boolean; + onConnectMyComputerCtaClick(): void; +}) { + let $content: React.ReactElement; + if (!props.canCreate) { + $content = ( + <> + + No Resources Found + + + Either there are no resources in the cluster, or your roles don't + grant you access. + + + ); + } else { + $content = ( + <> + + + Add your first resource to Teleport + + + {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.'} + + {props.canUseConnectMyComputer && ( + + + Connect My Computer + + )} + + ); + } + + return ( + + {$content} + + ); +} diff --git a/web/packages/teleterm/src/ui/DocumentCluster/actionButtons.tsx b/web/packages/teleterm/src/ui/DocumentCluster/actionButtons.tsx new file mode 100644 index 00000000000..39c28e8c89e --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentCluster/actionButtons.tsx @@ -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 ( + 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 ( + + Connect + + ); +} + +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 ( + getDatabaseUsers(appContext, props.database.uri)} + onSelect={(_, user) => { + connect(user); + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + anchorOrigin={{ + vertical: 'center', + horizontal: 'right', + }} + /> + ); +} + +function getDatabaseMenuLoginOptions( + protocol: GatewayProtocol +): Pick { + 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; + } +} diff --git a/web/packages/teleterm/src/ui/DocumentCluster/clusterContext.tsx b/web/packages/teleterm/src/ui/DocumentCluster/clusterContext.tsx index 8c07702c481..9226cc4128c 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/clusterContext.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/clusterContext.tsx @@ -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 { }); }; - connectKube = (kubeUri: KubeUri, params: { origin: DocumentOrigin }) => { - connectToKube(this.appCtx, { uri: kubeUri }, { origin: params.origin }); - }; - refresh = () => { const rootCluster = this.appCtx.clustersService.findRootClusterByResource( this.clusterUri diff --git a/web/packages/teleterm/src/ui/boot.tsx b/web/packages/teleterm/src/ui/boot.tsx index 4ffc92007c5..e9ac9d2df7a 100644 --- a/web/packages/teleterm/src/ui/boot.tsx +++ b/web/packages/teleterm/src/ui/boot.tsx @@ -32,8 +32,12 @@ async function boot(): Promise { 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 => { diff --git a/web/packages/teleterm/src/ui/services/resources/resourcesService.ts b/web/packages/teleterm/src/ui/services/resources/resourcesService.ts index fd9e03d1613..ea22697d7c1 100644 --- a/web/packages/teleterm/src/ui/services/resources/resourcesService.ts +++ b/web/packages/teleterm/src/ui/services/resources/resourcesService.ts @@ -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 {