From 30df269e1da2a8dfe5791e125a83ec8bab1aa675 Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Fri, 10 May 2024 17:01:52 +0200 Subject: [PATCH] [v15] Fetch resources using `ListUnifiedResources` in the search bar (#41400) * Get rid of `clusters.App` type We shouldn't have added it, `addrWithProtocol` is calculated from other `tsh.App` properties, so we can just do it when needed. This type prevents assigning `listUnifiedResources` response to the type returned from `searchResources`. Also, this change will make setting up mocks and tests simpler. * Remove resource kind from `ResourceSearchError` We will fetch all resources for a cluster in a single request, so it is no longer needed. * Replace separate calls for resources with `listUnifiedResources` * In preview mode, fetch 20 items instead of 5 Previously we fetched 5 items x 4 resource kinds. * Move `getAppAddrWithProtocol` to `services/tshd/app.ts` * Extract `MAX_RANKED_RESULTS` --- .../teleterm/src/services/tshd/app.ts | 25 ++++ .../teleterm/src/services/tshd/testHelpers.ts | 5 +- .../NewRequest/useNewRequest.ts | 6 +- .../ui/DocumentCluster/UnifiedResources.tsx | 6 +- .../ui/Search/ResourceSearchErrors.story.tsx | 24 --- .../teleterm/src/ui/Search/SearchBar.test.tsx | 5 +- .../ui/Search/pickers/ActionPicker.test.ts | 23 +-- .../src/ui/Search/pickers/ActionPicker.tsx | 3 +- .../src/ui/Search/pickers/results.story.tsx | 27 ++-- .../teleterm/src/ui/Search/useSearch.test.tsx | 10 +- .../teleterm/src/ui/Search/useSearch.ts | 29 ++-- .../ui/services/clusters/clustersService.ts | 30 ---- .../resources/resourcesService.test.ts | 140 +++++------------- .../ui/services/resources/resourcesService.ts | 104 +++++-------- 14 files changed, 141 insertions(+), 296 deletions(-) diff --git a/web/packages/teleterm/src/services/tshd/app.ts b/web/packages/teleterm/src/services/tshd/app.ts index 6d49312827695..fa4440c54c1d8 100644 --- a/web/packages/teleterm/src/services/tshd/app.ts +++ b/web/packages/teleterm/src/services/tshd/app.ts @@ -83,3 +83,28 @@ export function isWebApp(app: App): boolean { app.endpointUri.startsWith('https://') ); } + +/** + * Returns address with protocol which is an app protocol + a public address. + * If the public address is empty, it falls back to the endpoint URI. + * + * Always empty for SAML applications. + */ +export function getAppAddrWithProtocol(source: App): string { + const { publicAddr, endpointUri } = source; + + const isTcp = endpointUri && endpointUri.startsWith('tcp://'); + const isCloud = endpointUri && endpointUri.startsWith('cloud://'); + let addrWithProtocol = endpointUri; + if (publicAddr) { + if (isCloud) { + addrWithProtocol = `cloud://${publicAddr}`; + } else if (isTcp) { + addrWithProtocol = `tcp://${publicAddr}`; + } else { + addrWithProtocol = `https://${publicAddr}`; + } + } + + return addrWithProtocol; +} diff --git a/web/packages/teleterm/src/services/tshd/testHelpers.ts b/web/packages/teleterm/src/services/tshd/testHelpers.ts index 8be8bbf5b07c5..00d97d3e29b13 100644 --- a/web/packages/teleterm/src/services/tshd/testHelpers.ts +++ b/web/packages/teleterm/src/services/tshd/testHelpers.ts @@ -19,8 +19,6 @@ import * as tsh from './types'; import { TshdRpcError } from './cloneableClient'; -import type { App } from 'teleterm/ui/services/clusters'; - export const rootClusterUri = '/clusters/teleport-local'; export const leafClusterUri = `${rootClusterUri}/leaves/leaf`; @@ -60,7 +58,7 @@ export const makeKube = (props: Partial = {}): tsh.Kube => ({ ...props, }); -export const makeApp = (props: Partial = {}): App => ({ +export const makeApp = (props: Partial = {}): tsh.App => ({ name: 'foo', labels: [], endpointUri: 'tcp://localhost:3000', @@ -71,7 +69,6 @@ export const makeApp = (props: Partial = {}): App => ({ fqdn: 'local-app.example.com:3000', samlApp: false, uri: appUri, - addrWithProtocol: 'tcp://local-app.example.com:3000', awsRoles: [], ...props, }); diff --git a/web/packages/teleterm/src/ui/DocumentAccessRequests/NewRequest/useNewRequest.ts b/web/packages/teleterm/src/ui/DocumentAccessRequests/NewRequest/useNewRequest.ts index 45f28e9ae700a..26de548cf0669 100644 --- a/web/packages/teleterm/src/ui/DocumentAccessRequests/NewRequest/useNewRequest.ts +++ b/web/packages/teleterm/src/ui/DocumentAccessRequests/NewRequest/useNewRequest.ts @@ -28,7 +28,6 @@ import { makeDatabase, makeServer, makeKube, - makeApp, } from 'teleterm/ui/services/clusters'; import { retryWithRelogin } from 'teleterm/ui/utils'; @@ -39,6 +38,7 @@ import { } from 'teleterm/services/tshd/types'; import { routing } from 'teleterm/ui/uri'; import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser'; +import { getAppAddrWithProtocol } from 'teleterm/services/tshd/app'; import type { ResourceLabel, @@ -96,7 +96,9 @@ export default function useNewRequest() { teleportApps.App, 'name' | 'labels' | 'description' | 'userGroups' | 'addrWithProtocol' > = { - ...makeApp(source), + name: tshdApp.name, + labels: tshdApp.labels, + addrWithProtocol: getAppAddrWithProtocol(source), description: tshdApp.desc, //TODO(gzdunek): Enable requesting apps via user groups in Connect. // To make this work, we need diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx index 01ead0f04002c..0b102b50adc1e 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx @@ -57,7 +57,7 @@ import { DocumentCluster, DocumentClusterResourceKind, } from 'teleterm/ui/services/workspacesService'; -import { makeApp } from 'teleterm/ui/services/clusters'; +import { getAppAddrWithProtocol } from 'teleterm/services/tshd/app'; import { ConnectServerActionButton, @@ -346,7 +346,7 @@ const mapToSharedResource = ( }; } case 'app': { - const app = makeApp(resource.resource); + const { resource: app } = resource; return { resource: { @@ -354,7 +354,7 @@ const mapToSharedResource = ( labels: app.labels, name: app.name, id: app.name, - addrWithProtocol: app.addrWithProtocol, + addrWithProtocol: getAppAddrWithProtocol(app), awsConsole: app.awsConsole, description: app.desc, friendlyName: app.friendlyName, diff --git a/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.story.tsx b/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.story.tsx index ddab7f4bb2476..3864ded2f2bc5 100644 --- a/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.story.tsx +++ b/web/packages/teleterm/src/ui/Search/ResourceSearchErrors.story.tsx @@ -34,46 +34,22 @@ export const Story = () => ( errors={[ new ResourceSearchError( '/clusters/foo', - 'server', new Error( '14 UNAVAILABLE: connection error: desc = "transport: authentication handshake failed: EOF"' ) ), new ResourceSearchError( '/clusters/bar', - 'database', new Error( '2 UNKNOWN: Unable to connect to ssh proxy at teleport.local:443. Confirm connectivity and availability.\n dial tcp: lookup teleport.local: no such host' ) ), new ResourceSearchError( '/clusters/baz', - 'kube', new Error( '14 UNAVAILABLE: connection error: desc = "transport: authentication handshake failed: EOF"' ) ), - new ResourceSearchError( - '/clusters/foo', - 'server', - new Error( - '2 UNKNOWN: Unable to connect to ssh proxy at teleport.local:443. Confirm connectivity and availability.\n dial tcp: lookup teleport.local: no such host' - ) - ), - new ResourceSearchError( - '/clusters/baz', - 'kube', - new Error( - '14 UNAVAILABLE: connection error: desc = "transport: authentication handshake failed: EOF"' - ) - ), - new ResourceSearchError( - '/clusters/foo', - 'server', - new Error( - '2 UNKNOWN: Unable to connect to ssh proxy at teleport.local:443. Confirm connectivity and availability.\n dial tcp: lookup teleport.local: no such host' - ) - ), ]} /> ); diff --git a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx index 86ad4dcca8f7c..7dd0cfba48d8e 100644 --- a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx @@ -169,7 +169,6 @@ it('notifies about resource search errors and allows to display details', () => const resourceSearchError = new ResourceSearchError( '/clusters/foo', - 'server', new Error('whoops') ); @@ -206,7 +205,7 @@ it('notifies about resource search errors and allows to display details', () => expect(results).toHaveTextContent( 'Some of the search results are incomplete.' ); - expect(results).toHaveTextContent('Could not fetch servers from foo'); + expect(results).toHaveTextContent('Could not fetch resources from foo'); expect(results).not.toHaveTextContent(resourceSearchError.cause['message']); act(() => screen.getByText('Show details').click()); @@ -226,7 +225,6 @@ it('maintains focus on the search input after closing a resource search error mo const resourceSearchError = new ResourceSearchError( '/clusters/foo', - 'server', new Error('whoops') ); @@ -282,7 +280,6 @@ it('shows a login modal when a request to a cluster from the current workspace f const cluster = makeRootCluster(); const resourceSearchError = new ResourceSearchError( cluster.uri, - 'server', makeRetryableError() ); const resourceSearchResult = { diff --git a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.test.ts b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.test.ts index d1cfaa229827b..bb778e29ad25f 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.test.ts +++ b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.test.ts @@ -31,13 +31,11 @@ describe('getActionPickerStatus', () => { it('partitions resource search errors into clusters with expired certs and non-retryable errors', () => { const retryableError = new ResourceSearchError( '/clusters/foo', - 'server', makeRetryableError() ); const nonRetryableError = new ResourceSearchError( '/clusters/bar', - 'database', new Error('whoops') ); @@ -68,7 +66,6 @@ describe('getActionPickerStatus', () => { const offlineCluster = makeRootCluster({ connected: false }); const retryableError = new ResourceSearchError( '/clusters/foo', - 'server', makeRetryableError() ); @@ -95,17 +92,8 @@ describe('getActionPickerStatus', () => { it('includes a cluster with expired cert only once even if multiple requests fail with retryable errors', () => { const retryableErrors = [ - new ResourceSearchError( - '/clusters/foo', - 'server', - makeRetryableError() - ), - new ResourceSearchError( - '/clusters/foo', - 'database', - makeRetryableError() - ), - new ResourceSearchError('/clusters/foo', 'kube', makeRetryableError()), + new ResourceSearchError('/clusters/foo', makeRetryableError()), + new ResourceSearchError('/clusters/foo', makeRetryableError()), ]; const status = getActionPickerStatus({ inputValue: 'foo', @@ -186,15 +174,10 @@ describe('getActionPickerStatus', () => { it('returns non-retryable errors when fetching a preview after selecting a filter fails', () => { const nonRetryableError = new ResourceSearchError( '/clusters/bar', - 'server', new Error('non-retryable error') ); const resourceSearchErrors = [ - new ResourceSearchError( - '/clusters/foo', - 'server', - makeRetryableError() - ), + new ResourceSearchError('/clusters/foo', makeRetryableError()), nonRetryableError, ]; const status = getActionPickerStatus({ diff --git a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx index 4b6b72d89e12c..8d198fa4cbc7e 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx +++ b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx @@ -50,7 +50,6 @@ import { ResourceSearchError } from 'teleterm/ui/services/resources'; import { isRetryable } from 'teleterm/ui/utils/retryWithRelogin'; import { assertUnreachable } from 'teleterm/ui/utils'; import { isWebApp } from 'teleterm/services/tshd/app'; -import { App } from 'teleterm/ui/services/clusters'; import { SearchAction } from '../actions'; import { useSearchContext } from '../SearchContext'; @@ -790,7 +789,7 @@ export function AppItem(props: SearchResultItem) { ); } -function getAppItemCopy($appName: React.JSX.Element, app: App) { +function getAppItemCopy($appName: React.JSX.Element, app: tsh.App) { if (app.samlApp) { return <>Log in to {$appName} in the browser; } diff --git a/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx b/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx index 3f28111e2cad6..97ba006b0ad59 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx +++ b/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx @@ -21,6 +21,8 @@ import { makeSuccessAttempt } from 'shared/hooks/useAsync'; import { Flex } from 'design'; +import { App } from 'gen-proto-ts/teleport/lib/teleterm/v1/app_pb'; + import { routing } from 'teleterm/ui/uri'; import { makeDatabase, @@ -31,6 +33,7 @@ import { makeApp, } from 'teleterm/services/tshd/testHelpers'; import { ResourceSearchError } from 'teleterm/ui/services/resources'; +import { getAppAddrWithProtocol } from 'teleterm/services/tshd/app'; import { SearchResult } from '../searchResult'; import { makeResourceResult } from '../testHelpers'; @@ -102,6 +105,11 @@ export const ResultsNarrow = () => { return ; }; +function makeAppWithAddr(props: Partial) { + const app = makeApp(props); + return { ...app, addrWithProtocol: getAppAddrWithProtocol(app) }; +} + const SearchResultItems = () => { const searchResults: SearchResult[] = [ makeResourceResult({ @@ -168,11 +176,10 @@ const SearchResultItems = () => { }), makeResourceResult({ kind: 'app', - resource: makeApp({ + resource: makeAppWithAddr({ uri: `${clusterUri}/apps/web-app`, name: 'web-app', endpointUri: 'http://localhost:3000', - addrWithProtocol: 'http://local-app.example.com:3000', desc: '', labels: makeLabelsList({ access: 'cloudwatch-metrics,ec2,s3,cloudtrail', @@ -185,11 +192,10 @@ const SearchResultItems = () => { }), makeResourceResult({ kind: 'app', - resource: makeApp({ + resource: makeAppWithAddr({ uri: `${clusterUri}/apps/saml-app`, name: 'saml-app', endpointUri: '', - addrWithProtocol: '', samlApp: true, desc: 'SAML Application', labels: makeLabelsList({ @@ -203,7 +209,7 @@ const SearchResultItems = () => { }), makeResourceResult({ kind: 'app', - resource: makeApp({ + resource: makeAppWithAddr({ uri: `${clusterUri}/apps/no-desc`, name: 'no-desc', desc: '', @@ -218,7 +224,7 @@ const SearchResultItems = () => { }), makeResourceResult({ kind: 'app', - resource: makeApp({ + resource: makeAppWithAddr({ uri: `${clusterUri}/apps/short-desc`, name: 'short-desc', desc: 'Lorem ipsum', @@ -233,7 +239,7 @@ const SearchResultItems = () => { }), makeResourceResult({ kind: 'app', - resource: makeApp({ + resource: makeAppWithAddr({ uri: `${clusterUri}/apps/long-desc`, name: 'long-desc', desc: 'Eget dignissim lectus nisi vitae nunc', @@ -248,7 +254,7 @@ const SearchResultItems = () => { }), makeResourceResult({ kind: 'app', - resource: makeApp({ + resource: makeAppWithAddr({ uri: `${clusterUri}/apps/super-long-desc`, name: 'super-long-desc', desc: 'Duis id tortor at purus tincidunt finibus. Mauris eu semper orci, non commodo lacus. Praesent sollicitudin magna id laoreet porta. Nunc lobortis varius sem vel fringilla.', @@ -263,7 +269,7 @@ const SearchResultItems = () => { }), makeResourceResult({ kind: 'app', - resource: makeApp({ + resource: makeAppWithAddr({ name: 'super-long-app-with-uuid-1f96e498-88ec-442f-a25b-569fa915041c', desc: 'short-desc', uri: `${longClusterUri}/apps/super-long-desc`, @@ -528,7 +534,6 @@ const AuxiliaryItems = () => { errors={[ new ResourceSearchError( '/clusters/foo', - 'server', new Error( '14 UNAVAILABLE: connection error: desc = "transport: authentication handshake failed: EOF"' ) @@ -542,14 +547,12 @@ const AuxiliaryItems = () => { errors={[ new ResourceSearchError( '/clusters/bar', - 'database', new Error( '2 UNKNOWN: Unable to connect to ssh proxy at teleport.local:443. Confirm connectivity and availability.\n dial tcp: lookup teleport.local: no such host' ) ), new ResourceSearchError( '/clusters/foo', - 'server', new Error( '14 UNAVAILABLE: connection error: desc = "transport: authentication handshake failed: EOF"' ) diff --git a/web/packages/teleterm/src/ui/Search/useSearch.test.tsx b/web/packages/teleterm/src/ui/Search/useSearch.test.tsx index d61f35b34d5e3..28d48fb39f6f2 100644 --- a/web/packages/teleterm/src/ui/Search/useSearch.test.tsx +++ b/web/packages/teleterm/src/ui/Search/useSearch.test.tsx @@ -143,7 +143,7 @@ describe('useResourceSearch', () => { })); jest .spyOn(appContext.resourcesService, 'searchResources') - .mockResolvedValue([{ status: 'fulfilled', value: servers }]); + .mockResolvedValue(servers); const { result } = renderHook(() => useResourceSearch(), { wrapper: ({ children }) => ( @@ -174,7 +174,7 @@ describe('useResourceSearch', () => { }); jest .spyOn(appContext.resourcesService, 'searchResources') - .mockResolvedValue([{ status: 'fulfilled', value: [] }]); + .mockResolvedValue([]); const { result } = renderHook(() => useResourceSearch(), { wrapper: ({ children }) => ( @@ -190,7 +190,7 @@ describe('useResourceSearch', () => { clusterUri: cluster.uri, search: '', filters: [], - limit: 5, + limit: 10, }); expect(appContext.resourcesService.searchResources).toHaveBeenCalledTimes( 1 @@ -205,7 +205,7 @@ describe('useResourceSearch', () => { }); jest .spyOn(appContext.resourcesService, 'searchResources') - .mockResolvedValue([{ status: 'fulfilled', value: [] }]); + .mockResolvedValue([]); const { result } = renderHook(() => useResourceSearch(), { wrapper: ({ children }) => ( @@ -226,7 +226,7 @@ describe('useResourceSearch', () => { }); jest .spyOn(appContext.resourcesService, 'searchResources') - .mockResolvedValue([{ status: 'fulfilled', value: [] }]); + .mockResolvedValue([]); const { result } = renderHook(() => useResourceSearch(), { wrapper: ({ children }) => ( diff --git a/web/packages/teleterm/src/ui/Search/useSearch.ts b/web/packages/teleterm/src/ui/Search/useSearch.ts index ca6a31eb36674..6b612e7b9ae26 100644 --- a/web/packages/teleterm/src/ui/Search/useSearch.ts +++ b/web/packages/teleterm/src/ui/Search/useSearch.ts @@ -43,6 +43,7 @@ export type CrossClusterResourceSearchResult = { search: string; }; +const MAX_RANKED_RESULTS = 10; const SUPPORTED_RESOURCE_TYPES: ResourceTypeFilter[] = [ 'node', 'app', @@ -88,9 +89,9 @@ export function useResourceSearch() { } case 'preview': { // In preview mode we know that the user didn't specify any search terms. So instead of - // fetching all 100 resources for each request, we fetch only a bunch of them to show + // fetching all 100 resources, we fetch only a bunch of them to show // example results in the UI. - limit = 5; + limit = MAX_RANKED_RESULTS; break; } case 'full-search': { @@ -116,20 +117,16 @@ export function useResourceSearch() { ) : connectedClusters; - // ResourcesService.searchResources uses Promise.allSettled so the returned promise will never - // get rejected. - const promiseResults = ( - await Promise.all( - clustersToSearch.map(cluster => - resourcesService.searchResources({ - clusterUri: cluster.uri, - search, - filters: resourceTypeSearchFilters.map(f => f.resourceType), - limit, - }) - ) + const promiseResults = await Promise.allSettled( + clustersToSearch.map(cluster => + resourcesService.searchResources({ + clusterUri: cluster.uri, + search, + filters: resourceTypeSearchFilters.map(f => f.resourceType), + limit, + }) ) - ).flat(); + ); const results: resourcesServiceTypes.SearchResult[] = []; const errors: resourcesServiceTypes.ResourceSearchError[] = []; @@ -266,7 +263,7 @@ export function rankResults( b.score - a.score || collator.compare(mainResourceName(a), mainResourceName(b)) ) - .slice(0, 10); + .slice(0, MAX_RANKED_RESULTS); } function populateMatches( diff --git a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts index f18bfb03dba9e..2e97dbe32d48a 100644 --- a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts +++ b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts @@ -30,7 +30,6 @@ import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; import { Kube } from 'gen-proto-ts/teleport/lib/teleterm/v1/kube_pb'; import { Server } from 'gen-proto-ts/teleport/lib/teleterm/v1/server_pb'; import { Database } from 'gen-proto-ts/teleport/lib/teleterm/v1/database_pb'; -import { App as TshdApp } from 'gen-proto-ts/teleport/lib/teleterm/v1/app_pb'; import { CreateAccessRequestRequest, GetRequestableRolesRequest, @@ -749,32 +748,3 @@ export function makeKube(source: Kube) { labels: source.labels, }; } - -export interface App extends TshdApp { - /** - * `addrWithProtocol` is an app protocol + a public address. - * If the public address is empty, it falls back to the endpoint URI. - * - * Always empty for SAML applications. - */ - addrWithProtocol: string; -} - -export function makeApp(source: TshdApp): App { - const { publicAddr, endpointUri } = source; - - const isTcp = endpointUri && endpointUri.startsWith('tcp://'); - const isCloud = endpointUri && endpointUri.startsWith('cloud://'); - let addrWithProtocol = endpointUri; - if (publicAddr) { - if (isCloud) { - addrWithProtocol = `cloud://${publicAddr}`; - } else if (isTcp) { - addrWithProtocol = `tcp://${publicAddr}`; - } else { - addrWithProtocol = `https://${publicAddr}`; - } - } - - return { ...source, addrWithProtocol }; -} diff --git a/web/packages/teleterm/src/ui/services/resources/resourcesService.test.ts b/web/packages/teleterm/src/ui/services/resources/resourcesService.test.ts index 201c95c9f8a35..345654597731c 100644 --- a/web/packages/teleterm/src/ui/services/resources/resourcesService.test.ts +++ b/web/packages/teleterm/src/ui/services/resources/resourcesService.test.ts @@ -105,39 +105,30 @@ describe('getServerByHostname', () => { }); describe('searchResources', () => { - it('returns settled promises for each resource type', async () => { + it('returns a promise with resources', async () => { const server = makeServer(); const db = makeDatabase(); const kube = makeKube(); const app = makeApp(); const tshClient: Partial = { - getServers: jest.fn().mockResolvedValueOnce( + listUnifiedResources: jest.fn().mockResolvedValueOnce( new MockedUnaryCall({ - agents: [server], - totalCount: 1, - startKey: '', - }) - ), - getDatabases: jest.fn().mockResolvedValueOnce( - new MockedUnaryCall({ - agents: [db], - totalCount: 1, - startKey: '', - }) - ), - getKubes: jest.fn().mockResolvedValueOnce( - new MockedUnaryCall({ - agents: [kube], - totalCount: 1, - startKey: '', - }) - ), - getApps: jest.fn().mockResolvedValueOnce( - new MockedUnaryCall({ - agents: [app], - totalCount: 1, - startKey: '', + resources: [ + { + resource: { oneofKind: 'server', server }, + }, + { + resource: { oneofKind: 'app', app }, + }, + { + resource: { oneofKind: 'database', database: db }, + }, + { + resource: { oneofKind: 'kube', kube }, + }, + ], + nextKey: '', }) ), }; @@ -151,100 +142,35 @@ describe('searchResources', () => { }); expect(searchResults).toHaveLength(4); - const [actualServers, actualApps, actualDatabases, actualKubes] = - searchResults; - expect(actualServers).toEqual({ - status: 'fulfilled', - value: [{ kind: 'server', resource: server }], - }); - expect(actualApps).toEqual({ - status: 'fulfilled', - value: [{ kind: 'app', resource: app }], - }); - expect(actualDatabases).toEqual({ - status: 'fulfilled', - value: [{ kind: 'database', resource: db }], - }); - expect(actualKubes).toEqual({ - status: 'fulfilled', - value: [{ kind: 'kube', resource: kube }], + const [actualServer, actualApp, actualDatabase, actualKube] = searchResults; + expect(actualServer).toEqual({ kind: 'server', resource: server }); + expect(actualApp).toEqual({ + kind: 'app', + resource: { + ...app, + addrWithProtocol: 'tcp://local-app.example.com:3000', + }, }); + expect(actualDatabase).toEqual({ kind: 'database', resource: db }); + expect(actualKube).toEqual({ kind: 'kube', resource: kube }); }); - it('returns a single item if a filter is supplied', async () => { - const server = makeServer(); - const tshClient: Partial = { - getServers: jest.fn().mockResolvedValueOnce( - new MockedUnaryCall({ - agents: [server], - totalCount: 1, - startKey: '', - }) - ), - }; - const service = new ResourcesService(tshClient as TshdClient); - - const searchResults = await service.searchResources({ - clusterUri: '/clusters/foo', - search: '', - filters: ['node'], - limit: 10, - }); - expect(searchResults).toHaveLength(1); - - const [actualServers] = searchResults; - expect(actualServers).toEqual({ - status: 'fulfilled', - value: [{ kind: 'server', resource: server }], - }); - }); - - it('returns a custom error pointing at resource kind and cluster when an underlying promise gets rejected', async () => { + it('returns a custom error pointing at cluster when a promise gets rejected', async () => { const expectedCause = new Error('oops'); const tshClient: Partial = { - getServers: jest.fn().mockRejectedValueOnce(expectedCause), - getDatabases: jest.fn().mockRejectedValueOnce(expectedCause), - getKubes: jest.fn().mockRejectedValueOnce(expectedCause), - getApps: jest.fn().mockRejectedValueOnce(expectedCause), + listUnifiedResources: jest.fn().mockRejectedValueOnce(expectedCause), }; const service = new ResourcesService(tshClient as TshdClient); - const searchResults = await service.searchResources({ + const searchResults = service.searchResources({ clusterUri: '/clusters/foo', search: '', filters: [], limit: 10, }); - expect(searchResults).toHaveLength(4); - - const [actualServers, actualApps, actualDatabases, actualKubes] = - searchResults; - expect(actualServers).toEqual({ - status: 'rejected', - reason: new ResourceSearchError('/clusters/foo', 'server', expectedCause), - }); - expect(actualDatabases).toEqual({ - status: 'rejected', - reason: new ResourceSearchError( - '/clusters/foo', - 'database', - expectedCause - ), - }); - expect(actualKubes).toEqual({ - status: 'rejected', - reason: new ResourceSearchError('/clusters/foo', 'kube', expectedCause), - }); - expect(actualApps).toEqual({ - status: 'rejected', - reason: new ResourceSearchError('/clusters/foo', 'app', expectedCause), - }); - - expect((actualServers as PromiseRejectedResult).reason).toBeInstanceOf( - ResourceSearchError - ); - expect((actualServers as PromiseRejectedResult).reason.cause).toEqual( - expectedCause + await expect(searchResults).rejects.toThrow( + new ResourceSearchError('/clusters/foo', expectedCause) ); + await expect(searchResults).rejects.toThrow(ResourceSearchError); }); }); diff --git a/web/packages/teleterm/src/ui/services/resources/resourcesService.ts b/web/packages/teleterm/src/ui/services/resources/resourcesService.ts index 35fd224dbc18c..dc2a20e1609f1 100644 --- a/web/packages/teleterm/src/ui/services/resources/resourcesService.ts +++ b/web/packages/teleterm/src/ui/services/resources/resourcesService.ts @@ -16,10 +16,6 @@ * along with this program. If not, see . */ -import { pluralize } from 'shared/utils/text'; - -import { makeApp, App } from 'teleterm/ui/services/clusters'; - import { cloneAbortSignal, TshdRpcError, @@ -34,6 +30,8 @@ import { import Logger from 'teleterm/logger'; +import { getAppAddrWithProtocol } from 'teleterm/services/tshd/app'; + import type { TshdClient } from 'teleterm/services/tshd'; import type * as types from 'teleterm/services/tshd/types'; import type * as uri from 'teleterm/ui/uri'; @@ -118,66 +116,42 @@ export class ResourcesService { search: string; filters: ResourceTypeFilter[]; limit: number; - }): Promise[]> { - const params = { search, clusterUri, sort: null, limit, startKey: '' }; - - const getServers = () => - this.fetchServers(params).then( - res => - res.agents.map(resource => ({ - kind: 'server' as const, - resource, - })), - err => - Promise.reject(new ResourceSearchError(clusterUri, 'server', err)) - ); - const getApps = () => - this.fetchApps(params).then( - res => - res.agents.map(resource => ({ - kind: 'app' as const, - resource: makeApp(resource), - })), - err => Promise.reject(new ResourceSearchError(clusterUri, 'app', err)) - ); - const getDatabases = () => - this.fetchDatabases(params).then( - res => - res.agents.map(resource => ({ - kind: 'database' as const, - resource, - })), - err => - Promise.reject(new ResourceSearchError(clusterUri, 'database', err)) - ); - const getKubes = () => - this.fetchKubes(params).then( - res => - res.agents.map(resource => ({ - kind: 'kube' as const, - resource, - })), - err => Promise.reject(new ResourceSearchError(clusterUri, 'kube', err)) - ); - - const promises = filters?.length - ? [ - filters.includes('node') && getServers(), - filters.includes('app') && getApps(), - filters.includes('db') && getDatabases(), - filters.includes('kube_cluster') && getKubes(), - ].filter(Boolean) - : [getServers(), getApps(), getDatabases(), getKubes()]; - - return Promise.allSettled(promises); + }): Promise { + try { + const { resources } = await this.listUnifiedResources({ + clusterUri, + kinds: filters, + limit, + search, + query: '', + searchAsRoles: false, + pinnedOnly: false, + startKey: '', + sortBy: { field: 'name', isDesc: true }, + }); + return resources.map(r => { + if (r.kind === 'app') { + return { + ...r, + resource: { + ...r.resource, + addrWithProtocol: getAppAddrWithProtocol(r.resource), + }, + }; + } + return r; + }); + } catch (err) { + throw new ResourceSearchError(clusterUri, err); + } } async listUnifiedResources( params: types.ListUnifiedResourcesRequest, - abortSignal: AbortSignal + abortSignal?: AbortSignal ): Promise<{ nextKey: string; resources: UnifiedResourceResponse[] }> { const { response } = await this.tshClient.listUnifiedResources(params, { - abort: cloneAbortSignal(abortSignal), + abort: abortSignal && cloneAbortSignal(abortSignal), }); return { nextKey: response.nextKey, @@ -230,28 +204,24 @@ export class AmbiguousHostnameError extends Error { export class ResourceSearchError extends Error { constructor( public clusterUri: uri.ClusterUri, - public resourceKind: SearchResult['kind'], cause: Error | TshdRpcError ) { - super( - `Error while fetching resources of type ${resourceKind} from cluster ${clusterUri}`, - { cause } - ); + super(`Error while fetching resources from cluster ${clusterUri}`, { + cause, + }); this.name = 'ResourceSearchError'; this.clusterUri = clusterUri; - this.resourceKind = resourceKind; } messageWithClusterName( getClusterName: (resourceUri: uri.ClusterOrResourceUri) => string, opts = { capitalize: true } ) { - const resource = pluralize(2, this.resourceKind); const cluster = getClusterName(this.clusterUri); return `${ opts.capitalize ? 'Could' : 'could' - } not fetch ${resource} from ${cluster}`; + } not fetch resources from ${cluster}`; } messageAndCauseWithClusterName( @@ -271,7 +241,7 @@ export type SearchResultDatabase = { export type SearchResultKube = { kind: 'kube'; resource: types.Kube }; export type SearchResultApp = { kind: 'app'; - resource: App; + resource: types.App & { addrWithProtocol: string }; }; export type SearchResult =