diff --git a/lib/teleterm/apiserver/handler/handler_user_preferences.go b/lib/teleterm/apiserver/handler/handler_user_preferences.go
index d963857396dce..02c55440ed709 100644
--- a/lib/teleterm/apiserver/handler/handler_user_preferences.go
+++ b/lib/teleterm/apiserver/handler/handler_user_preferences.go
@@ -1,16 +1,18 @@
-// Copyright 2023 Gravitational, Inc
+// Teleport
+// Copyright (C) 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
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
//
-// http://www.apache.org/licenses/LICENSE-2.0
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
//
-// 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.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
package handler
diff --git a/lib/teleterm/services/userpreferences/userpreferences.go b/lib/teleterm/services/userpreferences/userpreferences.go
index f5f195b64dc48..39676d052ecd6 100644
--- a/lib/teleterm/services/userpreferences/userpreferences.go
+++ b/lib/teleterm/services/userpreferences/userpreferences.go
@@ -1,16 +1,18 @@
-// Copyright 2023 Gravitational, Inc
+// Teleport
+// Copyright (C) 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
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
//
-// http://www.apache.org/licenses/LICENSE-2.0
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
//
-// 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.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
package userpreferences
diff --git a/lib/teleterm/services/userpreferences/userpreferences_test.go b/lib/teleterm/services/userpreferences/userpreferences_test.go
index 3acab930ce5b7..230da4a3c3f4c 100644
--- a/lib/teleterm/services/userpreferences/userpreferences_test.go
+++ b/lib/teleterm/services/userpreferences/userpreferences_test.go
@@ -1,16 +1,18 @@
-// Copyright 2023 Gravitational, Inc
+// Teleport
+// Copyright (C) 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
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
//
-// http://www.apache.org/licenses/LICENSE-2.0
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
//
-// 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.
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
package userpreferences
diff --git a/web/packages/shared/components/UnifiedResources/FilterPanel.tsx b/web/packages/shared/components/UnifiedResources/FilterPanel.tsx
index 7c3793bdd5f59..d1bb683627176 100644
--- a/web/packages/shared/components/UnifiedResources/FilterPanel.tsx
+++ b/web/packages/shared/components/UnifiedResources/FilterPanel.tsx
@@ -131,7 +131,6 @@ export function FilterPanel({
padding-left: ${props => props.theme.space[2]}px;
padding-right: ${props => props.theme.space[2]}px;
height: 22px;
- width: 128px;
`}
onClick={() => setExpandAllLabels(!expandAllLabels)}
>
diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx
index 853ba559d6842..6429dbe4309c4 100644
--- a/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx
+++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.story.tsx
@@ -36,12 +36,14 @@ import {
LabelsViewMode,
} from 'shared/services/unifiedResourcePreferences';
-import { UnifiedResources, useUnifiedResourcesFetch } from './UnifiedResources';
+import { makeErrorAttempt, makeProcessingAttempt } from 'shared/hooks/useAsync';
+
import {
- SharedUnifiedResource,
- UnifiedResourcesPinning,
- UnifiedResourcesQueryParams,
-} from './types';
+ UnifiedResources,
+ useUnifiedResourcesFetch,
+ UnifiedResourcesProps,
+} from './UnifiedResources';
+import { SharedUnifiedResource, UnifiedResourcesQueryParams } from './types';
export default {
title: 'Shared/UnifiedResources',
@@ -77,14 +79,13 @@ const story = ({
updateClusterPinnedResources: async () => undefined,
},
params,
+ ...props
}: {
fetchFunc: (
params: UrlResourcesParams,
signal: AbortSignal
) => Promise>;
- pinning?: UnifiedResourcesPinning;
- params?: Partial;
-}) => {
+} & Omit, 'fetchResources'>) => {
const mergedParams: UnifiedResourcesQueryParams = {
...{
sort: {
@@ -141,6 +142,7 @@ const story = ({
ActionButton: Connect,
},
}))}
+ {...props}
/>
);
};
@@ -182,13 +184,13 @@ export const LoadingAfterScrolling = story({
},
});
-export const Errored = story({
+export const Failed = story({
fetchFunc: async () => {
throw new Error('Failed to fetch');
},
});
-export const ErroredAfterScrolling = story({
+export const FailedAfterScrolling = story({
fetchFunc: async params => {
if (params.startKey === 'next-key') {
throw new Error('Failed to fetch');
@@ -197,6 +199,22 @@ export const ErroredAfterScrolling = story({
},
});
+export const FailedToLoadPreferences = story({
+ fetchFunc: async () => ({
+ agents: allResources,
+ }),
+ unifiedResourcePreferencesAttempt: makeErrorAttempt(
+ new Error('Network error')
+ ),
+});
+
+export const LoadingPreferences = story({
+ fetchFunc: async () => ({
+ agents: allResources,
+ }),
+ unifiedResourcePreferencesAttempt: makeProcessingAttempt(),
+});
+
export const PinningNotSupported = story({
fetchFunc: async () => {
return { agents: allResources, startKey: 'next-key' };
diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx
index fd6f0fc0ed35a..214f0d9166121 100644
--- a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx
+++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx
@@ -16,7 +16,13 @@
* along with this program. If not, see .
*/
-import React, { useEffect, useState, useCallback } from 'react';
+import React, {
+ useEffect,
+ useState,
+ useCallback,
+ Children,
+ PropsWithChildren,
+} from 'react';
import styled from 'styled-components';
import {
@@ -46,6 +52,7 @@ import {
makeSuccessAttempt,
useAsync,
Attempt as AsyncAttempt,
+ hasFinished,
} from 'shared/hooks/useAsync';
import {
useKeyBasedPagination,
@@ -117,7 +124,7 @@ export type FilterKind = {
disabled: boolean;
};
-interface UnifiedResourcesProps {
+export interface UnifiedResourcesProps {
params: UnifiedResourcesQueryParams;
resourcesFetchAttempt: Attempt;
fetchResources(options?: { force?: boolean }): Promise;
@@ -138,6 +145,13 @@ interface UnifiedResourcesProps {
setParams(params: UnifiedResourcesQueryParams): void;
/** A list of actions that can be performed on the selected items. */
bulkActions?: BulkAction[];
+ /**
+ * It is an attempt for initial fetch of preferences.
+ * When it is in progress, the component shows loading skeleton.
+ * Used only in Connect, where we fetch user preferences
+ * while the unified resources component is visible.
+ */
+ unifiedResourcePreferencesAttempt?: AsyncAttempt;
unifiedResourcePreferences: UnifiedResourcePreferences;
updateUnifiedResourcesPreferences(
preferences: UnifiedResourcePreferences
@@ -153,8 +167,9 @@ export function UnifiedResources(props: UnifiedResourcesProps) {
fetchResources,
availableKinds,
pinning,
- unifiedResourcePreferences,
+ unifiedResourcePreferencesAttempt,
updateUnifiedResourcesPreferences,
+ unifiedResourcePreferences,
bulkActions = [],
} = props;
@@ -354,38 +369,39 @@ export function UnifiedResources(props: UnifiedResourcesProps) {
margin: 0 auto;
`}
>
- {resourcesFetchAttempt.status === 'failed' && (
-
- {/* If pinning is hidden, we hide the different tabs to select a view (All resources, pinning).
- This causes this error box to cover the search bar. If pinning isn't supported, we push down the
- error by 60px to not hide the search bar.
- */}
-
-
- {resourcesFetchAttempt.statusText}
- {/* we don't want them to try another request with BAD REQUEST, it will just fail again. */}
- {resourcesFetchAttempt.statusCode !== 400 &&
- resourcesFetchAttempt.statusCode !== 403 && (
-
- Retry
-
- )}
-
-
-
- )}
- {getPinnedResourcesAttempt.status === 'error' && (
-
- {getPinnedResourcesAttempt.statusText}
-
- )}
- {updatePinnedResourcesAttempt.status === 'error' && (
-
- {updatePinnedResourcesAttempt.statusText}
-
- )}
+
+ {resourcesFetchAttempt.status === 'failed' && (
+
+ Could not fetch resources: {resourcesFetchAttempt.statusText}
+ {/* we don't want them to try another request with BAD REQUEST, it will just fail again. */}
+ {resourcesFetchAttempt.statusCode !== 400 &&
+ resourcesFetchAttempt.statusCode !== 403 && (
+
+ Retry
+
+ )}
+
+ )}
+ {getPinnedResourcesAttempt.status === 'error' && (
+
+ Could not fetch pinned resources:{' '}
+ {getPinnedResourcesAttempt.statusText}
+
+ )}
+ {updatePinnedResourcesAttempt.status === 'error' && (
+
+ Could not update pinned resources:{' '}
+ {updatePinnedResourcesAttempt.statusText}
+
+ )}
+ {unifiedResourcePreferencesAttempt?.status === 'error' && (
+
+ Could not fetch unified view preferences:{' '}
+ {unifiedResourcePreferencesAttempt.statusText}
+
+ )}
+
+
{props.Header}
) : (
-
- setParams({
- ...params,
- search: '',
- query: makeAdvancedSearchQueryForLabel(label, params),
- })
- }
- pinnedResources={pinnedResources}
- selectedResources={selectedResources}
- onSelectResource={handleSelectResource}
- onPinResource={handlePinResource}
- pinningSupport={getResourcePinningSupport(
- pinning.kind,
- updatePinnedResourcesAttempt
- )}
- isProcessing={
- resourcesFetchAttempt.status === 'processing' ||
- getPinnedResourcesAttempt.status === 'processing'
- }
- mappedResources={resources.map(unifiedResource => ({
- item: mapResourceToViewItem(unifiedResource),
- key: generateUnifiedResourceKey(unifiedResource.resource),
- }))}
- expandAllLabels={expandAllLabels}
- />
- )}
-
-
- {resourcesFetchAttempt.status === 'failed' && resources.length > 0 && (
- Load more
- )}
- {noResults && isSearchEmpty && !params.pinnedOnly && props.NoResources}
- {noResults && params.pinnedOnly && isSearchEmpty && }
- {noResults && !isSearchEmpty && (
-
+
+ setParams({
+ ...params,
+ search: '',
+ query: makeAdvancedSearchQueryForLabel(label, params),
+ })
+ }
+ pinnedResources={pinnedResources}
+ selectedResources={selectedResources}
+ onSelectResource={handleSelectResource}
+ onPinResource={handlePinResource}
+ pinningSupport={getResourcePinningSupport(
+ pinning.kind,
+ updatePinnedResourcesAttempt
+ )}
+ isProcessing={
+ // we don't check for '' in resourcesFetchAttempt because
+ // `keyBasedPagination` returns to that status on abort errors.
+ resourcesFetchAttempt.status === 'processing' ||
+ getPinnedResourcesAttempt.status === '' ||
+ getPinnedResourcesAttempt.status === 'processing' ||
+ unifiedResourcePreferencesAttempt?.status === '' ||
+ unifiedResourcePreferencesAttempt?.status === 'processing'
+ }
+ mappedResources={
+ // Hide the resources until the preferences are fetched.
+ // ViewComponent supports infinite scroll, so it shows both already loaded resources
+ // and a loading indicator if needed.
+ !unifiedResourcePreferencesAttempt ||
+ hasFinished(unifiedResourcePreferencesAttempt)
+ ? resources.map(unifiedResource => ({
+ item: mapResourceToViewItem(unifiedResource),
+ key: generateUnifiedResourceKey(unifiedResource.resource),
+ }))
+ : []
+ }
+ expandAllLabels={expandAllLabels}
/>
- )}
-
+
+
+ {resourcesFetchAttempt.status === 'failed' &&
+ resources.length > 0 && (
+
+ Load more
+
+ )}
+ {noResults &&
+ isSearchEmpty &&
+ !params.pinnedOnly &&
+ props.NoResources}
+ {noResults && params.pinnedOnly && isSearchEmpty && }
+ {noResults && !isSearchEmpty && (
+
+ )}
+
+ >
+ )}
);
}
@@ -603,20 +640,24 @@ function NoResults({
return null;
}
-const ErrorBox = styled(Box)`
+function ErrorsContainer(props: PropsWithChildren) {
+ if (!Children.toArray(props.children).length) {
+ return null;
+ }
+
+ return {props.children};
+}
+
+const ErrorBox = styled(Flex)`
position: sticky;
- top: 0;
+ flex-direction: column;
+ top: ${props => props.theme.space[3]}px;
+ gap: ${props => props.theme.space[1]}px;
+ padding-top: ${props => props.theme.space[1]}px;
+ padding-bottom: ${props => props.theme.space[3]}px;
z-index: 1;
`;
-const ErrorBoxInternal = styled(Box)`
- position: absolute;
- left: 0;
- right: 0;
- top: ${props => props.topPadding};
- margin: ${props => props.theme.space[1]}px 10% 0 10%;
-`;
-
const INDICATOR_SIZE = '48px';
// It's important to make the footer at least as big as the loading indicator,
diff --git a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx
index 3a9559ced03b7..9fea557a48ff9 100644
--- a/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx
+++ b/web/packages/teleterm/src/ui/DocumentCluster/DocumentCluster.tsx
@@ -206,7 +206,7 @@ function PrintState(props: {
);
}
-const Layout = styled(Box).attrs({ mx: 'auto', px: 5, pt: 4 })`
+const Layout = styled(Box).attrs({ mx: 'auto', px: 4, pt: 3 })`
flex-direction: column;
display: flex;
flex: 1;
diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx
index bf07a8b0dd31c..b78c198883ae5 100644
--- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx
+++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.tsx
@@ -16,13 +16,14 @@
* along with this program. If not, see .
*/
-import React, { useState, useCallback, useEffect } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
import {
UnifiedResources as SharedUnifiedResources,
useUnifiedResourcesFetch,
UnifiedResourcesQueryParams,
SharedUnifiedResource,
+ UnifiedResourcesPinning,
} from 'shared/components/UnifiedResources';
import {
DbProtocol,
@@ -36,14 +37,14 @@ import * as icons from 'design/Icon';
import Image from 'design/Image';
import stack from 'design/assets/resources/stack.png';
-import {
- UnifiedResourcePreferences,
- DefaultTab,
- ViewMode,
- LabelsViewMode,
-} from 'shared/services/unifiedResourcePreferences';
+import { DefaultTab } from 'shared/services/unifiedResourcePreferences';
+
+import { Attempt } from 'shared/hooks/useAsync';
-import { UnifiedResourceResponse } from 'teleterm/services/tshd/types';
+import {
+ UnifiedResourceResponse,
+ UserPreferences,
+} from 'teleterm/services/tshd/types';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import * as uri from 'teleterm/ui/uri';
import { useWorkspaceContext } from 'teleterm/ui/Documents';
@@ -63,31 +64,42 @@ import {
ConnectDatabaseActionButton,
} from './actionButtons';
import { useResourcesContext } from './resourcesContext';
+import { useUserPreferences } from './useUserPreferences';
export function UnifiedResources(props: {
clusterUri: uri.ClusterUri;
docUri: uri.DocumentUri;
queryParams: DocumentClusterQueryParams;
}) {
- // TODO: Add user preferences to Connect.
- // Until we add stored user preferences to Connect, store it in the state.
- const [userPreferences, setUserPreferences] =
- useState({
- defaultTab: DefaultTab.DEFAULT_TAB_ALL,
- viewMode: ViewMode.VIEW_MODE_CARD,
- labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED,
- });
+ const { userPreferencesAttempt, updateUserPreferences, userPreferences } =
+ useUserPreferences(props.clusterUri);
+
+ const { unifiedResourcePreferences } = userPreferences;
+
+ const mergedParams: UnifiedResourcesQueryParams = {
+ kinds: props.queryParams.resourceKinds,
+ sort: props.queryParams.sort,
+ pinnedOnly:
+ unifiedResourcePreferences.defaultTab === DefaultTab.DEFAULT_TAB_PINNED,
+ search: props.queryParams.advancedSearchEnabled
+ ? ''
+ : props.queryParams.search,
+ query: props.queryParams.advancedSearchEnabled
+ ? props.queryParams.search
+ : '',
+ };
return (
);
}
@@ -95,25 +107,14 @@ export function UnifiedResources(props: {
function Resources(props: {
clusterUri: uri.ClusterUri;
docUri: uri.DocumentUri;
- queryParams: DocumentClusterQueryParams;
- userPreferences: UnifiedResourcePreferences;
- setUserPreferences(u: UnifiedResourcePreferences): void;
+ queryParams: UnifiedResourcesQueryParams;
+ userPreferencesAttempt?: Attempt;
+ userPreferences: UserPreferences;
+ updateUserPreferences(u: UserPreferences): Promise;
}) {
const appContext = useAppContext();
const { onResourcesRefreshRequest } = useResourcesContext();
- const mergedParams: UnifiedResourcesQueryParams = {
- kinds: props.queryParams.resourceKinds,
- sort: props.queryParams.sort,
- pinnedOnly: false, //TODO: add support for pinning
- search: props.queryParams.advancedSearchEnabled
- ? ''
- : props.queryParams.search,
- query: props.queryParams.advancedSearchEnabled
- ? props.queryParams.search
- : '',
- };
-
const { documentsService, rootClusterUri } = useWorkspaceContext();
const loggedInUser = useWorkspaceLoggedInUser();
const { canUse: hasPermissionsForConnectMyComputer, agentCompatibility } =
@@ -139,13 +140,13 @@ function Resources(props: {
clusterUri: props.clusterUri,
searchAsRoles: false,
sortBy: {
- isDesc: mergedParams.sort.dir === 'DESC',
- field: mergedParams.sort.fieldName,
+ isDesc: props.queryParams.sort.dir === 'DESC',
+ field: props.queryParams.sort.fieldName,
},
- search: mergedParams.search,
- kindsList: mergedParams.kinds,
- query: mergedParams.query,
- pinnedOnly: mergedParams.pinnedOnly,
+ search: props.queryParams.search,
+ kindsList: props.queryParams.kinds,
+ query: props.queryParams.query,
+ pinnedOnly: props.queryParams.pinnedOnly,
startKey: paginationParams.startKey,
limit: paginationParams.limit,
},
@@ -161,12 +162,12 @@ function Resources(props: {
},
[
appContext,
- mergedParams.kinds,
- mergedParams.pinnedOnly,
- mergedParams.query,
- mergedParams.search,
- mergedParams.sort.dir,
- mergedParams.sort.fieldName,
+ props.queryParams.kinds,
+ props.queryParams.pinnedOnly,
+ props.queryParams.query,
+ props.queryParams.search,
+ props.queryParams.sort.dir,
+ props.queryParams.sort.fieldName,
props.clusterUri,
]
),
@@ -195,13 +196,36 @@ function Resources(props: {
});
}
+ const resourceIdsList =
+ props.userPreferences.clusterPreferences?.pinnedResources?.resourceIdsList;
+ const { updateUserPreferences } = props;
+ const pinning = useMemo(() => {
+ return resourceIdsList
+ ? {
+ kind: 'supported',
+ getClusterPinnedResources: async () => resourceIdsList,
+ updateClusterPinnedResources: pinnedIds =>
+ updateUserPreferences({
+ clusterPreferences: {
+ pinnedResources: { resourceIdsList: pinnedIds },
+ },
+ }),
+ }
+ : { kind: 'not-supported' };
+ }, [updateUserPreferences, resourceIdsList]);
+
return (
+ props.updateUserPreferences({ unifiedResourcePreferences })
+ }
+ pinning={pinning}
resources={resources.map(mapToSharedResource)}
resourcesFetchAttempt={attempt}
fetchResources={fetch}
diff --git a/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.test.tsx
new file mode 100644
index 0000000000000..6872fd940f552
--- /dev/null
+++ b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.test.tsx
@@ -0,0 +1,254 @@
+/**
+ * Teleport
+ * Copyright (C) 2023 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { renderHook, act } from '@testing-library/react';
+import {
+ ViewMode,
+ DefaultTab,
+ LabelsViewMode,
+} from 'shared/services/unifiedResourcePreferences';
+
+import { makeRootCluster } from 'teleterm/services/tshd/testHelpers';
+import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
+
+import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
+import { UserPreferences } from 'teleterm/services/tshd/types';
+
+import { useUserPreferences } from './useUserPreferences';
+
+const cluster = makeRootCluster();
+const preferences: UserPreferences = {
+ clusterPreferences: { pinnedResources: { resourceIdsList: ['abc'] } },
+ unifiedResourcePreferences: {
+ viewMode: ViewMode.VIEW_MODE_CARD,
+ defaultTab: DefaultTab.DEFAULT_TAB_ALL,
+ labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED,
+ },
+};
+
+test('user preferences are fetched', async () => {
+ const appContext = new MockAppContext();
+ const getUserPreferencesPromise = Promise.resolve(preferences);
+
+ jest
+ .spyOn(appContext.tshd, 'getUserPreferences')
+ .mockImplementation(() => getUserPreferencesPromise);
+ jest
+ .spyOn(appContext.workspacesService, 'getUnifiedResourcePreferences')
+ .mockReturnValue(undefined);
+ jest
+ .spyOn(appContext.workspacesService, 'setUnifiedResourcePreferences')
+ .mockImplementation();
+
+ const { result } = renderHook(() => useUserPreferences(cluster.uri), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ await act(() => getUserPreferencesPromise);
+
+ expect(result.current.userPreferences).toEqual(preferences);
+ expect(result.current.userPreferencesAttempt.status).toBe('success');
+
+ // updating the fallback
+ expect(
+ appContext.workspacesService.setUnifiedResourcePreferences
+ ).toHaveBeenCalledWith(cluster.uri, preferences.unifiedResourcePreferences);
+});
+
+test('unified resources fallback preferences are taken from a workspace', async () => {
+ const appContext = new MockAppContext();
+ let resolveGetUserPreferencesPromise: (u: UserPreferences) => void;
+ const getUserPreferencesPromise = new Promise(resolve => {
+ resolveGetUserPreferencesPromise = resolve;
+ });
+
+ jest
+ .spyOn(appContext.tshd, 'getUserPreferences')
+ .mockImplementation(() => getUserPreferencesPromise);
+ jest
+ .spyOn(appContext.workspacesService, 'getUnifiedResourcePreferences')
+ .mockReturnValue(preferences.unifiedResourcePreferences);
+ jest
+ .spyOn(appContext.workspacesService, 'setUnifiedResourcePreferences')
+ .mockImplementation();
+
+ const { result } = renderHook(() => useUserPreferences(cluster.uri), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ expect(result.current.userPreferences.unifiedResourcePreferences).toEqual(
+ preferences.unifiedResourcePreferences
+ );
+ resolveGetUserPreferencesPromise(null);
+ await act(() => getUserPreferencesPromise);
+});
+
+describe('updating preferences', () => {
+ const appContext = new MockAppContext();
+ beforeEach(() => {
+ jest
+ .spyOn(appContext.workspacesService, 'getUnifiedResourcePreferences')
+ .mockReturnValue(undefined);
+ jest
+ .spyOn(appContext.workspacesService, 'setUnifiedResourcePreferences')
+ .mockImplementation();
+ });
+
+ it('works correctly when the initial preferences were fetched', async () => {
+ const getUserPreferencesPromise = Promise.resolve(preferences);
+
+ jest
+ .spyOn(appContext.tshd, 'getUserPreferences')
+ .mockImplementation(() => getUserPreferencesPromise);
+ jest
+ .spyOn(appContext.tshd, 'updateUserPreferences')
+ .mockImplementation(async preferences => preferences.userPreferences);
+
+ const { result } = renderHook(() => useUserPreferences(cluster.uri), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ await act(() => getUserPreferencesPromise);
+
+ const newPreferences: UserPreferences = {
+ clusterPreferences: {},
+ unifiedResourcePreferences: {
+ viewMode: ViewMode.VIEW_MODE_LIST,
+ defaultTab: DefaultTab.DEFAULT_TAB_PINNED,
+ labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED,
+ },
+ };
+
+ await act(() => result.current.updateUserPreferences(newPreferences));
+
+ // updating state
+ expect(
+ appContext.workspacesService.setUnifiedResourcePreferences
+ ).toHaveBeenCalledWith(
+ cluster.uri,
+ newPreferences.unifiedResourcePreferences
+ );
+ expect(result.current.userPreferences.unifiedResourcePreferences).toEqual(
+ newPreferences.unifiedResourcePreferences
+ );
+
+ expect(result.current.userPreferencesAttempt.status).toBe('success');
+ expect(appContext.tshd.updateUserPreferences).toHaveBeenCalledWith({
+ clusterUri: cluster.uri,
+ userPreferences: newPreferences,
+ });
+ });
+
+ it('works correctly when the initial preferences have not been fetched yet', async () => {
+ let rejectGetUserPreferencesPromise: (error: Error) => void;
+ const getUserPreferencesPromise = new Promise(
+ (resolve, reject) => {
+ rejectGetUserPreferencesPromise = reject;
+ }
+ );
+ let resolveUpdateUserPreferencesPromise: (u: UserPreferences) => void;
+ const updateUserPreferencesPromise = new Promise(resolve => {
+ resolveUpdateUserPreferencesPromise = resolve;
+ });
+
+ jest
+ .spyOn(appContext.tshd, 'getUserPreferences')
+ .mockImplementation((requestParams, abortSignal) => {
+ abortSignal.addEventListener(() =>
+ rejectGetUserPreferencesPromise(new Error('Aborted'))
+ );
+ return getUserPreferencesPromise;
+ });
+ jest
+ .spyOn(appContext.tshd, 'updateUserPreferences')
+ .mockImplementation(() => updateUserPreferencesPromise);
+
+ const { result } = renderHook(() => useUserPreferences(cluster.uri), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ const newPreferences: UserPreferences = {
+ clusterPreferences: {},
+ unifiedResourcePreferences: {
+ viewMode: ViewMode.VIEW_MODE_LIST,
+ defaultTab: DefaultTab.DEFAULT_TAB_PINNED,
+ labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED,
+ },
+ };
+
+ act(() => {
+ result.current.updateUserPreferences(newPreferences);
+ });
+
+ // updating state
+ expect(
+ appContext.workspacesService.setUnifiedResourcePreferences
+ ).toHaveBeenCalledWith(
+ cluster.uri,
+ newPreferences.unifiedResourcePreferences
+ );
+ expect(result.current.userPreferences.unifiedResourcePreferences).toEqual(
+ newPreferences.unifiedResourcePreferences
+ );
+
+ expect(result.current.userPreferencesAttempt.status).toBe('processing');
+ expect(appContext.tshd.updateUserPreferences).toHaveBeenCalledWith({
+ clusterUri: cluster.uri,
+ userPreferences: newPreferences,
+ });
+
+ // suddenly, the request returns other preferences than what we wanted
+ // (e.g., because they were changed it in the browser in the meantime)
+ act(() =>
+ resolveUpdateUserPreferencesPromise({
+ clusterPreferences: { pinnedResources: { resourceIdsList: ['abc'] } },
+ unifiedResourcePreferences: {
+ viewMode: ViewMode.VIEW_MODE_CARD,
+ defaultTab: DefaultTab.DEFAULT_TAB_PINNED,
+ labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED,
+ },
+ })
+ );
+ await act(() => updateUserPreferencesPromise);
+
+ // but our view preferences are still the same as what we sent in the update request!
+ expect(result.current.userPreferences.unifiedResourcePreferences).toEqual(
+ newPreferences.unifiedResourcePreferences
+ );
+ expect(
+ result.current.userPreferences.clusterPreferences.pinnedResources
+ .resourceIdsList
+ ).toEqual(['abc']);
+ });
+});
diff --git a/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts
new file mode 100644
index 0000000000000..533194c5df5f0
--- /dev/null
+++ b/web/packages/teleterm/src/ui/DocumentCluster/useUserPreferences.ts
@@ -0,0 +1,228 @@
+/**
+ * Teleport
+ * Copyright (C) 2023 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import {
+ useAsync,
+ Attempt,
+ makeEmptyAttempt,
+ makeProcessingAttempt,
+ makeErrorAttempt,
+ makeSuccessAttempt,
+ mapAttempt,
+ CanceledError,
+ hasFinished,
+} from 'shared/hooks/useAsync';
+
+import {
+ DefaultTab,
+ ViewMode,
+ UnifiedResourcePreferences,
+ LabelsViewMode,
+} from 'shared/services/unifiedResourcePreferences';
+
+import { useAppContext } from 'teleterm/ui/appContextProvider';
+
+import { routing, ClusterUri } from 'teleterm/ui/uri';
+
+import { UserPreferences } from 'teleterm/services/tshd/types';
+import { retryWithRelogin } from 'teleterm/ui/utils';
+import createAbortController from 'teleterm/services/tshd/createAbortController';
+
+export function useUserPreferences(clusterUri: ClusterUri): {
+ userPreferencesAttempt: Attempt;
+ updateUserPreferences(newPreferences: UserPreferences): Promise;
+ userPreferences: UserPreferences;
+} {
+ const appContext = useAppContext();
+ const initialFetchAttemptAbortController = useRef(createAbortController());
+ // Consider storing the unified resource view preferences on the document.
+ // https://github.com/gravitational/teleport/pull/35251#discussion_r1424116275
+ const [unifiedResourcePreferences, setUnifiedResourcePreferences] = useState<
+ UserPreferences['unifiedResourcePreferences']
+ >(
+ mergeWithDefaultUnifiedResourcePreferences(
+ appContext.workspacesService.getUnifiedResourcePreferences(
+ routing.ensureRootClusterUri(clusterUri)
+ )
+ ) || {
+ defaultTab: DefaultTab.DEFAULT_TAB_ALL,
+ viewMode: ViewMode.VIEW_MODE_CARD,
+ labelsViewMode: LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED,
+ }
+ );
+ const [clusterPreferences, setClusterPreferences] = useState<
+ UserPreferences['clusterPreferences']
+ >({
+ // we pass an empty array, so pinning is enabled by default
+ pinnedResources: { resourceIdsList: [] },
+ });
+
+ const [initialFetchAttempt, runInitialFetchAttempt] = useAsync(
+ useCallback(
+ async () =>
+ retryWithRelogin(appContext, clusterUri, () =>
+ appContext.tshd.getUserPreferences(
+ { clusterUri },
+ initialFetchAttemptAbortController.current.signal
+ )
+ ),
+ [appContext, clusterUri]
+ )
+ );
+
+ // In a situation where the initial fetch attempt is still in progress,
+ // but the user has changed the preferences, we want
+ // to abort the previous attempt and replace it with the update attempt.
+ // This is done through `supersededInitialFetchAttempt`.
+ const [supersededInitialFetchAttempt, setSupersededInitialFetchAttempt] =
+ useState>(makeEmptyAttempt());
+
+ const [, runUpdateAttempt] = useAsync(
+ async (newPreferences: UserPreferences) =>
+ retryWithRelogin(appContext, clusterUri, () =>
+ appContext.tshd.updateUserPreferences({
+ clusterUri,
+ userPreferences: newPreferences,
+ })
+ )
+ );
+
+ const updateUnifiedResourcePreferencesStateAndWorkspace = useCallback(
+ (unifiedResourcePreferences: UnifiedResourcePreferences) => {
+ const prefsWithDefaults = mergeWithDefaultUnifiedResourcePreferences(
+ unifiedResourcePreferences
+ );
+ setUnifiedResourcePreferences(prefsWithDefaults);
+ appContext.workspacesService.setUnifiedResourcePreferences(
+ routing.ensureRootClusterUri(clusterUri),
+ prefsWithDefaults
+ );
+ },
+ [appContext.workspacesService, clusterUri]
+ );
+
+ useEffect(() => {
+ const fetchPreferences = async () => {
+ if (
+ initialFetchAttempt.status === '' &&
+ supersededInitialFetchAttempt.status === ''
+ ) {
+ const [prefs, error] = await runInitialFetchAttempt();
+ if (!error) {
+ updateUnifiedResourcePreferencesStateAndWorkspace(
+ prefs?.unifiedResourcePreferences
+ );
+ setClusterPreferences(prefs?.clusterPreferences);
+ }
+ }
+ };
+
+ fetchPreferences();
+ }, [
+ supersededInitialFetchAttempt.status,
+ runInitialFetchAttempt,
+ updateUnifiedResourcePreferencesStateAndWorkspace,
+ initialFetchAttempt.status,
+ ]);
+
+ const hasUpdateSupersededInitialFetch =
+ initialFetchAttempt.status !== 'success' &&
+ !hasFinished(supersededInitialFetchAttempt);
+ const updateUserPreferences = useCallback(
+ async (newPreferences: Partial): Promise => {
+ if (newPreferences.unifiedResourcePreferences) {
+ updateUnifiedResourcePreferencesStateAndWorkspace(
+ newPreferences.unifiedResourcePreferences
+ );
+ }
+
+ if (hasUpdateSupersededInitialFetch) {
+ setSupersededInitialFetchAttempt(makeProcessingAttempt());
+ initialFetchAttemptAbortController.current.abort();
+ }
+
+ const [prefs, error] = await runUpdateAttempt(newPreferences);
+ if (!error) {
+ // We always try to update cluster preferences based on the cluster response so that the
+ // pinned resources are up-to-date.
+ // We don't do it for unified resources preferences because if the view mode got updated on
+ // the server while the user, say, updated a pin, we don't want to suddenly change the view
+ // mode.
+ setClusterPreferences(prefs?.clusterPreferences);
+ if (hasUpdateSupersededInitialFetch) {
+ setSupersededInitialFetchAttempt(makeSuccessAttempt(undefined));
+ }
+ return;
+ }
+ if (!(error instanceof CanceledError)) {
+ if (hasUpdateSupersededInitialFetch) {
+ setSupersededInitialFetchAttempt(makeErrorAttempt(error));
+ }
+ appContext.notificationsService.notifyWarning({
+ title: 'Failed to update user preferences',
+ description: error.message,
+ });
+ }
+ },
+ [
+ hasUpdateSupersededInitialFetch,
+ runUpdateAttempt,
+ updateUnifiedResourcePreferencesStateAndWorkspace,
+ appContext.notificationsService,
+ ]
+ );
+
+ return {
+ userPreferencesAttempt:
+ supersededInitialFetchAttempt.status !== ''
+ ? supersededInitialFetchAttempt
+ : mapAttempt(initialFetchAttempt, () => undefined),
+ updateUserPreferences,
+ userPreferences: {
+ unifiedResourcePreferences,
+ clusterPreferences,
+ },
+ };
+}
+
+// TODO(gzdunek): DELETE IN 16.0.0.
+// Support for UnifiedTabPreference has been added in 14.1 and for
+// UnifiedViewModePreference in 14.1.5.
+// We have to support these values being undefined/unset in Connect v15.
+function mergeWithDefaultUnifiedResourcePreferences(
+ unifiedResourcePreferences: UnifiedResourcePreferences
+): UnifiedResourcePreferences {
+ return {
+ defaultTab: unifiedResourcePreferences
+ ? unifiedResourcePreferences.defaultTab
+ : DefaultTab.DEFAULT_TAB_ALL,
+ viewMode:
+ unifiedResourcePreferences &&
+ unifiedResourcePreferences.viewMode !== ViewMode.VIEW_MODE_UNSPECIFIED
+ ? unifiedResourcePreferences.viewMode
+ : ViewMode.VIEW_MODE_CARD,
+ labelsViewMode:
+ unifiedResourcePreferences &&
+ unifiedResourcePreferences.labelsViewMode !==
+ LabelsViewMode.LABELS_VIEW_MODE_UNSPECIFIED
+ ? unifiedResourcePreferences.labelsViewMode
+ : LabelsViewMode.LABELS_VIEW_MODE_COLLAPSED,
+ };
+}
diff --git a/web/packages/teleterm/src/ui/appContext.ts b/web/packages/teleterm/src/ui/appContext.ts
index 5e145952c6169..9b8c8a7161c17 100644
--- a/web/packages/teleterm/src/ui/appContext.ts
+++ b/web/packages/teleterm/src/ui/appContext.ts
@@ -43,6 +43,7 @@ import { UsageService } from 'teleterm/ui/services/usage';
import { ResourcesService } from 'teleterm/ui/services/resources';
import { ConnectMyComputerService } from 'teleterm/ui/services/connectMyComputer';
import { ConfigService } from 'teleterm/services/config';
+import { TshClient } from 'teleterm/services/tshd/types';
import { IAppContext } from 'teleterm/ui/types';
import { DeepLinksService } from 'teleterm/ui/services/deepLinks';
import { parseDeepLink } from 'teleterm/deepLinks';
@@ -63,6 +64,7 @@ export default class AppContext implements IAppContext {
connectionTracker: ConnectionTrackerService;
fileTransferService: FileTransferService;
resourcesService: ResourcesService;
+ tshd: TshClient;
/**
* subscribeToTshdEvent lets you add a listener that's going to be called every time a client
* makes a particular RPC to the tshd events service. The listener receives the request converted
@@ -88,6 +90,7 @@ export default class AppContext implements IAppContext {
constructor(config: ElectronGlobals) {
const { tshClient, ptyServiceClient, mainProcessClient } = config;
this.logger = new Logger('AppContext');
+ this.tshd = tshClient;
this.subscribeToTshdEvent = config.subscribeToTshdEvent;
this.mainProcessClient = mainProcessClient;
this.notificationsService = new NotificationsService();
diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts
index 01115d501641d..6497cb5a09829 100644
--- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts
+++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.test.ts
@@ -86,6 +86,7 @@ describe('restoring workspace', () => {
location: testWorkspace.location,
},
connectMyComputer: undefined,
+ unifiedResourcePreferences: undefined,
},
});
});
@@ -117,6 +118,7 @@ describe('restoring workspace', () => {
location: clusterDocument.uri,
previous: undefined,
connectMyComputer: undefined,
+ unifiedResourcePreferences: undefined,
},
});
});
@@ -142,7 +144,9 @@ describe('setActiveWorkspace', () => {
throw new Error(`Got unexpected dialog ${dialog.kind}`);
}
- return { closeDialog: () => {} };
+ return {
+ closeDialog: () => {},
+ };
});
const { isAtDesiredWorkspace } = await workspacesService.setActiveWorkspace(
@@ -186,7 +190,9 @@ describe('setActiveWorkspace', () => {
throw new Error(`Got unexpected dialog ${dialog.kind}`);
}
- return { closeDialog: () => {} };
+ return {
+ closeDialog: () => {},
+ };
});
const { isAtDesiredWorkspace } = await workspacesService.setActiveWorkspace(
diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts
index 768028548fa18..f75b75d61d28e 100644
--- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts
+++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts
@@ -16,6 +16,7 @@
* along with this program. If not, see .
*/
+import { z } from 'zod';
import { useStore } from 'shared/libs/stores';
import { arrayObjectIsEqual } from 'shared/utils/highbar';
@@ -23,6 +24,13 @@ import { arrayObjectIsEqual } from 'shared/utils/highbar';
// @ts-ignore
import { ResourceKind } from 'e-teleport/Workflow/NewRequest/useNewRequest';
+import {
+ UnifiedResourcePreferences,
+ DefaultTab,
+ ViewMode,
+ LabelsViewMode,
+} from 'shared/services/unifiedResourcePreferences';
+
import { ModalsService } from 'teleterm/ui/services/modals';
import { ClustersService } from 'teleterm/ui/services/clusters';
import {
@@ -66,6 +74,7 @@ export interface Workspace {
connectMyComputer?: {
autoStart: boolean;
};
+ unifiedResourcePreferences?: UnifiedResourcePreferences;
previous?: {
documents: Document[];
location: DocumentUri;
@@ -216,6 +225,22 @@ export class WorkspacesService extends ImmutableStore {
});
}
+ setUnifiedResourcePreferences(
+ rootClusterUri: RootClusterUri,
+ preferences: UnifiedResourcePreferences
+ ): void {
+ this.setState(draftState => {
+ draftState.workspaces[rootClusterUri].unifiedResourcePreferences =
+ preferences;
+ });
+ }
+
+ getUnifiedResourcePreferences(
+ rootClusterUri: RootClusterUri
+ ): UnifiedResourcePreferences | undefined {
+ return this.state.workspaces[rootClusterUri].unifiedResourcePreferences;
+ }
+
/**
* setActiveWorkspace changes the active workspace to that of the given root cluster.
* If the root cluster doesn't have a workspace yet, setActiveWorkspace creates a default
@@ -360,6 +385,9 @@ export class WorkspacesService extends ImmutableStore {
}
: undefined,
connectMyComputer: persistedWorkspace?.connectMyComputer,
+ unifiedResourcePreferences: this.parseUnifiedResourcePreferences(
+ persistedWorkspace?.unifiedResourcePreferences
+ ),
};
return workspaces;
}, {});
@@ -373,6 +401,18 @@ export class WorkspacesService extends ImmutableStore {
}
}
+ // TODO(gzdunek): Parse the entire workspace state read from disk like below.
+ private parseUnifiedResourcePreferences(
+ unifiedResourcePreferences: unknown
+ // TODO(gzdunek): DELETE IN 16.0.0. See comment in useUserPreferences.ts.
+ ): Partial | undefined {
+ try {
+ return unifiedResourcePreferencesSchema.parse(unifiedResourcePreferences);
+ } catch (e) {
+ this.logger.error('Failed to parse unified resource preferences', e);
+ }
+ }
+
private reopenPreviousDocuments(clusterUri: RootClusterUri): void {
this.setState(draftState => {
const workspace = draftState.workspaces[clusterUri];
@@ -476,12 +516,19 @@ export class WorkspacesService extends ImmutableStore {
location: workspace.previous?.location || workspace.location,
documents: workspace.previous?.documents || workspace.documents,
connectMyComputer: workspace.connectMyComputer,
+ unifiedResourcePreferences: workspace.unifiedResourcePreferences,
};
}
this.statePersistenceService.saveWorkspacesState(stateToSave);
}
}
+const unifiedResourcePreferencesSchema = z.object({
+ defaultTab: z.nativeEnum(DefaultTab),
+ viewMode: z.nativeEnum(ViewMode),
+ labelsViewMode: z.nativeEnum(LabelsViewMode),
+});
+
export type PendingAccessRequest = {
[k in Exclude]: Record;
};
diff --git a/web/packages/teleterm/src/ui/types.ts b/web/packages/teleterm/src/ui/types.ts
index f35d245075aea..765285b05c7a0 100644
--- a/web/packages/teleterm/src/ui/types.ts
+++ b/web/packages/teleterm/src/ui/types.ts
@@ -34,6 +34,7 @@ import { UsageService } from 'teleterm/ui/services/usage';
import { ConfigService } from 'teleterm/services/config';
import { ConnectMyComputerService } from 'teleterm/ui/services/connectMyComputer';
import { HeadlessAuthenticationService } from 'teleterm/ui/services/headlessAuthn/headlessAuthnService';
+import { TshClient } from 'teleterm/services/tshd/types';
export interface IAppContext {
clustersService: ClustersService;
@@ -55,6 +56,7 @@ export interface IAppContext {
configService: ConfigService;
connectMyComputerService: ConnectMyComputerService;
headlessAuthenticationService: HeadlessAuthenticationService;
+ tshd: TshClient;
pullInitialState(): Promise;
}