diff --git a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx
index 64996b898cedf..02c7a8f5e69bd 100644
--- a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx
+++ b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx
@@ -78,7 +78,7 @@ it('does not display empty results copy after selecting two filters', () => {
expect(results).not.toHaveTextContent('No matching results found');
});
-it('does display empty results copy after providing search query for which there is no results', () => {
+it('displays empty results copy after providing search query for which there is no results', () => {
const appContext = new MockAppContext();
appContext.workspacesService.setState(draft => {
draft.rootClusterUri = '/clusters/foo';
@@ -110,22 +110,14 @@ it('does display empty results copy after providing search query for which there
expect(results).toHaveTextContent('No matching results found.');
});
-it('does display empty results copy and excluded clusters after providing search query for which there is no results', () => {
+it('includes offline cluster names in the empty results copy', () => {
const appContext = new MockAppContext();
- jest
- .spyOn(appContext.clustersService, 'getRootClusters')
- .mockImplementation(() => [
- {
- uri: '/clusters/teleport-12-ent.asteroid.earth',
- name: 'teleport-12-ent.asteroid.earth',
- connected: false,
- leaf: false,
- proxyHost: 'test:3030',
- authClusterId: '73c4746b-d956-4f16-9848-4e3469f70762',
- },
- ]);
+ const cluster = makeRootCluster({ connected: false });
+ appContext.clustersService.setState(draftState => {
+ draftState.clusters.set(cluster.uri, cluster);
+ });
appContext.workspacesService.setState(draft => {
- draft.rootClusterUri = '/clusters/foo';
+ draft.rootClusterUri = cluster.uri;
});
const mockActionAttempts = {
@@ -153,7 +145,7 @@ it('does display empty results copy and excluded clusters after providing search
const results = screen.getByRole('menu');
expect(results).toHaveTextContent('No matching results found.');
expect(results).toHaveTextContent(
- 'The cluster teleport-12-ent.asteroid.earth was excluded from the search because you are not logged in to it.'
+ `The cluster ${cluster.name} was excluded from the search because you are not logged in to it.`
);
});
@@ -216,6 +208,7 @@ it('notifies about resource search errors and allows to display details', () =>
});
it('maintains focus on the search input after closing a resource search error modal', async () => {
+ const user = userEvent.setup();
const appContext = new MockAppContext();
appContext.workspacesService.setState(draft => {
draft.rootClusterUri = '/clusters/foo';
@@ -247,7 +240,8 @@ it('maintains focus on the search input after closing a resource search error mo
);
- screen.getByRole('searchbox').focus();
+ await user.type(screen.getByRole('searchbox'), 'foo');
+
expect(screen.getByRole('menu')).toHaveTextContent(
'Some of the search results are incomplete.'
);
diff --git a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.story.tsx b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.story.tsx
index 5cbfeeb85ecf4..00652ec466598 100644
--- a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.story.tsx
+++ b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.story.tsx
@@ -355,20 +355,16 @@ const AuxiliaryItems = () => (
ExtraTopComponent={
<>
+
window.alert('Error details')}
+ showErrorsInModal={() => window.alert('Error details')}
errors={[
new ResourceSearchError(
'/clusters/foo',
@@ -381,7 +377,7 @@ const AuxiliaryItems = () => (
/>
window.alert('Error details')}
+ showErrorsInModal={() => window.alert('Error details')}
errors={[
new ResourceSearchError(
'/clusters/bar',
diff --git a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.test.ts b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.test.ts
new file mode 100644
index 0000000000000..0942e63ca5d19
--- /dev/null
+++ b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.test.ts
@@ -0,0 +1,121 @@
+/**
+ * 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 { makeSuccessAttempt } from 'shared/hooks/useAsync';
+
+import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
+import { ResourceSearchError } from 'teleterm/ui/services/resources';
+
+import { getActionPickerStatus } from './ActionPicker';
+
+describe('getActionPickerStatus', () => {
+ it('partitions resource search errors into clusters with expired certs and non-retryable errors', () => {
+ const retryableError = new ResourceSearchError(
+ '/clusters/foo',
+ 'server',
+ new Error('ssh: cert has expired')
+ );
+
+ const nonRetryableError = new ResourceSearchError(
+ '/clusters/bar',
+ 'database',
+ new Error('whoops')
+ );
+
+ const status = getActionPickerStatus({
+ inputValue: 'foo',
+ filterActionsAttempt: makeSuccessAttempt([]),
+ allClusters: [],
+ actionAttempts: [makeSuccessAttempt([])],
+ resourceSearchAttempt: makeSuccessAttempt({
+ errors: [retryableError, nonRetryableError],
+ results: [],
+ search: 'foo',
+ }),
+ });
+
+ expect(status.status).toBe('finished');
+
+ const { clustersWithExpiredCerts, nonRetryableResourceSearchErrors } =
+ status.status === 'finished' && status;
+
+ expect([...clustersWithExpiredCerts]).toEqual([retryableError.clusterUri]);
+ expect(nonRetryableResourceSearchErrors).toEqual([nonRetryableError]);
+ });
+
+ it('merges non-connected clusters with clusters that returned retryable errors', () => {
+ const offlineCluster = makeRootCluster({ connected: false });
+ const retryableError = new ResourceSearchError(
+ '/clusters/foo',
+ 'server',
+ new Error('ssh: cert has expired')
+ );
+
+ const status = getActionPickerStatus({
+ inputValue: 'foo',
+ filterActionsAttempt: makeSuccessAttempt([]),
+ allClusters: [offlineCluster],
+ actionAttempts: [makeSuccessAttempt([])],
+ resourceSearchAttempt: makeSuccessAttempt({
+ errors: [retryableError],
+ results: [],
+ search: 'foo',
+ }),
+ });
+
+ expect(status.status).toBe('finished');
+ const { clustersWithExpiredCerts } = status.status === 'finished' && status;
+
+ expect(clustersWithExpiredCerts.size).toBe(2);
+ expect(clustersWithExpiredCerts).toContain(offlineCluster.uri);
+ expect(clustersWithExpiredCerts).toContain(retryableError.clusterUri);
+ });
+
+ it('includes a cluster with expired cert only once even if multiple requests fail with retryable errors', () => {
+ const retryableErrors = [
+ new ResourceSearchError(
+ '/clusters/foo',
+ 'server',
+ new Error('ssh: cert has expired')
+ ),
+ new ResourceSearchError(
+ '/clusters/foo',
+ 'database',
+ new Error('ssh: cert has expired')
+ ),
+ new ResourceSearchError(
+ '/clusters/foo',
+ 'kube',
+ new Error('ssh: cert has expired')
+ ),
+ ];
+ const status = getActionPickerStatus({
+ inputValue: 'foo',
+ filterActionsAttempt: makeSuccessAttempt([]),
+ allClusters: [],
+ actionAttempts: [makeSuccessAttempt([])],
+ resourceSearchAttempt: makeSuccessAttempt({
+ errors: retryableErrors,
+ results: [],
+ search: 'foo',
+ }),
+ });
+
+ expect(status.status).toBe('finished');
+ const { clustersWithExpiredCerts } = status.status === 'finished' && status;
+ expect([...clustersWithExpiredCerts]).toEqual(['/clusters/foo']);
+ });
+});
diff --git a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx
index 5c0f85f688e59..b399bff3d2088 100644
--- a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx
+++ b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import React, { ReactElement, useCallback } from 'react';
+import React, { ReactElement, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import {
Box,
@@ -26,7 +26,7 @@ import {
} from 'design';
import * as icons from 'design/Icon';
import { Highlight } from 'shared/components/Highlight';
-import { hasFinished } from 'shared/hooks/useAsync';
+import { Attempt, hasFinished } from 'shared/hooks/useAsync';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import {
@@ -42,9 +42,12 @@ import {
import * as tsh from 'teleterm/services/tshd/types';
import * as uri from 'teleterm/ui/uri';
import { ResourceSearchError } from 'teleterm/ui/services/resources';
+import { isRetryable } from 'teleterm/ui/utils/retryWithRelogin';
+import { assertUnreachable } from 'teleterm/ui/utils';
import { SearchAction } from '../actions';
import { useSearchContext } from '../SearchContext';
+import { CrossClusterResourceSearchResult } from '../useSearch';
import { useActionAttempts } from './useActionAttempts';
import { getParameterPicker } from './pickers';
@@ -77,6 +80,11 @@ export function ActionPicker(props: { input: ReactElement }) {
resourceSearchAttempt,
} = useActionAttempts();
const totalCountOfClusters = clustersService.getClusters().length;
+ // The order of attempts is important. Filter actions should be displayed before resource actions.
+ const actionAttempts = useMemo(
+ () => [filterActionsAttempt, resourceActionsAttempt],
+ [filterActionsAttempt, resourceActionsAttempt]
+ );
const getClusterName = useCallback(
(resourceUri: uri.ClusterOrResourceUri) => {
@@ -146,55 +154,38 @@ export function ActionPicker(props: { input: ReactElement }) {
}
}
- let ExtraTopComponent = null;
- // The order of attempts is important. Filter actions should be displayed before resource actions.
- const actionAttempts = [filterActionsAttempt, resourceActionsAttempt];
- const attemptsHaveFinishedWithoutActions = actionAttempts.every(
- a => hasFinished(a) && a.data.length === 0
+ const actionPickerStatus = useMemo(
+ () =>
+ getActionPickerStatus({
+ inputValue,
+ filterActionsAttempt,
+ actionAttempts,
+ resourceSearchAttempt,
+ allClusters: clustersService.getClusters(),
+ }),
+ [
+ inputValue,
+ filterActionsAttempt,
+ actionAttempts,
+ resourceSearchAttempt,
+ clustersService,
+ ]
);
- const noRemainingFilters =
- filterActionsAttempt.status === 'success' &&
- filterActionsAttempt.data.length === 0;
-
- if (inputValue && attemptsHaveFinishedWithoutActions) {
- ExtraTopComponent = (
-
- );
- }
-
- if (!inputValue && noRemainingFilters) {
- ExtraTopComponent = ;
- }
-
- if (
- resourceSearchAttempt.status === 'success' &&
- resourceSearchAttempt.data.errors.length > 0
- ) {
- const showErrorsInModal = () => {
+ const showErrorsInModal = useCallback(
+ errors =>
pauseUserInteraction(
() =>
new Promise(resolve => {
modalsService.openRegularDialog({
kind: 'resource-search-errors',
- errors: resourceSearchAttempt.data.errors,
+ errors,
getClusterName,
onCancel: () => resolve(undefined),
});
})
- );
- };
-
- ExtraTopComponent = (
- <>
-
- {ExtraTopComponent}
- >
- );
- }
+ ),
+ [pauseUserInteraction, modalsService, getClusterName]
+ );
return (
@@ -222,7 +213,13 @@ export function ActionPicker(props: { input: ReactElement }) {
),
};
}}
- ExtraTopComponent={ExtraTopComponent}
+ ExtraTopComponent={
+
+ }
/>
);
@@ -245,6 +242,124 @@ export const InputWrapper = styled(Flex).attrs({ px: 2 })`
}
`;
+const ExtraTopComponents = (props: {
+ status: ActionPickerStatus;
+ getClusterName: (resourceUri: uri.ClusterOrResourceUri) => string;
+ showErrorsInModal: (errors: ResourceSearchError[]) => void;
+}) => {
+ const { status, getClusterName, showErrorsInModal } = props;
+
+ switch (status.status) {
+ case 'no-input': {
+ return status.hasNoRemainingFilterActions && ;
+ }
+ case 'processing': {
+ return null;
+ }
+ case 'finished': {
+ return (
+ <>
+ {status.nonRetryableResourceSearchErrors.length > 0 && (
+ {
+ showErrorsInModal(status.nonRetryableResourceSearchErrors);
+ }}
+ />
+ )}
+ {status.hasNoResults && (
+
+ )}
+ >
+ );
+ }
+ default: {
+ assertUnreachable(status);
+ }
+ }
+};
+
+type ActionPickerStatus =
+ | { status: 'no-input'; hasNoRemainingFilterActions: boolean }
+ | { status: 'processing' }
+ | {
+ status: 'finished';
+ hasNoResults: boolean;
+ nonRetryableResourceSearchErrors: ResourceSearchError[];
+ clustersWithExpiredCerts: Set;
+ };
+
+export function getActionPickerStatus({
+ inputValue,
+ filterActionsAttempt,
+ allClusters,
+ actionAttempts,
+ resourceSearchAttempt,
+}: {
+ inputValue: string;
+ filterActionsAttempt: Attempt;
+ allClusters: tsh.Cluster[];
+ actionAttempts: Attempt[];
+ resourceSearchAttempt: Attempt;
+}): ActionPickerStatus {
+ if (!inputValue) {
+ // The number of available filters the user can select changes dynamically based on how many
+ // clusters are in the state. That's why instead of inspecting the filters array from
+ // SearchContext, we inspect the actual filter actions attempt to see if any further filter
+ // suggestions will be shown to the user.
+ //
+ // We also know that this attempt is always successful as filters are calculated in a sync way.
+ // They're converted into an attempt only to conform to the interface of ResultList.
+ const hasNoRemainingFilterActions =
+ filterActionsAttempt.status === 'success' &&
+ filterActionsAttempt.data.length === 0;
+
+ return {
+ status: 'no-input',
+ hasNoRemainingFilterActions,
+ };
+ }
+
+ const haveActionAttemptsFinished = actionAttempts.every(attempt =>
+ hasFinished(attempt)
+ );
+
+ if (!haveActionAttemptsFinished) {
+ return {
+ status: 'processing',
+ };
+ }
+
+ const hasNoResults = actionAttempts.every(
+ attempt => attempt.data.length === 0
+ );
+ const clustersWithExpiredCerts = new Set(
+ allClusters.filter(c => !c.connected).map(c => c.uri)
+ );
+ const nonRetryableResourceSearchErrors = [];
+
+ if (resourceSearchAttempt.status === 'success') {
+ resourceSearchAttempt.data.errors.forEach(err => {
+ if (isRetryable(err.cause)) {
+ clustersWithExpiredCerts.add(err.clusterUri);
+ } else {
+ nonRetryableResourceSearchErrors.push(err);
+ }
+ });
+ }
+
+ return {
+ status: 'finished',
+ hasNoResults,
+ clustersWithExpiredCerts,
+ nonRetryableResourceSearchErrors,
+ };
+}
+
export const ComponentMap: Record<
SearchResult['kind'],
React.FC>
@@ -478,15 +593,31 @@ export function KubeItem(props: SearchResultItem) {
);
}
-export function NoResultsItem(props: { clusters: tsh.Cluster[] }) {
- const excludedClustersCopy = getExcludedClustersCopy(props.clusters);
+export function NoResultsItem(props: {
+ clustersWithExpiredCerts: Set;
+ getClusterName: (resourceUri: uri.ClusterOrResourceUri) => string;
+}) {
+ const clustersWithExpiredCerts = Array.from(
+ props.clustersWithExpiredCerts,
+ clusterUri => props.getClusterName(clusterUri)
+ );
+ clustersWithExpiredCerts.sort();
+ let expiredCertsCopy = '';
+
+ if (clustersWithExpiredCerts.length === 1) {
+ expiredCertsCopy = `The cluster ${clustersWithExpiredCerts[0]} was excluded from the search because you are not logged in to it.`;
+ }
+
+ if (clustersWithExpiredCerts.length > 1) {
+ // prettier-ignore
+ expiredCertsCopy = `The following clusters were excluded from the search because you are not logged in to them: ${clustersWithExpiredCerts.join(', ')}.`;
+ }
+
return (
-
No matching results found.
- {excludedClustersCopy && (
- {excludedClustersCopy}
- )}
+ {expiredCertsCopy && {expiredCertsCopy}}
);
@@ -505,7 +636,7 @@ export function TypeToSearchItem() {
export function ResourceSearchErrorsItem(props: {
errors: ResourceSearchError[];
getClusterName: (resourceUri: uri.ClusterOrResourceUri) => string;
- onShowDetails: () => void;
+ showErrorsInModal: () => void;
}) {
const { errors, getClusterName } = props;
@@ -547,7 +678,7 @@ export function ResourceSearchErrorsItem(props: {
css={`
flex-shrink: 0;
`}
- onClick={props.onShowDetails}
+ onClick={props.showErrorsInModal}
>
Show details
@@ -557,19 +688,6 @@ export function ResourceSearchErrorsItem(props: {
);
}
-function getExcludedClustersCopy(allClusters: tsh.Cluster[]): string {
- // TODO(ravicious): Include leaf clusters.
- const excludedClusters = allClusters.filter(c => !c.connected);
- const excludedClustersString = excludedClusters.map(c => c.name).join(', ');
- if (excludedClusters.length === 0) {
- return '';
- }
- if (excludedClusters.length === 1) {
- return `The cluster ${excludedClustersString} was excluded from the search because you are not logged in to it.`;
- }
- return `Clusters ${excludedClustersString} were excluded from the search because you are not logged in to them.`;
-}
-
function Labels(
props: React.PropsWithChildren<{
searchResult: ResourceSearchResult;
diff --git a/web/packages/teleterm/src/ui/Search/useSearch.ts b/web/packages/teleterm/src/ui/Search/useSearch.ts
index 7a261669314ae..09162a771e560 100644
--- a/web/packages/teleterm/src/ui/Search/useSearch.ts
+++ b/web/packages/teleterm/src/ui/Search/useSearch.ts
@@ -34,6 +34,12 @@ import {
import type * as resourcesServiceTypes from 'teleterm/ui/services/resources';
+export type CrossClusterResourceSearchResult = {
+ results: resourcesServiceTypes.SearchResult[];
+ errors: resourcesServiceTypes.ResourceSearchError[];
+ search: string;
+};
+
/**
* useResourceSearch returns a function which searches for the given list of space-separated keywords across
* all root and leaf clusters that the user is currently logged in to.
@@ -48,11 +54,7 @@ export function useResourceSearch() {
async (
search: string,
filters: SearchFilter[]
- ): Promise<{
- results: resourcesServiceTypes.SearchResult[];
- errors: resourcesServiceTypes.ResourceSearchError[];
- search: string;
- }> => {
+ ): Promise => {
// useResourceSearch has to return _something_ when the input is empty. Imagine this scenario:
//
// 1. The user types in 'data' into the search bar.