Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,23 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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 (
<Notifications
items={notificationsService.getNotifications()}
items={[...notifications.values()]}
onRemoveItem={item => notificationsService.removeNotification(item)}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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' }),
}),
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,59 +20,101 @@ 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<string, ToastNotificationItem>;

export class NotificationsService extends ImmutableStore<State> {
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 {
if (!id) {
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<ToastNotificationItem, 'id'>): string {
const id = unique();
private notify(
options: Omit<ToastNotificationItem, 'id'> & { 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { ToastNotificationItemContent } from 'shared/components/ToastNotification';

import {
cannotProxyVnetConnectionReasonIsCertReissueError,
cannotProxyVnetConnectionReasonIsInvalidLocalPort,
Expand All @@ -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 {
Expand All @@ -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.
Expand All @@ -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;
Expand All @@ -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': {
Expand All @@ -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': {
Expand All @@ -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': {
Expand Down Expand Up @@ -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: {
Expand Down
Loading