Skip to content

Commit 8c97fec

Browse files
authored
Merge pull request #4409 from FlowFuse/4295-crash-notifications
Add crash notifications
2 parents 75766c3 + 4ecfb0b commit 8c97fec

12 files changed

Lines changed: 495 additions & 39 deletions

File tree

forge/db/models/Notification.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ module.exports = {
5353
where: {
5454
reference,
5555
UserId: user.id
56-
}
56+
},
57+
order: [['id', 'DESC']]
5758
})
5859
},
5960
forUser: async (user, pagination = {}) => {

forge/db/models/Team.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,23 @@ module.exports = {
274274
// In this case the findAll above will return an array that includes null, this needs to be guarded against
275275
return owners.filter((owner) => owner !== null)
276276
},
277+
/**
278+
* Get all members of the team optionally filtered by `role` array
279+
* @param {Array<Number> | null} roleFilter - Array of roles to filter by
280+
* @example
281+
* // Get all members of the team
282+
* const members = await team.getTeamMembers()
283+
* @example
284+
* // Get viewers only
285+
* const viewers = await team.getTeamMembers([Roles.Viewer])
286+
*/
287+
getTeamMembers: async function (roleFilter = null) {
288+
const where = { TeamId: this.id }
289+
if (roleFilter && Array.isArray(roleFilter)) {
290+
where.role = roleFilter
291+
}
292+
return (await M.TeamMember.findAll({ where, include: M.User })).filter(tm => tm && tm.User).map(tm => tm.User)
293+
},
277294
memberCount: async function (role) {
278295
const where = {
279296
TeamId: this.id

forge/notifications/index.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,36 @@ module.exports = fp(async function (app, _opts) {
77
* @param {string} type the type of the notification
88
* @param {Object} data meta data for the notification - specific to the type
99
* @param {string} reference a key that can be used to lookup this notification, for example: `invite:HASHID`
10-
*
10+
* @param {Object} [options]
11+
* @param {boolean} [options.upsert] if true, updates the existing notification with the same reference & adds/increments `data.meta.counter`
12+
* @param {boolean} [options.supersede] if true, marks existing notification (with the same reference) as read & adds a new one
1113
*/
12-
async function send (user, type, data, reference = null) {
14+
async function send (user, type, data, reference = null, options = null) {
15+
if (reference && options && typeof options === 'object') {
16+
if (options.upsert) {
17+
const existing = await app.db.models.Notification.byReference(reference, user)
18+
if (existing && !existing.read) {
19+
const updatedData = Object.assign({}, existing.data, data)
20+
if (!updatedData.meta || typeof updatedData.meta !== 'object') {
21+
updatedData.meta = {}
22+
}
23+
if (typeof updatedData.meta.counter === 'number') {
24+
updatedData.meta.counter += 1
25+
} else {
26+
updatedData.meta.counter = 2 // if notification already exists, then this is the 2nd occurrence!
27+
}
28+
await existing.update({ data: updatedData })
29+
return existing
30+
}
31+
} else if (options.supersede) {
32+
const existing = await app.db.models.Notification.byReference(reference, user)
33+
if (existing && !existing.read) {
34+
existing.read = true
35+
await existing.save()
36+
}
37+
}
38+
}
39+
1340
return app.db.models.Notification.create({
1441
UserId: user.id,
1542
type,

forge/routes/logging/index.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { getLoggers: getDeviceLogger } = require('../../auditLog/device')
22
const { getLoggers: getProjectLogger } = require('../../auditLog/project')
3+
const { Roles } = require('../../lib/roles')
34

45
/** Node-RED Audit Logging backend
56
*
@@ -75,6 +76,24 @@ module.exports = async function (app) {
7576
if (app.config.features.enabled('emailAlerts')) {
7677
await app.auditLog.alerts.generate(projectId, event)
7778
}
79+
// send notification to all members and owners in the team
80+
const teamMembersAndOwners = await request.project.Team.getTeamMembers([Roles.Member, Roles.Owner])
81+
if (teamMembersAndOwners && teamMembersAndOwners.length > 0) {
82+
const notificationType = event === 'crashed' ? 'instance-crashed' : 'instance-safe-mode'
83+
const reference = `${notificationType}:${projectId}`
84+
const data = {
85+
instance: {
86+
id: projectId,
87+
name: request.project.name
88+
},
89+
meta: {
90+
severity: event === 'crashed' ? 'error' : 'warning'
91+
}
92+
}
93+
for (const user of teamMembersAndOwners) {
94+
await app.notifications.send(user, notificationType, data, reference, { upsert: true })
95+
}
96+
}
7897
}
7998

8099
response.status(200).send()
@@ -154,6 +173,27 @@ module.exports = async function (app) {
154173
)
155174
}
156175

176+
if (event === 'crashed' || event === 'safe-mode') {
177+
// send notification to all members and owners in the team
178+
const teamMembersAndOwners = await request.device.Team.getTeamMembers([Roles.Member, Roles.Owner])
179+
if (teamMembersAndOwners && teamMembersAndOwners.length > 0) {
180+
const notificationType = event === 'crashed' ? 'device-crashed' : 'device-safe-mode'
181+
const reference = `${notificationType}:${deviceId}`
182+
const data = {
183+
device: {
184+
id: deviceId,
185+
name: request.device.name
186+
},
187+
meta: {
188+
severity: event === 'crashed' ? 'error' : 'warning'
189+
}
190+
}
191+
for (const user of teamMembersAndOwners) {
192+
await app.notifications.send(user, notificationType, data, reference, { upsert: true })
193+
}
194+
}
195+
}
196+
157197
response.status(200).send()
158198

159199
// For application owned devices, perform an auto snapshot

frontend/src/components/drawers/notifications/NotificationsDrawer.vue

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
<template>
22
<div class="ff-notifications-drawer" data-el="notifications-drawer">
33
<div class="header">
4-
<h2 class="title">Notifications</h2>
4+
<div class="flex">
5+
<h2 class="title flex-grow">Notifications</h2>
6+
<ff-checkbox v-model="hideReadNotifications" class=" mt-2 mr-4" data-action="show-read-check">
7+
Hide Read
8+
</ff-checkbox>
9+
</div>
10+
511
<!-- <div class="actions">-->
612
<!-- <span class="forge-badge" :class="{disabled: !canSelectAll}" @click="selectAll">select all</span>-->
713
<!-- <span class="forge-badge" :class="{disabled: !canDeselectAll}" @click="deselectAll">deselect all</span>-->
@@ -10,18 +16,21 @@
1016
<!-- </div>-->
1117
</div>
1218
<ul v-if="hasNotificationMessages" class="messages-wrapper" data-el="messages-wrapper">
13-
<li v-for="notification in notifications" :key="notification.id" data-el="message">
19+
<li v-for="notification in filteredNotifications" :key="notification.id" data-el="message">
1420
<component
15-
:is="notificationsComponentMap[notification.type]"
21+
:is="getNotificationsComponent(notification)"
1622
:notification="notification"
1723
:selections="selections"
1824
@selected="onSelected"
1925
@deselected="onDeselected"
2026
/>
2127
</li>
2228
</ul>
29+
<div v-else-if="hideReadNotifications" class="empty">
30+
<p>No unread notifications...</p>
31+
</div>
2332
<div v-else class="empty">
24-
<p>Nothing so far...</p>
33+
<p>No notifications...</p>
2534
</div>
2635
</div>
2736
</template>
@@ -30,19 +39,17 @@
3039
import { markRaw } from 'vue'
3140
import { mapGetters } from 'vuex'
3241
42+
import GenericNotification from '../../notifications/Generic.vue'
3343
import TeamInvitationAcceptedNotification from '../../notifications/invitations/Accepted.vue'
3444
import TeamInvitationReceivedNotification from '../../notifications/invitations/Received.vue'
3545
3646
export default {
3747
name: 'NotificationsDrawer',
3848
data () {
3949
return {
40-
notificationsComponentMap: {
41-
// todo replace hardcoded value with actual notification type
42-
'team-invite': markRaw(TeamInvitationReceivedNotification),
43-
'team-invite-accepted-invitor': markRaw(TeamInvitationAcceptedNotification)
44-
},
45-
selections: []
50+
componentCache: {},
51+
selections: [],
52+
hideReadNotifications: true
4653
}
4754
},
4855
computed: {
@@ -54,10 +61,34 @@ export default {
5461
return this.selections.length > 0
5562
},
5663
hasNotificationMessages () {
57-
return this.notifications.length > 0
64+
return this.filteredNotifications.length > 0
65+
},
66+
filteredNotifications () {
67+
return this.hideReadNotifications ? this.notifications.filter(n => !n.read) : this.notifications
5868
}
5969
},
6070
methods: {
71+
getNotificationsComponent (notification) {
72+
let comp = this.componentCache[notification.type]
73+
if (comp) {
74+
return comp
75+
}
76+
// return specific notification component based on type
77+
switch (notification.type) {
78+
case 'team-invite':
79+
comp = markRaw(TeamInvitationReceivedNotification)
80+
break
81+
case 'team-invite-accepted-invitor':
82+
comp = markRaw(TeamInvitationAcceptedNotification)
83+
break
84+
default:
85+
// default to generic notification
86+
comp = markRaw(GenericNotification)
87+
break
88+
}
89+
this.componentCache[notification.type] = comp
90+
return comp
91+
},
6192
onSelected (notification) {
6293
this.selections.push(notification)
6394
},
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<template>
2+
<NotificationMessage
3+
:notification="notification"
4+
:selections="selections"
5+
data-el="generic-notification" :to="to"
6+
>
7+
<template #icon>
8+
<component :is="notificationData.iconComponent" />
9+
</template>
10+
<template #title>
11+
{{ notificationData.title }}
12+
</template>
13+
<template #message>
14+
<!-- eslint-disable-next-line vue/no-v-html -->
15+
<span v-html="notificationData.message" />
16+
</template>
17+
</NotificationMessage>
18+
</template>
19+
20+
<script>
21+
import { defineAsyncComponent } from 'vue'
22+
23+
import IconDeviceSolid from '../../components/icons/DeviceSolid.js'
24+
import IconNodeRedSolid from '../../components/icons/NodeRedSolid.js'
25+
import NotificationMessageMixin from '../../mixins/NotificationMessage.js'
26+
27+
import NotificationMessage from './Notification.vue'
28+
29+
export default {
30+
name: 'GenericNotification',
31+
components: { NotificationMessage, IconNodeRedSolid },
32+
mixins: [NotificationMessageMixin],
33+
data () {
34+
return {
35+
knownEvents: {
36+
'instance-crashed': {
37+
icon: 'instance',
38+
title: 'Node-RED Instance Crashed',
39+
message: '"<i>{{instance.name}}</i>" has crashed'
40+
},
41+
'instance-safe-mode': {
42+
icon: 'instance',
43+
title: 'Node-RED Instance Safe Mode',
44+
message: '"<i>{{instance.name}}</i>" is running in safe mode'
45+
},
46+
'device-crashed': {
47+
icon: 'device',
48+
title: 'Node-RED Device Crashed',
49+
message: '"<i>{{device.name}}</i>" has crashed'
50+
},
51+
'device-safe-mode': {
52+
icon: 'device',
53+
title: 'Node-RED Device Safe Mode',
54+
message: '"<i>{{device.name}}</i>" is running in safe mode'
55+
}
56+
}
57+
}
58+
},
59+
computed: {
60+
to () {
61+
if (typeof this.notification.data?.to === 'object') { return this.notification.data.to }
62+
if (typeof this.notification.data?.to === 'string') { return { path: this.notification.data.to } }
63+
if (typeof this.notification.data?.url === 'string') { return { url: this.notification.data.url } }
64+
if (this.notification.data?.instance?.id) {
65+
return {
66+
name: 'instance-overview',
67+
params: { id: this.notification.data.instance.id }
68+
}
69+
}
70+
return null // no link
71+
},
72+
notificationData () {
73+
const event = this.knownEvents[this.notification.type] || {}
74+
event.createdAt = new Date(this.notification.createdAt).toLocaleString()
75+
// get title and message
76+
if (!event.title) {
77+
event.title = this.notification.data?.title || this.notification.type.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
78+
}
79+
if (!event.message) {
80+
event.message = this.notification.data?.message || `Event occurred at ${event.createdAt}`
81+
}
82+
83+
// icon handling
84+
event.icon = event.icon || this.notification.data?.icon
85+
if (event.icon === 'instance' || event.icon === 'project') {
86+
event.iconComponent = IconNodeRedSolid
87+
} else if (event.icon === 'device') {
88+
event.iconComponent = IconDeviceSolid
89+
} else {
90+
event.iconComponent = defineAsyncComponent(() => import('@heroicons/vue/solid').then(x => x[event.icon] || x.BellIcon))
91+
}
92+
93+
// Perform known substitutions
94+
event.title = this.substitutions(event.title)
95+
event.message = this.substitutions(event.message)
96+
return event
97+
}
98+
},
99+
methods: {
100+
substitutions (str) {
101+
let result = str
102+
const regex = /{{([^}]+)}}/g // find all {{key}} occurrences
103+
let match = regex.exec(result)
104+
while (match) {
105+
const key = match[1]
106+
const value = this.getObjectProperty(this.notification.data || {}, key) || key.split('.')[0].replace(/\b\w/g, l => l.toUpperCase())
107+
result = result.replace(match[0], value)
108+
match = regex.exec(result)
109+
}
110+
return result
111+
},
112+
getObjectProperty (obj, path) {
113+
return (path || '').trim().split('.').reduce((acc, part) => acc && acc[part], obj)
114+
}
115+
}
116+
}
117+
</script>
118+
119+
<style scoped lang="scss">
120+
121+
</style>

0 commit comments

Comments
 (0)