Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add crash notifications #4409

Merged
merged 11 commits into from
Aug 28, 2024
Merged
3 changes: 2 additions & 1 deletion forge/db/models/Notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ module.exports = {
where: {
reference,
UserId: user.id
}
},
order: [['id', 'DESC']]
})
},
forUser: async (user, pagination = {}) => {
Expand Down
17 changes: 17 additions & 0 deletions forge/db/models/Team.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,23 @@ module.exports = {
// In this case the findAll above will return an array that includes null, this needs to be guarded against
return owners.filter((owner) => owner !== null)
},
/**
* Get all members of the team optionally filtered by `role` array
* @param {Array<Number> | null} roleFilter - Array of roles to filter by
* @example
* // Get all members of the team
* const members = await team.getTeamMembers()
* @example
* // Get viewers only
* const viewers = await team.getTeamMembers([Roles.Viewer])
*/
getTeamMembers: async function (roleFilter = null) {
const where = { TeamId: this.id }
if (roleFilter && Array.isArray(roleFilter)) {
where.role = roleFilter
}
return (await M.TeamMember.findAll({ where, include: M.User })).filter(tm => tm && tm.User).map(tm => tm.User)
},
memberCount: async function (role) {
const where = {
TeamId: this.id
Expand Down
31 changes: 29 additions & 2 deletions forge/notifications/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,36 @@ module.exports = fp(async function (app, _opts) {
* @param {string} type the type of the notification
* @param {Object} data meta data for the notification - specific to the type
* @param {string} reference a key that can be used to lookup this notification, for example: `invite:HASHID`
*
* @param {Object} [options]
* @param {boolean} [options.upsert] if true, updates the existing notification with the same reference & adds/increments `data.meta.counter`
* @param {boolean} [options.supersede] if true, marks existing notification (with the same reference) as read & adds a new one
*/
async function send (user, type, data, reference = null) {
async function send (user, type, data, reference = null, options = null) {
if (reference && options && typeof options === 'object') {
if (options.upsert) {
const existing = await app.db.models.Notification.byReference(reference, user)
if (existing && !existing.read) {
const updatedData = Object.assign({}, existing.data, data)
if (!updatedData.meta || typeof updatedData.meta !== 'object') {
updatedData.meta = {}
}
if (typeof updatedData.meta.counter === 'number') {
updatedData.meta.counter += 1
} else {
updatedData.meta.counter = 2 // if notification already exists, then this is the 2nd occurrence!
}
await existing.update({ data: updatedData })
return existing
}
} else if (options.supersede) {
const existing = await app.db.models.Notification.byReference(reference, user)
if (existing && !existing.read) {
existing.read = true
await existing.save()
}
}
}

return app.db.models.Notification.create({
UserId: user.id,
type,
Expand Down
40 changes: 40 additions & 0 deletions forge/routes/logging/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const { getLoggers: getDeviceLogger } = require('../../auditLog/device')
const { getLoggers: getProjectLogger } = require('../../auditLog/project')
const { Roles } = require('../../lib/roles')

/** Node-RED Audit Logging backend
*
Expand Down Expand Up @@ -75,6 +76,24 @@ module.exports = async function (app) {
if (app.config.features.enabled('emailAlerts')) {
await app.auditLog.alerts.generate(projectId, event)
}
// send notification to all members and owners in the team
const teamMembersAndOwners = await request.project.Team.getTeamMembers([Roles.Member, Roles.Owner])
if (teamMembersAndOwners && teamMembersAndOwners.length > 0) {
const notificationType = event === 'crashed' ? 'instance-crashed' : 'instance-safe-mode'
const reference = `${notificationType}:${projectId}`
const data = {
instance: {
id: projectId,
name: request.project.name
},
meta: {
severity: event === 'crashed' ? 'error' : 'warning'
}
}
for (const user of teamMembersAndOwners) {
await app.notifications.send(user, notificationType, data, reference, { upsert: true })
}
}
}

response.status(200).send()
Expand Down Expand Up @@ -154,6 +173,27 @@ module.exports = async function (app) {
)
}

if (event === 'crashed' || event === 'safe-mode') {
// send notification to all members and owners in the team
const teamMembersAndOwners = await request.device.Team.getTeamMembers([Roles.Member, Roles.Owner])
if (teamMembersAndOwners && teamMembersAndOwners.length > 0) {
const notificationType = event === 'crashed' ? 'device-crashed' : 'device-safe-mode'
const reference = `${notificationType}:${deviceId}`
const data = {
device: {
id: deviceId,
name: request.device.name
},
meta: {
severity: event === 'crashed' ? 'error' : 'warning'
}
}
for (const user of teamMembersAndOwners) {
await app.notifications.send(user, notificationType, data, reference, { upsert: true })
}
}
}

response.status(200).send()

// For application owned devices, perform an auto snapshot
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
<template>
<div class="ff-notifications-drawer" data-el="notifications-drawer">
<div class="header">
<h2 class="title">Notifications</h2>
<div class="flex">
<h2 class="title flex-grow">Notifications</h2>
<ff-checkbox v-model="hideReadNotifications" class=" mt-2 mr-4" data-action="show-read-check">
Hide Read
</ff-checkbox>
</div>

<!-- <div class="actions">-->
<!-- <span class="forge-badge" :class="{disabled: !canSelectAll}" @click="selectAll">select all</span>-->
<!-- <span class="forge-badge" :class="{disabled: !canDeselectAll}" @click="deselectAll">deselect all</span>-->
Expand All @@ -10,18 +16,21 @@
<!-- </div>-->
</div>
<ul v-if="hasNotificationMessages" class="messages-wrapper" data-el="messages-wrapper">
<li v-for="notification in notifications" :key="notification.id" data-el="message">
<li v-for="notification in filteredNotifications" :key="notification.id" data-el="message">
<component
:is="notificationsComponentMap[notification.type]"
:is="getNotificationsComponent(notification)"
:notification="notification"
:selections="selections"
@selected="onSelected"
@deselected="onDeselected"
/>
</li>
</ul>
<div v-else-if="hideReadNotifications" class="empty">
<p>No unread notifications...</p>
</div>
<div v-else class="empty">
<p>Nothing so far...</p>
<p>No notifications...</p>
</div>
</div>
</template>
Expand All @@ -30,19 +39,17 @@
import { markRaw } from 'vue'
import { mapGetters } from 'vuex'

import GenericNotification from '../../notifications/Generic.vue'
import TeamInvitationAcceptedNotification from '../../notifications/invitations/Accepted.vue'
import TeamInvitationReceivedNotification from '../../notifications/invitations/Received.vue'

export default {
name: 'NotificationsDrawer',
data () {
return {
notificationsComponentMap: {
// todo replace hardcoded value with actual notification type
'team-invite': markRaw(TeamInvitationReceivedNotification),
'team-invite-accepted-invitor': markRaw(TeamInvitationAcceptedNotification)
},
selections: []
componentCache: {},
selections: [],
hideReadNotifications: true
}
},
computed: {
Expand All @@ -54,10 +61,34 @@ export default {
return this.selections.length > 0
},
hasNotificationMessages () {
return this.notifications.length > 0
return this.filteredNotifications.length > 0
},
filteredNotifications () {
return this.hideReadNotifications ? this.notifications.filter(n => !n.read) : this.notifications
}
},
methods: {
getNotificationsComponent (notification) {
let comp = this.componentCache[notification.type]
if (comp) {
return comp
}
// return specific notification component based on type
switch (notification.type) {
case 'team-invite':
comp = markRaw(TeamInvitationReceivedNotification)
break
case 'team-invite-accepted-invitor':
comp = markRaw(TeamInvitationAcceptedNotification)
break
default:
// default to generic notification
comp = markRaw(GenericNotification)
break
}
this.componentCache[notification.type] = comp
return comp
},
onSelected (notification) {
this.selections.push(notification)
},
Expand Down
121 changes: 121 additions & 0 deletions frontend/src/components/notifications/Generic.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<template>
<NotificationMessage
:notification="notification"
:selections="selections"
data-el="generic-notification" :to="to"
>
<template #icon>
<component :is="notificationData.iconComponent" />
</template>
<template #title>
{{ notificationData.title }}
</template>
<template #message>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="notificationData.message" />
</template>
</NotificationMessage>
</template>

<script>
import { defineAsyncComponent } from 'vue'

import IconDeviceSolid from '../../components/icons/DeviceSolid.js'
import IconNodeRedSolid from '../../components/icons/NodeRedSolid.js'
import NotificationMessageMixin from '../../mixins/NotificationMessage.js'

import NotificationMessage from './Notification.vue'

export default {
name: 'GenericNotification',
components: { NotificationMessage, IconNodeRedSolid },
mixins: [NotificationMessageMixin],
data () {
return {
knownEvents: {
'instance-crashed': {
icon: 'instance',
title: 'Node-RED Instance Crashed',
message: '"<i>{{instance.name}}</i>" has crashed'
},
'instance-safe-mode': {
icon: 'instance',
title: 'Node-RED Instance Safe Mode',
message: '"<i>{{instance.name}}</i>" is running in safe mode'
},
'device-crashed': {
icon: 'device',
title: 'Node-RED Device Crashed',
message: '"<i>{{device.name}}</i>" has crashed'
},
'device-safe-mode': {
icon: 'device',
title: 'Node-RED Device Safe Mode',
message: '"<i>{{device.name}}</i>" is running in safe mode'
}
}
}
},
computed: {
to () {
if (typeof this.notification.data?.to === 'object') { return this.notification.data.to }
if (typeof this.notification.data?.to === 'string') { return { path: this.notification.data.to } }
if (typeof this.notification.data?.url === 'string') { return { url: this.notification.data.url } }
if (this.notification.data?.instance?.id) {
return {
name: 'instance-overview',
params: { id: this.notification.data.instance.id }
}
}
return null // no link
},
notificationData () {
const event = this.knownEvents[this.notification.type] || {}
event.createdAt = new Date(this.notification.createdAt).toLocaleString()
// get title and message
if (!event.title) {
event.title = this.notification.data?.title || this.notification.type.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
if (!event.message) {
event.message = this.notification.data?.message || `Event occurred at ${event.createdAt}`
}

// icon handling
event.icon = event.icon || this.notification.data?.icon
if (event.icon === 'instance' || event.icon === 'project') {
event.iconComponent = IconNodeRedSolid
} else if (event.icon === 'device') {
event.iconComponent = IconDeviceSolid
} else {
event.iconComponent = defineAsyncComponent(() => import('@heroicons/vue/solid').then(x => x[event.icon] || x.BellIcon))
}

// Perform known substitutions
event.title = this.substitutions(event.title)
event.message = this.substitutions(event.message)
return event
}
},
methods: {
substitutions (str) {
let result = str
const regex = /{{([^}]+)}}/g // find all {{key}} occurrences
let match = regex.exec(result)
while (match) {
const key = match[1]
const value = this.getObjectProperty(this.notification.data || {}, key) || key.split('.')[0].replace(/\b\w/g, l => l.toUpperCase())
result = result.replace(match[0], value)
match = regex.exec(result)
}
return result
},
Comment on lines +100 to +111
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ewww... but okay

getObjectProperty (obj, path) {
return (path || '').trim().split('.').reduce((acc, part) => acc && acc[part], obj)
}
}
}
</script>

<style scoped lang="scss">

</style>
Loading
Loading