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: {