Skip to content

Commit

Permalink
feat(settings): user can enable/disable notifications by category (#387)
Browse files Browse the repository at this point in the history
* feat(settings): user can enable/disable notifications by category

* restyle component using switches

* display nicer category names

* hide notifications as popups but not from drawer

* add control for all notifications

* add expandable section for notification types

* minor refactor

* do not implicitly hide non-success/non-problem notifications - allow user to control this

* refactor

* simplify iterating over map

* rename refactor

* disambiguate notification titles

* Revert "simplify iterating over map"

This reverts commit d59f516.
  • Loading branch information
andrewazores authored Mar 10, 2022
1 parent 10ffd95 commit 12e9691
Show file tree
Hide file tree
Showing 10 changed files with 211 additions and 44 deletions.
5 changes: 3 additions & 2 deletions src/app/AppLayout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { SslErrorModal } from './SslErrorModal';
import { AboutCryostatModal } from '@app/About/AboutCryostatModal';
import cryostatLogoHorizontal from '@app/assets/logo-cryostat-3-horizontal.svg';
import { SessionState } from '@app/Shared/Services/Login.service';
import { combineLatest } from 'rxjs';
import { NotificationCategory } from '@app/Shared/Services/NotificationChannel.service';

interface IAppLayout {
children: React.ReactNode;
Expand Down Expand Up @@ -285,7 +285,8 @@ const AppLayout: React.FunctionComponent<IAppLayout> = ({children}) => {
<AlertGroup isToast>
{
notifications
.filter(n => !n.read && (Notifications.isProblemNotification(n) || n.variant === AlertVariant.success))
.filter(n => !n.read)
.filter(n => serviceContext.settings.notificationsEnabledFor(NotificationCategory[n.category || '']))
.sort((prev, curr) => {
if(!prev.timestamp) return -1;
if(!curr.timestamp) return 1;
Expand Down
2 changes: 1 addition & 1 deletion src/app/Events/EventTemplates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const EventTemplates = () => {

React.useEffect(() => {
addSubscription(
context.notificationChannel.messages(NotificationCategory.TemplateCreated)
context.notificationChannel.messages(NotificationCategory.TemplateUploaded)
.subscribe(v => setTemplates(old => old.concat(v.message.template)))
);
}, [addSubscription, context, context.notificationChannel, setTemplates]);
Expand Down
2 changes: 1 addition & 1 deletion src/app/Notifications/Notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export class Notifications {
}

private isJvmDiscovery(n: Notification): boolean {
return (n.category === NotificationCategory.JvmDiscovery);
return (n.category === NotificationCategory.TargetJvmDiscovery);
}

static isProblemNotification(n: Notification): boolean {
Expand Down
2 changes: 1 addition & 1 deletion src/app/Settings/AutoRefresh.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
*/

import * as React from 'react';
import { Checkbox, Text, TextVariants } from '@patternfly/react-core';
import { Checkbox } from '@patternfly/react-core';
import { ServiceContext } from '@app/Shared/Services/Services';
import { DurationPicker } from '@app/DurationPicker/DurationPicker';
import { UserSetting } from './Settings';
Expand Down
98 changes: 98 additions & 0 deletions src/app/Settings/NotificationControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright The Cryostat Authors
*
* The Universal Permissive License (UPL), Version 1.0
*
* Subject to the condition set forth below, permission is hereby granted to any
* person obtaining a copy of this software, associated documentation and/or data
* (collectively the "Software"), free of charge and under any and all copyright
* rights in the Software, and any and all patent rights owned or freely
* licensable by each licensor hereunder covering either (i) the unmodified
* Software as contributed to or provided by such licensor, or (ii) the Larger
* Works (as defined below), to deal in both
*
* (a) the Software, and
* (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if
* one is included with the Software (each a "Larger Work" to which the Software
* is contributed by such licensors),
*
* without restriction, including without limitation the rights to copy, create
* derivative works of, display, perform, and distribute the Software and make,
* use, sell, offer for sale, import, export, have made, and have sold the
* Software and the Larger Work(s), and to sublicense the foregoing rights on
* either these or other terms.
*
* This license is subject to the following condition:
* The above copyright notice and either this complete permission notice or at
* a minimum a reference to the UPL must be included in all copies or
* substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

import * as React from 'react';
import { Divider, ExpandableSection, Switch, Stack, StackItem } from '@patternfly/react-core';
import { ServiceContext } from '@app/Shared/Services/Services';
import { NotificationCategory, messageKeys } from '@app/Shared/Services/NotificationChannel.service';
import { UserSetting } from './Settings';

const Component = () => {
const context = React.useContext(ServiceContext);
const [state, setState] = React.useState(context.settings.notificationsEnabled());
const [expanded, setExpanded] = React.useState(false);

const handleCheckboxChange = React.useCallback((checked, element) => {
state.set(NotificationCategory[element.target.id], checked);
context.settings.setNotificationsEnabled(state);
setState(new Map(state));
}, [state, setState, context.settings]);

const handleCheckAll = React.useCallback(checked => {
const newState = new Map();
Array.from(state.entries()).forEach(v => newState.set(v[0], checked));
context.settings.setNotificationsEnabled(newState);
setState(newState);
}, [state, setState]);

const allChecked = React.useMemo(() => {
return Array.from(state.entries()).map(e => e[1]).reduce((a, b) => a && b);
}, [state]);

const labels = React.useMemo(() => {
const result = new Map<NotificationCategory, string>();
messageKeys.forEach((v, k) => {
result.set(k, v?.title || k);
});
return result;
}, [messageKeys]);

const switches = React.useMemo(() => {
return Array.from(state.entries(), ([key, value]) => <StackItem><Switch id={key} label={labels.get(key)} isChecked={value} onChange={handleCheckboxChange} /></StackItem>);
}, [state, labels]);

return (<>
<Stack hasGutter>
<StackItem><Switch id='all-notifications' label='All Notifications' isChecked={allChecked} onChange={handleCheckAll} /></StackItem>
<Divider />
<ExpandableSection
toggleText={expanded ? 'Show less' : 'Show more'}
onToggle={setExpanded}
isExpanded={expanded}
>
{ switches }
</ExpandableSection>
</Stack>
</>);
}

export const NotificationControl: UserSetting = {
title: 'Notifications',
description: 'Enable or disable notifications by category.',
content: Component,
}
2 changes: 2 additions & 0 deletions src/app/Settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ import { Card, CardBody, CardTitle, Text, TextVariants } from '@patternfly/react
import { BreadcrumbPage } from '@app/BreadcrumbPage/BreadcrumbPage';

import { AutoRefresh } from './AutoRefresh';
import { NotificationControl } from './NotificationControl';
import { WebSocketDebounce } from './WebSocketDebounce';

export const Settings: React.FunctionComponent<{}> = () => {

const settings =
[
AutoRefresh,
NotificationControl,
WebSocketDebounce,
].map(c => ({
title: c.title,
Expand Down
13 changes: 7 additions & 6 deletions src/app/Shared/Services/Api.service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ import { catchError, concatMap, first, map, mergeMap, tap } from 'rxjs/operators
import { Target, TargetService } from './Target.service';
import { Notifications } from '@app/Notifications/Notifications';
import { AuthMethod, LoginService, SessionState } from './Login.service';
import {Rule} from '@app/Rules/Rules';
import { RecordingLabel } from '@app/CreateRecording/EditRecordingLabels';
import { Rule } from '@app/Rules/Rules';
import { NotificationCategory } from './NotificationChannel.service';

type ApiVersion = 'v1' | 'v2' | 'v2.1' | 'beta';

Expand Down Expand Up @@ -155,9 +156,9 @@ export class ApiService {
error: err => {
window.console.error(err);
if (err.state === 'unavailable') {
this.notifications.danger(`Grafana ${err.state}`, err.message);
this.notifications.danger(`Grafana ${err.state}`, err.message, NotificationCategory.GrafanaConfiguration);
} else {
this.notifications.warning(`Grafana ${err.state}`, err.message);
this.notifications.warning(`Grafana ${err.state}`, err.message, NotificationCategory.GrafanaConfiguration);
}
}
});
Expand Down Expand Up @@ -514,7 +515,7 @@ export class ApiService {
return from(resp.text());
}
throw resp.text();
})
})
);
}

Expand All @@ -532,7 +533,7 @@ export class ApiService {
return from(resp.text());
}
throw resp.text();
})
})
)
));
}
Expand Down Expand Up @@ -593,7 +594,7 @@ export class ApiService {

private stringifyRecordingLabels(labels: RecordingLabel[]): string {
let arr = [] as Map<string, string>[];
labels.forEach(l => {
labels.forEach(l => {
arr[l.key] = l.value;
});
return JSON.stringify(Object.entries(arr));
Expand Down
65 changes: 42 additions & 23 deletions src/app/Shared/Services/NotificationChannel.service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,24 @@ import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { AlertVariant } from '@patternfly/react-core';
import { concatMap, distinctUntilChanged, filter } from 'rxjs/operators';
import { AuthMethod, LoginService, SessionState } from './Login.service';
import { Target } from './Target.service';
import { TargetDiscoveryEvent } from './Targets.service';

export enum NotificationCategory {
WsClientActivity = 'WsClientActivity',
JvmDiscovery = 'TargetJvmDiscovery',
TargetJvmDiscovery = 'TargetJvmDiscovery',
ActiveRecordingCreated = 'ActiveRecordingCreated',
ActiveRecordingStopped = 'ActiveRecordingStopped',
ActiveRecordingSaved = 'ActiveRecordingSaved',
ActiveRecordingDeleted = 'ActiveRecordingDeleted',
ArchivedRecordingCreated = 'ArchivedRecordingCreated',
ArchivedRecordingDeleted = 'ArchivedRecordingDeleted',
TemplateCreated = 'TemplateUploaded',
TemplateUploaded = 'TemplateUploaded',
TemplateDeleted = 'TemplateDeleted',
RuleCreated = 'RuleCreated',
RuleDeleted = 'RuleDeleted',
RecordingMetadataUpdated = 'RecordingMetadataUpdated',
GrafanaConfiguration = 'GrafanaConfiguration', // generated client-side
}

export enum CloseStatus {
Expand All @@ -72,12 +75,32 @@ interface ReadyState {
code?: CloseStatus;
}

const messageKeys = new Map([
export const messageKeys = new Map([
[
// explicitly configure this category with a null mapper.
// This is a special case because we do not want to display an alert,
// the Targets.service already handles this
NotificationCategory.JvmDiscovery, null
// explicitly configure this category with a null message body mapper.
// This is a special case because this is generated client-side,
// not sent by the backend
NotificationCategory.GrafanaConfiguration, {
title: 'Grafana Configuration',
},
],
[
NotificationCategory.TargetJvmDiscovery, {
variant: AlertVariant.info,
title: 'Target JVM Discovery',
body: v => {
const evt: TargetDiscoveryEvent = v.message.event;
const target: Target = evt.serviceRef;
switch (evt.kind) {
case 'FOUND':
return `Target "${target.alias}" appeared (${target.connectUrl})"`;
case 'LOST':
return `Target "${target.alias}" disappeared (${target.connectUrl})"`;
default:
return `Received a notification with category ${NotificationCategory.TargetJvmDiscovery} and unrecognized kind ${evt.kind}`;
}
}
} as NotificationMessageMapper,
],
[
NotificationCategory.WsClientActivity, {
Expand Down Expand Up @@ -121,19 +144,19 @@ const messageKeys = new Map([
[
NotificationCategory.ArchivedRecordingCreated, {
variant: AlertVariant.success,
title: 'Recording Archived',
title: 'Archived Recording Uploaded',
body: evt => `${evt.message.recording.name} was uploaded into archives`
} as NotificationMessageMapper
],
[
NotificationCategory.ArchivedRecordingDeleted, {
variant: AlertVariant.success,
title: 'Recording Deleted',
title: 'Archived Recording Deleted',
body: evt => `${evt.message.recording.name} was deleted`
} as NotificationMessageMapper
],
[
NotificationCategory.TemplateCreated, {
NotificationCategory.TemplateUploaded, {
variant: AlertVariant.success,
title: 'Template Created',
body: evt => `${evt.message.template.name} was created`
Expand Down Expand Up @@ -171,8 +194,8 @@ const messageKeys = new Map([

interface NotificationMessageMapper {
title: string;
body: (evt: NotificationMessage) => string;
variant: AlertVariant;
body?: (evt: NotificationMessage) => string;
variant?: AlertVariant;
}

export class NotificationChannel {
Expand All @@ -186,28 +209,24 @@ export class NotificationChannel {
private readonly login: LoginService
) {
messageKeys.forEach((value, key) => {
if (!value) {
if (!value || !value.body || !value.variant) {
return;
}
this.messages(key).subscribe((msg: NotificationMessage) => {
if (!value || !value.body || !value.variant) {
return;
}
const message = value.body(msg);
notifications.notify({ title: value.title, message, category: key, variant: value.variant })
});
});

// fallback handler for unknown categories of message
this._messages.pipe(
filter(msg => !messageKeys.has(msg.meta.category as NotificationCategory))
).subscribe(msg => {
const category = NotificationCategory[msg.meta.category as keyof typeof NotificationCategory];

var variant: AlertVariant;
if (category == NotificationCategory.WsClientActivity) {
variant = AlertVariant.info;
} else if (category == NotificationCategory.JvmDiscovery) {
variant = AlertVariant.info;
} else {
variant = AlertVariant.success;
}
notifications.notify({ title: msg.meta.category, message: msg.message, category, variant });
notifications.notify({ title: msg.meta.category, message: msg.message, category, variant: AlertVariant.success });
});

const notificationsUrl = fromFetch(`${this.login.authority}/api/v1/notifications_url`)
Expand Down
Loading

0 comments on commit 12e9691

Please sign in to comment.