diff --git a/web/packages/teleterm/src/mainProcess/awaitableSender/awaitableSender.ts b/web/packages/teleterm/src/mainProcess/awaitableSender/awaitableSender.ts index 9214fcce431c8..089534fd162f3 100644 --- a/web/packages/teleterm/src/mainProcess/awaitableSender/awaitableSender.ts +++ b/web/packages/teleterm/src/mainProcess/awaitableSender/awaitableSender.ts @@ -40,6 +40,11 @@ function isMessageAck(v: unknown): v is MessageAck { return typeof v === 'object' && 'type' in v && v.type === 'ack'; } +export interface IAwaitableSender { + send(payload: T, options?: { signal?: AbortSignal }): Promise; + whenDisposed(): Promise; +} + /** * Enables sending messages from the main process to the renderer * and awaiting delivery confirmation. @@ -48,7 +53,7 @@ function isMessageAck(v: unknown): v is MessageAck { * `AwaitableSender` is pull-based — the renderer must explicitly subscribe * to receive messages. */ -export class AwaitableSender { +export class AwaitableSender implements IAwaitableSender { private messages = new Map< string, { resolve(): void; reject(reason: unknown): void } diff --git a/web/packages/teleterm/src/mainProcess/clusterLifecycleManager/clusterLifecycleManager.test.ts b/web/packages/teleterm/src/mainProcess/clusterLifecycleManager/clusterLifecycleManager.test.ts new file mode 100644 index 0000000000000..fe6036c2a03f6 --- /dev/null +++ b/web/packages/teleterm/src/mainProcess/clusterLifecycleManager/clusterLifecycleManager.test.ts @@ -0,0 +1,352 @@ +/** + * Teleport + * Copyright (C) 2025 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 { enablePatches } from 'immer'; + +import Logger, { NullService } from 'teleterm/logger'; +import { IAwaitableSender } from 'teleterm/mainProcess/awaitableSender'; +import { ClusterStore } from 'teleterm/mainProcess/clusterStore'; +import { ProfileChangeSet } from 'teleterm/mainProcess/profileWatcher'; +import { RendererIpc } from 'teleterm/mainProcess/types'; +import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; +import { MockTshClient } from 'teleterm/services/tshd/fixtures/mocks'; +import { + makeLoggedInUser, + makeRootCluster, +} from 'teleterm/services/tshd/testHelpers'; + +import { + ClusterLifecycleEvent, + ClusterLifecycleManager, +} from './clusterLifecycleManager'; + +beforeAll(() => { + Logger.init(new NullService()); +}); + +enablePatches(); + +const cluster = makeRootCluster(); + +const tests: { + name: string; + setup(opts: { tshdClient: MockTshClient }): Promise<{ + profileWatcher: () => AsyncGenerator; + throwInRendererHandler?: boolean; + }>; + expect(opts: { + clusterStore: ClusterStore; + tshdClient: MockTshClient; + rendererHandler: IAwaitableSender; + globalErrorHandler: jest.Mock; + }): void; +}[] = [ + { + name: 'when cluster is added, it updates state and notifies renderer', + setup: async () => { + return { + profileWatcher: makeWatcher([{ op: 'added', cluster }]), + }; + }, + expect: ({ clusterStore, rendererHandler }) => { + expect(clusterStore.getState().get(cluster.uri)).toBeDefined(); + expect(rendererHandler.send).toHaveBeenCalledWith({ + op: 'did-add-cluster', + uri: cluster.uri, + }); + }, + }, + { + name: 'when cluster is added and renderer fails, it updates state and reports an error', + setup: async () => { + return { + throwInRendererHandler: true, + profileWatcher: makeWatcher([{ op: 'added', cluster }]), + }; + }, + expect: ({ clusterStore, globalErrorHandler }) => { + expect(clusterStore.getState().get(cluster.uri)).toBeDefined(); + expect(globalErrorHandler).toHaveBeenCalledWith( + RendererIpc.ProfileWatcherError, + { + error: new Error('Error in renderer'), + reason: 'processing-error', + } + ); + }, + }, + { + name: 'when cluster is removed, it updates state and notifies renderer', + setup: async ({ tshdClient }) => { + jest.spyOn(tshdClient, 'logout'); + jest + .spyOn(tshdClient, 'listRootClusters') + .mockResolvedValue(new MockedUnaryCall({ clusters: [cluster] })); + return { + profileWatcher: makeWatcher([{ op: 'removed', cluster }]), + }; + }, + expect: ({ clusterStore, tshdClient, rendererHandler }) => { + expect(clusterStore.getState().get(cluster.uri)).toBeUndefined(); + expect(tshdClient.logout).toHaveBeenCalledWith({ + clusterUri: cluster.uri, + removeProfile: true, + }); + expect(rendererHandler.send).toHaveBeenCalledWith({ + op: 'will-logout-and-remove', + uri: cluster.uri, + }); + }, + }, + { + name: 'when cluster is removed and renderer fails, it keeps the cluster and reports an error', + setup: async ({ tshdClient }) => { + jest.spyOn(tshdClient, 'logout'); + jest + .spyOn(tshdClient, 'listRootClusters') + .mockResolvedValue(new MockedUnaryCall({ clusters: [cluster] })); + return { + throwInRendererHandler: true, + profileWatcher: makeWatcher([{ op: 'removed', cluster }]), + }; + }, + expect: ({ clusterStore, tshdClient, globalErrorHandler }) => { + expect(clusterStore.getState().get(cluster.uri)).toBeDefined(); + expect(tshdClient.logout).not.toHaveBeenCalled(); + expect(globalErrorHandler).toHaveBeenCalledWith( + RendererIpc.ProfileWatcherError, + { + error: new Error('Error in renderer'), + reason: 'processing-error', + } + ); + }, + }, + { + name: 'when cluster becomes logged-out, it updates state and notifies renderer', + setup: async ({ tshdClient }) => { + const next = makeRootCluster({ + connected: false, + loggedInUser: makeLoggedInUser({ name: '' }), + }); + jest.spyOn(tshdClient, 'logout'); + jest + .spyOn(tshdClient, 'listRootClusters') + .mockResolvedValue(new MockedUnaryCall({ clusters: [cluster] })); + return { + profileWatcher: makeWatcher([ + { op: 'changed', next, previous: cluster }, + ]), + }; + }, + expect: ({ clusterStore, tshdClient, rendererHandler }) => { + expect(clusterStore.getState().get(cluster.uri).loggedInUser.name).toBe( + '' + ); + expect(tshdClient.logout).toHaveBeenCalledWith({ + clusterUri: cluster.uri, + removeProfile: false, + }); + expect(rendererHandler.send).toHaveBeenCalledWith({ + op: 'will-logout', + uri: cluster.uri, + }); + }, + }, + { + name: 'when cluster becomes logged-out and renderer fails, it keeps logged-in state and reports an error', + setup: async ({ tshdClient }) => { + const next = makeRootCluster({ + connected: false, + loggedInUser: makeLoggedInUser({ name: '' }), + }); + jest.spyOn(tshdClient, 'logout'); + jest + .spyOn(tshdClient, 'listRootClusters') + .mockResolvedValue(new MockedUnaryCall({ clusters: [cluster] })); + return { + throwInRendererHandler: true, + profileWatcher: makeWatcher([ + { op: 'changed', next, previous: cluster }, + ]), + }; + }, + expect: ({ clusterStore, tshdClient, globalErrorHandler }) => { + expect(clusterStore.getState().get(cluster.uri).loggedInUser.name).toBe( + cluster.loggedInUser.name + ); + expect(tshdClient.logout).not.toHaveBeenCalled(); + expect(globalErrorHandler).toHaveBeenCalledWith( + RendererIpc.ProfileWatcherError, + { + error: new Error('Error in renderer'), + reason: 'processing-error', + } + ); + }, + }, + { + name: 'when cluster changes, it updates state and clears stale clients', + setup: async ({ tshdClient }) => { + const next = makeRootCluster({ + connected: false, + }); + jest + .spyOn(tshdClient, 'listRootClusters') + .mockResolvedValue(new MockedUnaryCall({ clusters: [cluster] })); + jest.spyOn(tshdClient, 'clearStaleClusterClients'); + return { + profileWatcher: makeWatcher([ + { op: 'changed', next, previous: cluster }, + ]), + }; + }, + expect: ({ clusterStore, tshdClient, rendererHandler }) => { + expect(clusterStore.getState().get(cluster.uri).connected).toBe(false); + expect(tshdClient.clearStaleClusterClients).toHaveBeenCalledWith({ + rootClusterUri: cluster.uri, + }); + expect(rendererHandler.send).not.toHaveBeenCalled(); + }, + }, + { + name: 'when access of logged in user changes, it updates state, clears stale clients, and notifies renderer', + setup: async ({ tshdClient }) => { + const next = makeRootCluster({ + loggedInUser: makeLoggedInUser({ activeRequests: ['abcd'] }), + }); + jest + .spyOn(tshdClient, 'listRootClusters') + .mockResolvedValue(new MockedUnaryCall({ clusters: [cluster] })); + jest.spyOn(tshdClient, 'clearStaleClusterClients'); + return { + profileWatcher: makeWatcher([ + { op: 'changed', next, previous: cluster }, + ]), + }; + }, + expect: ({ clusterStore, tshdClient, rendererHandler }) => { + expect( + clusterStore.getState().get(cluster.uri).loggedInUser.activeRequests + ).toEqual(['abcd']); + expect(tshdClient.clearStaleClusterClients).toHaveBeenCalledWith({ + rootClusterUri: cluster.uri, + }); + expect(rendererHandler.send).toHaveBeenCalledWith({ + op: 'did-change-access', + uri: cluster.uri, + }); + }, + }, + { + name: 'when access of logged in user changes and renderer fails, it updates state, clears stale clients and reports error', + setup: async ({ tshdClient }) => { + const next = makeRootCluster({ + loggedInUser: makeLoggedInUser({ activeRequests: ['abcd'] }), + }); + jest + .spyOn(tshdClient, 'listRootClusters') + .mockResolvedValue(new MockedUnaryCall({ clusters: [cluster] })); + jest.spyOn(tshdClient, 'clearStaleClusterClients'); + return { + throwInRendererHandler: true, + profileWatcher: makeWatcher([ + { op: 'changed', next, previous: cluster }, + ]), + }; + }, + expect: ({ clusterStore, tshdClient, globalErrorHandler }) => { + expect( + clusterStore.getState().get(cluster.uri).loggedInUser.activeRequests + ).toEqual(['abcd']); + expect(tshdClient.clearStaleClusterClients).toHaveBeenCalledWith({ + rootClusterUri: cluster.uri, + }); + expect(globalErrorHandler).toHaveBeenCalledWith( + RendererIpc.ProfileWatcherError, + { + error: new Error('Error in renderer'), + reason: 'processing-error', + } + ); + }, + }, +]; + +// eslint-disable-next-line jest/expect-expect +test.each(tests)('$name', async ({ setup, expect: testExpect }) => { + const mockTshdClient = new MockTshClient(); + const mockAppUpdater = { + maybeRemoveManagingCluster: jest.fn().mockResolvedValue(undefined), + }; + const clusterStore = new ClusterStore(async () => mockTshdClient, { + crashWindow: async () => {}, + }); + const globalErrorHandler = jest.fn(); + const windowsManager = { + getWindow: () => ({ + webContents: { + send: globalErrorHandler, + }, + }), + }; + const { profileWatcher, throwInRendererHandler } = await setup({ + tshdClient: mockTshdClient, + }); + + const done = Promise.withResolvers(); + const consumer = (async function* () { + try { + for await (const value of profileWatcher()) { + yield value; + } + } finally { + done.resolve(); + } + })(); + + const manager = new ClusterLifecycleManager( + clusterStore, + async () => mockTshdClient, + mockAppUpdater, + windowsManager, + consumer + ); + const mockRendererHandler = { + send: throwInRendererHandler + ? jest.fn().mockRejectedValue(new Error('Error in renderer')) + : jest.fn().mockResolvedValue(undefined), + whenDisposed: () => new Promise(() => {}), + }; + manager.setRendererEventHandler(mockRendererHandler); + await manager.syncRootClustersAndStartProfileWatcher(); + await done.promise; + + testExpect({ + globalErrorHandler, + clusterStore, + tshdClient: mockTshdClient, + rendererHandler: mockRendererHandler, + }); +}); + +function makeWatcher(...events: ProfileChangeSet[]) { + return async function* () { + yield* events; + }; +} diff --git a/web/packages/teleterm/src/mainProcess/clusterLifecycleManager/clusterLifecycleManager.ts b/web/packages/teleterm/src/mainProcess/clusterLifecycleManager/clusterLifecycleManager.ts index 28a0b8aea954f..2301dc5b96d53 100644 --- a/web/packages/teleterm/src/mainProcess/clusterLifecycleManager/clusterLifecycleManager.ts +++ b/web/packages/teleterm/src/mainProcess/clusterLifecycleManager/clusterLifecycleManager.ts @@ -16,12 +16,14 @@ * along with this program. If not, see . */ -import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; +import { + Cluster, + LoggedInUser, +} from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; import Logger from 'teleterm/logger'; -import { AwaitableSender } from 'teleterm/mainProcess/awaitableSender'; +import type { IAwaitableSender } from 'teleterm/mainProcess/awaitableSender'; import { RendererIpc } from 'teleterm/mainProcess/types'; -import type { WindowsManager } from 'teleterm/mainProcess/windowsManager'; import { AppUpdater } from 'teleterm/services/appUpdater'; import { isTshdRpcError, TshdClient } from 'teleterm/services/tshd'; import { mergeClusterProfileWithDetails } from 'teleterm/services/tshd/cluster'; @@ -41,8 +43,20 @@ export interface ClusterLifecycleEvent { * * Operations prefixed with `did-` occur after the action has already happened * in the main process, so they cannot prevent it. + * + * Operation meanings: + * * did-add-cluster - A cluster has been successfully added. + * * did-change-access - The logged-in user has changed, or their roles, + * or access requests have been updated. + * * will-logout - The user is about to be logged out. + * * will-logout-and-remove - The user is about to be logged out + * and their profile (cluster) will be removed. */ - op: 'did-add-cluster' | 'will-logout' | 'will-logout-and-remove'; + op: + | 'did-add-cluster' + | 'did-change-access' + | 'will-logout' + | 'will-logout-and-remove'; } export interface ProfileWatcherError { @@ -50,6 +64,12 @@ export interface ProfileWatcherError { reason: 'processing-error' | 'exited'; } +interface WindowsManager { + getWindow(): { + webContents: { send(channel: string, ...args: any[]): void }; + }; +} + /** * Manages the lifecycle of clusters by handling both UI actions that update them * (e.g., adding a cluster, logging out) and profile watcher events. @@ -67,20 +87,20 @@ export interface ProfileWatcherError { export class ClusterLifecycleManager { private readonly logger = new Logger('ClusterLifecycleManager'); private rendererEventHandler: - | AwaitableSender + | IAwaitableSender | undefined; private watcherStarted = false; constructor( private readonly clusterStore: ClusterStore, private readonly getTshdClient: () => Promise, - private readonly appUpdater: AppUpdater, - private readonly windowsManager: Pick, + private readonly appUpdater: Pick, + private readonly windowsManager: WindowsManager, private readonly profileWatcher: AsyncIterable ) {} setRendererEventHandler( - handler: AwaitableSender + handler: IAwaitableSender ): void { if (this.rendererEventHandler) { this.logger.error( @@ -112,6 +132,18 @@ export class ClusterLifecycleManager { await this.clusterStore.logoutAndRemove(uri); } + async syncCluster(uri: RootClusterUri): Promise { + const { previous, next } = await this.clusterStore.sync(uri); + if (!hasAccessChanged(previous.loggedInUser, next.loggedInUser)) { + return; + } + + await this.rendererEventHandler.send({ + op: 'did-change-access', + uri: next.uri, + }); + } + async syncRootClustersAndStartProfileWatcher(): Promise { await this.clusterStore.syncRootClusters(); if (!this.watcherStarted) { @@ -135,7 +167,7 @@ export class ClusterLifecycleManager { private async syncOrUpdateCluster(cluster: Cluster): Promise { if (cluster.connected) { try { - return this.clusterStore.sync(cluster.uri); + await this.clusterStore.sync(cluster.uri); } catch (e) { // Theoretically, the cert could just expire and result in an error // resolvable with relogin when trying to sync the cluster. @@ -208,15 +240,24 @@ export class ClusterLifecycleManager { if (hasLoggedOut) { await this.handleClusterLogout(next); - } else { - const client = await this.getTshdClient(); - // Only clear clients with outdated certificates. - // The watcher 'changed' event may be emitted right after the user logs in - // or assumes a role via Connect (which already closes all clients - // for the profile), so we avoid closing them again if they're already up to date. - await client.clearStaleClusterClients({ rootClusterUri: next.uri }); - await this.syncOrUpdateCluster(next); + return; } + + const client = await this.getTshdClient(); + // Only clear clients with outdated certificates. + // The watcher 'changed' event may be emitted right after the user logs in + // or assumes a role via Connect (which already closes all clients + // for the profile), so we avoid closing them again if they're already up to date. + await client.clearStaleClusterClients({ rootClusterUri: next.uri }); + await this.syncOrUpdateCluster(next); + + if (!hasAccessChanged(previous.loggedInUser, next.loggedInUser)) { + return; + } + await this.rendererEventHandler.send({ + op: 'did-change-access', + uri: next.uri, + }); } private async handleClusterRemoved(cluster: Cluster): Promise { @@ -244,3 +285,32 @@ export class ClusterLifecycleManager { .webContents.send(RendererIpc.ProfileWatcherError, error); } } + +/** + * Checks if the username, roles or active requests changed. + * If yes, then probably the user has access to different resources. + */ +function hasAccessChanged( + previousUser: LoggedInUser | undefined, + nextUser: LoggedInUser | undefined +): boolean { + // No user, we don't know if access changed. + if (!(previousUser?.name && nextUser?.name)) { + return false; + } + + const hasChangedUsername = previousUser.name !== nextUser.name; + const hasChangedRoles = !areArraysEqual(previousUser.roles, nextUser.roles); + const hasChangedActiveRequests = !areArraysEqual( + previousUser.activeRequests, + nextUser.activeRequests + ); + + return hasChangedUsername || hasChangedRoles || hasChangedActiveRequests; +} + +function areArraysEqual(a: string[], b: string[]): boolean { + const aSet = new Set(a); + const bSet = new Set(b); + return aSet.size === bSet.size && aSet.isSubsetOf(bSet); +} diff --git a/web/packages/teleterm/src/mainProcess/clusterStore/clusterStore.ts b/web/packages/teleterm/src/mainProcess/clusterStore/clusterStore.ts index ad5baeb93b20e..83d5ef65c5ccf 100644 --- a/web/packages/teleterm/src/mainProcess/clusterStore/clusterStore.ts +++ b/web/packages/teleterm/src/mainProcess/clusterStore/clusterStore.ts @@ -107,10 +107,13 @@ export class ClusterStore { } /** - * Synchronizes a root cluster. + * Synchronizes the root cluster and returns its state before and after the update. * Makes network calls to get cluster details and its leaf clusters. + * Should only be called via ClusterLifecycleManager. */ - async sync(uri: RootClusterUri): Promise { + async sync( + uri: RootClusterUri + ): Promise<{ previous: Cluster | undefined; next: Cluster }> { let cluster: Cluster; let leafs: Cluster[]; const client = await this.getTshdClient(); @@ -132,12 +135,14 @@ export class ClusterStore { throw error; } + const previous = this.state.get(uri); await this.update(draft => { draft.set(cluster.uri, cluster); leafs.forEach(leaf => { draft.set(leaf.uri, leaf); }); }); + return { previous, next: cluster }; } async set(cluster: Cluster): Promise { diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index 578508c100593..7b9ef31a1501e 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -708,7 +708,7 @@ export default class MainProcess { ); ipcMain.handle(MainProcessIpc.SyncCluster, (_, args) => - this.clusterStore.sync(args.clusterUri) + this.clusterLifecycleManager.syncCluster(args.clusterUri) ); ipcMain.handle(MainProcessIpc.Logout, async (_, args) => { diff --git a/web/packages/teleterm/src/ui/AccessRequests/SelectorMenu.test.tsx b/web/packages/teleterm/src/ui/AccessRequests/SelectorMenu.test.tsx index 9193a52a10b9e..1af297ba33312 100644 --- a/web/packages/teleterm/src/ui/AccessRequests/SelectorMenu.test.tsx +++ b/web/packages/teleterm/src/ui/AccessRequests/SelectorMenu.test.tsx @@ -18,7 +18,6 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useEffect } from 'react'; import { render } from 'design/utils/testing'; @@ -29,27 +28,35 @@ import { makeRootCluster, } from 'teleterm/services/tshd/testHelpers'; import { SelectorMenu } from 'teleterm/ui/AccessRequests/SelectorMenu'; -import { - ResourcesContextProvider, - useResourcesContext, -} from 'teleterm/ui/DocumentCluster/resourcesContext'; +import { ResourcesContextProvider } from 'teleterm/ui/DocumentCluster/resourcesContext'; import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; -import { RootClusterUri } from 'teleterm/ui/uri'; import { AccessRequestsContextProvider } from './AccessRequestsContext'; -test('assuming or dropping a request refreshes resources', async () => { +test('assuming or dropping a request calls API', async () => { const appContext = new MockAppContext(); const accessRequest = makeAccessRequest(); const cluster = makeRootCluster({ features: { advancedAccessWorkflows: true, isUsageBasedBilling: false }, - loggedInUser: makeLoggedInUser({ activeRequests: [accessRequest.id] }), }); appContext.addRootCluster(cluster); jest.spyOn(appContext.clustersService, 'dropRoles'); - const refreshListener = jest.fn(); + jest + .spyOn(appContext.clustersService, 'assumeRoles') + .mockImplementation(async () => { + appContext.clustersService.setState(state => { + state.clusters.get(cluster.uri).loggedInUser.activeRequests = [ + accessRequest.id, + ]; + }); + }); + appContext.tshd.getAccessRequests = () => { + return new MockedUnaryCall({ + requests: [accessRequest], + }); + }; appContext.tshd.getAccessRequest = () => { return new MockedUnaryCall({ request: accessRequest, @@ -61,10 +68,6 @@ test('assuming or dropping a request refreshes resources', async () => { - @@ -77,12 +80,18 @@ test('assuming or dropping a request refreshes resources', async () => { const item = await screen.findByText(accessRequest.resources.at(0).id.name); await userEvent.click(item); + expect(appContext.clustersService.assumeRoles).toHaveBeenCalledTimes(1); + expect(appContext.clustersService.assumeRoles).toHaveBeenCalledWith( + cluster.uri, + [accessRequest.id] + ); + expect(await screen.findByText(/access assumed/i)).toBeInTheDocument(); + await userEvent.click(item); expect(appContext.clustersService.dropRoles).toHaveBeenCalledTimes(1); expect(appContext.clustersService.dropRoles).toHaveBeenCalledWith( cluster.uri, [accessRequest.id] ); - expect(refreshListener).toHaveBeenCalledTimes(1); }); test('assumed request are always visible, even if getAccessRequests no longer returns them', async () => { @@ -139,17 +148,3 @@ test('assumed request are always visible, even if getAccessRequests no longer re await screen.findByText('request-with-details-not-available') ).toBeInTheDocument(); }); - -function RequestRefreshSubscriber(props: { - rootClusterUri: RootClusterUri; - onResourcesRefreshRequest: () => void; -}) { - const { onResourcesRefreshRequest } = useResourcesContext( - props.rootClusterUri - ); - useEffect(() => { - onResourcesRefreshRequest(props.onResourcesRefreshRequest); - }, [onResourcesRefreshRequest, props.onResourcesRefreshRequest]); - - return null; -} diff --git a/web/packages/teleterm/src/ui/AccessRequests/SelectorMenu.tsx b/web/packages/teleterm/src/ui/AccessRequests/SelectorMenu.tsx index ffd9c1a72d5af..96d4a2b55246e 100644 --- a/web/packages/teleterm/src/ui/AccessRequests/SelectorMenu.tsx +++ b/web/packages/teleterm/src/ui/AccessRequests/SelectorMenu.tsx @@ -44,7 +44,6 @@ import { MenuListItem, Separator, } from 'teleterm/ui/components/Menu'; -import { useResourcesContext } from 'teleterm/ui/DocumentCluster/resourcesContext'; import { useWorkspaceContext } from 'teleterm/ui/Documents'; import { useLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser'; import { TopBarButton } from 'teleterm/ui/TopBar/TopBarButton'; @@ -83,7 +82,6 @@ export function SelectorMenu() { const ctx = useAppContext(); const { clustersService } = ctx; const selectorRef = useRef(null); - const { requestResourcesRefresh } = useResourcesContext(rootClusterUri); const loggedInUser = useLoggedInUser(); const username = loggedInUser?.name; @@ -210,7 +208,6 @@ export function SelectorMenu() { await clustersService.assumeRoles(rootClusterUri, [requestId]); } }); - requestResourcesRefresh(); } const isResourceRequestAssumed = sortedRequests diff --git a/web/packages/teleterm/src/ui/DocumentAccessRequests/useAssumeAccess.ts b/web/packages/teleterm/src/ui/DocumentAccessRequests/useAssumeAccess.ts index ed4742889ada8..63639d475b3b7 100644 --- a/web/packages/teleterm/src/ui/DocumentAccessRequests/useAssumeAccess.ts +++ b/web/packages/teleterm/src/ui/DocumentAccessRequests/useAssumeAccess.ts @@ -35,8 +35,6 @@ export function useAssumeAccess() { const [assumeRoleAttempt, runAssumeRole] = useAsync((requestId: string) => retryWithRelogin(ctx, clusterUri, async () => { await ctx.clustersService.assumeRoles(rootClusterUri, [requestId]); - // Refresh the current resource tabs in the workspace. - requestResourcesRefresh(); }) ); diff --git a/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.test.tsx index 12c220e43632b..3c653eb549f15 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.test.tsx @@ -19,6 +19,7 @@ import { renderHook } from '@testing-library/react'; import { rootClusterUri } from 'teleterm/services/tshd/testHelpers'; +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { ResourcesContextProvider, @@ -28,7 +29,9 @@ import { describe('requestResourcesRefresh', () => { it('calls listener registered with onResourcesRefreshRequest', () => { const wrapper = ({ children }) => ( - {children} + + {children} + ); const { result } = renderHook( () => ({ @@ -58,7 +61,9 @@ describe('requestResourcesRefresh', () => { describe('onResourcesRefreshRequest cleanup function', () => { it('removes the listener', () => { const wrapper = ({ children }) => ( - {children} + + {children} + ); const { result } = renderHook(() => useResourcesContext(rootClusterUri), { wrapper, diff --git a/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.tsx b/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.tsx index 13c8964127ea8..e5bb664dc8ac5 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/resourcesContext.tsx @@ -24,9 +24,11 @@ import { PropsWithChildren, useCallback, useContext, + useEffect, useRef, } from 'react'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; import { RootClusterUri } from 'teleterm/ui/uri'; export interface ResourcesContext { @@ -54,6 +56,7 @@ export interface ResourcesContext { const ResourcesContext = createContext(null); export const ResourcesContextProvider: FC = props => { + const appCtx = useAppContext(); const emitterRef = useRef(undefined); if (!emitterRef.current) { emitterRef.current = new EventEmitter(); @@ -86,6 +89,12 @@ export const ResourcesContextProvider: FC = props => { [] ); + useEffect(() => { + return appCtx.addResourceRefreshListener(uri => { + requestResourcesRefresh(uri); + }); + }, [appCtx, requestResourcesRefresh]); + return ( void { + this._resourceRefreshListener = listener; + + return () => { + this._resourceRefreshListener = undefined; + }; + } + + /** Gets called when `ClusterLifecycleManager` requests resource refresh. */ + get resourceRefreshListener(): ResourceRefreshListener { + return this._resourceRefreshListener; + } + private registerClusterLifecycleHandler(): void { // Queue chain ensures sequential processing. let processingQueue = Promise.resolve(); @@ -211,6 +230,12 @@ export default class AppContext implements IAppContext { switch (op) { case 'did-add-cluster': return this.workspacesService.addWorkspace(uri); + case 'did-change-access': + if (!this.clustersService.findCluster(uri).connected) { + // Only refresh resources when the cluster is connected. + return; + } + return this.resourceRefreshListener(uri); case 'will-logout': return cleanUpBeforeLogout(this, uri, { removeWorkspace: false }); case 'will-logout-and-remove': diff --git a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts index 5cd44a42d4c03..19806324102a3 100644 --- a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts +++ b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts @@ -230,7 +230,11 @@ export class ClustersService extends ImmutableStore { } } - /** Assumes roles for the given requests. */ + /** + * Assumes roles for the given requests. + * After it's done, resources refresh is requested in + * ClusterLifecycleManager.syncCluster. + */ async assumeRoles( rootClusterUri: uri.RootClusterUri, requestIds: string[] @@ -244,7 +248,11 @@ export class ClustersService extends ImmutableStore { await this.syncRootCluster(rootClusterUri); } - /** Drops roles for the given requests. */ + /** + * Drops roles for the given requests. + * After it's done, resources refresh is requested in + * ClusterLifecycleManager.syncCluster. + */ async dropRoles( rootClusterUri: uri.RootClusterUri, requestIds: string[] diff --git a/web/packages/teleterm/src/ui/types.ts b/web/packages/teleterm/src/ui/types.ts index ed00bfb246c14..09ec13cfc14f5 100644 --- a/web/packages/teleterm/src/ui/types.ts +++ b/web/packages/teleterm/src/ui/types.ts @@ -39,6 +39,7 @@ import { TerminalsService } from 'teleterm/ui/services/terminals'; import { TshdNotificationsService } from 'teleterm/ui/services/tshdNotifications/tshdNotificationService'; import { UsageService } from 'teleterm/ui/services/usage'; import { WorkspacesService } from 'teleterm/ui/services/workspacesService'; +import { RootClusterUri } from 'teleterm/ui/uri'; export interface IAppContext { clustersService: ClustersService; @@ -83,8 +84,15 @@ export interface IAppContext { * process (renderer). */ unexpectedVnetShutdownListener: UnexpectedVnetShutdownListener | undefined; + + /** Sets the listener and returns a cleanup function which removes the listener. */ + addResourceRefreshListener: (listener: ResourceRefreshListener) => () => void; + /** Gets called when `ClusterLifecycleManager` requests resource refresh. */ + resourceRefreshListener: ResourceRefreshListener | undefined; } export type UnexpectedVnetShutdownListener = ( request: tshdEventsApi.ReportUnexpectedVnetShutdownRequest ) => void; + +export type ResourceRefreshListener = (uri: RootClusterUri) => void;