diff --git a/web/packages/teleterm/src/ui/components/Notifcations/NotificationsHost.tsx b/web/packages/teleterm/src/ui/components/Notifcations/NotificationsHost.tsx index a3c8e3a54d56f..7115367e474bf 100644 --- a/web/packages/teleterm/src/ui/components/Notifcations/NotificationsHost.tsx +++ b/web/packages/teleterm/src/ui/components/Notifcations/NotificationsHost.tsx @@ -16,18 +16,23 @@ * along with this program. If not, see . */ +import { useCallback } from 'react'; + import { useAppContext } from 'teleterm/ui/appContextProvider'; +import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector'; import { Notifications } from './Notifications'; export function NotificationsHost() { const { notificationsService } = useAppContext(); - - notificationsService.useState(); + const notifications = useStoreSelector( + 'notificationsService', + useCallback(state => state, []) + ); return ( notificationsService.removeNotification(item)} /> ); diff --git a/web/packages/teleterm/src/ui/services/notifications/notificationsService.test.ts b/web/packages/teleterm/src/ui/services/notifications/notificationsService.test.ts new file mode 100644 index 0000000000000..a1dfee014e93c --- /dev/null +++ b/web/packages/teleterm/src/ui/services/notifications/notificationsService.test.ts @@ -0,0 +1,49 @@ +/** + * 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 { + NotificationContent, + NotificationsService, +} from './notificationsService'; + +describe('using a key', () => { + it('replaces previous notification with same key', () => { + const service = new NotificationsService(); + const firstNotification: NotificationContent = { + description: 'bar', + key: 'foo-1', + }; + service.notifyInfo(firstNotification); + expect(service.getNotifications()).toEqual([ + expect.objectContaining({ + content: expect.objectContaining({ description: 'bar' }), + }), + ]); + + const secondNotification: NotificationContent = { + description: 'baz', + key: ['foo', 1], + }; + service.notifyWarning(secondNotification); + expect(service.getNotifications()).toEqual([ + expect.objectContaining({ + content: expect.objectContaining({ description: 'baz' }), + }), + ]); + }); +}); diff --git a/web/packages/teleterm/src/ui/services/notifications/notificationsService.ts b/web/packages/teleterm/src/ui/services/notifications/notificationsService.ts index c5d1c8906638f..93909d68a1e92 100644 --- a/web/packages/teleterm/src/ui/services/notifications/notificationsService.ts +++ b/web/packages/teleterm/src/ui/services/notifications/notificationsService.ts @@ -20,26 +20,66 @@ import type { ToastNotificationItem, ToastNotificationItemContent, } from 'shared/components/ToastNotification'; -import { useStore } from 'shared/libs/stores'; import { ImmutableStore } from 'teleterm/ui/services/immutableStore'; import { unique } from 'teleterm/ui/utils/uid'; -export class NotificationsService extends ImmutableStore< - ToastNotificationItem[] -> { - state: ToastNotificationItem[] = []; +export type NotificationContent = ToastNotificationItemContent & { + key?: NotificationKey; +}; +export type NotificationKey = string | (string | number)[]; - notifyError(content: ToastNotificationItemContent): string { - return this.notify({ severity: 'error', content }); +type State = Map; + +export class NotificationsService extends ImmutableStore { + state: State = new Map(); + + /** + * Adds a notification with error severity. + * + * If key is passed, replaces any previous notification with equal key. If an array is passed as + * a key, the array is joined with '-'. + */ + notifyError(rawContent: NotificationContent): string { + const severity = 'error'; + if (typeof rawContent === 'string') { + return this.notify({ severity, content: rawContent }); + } + + const { key, ...content } = rawContent; + return this.notify({ severity, content, key }); } - notifyWarning(content: ToastNotificationItemContent): string { - return this.notify({ severity: 'warn', content }); + /** + * Adds a notification with warn severity. + * + * If key is passed, replaces any previous notification with equal key. If an array is passed as + * a key, the array is joined with '-'. + */ + notifyWarning(rawContent: NotificationContent): string { + const severity = 'warn'; + if (typeof rawContent === 'string') { + return this.notify({ severity, content: rawContent }); + } + + const { key, ...content } = rawContent; + return this.notify({ severity, content, key }); } - notifyInfo(content: ToastNotificationItemContent): string { - return this.notify({ severity: 'info', content }); + /** + * Adds a notification with info severity. + * + * If key is passed, replaces any previous notification with equal key. If an array is passed as + * a key, the array is joined with '-'. + */ + notifyInfo(rawContent: NotificationContent): string { + const severity = 'info'; + if (typeof rawContent === 'string') { + return this.notify({ severity, content: rawContent }); + } + + const { key, ...content } = rawContent; + return this.notify({ severity, content, key }); } removeNotification(id: string): void { @@ -47,32 +87,34 @@ export class NotificationsService extends ImmutableStore< return; } - if (!this.state.length) { + if (this.state.size === 0) { return; } - this.setState(draftState => - draftState.filter(stateItem => stateItem.id !== id) - ); + this.setState(draftState => { + draftState.delete(id); + }); } getNotifications(): ToastNotificationItem[] { - return this.state; + return [...this.state.values()]; } hasNotification(id: string): boolean { - return !!this.state.find(n => n.id === id); - } - - useState(): ToastNotificationItem[] { - return useStore(this).state; + return this.state.has(id); } - private notify(options: Omit): string { - const id = unique(); + private notify( + options: Omit & { key?: NotificationKey } + ): string { + const id = options.key + ? typeof options.key === 'string' + ? options.key + : options.key.join('-') + : unique(); this.setState(draftState => { - draftState.push({ + draftState.set(id, { severity: options.severity, content: options.content, id, diff --git a/web/packages/teleterm/src/ui/services/tshdNotifications/tshdNotificationService.ts b/web/packages/teleterm/src/ui/services/tshdNotifications/tshdNotificationService.ts index a304a6936290e..8f45a3f60cd46 100644 --- a/web/packages/teleterm/src/ui/services/tshdNotifications/tshdNotificationService.ts +++ b/web/packages/teleterm/src/ui/services/tshdNotifications/tshdNotificationService.ts @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import { ToastNotificationItemContent } from 'shared/components/ToastNotification'; - import { cannotProxyVnetConnectionReasonIsCertReissueError, cannotProxyVnetConnectionReasonIsInvalidLocalPort, @@ -31,7 +29,11 @@ import { import { getTargetNameFromUri } from 'teleterm/services/tshd/gateway'; import { SendNotificationRequest } from 'teleterm/services/tshdEvents'; import { ClustersService } from 'teleterm/ui/services/clusters'; -import { NotificationsService } from 'teleterm/ui/services/notifications'; +import { + NotificationContent, + NotificationKey, + NotificationsService, +} from 'teleterm/ui/services/notifications'; import { ResourceUri, routing } from 'teleterm/ui/uri'; export class TshdNotificationsService { @@ -45,9 +47,14 @@ export class TshdNotificationsService { this.notificationsService.notifyError(notificationContent); } + // All returned notification contents should include a key that limits the notification to a + // single kind per root cluster. This is to avoid spamming the user with notifications if a + // 3rd-party client is trying to, say, reconnect through a gateway connection. private getNotificationContent( request: SendNotificationRequest - ): ToastNotificationItemContent { + ): NotificationContent & { + key: NotificationKey; + } { const { subject } = request; // switch followed by a type guard is awkward, but it helps with ensuring that we get type // errors whenever a new request reason is added. @@ -62,6 +69,7 @@ export class TshdNotificationsService { subject.cannotProxyGatewayConnection; const gateway = this.clustersService.findGateway(gatewayUri); const clusterName = this.getClusterName(targetUri); + const rootClusterUri = routing.ensureRootClusterUri(targetUri); let targetName: string; let targetUser: string; let targetDesc: string; @@ -82,6 +90,7 @@ export class TshdNotificationsService { return { title: `Cannot connect to ${targetDesc} (${clusterName})`, description: `A connection attempt to ${targetDesc} failed due to an unexpected error: ${error}`, + key: ['cannotProxyGatewayConnection', rootClusterUri], }; } case 'cannotProxyVnetConnection': { @@ -91,6 +100,7 @@ export class TshdNotificationsService { const { routeToApp, targetUri, reason } = subject.cannotProxyVnetConnection; const clusterName = this.getClusterName(targetUri); + const rootClusterUri = routing.ensureRootClusterUri(targetUri); switch (reason.oneofKind) { case 'certReissueError': { @@ -102,6 +112,11 @@ export class TshdNotificationsService { return { title: `Cannot connect to ${publicAddrWithTargetPort(routeToApp)}`, description: `A connection attempt to the app in the cluster ${clusterName} failed due to an unexpected error: ${error}`, + key: [ + 'cannotProxyVnetConnection', + 'certReissueError', + rootClusterUri, + ], }; } case 'invalidLocalPort': { @@ -129,6 +144,11 @@ export class TshdNotificationsService { // port within a short time. As all notifications from this service go as errors, we // don't want to force the user to manually close each notification. isAutoRemovable: true, + key: [ + 'cannotProxyVnetConnection', + 'invalidLocalPort', + rootClusterUri, + ], }; } default: {