From e3f78b7418853ee6700113394c7987b74dbb1153 Mon Sep 17 00:00:00 2001 From: panaaj <38519157+panaaj@users.noreply.github.com> Date: Sat, 4 Jan 2025 16:01:15 +1030 Subject: [PATCH] Realign to #1857 --- docs/src/SUMMARY.md | 2 +- docs/src/develop/rest-api/alerts_api.md | 296 +++++++++ .../src/develop/rest-api/notifications_api.md | 18 - package.json | 10 +- src/api/alerts/alertmanager.ts | 312 +++++++++ src/api/alerts/index.ts | 406 ++++++++++++ src/api/alerts/openApi.json | 624 ++++++++++++++++++ src/api/alerts/openApi.ts | 8 + src/api/index.ts | 11 +- src/api/notifications/index.ts | 578 ---------------- src/api/notifications/openApi.json | 437 ------------ src/api/notifications/openApi.ts | 8 - src/api/swagger.ts | 4 +- src/interfaces/plugins.ts | 10 +- 14 files changed, 1664 insertions(+), 1060 deletions(-) create mode 100644 docs/src/develop/rest-api/alerts_api.md delete mode 100644 docs/src/develop/rest-api/notifications_api.md create mode 100644 src/api/alerts/alertmanager.ts create mode 100644 src/api/alerts/index.ts create mode 100644 src/api/alerts/openApi.json create mode 100644 src/api/alerts/openApi.ts delete mode 100644 src/api/notifications/index.ts delete mode 100644 src/api/notifications/openApi.json delete mode 100644 src/api/notifications/openApi.ts diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index adaf5b516..1e6140300 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -36,7 +36,7 @@ * [Course API](./develop/rest-api/course_api.md) * [Course Calculations](./develop/rest-api/course_calculations.md) * [Resources API](./develop/rest-api/resources_api.md) - * [Notifications API](./develop/rest-api/notifications_api.md) + * [Alerts API](./develop/rest-api/alerts_api.md) * [Autopilot API](./develop/rest-api/autopilot_api.md) * [Anchor API](./develop/rest-api/anchor_api.md) * [Contribute](./develop/contributing.md) diff --git a/docs/src/develop/rest-api/alerts_api.md b/docs/src/develop/rest-api/alerts_api.md new file mode 100644 index 000000000..251cdee9a --- /dev/null +++ b/docs/src/develop/rest-api/alerts_api.md @@ -0,0 +1,296 @@ +# Working with the Alerts API + +#### (Under Development) + +_Note: This API is currently under development and the information provided here is likely to change._ + +[View the PR](https://github.com/SignalK/signalk-server/pull/1560) for more details. + +## Overview + +The Alerts API provides a mechanism for applications to issue requests for raising and actioning alarms and notifications when operating conditions +become abnormal and measured values fall outside of acceptable thresholds. + +The Alerts API requests are sent to `/signalk/v2/api/alerts`. + +## Supported Operations + +### Individual Alert + +- [Raise](#raising-alerts): `POST /signalk/v2/api/alerts` +- [Retrieve](#retrieve-alert): `GET /signalk/v2/api/alerts/{id}` +- [Acknowledge](#acknowledge-alert): `POST /signalk/v2/api/alerts/{id}/ack` +- [Unacknowledge](#unacknowledge-alert): `POST /signalk/v2/api/alerts/{id}/unack` +- [Silence](#silence-alert): `POST /signalk/v2/api/alerts/{id}/silence` +- [Update metadata](#update-alert-metadata): `PUT /signalk/v2/api/alerts/{id}/properties` +- [Change priority](#change-alert-priority): `PUT /signalk/v2/api/alerts/{id}/priority` +- [Resolve](#resolve-alert): `POST /signalk/v2/api/alerts/{id}/resolve` +- [Remove](#remove-alert): `DELETE /signalk/v2/api/alerts/{id}` + +### ALL Alerts + +- [List](#listing-alerts): `GET /signalk/v2/api/alerts` +- [Acknowledge](#acknowledge-all-alerts): `POST /signalk/v2/api/alerts/ack` +- [Silence](#silence-all-alerts): `POST /signalk/v2/api/alerts/silence` +- [Clean](#remove-all-resolved-alerts): `DELETE /signalk/v2/api/alerts` + + +### Notifications + +Alerts will emit Notifications throughout their lifecycle to indicate changes in status and when they are resolved. See [Notifications](#notifications-emitted-by-alerts) section for details. + +--- + +### Raising Alerts + +To create a new alert, submit an HTTP `POST` request to `/signalk/v2/api/alerts` providing the `priority` of the alert. + +You can also specify additional `properties` which will be included in the alert `metadata` attribute. + +_Example:_ +```typescript +HTTP GET "/signalk/v2/api/alerts" { + "priority": "alarm". + "properties": { + "name": "My Alert", + "message": "My alert message" + } +} +``` +The `properties` attribute is an object containing key | value pairs, noting that the following pre-defined keys have been defined: +```javascript +{ + name: //string value representing the alert name + message: //string value containing the alert message + position: //Signal K position object representing the vessel's postion when the alert was raised e.g. {latitude: 5.3876, longitude: 10.76533} + path: // Signal K path associated with the alert e.g. "electrical.battery.1" + sourceRef: // Source of the alert +} +``` + +The response will be an object detailing the status of the operation which includes the `id` +of the alert created. This `id` can be used to take further action on the alert and it is included the path of notifications emitted by the alert. + +_Example:_ +```JSON +{ + "state": "COMPLETED", + "statusCode": 201, + "id": "74dbf514-ff33-4a3f-b212-fd28bd106a88" +} +``` + + +### Retrieve Alert +To retireve the value of an alert, submit an HTTP `GET` request to `/signalk/v2/api/alerts/{id}` supplying th the id of the alert. + +_Request:_ +```bash +HTTP GET "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88" +``` +_Response:_ +```JSON +{ + "id": "74dbf514-ff33-4a3f-b212-fd28bd106a88", + "created": "2025-01-02T08:19:58.676Z", + "resolved": "2025-01-02T08:21:12.382Z", + "priority": "alarm", + "process": "normal", + "alarmState": "inactive", + "acknowledged": false, + "silenced": false, + "metaData": { + "sourceRef": "alertsApi", + "name": "My Alert", + "message": "My alert message" + } +} +``` + +### Acknowledge Alert +To acknowledge an alert, submit an HTTP `POST` request to `/signalk/v2/api/alerts/{id}/ack` supplying th the id of the alert. + +_Example:_ +```typescript +HTTP PUT "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88/ack" +``` + +### Unacknowledge Alert +To unacknowledge an alert, submit an HTTP `POST` request to `/signalk/v2/api/alerts/{id}/unack` supplying th the id of the alert. + +_Example:_ +```typescript +HTTP PUT "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88/unack" +``` + +### Silence Alert +To silence an alert for 30 seconds, submit an HTTP `POST` request to `/signalk/v2/api/alerts/{id}/silence` supplying th the id of the alert. + +_Example:_ +```typescript +HTTP PUT "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88/silence" +``` + +### Resolve Alert +To resolve (set condition to normal), submit an HTTP `POST` request to `/signalk/v2/api/alerts/{id}/resolve` supplying th the id of the alert. + +_Example:_ +```typescript +HTTP DELETE "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88/resolve" +``` + + +### Update Alert Metadata + +To update the alert metadata, submit an HTTP `PUT` request to `/signalk/v2/api/alerts/{id}/properties` and provide the data in the body of the request. + +_Example:_ +```typescript +HTTP PUT "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88" { + "message": "My updated alert message" +} +``` + +### Change Alert Priority + +To update the alert priority, submit an HTTP `PUT` request to `/signalk/v2/api/alerts/{id}/priority` and provide the new priority in the body of the request. + +_Example:_ +```typescript +HTTP PUT "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88/priority" { + "priority": "emergency" +} +``` + +### Remove Alert +To remove an alert from the alert list, submit an HTTP `DELETE` request to `/signalk/v2/api/alerts/{id}` supplying th the id of the alert. + +_Example:_ +```typescript +HTTP DELETE "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88" +``` + + +### Listing Alerts + +To retrieve a list of all alerts, submit an HTTP `GET` request to `/signalk/v2/api/alerts`. + +The response will be an object containing all alerts currently being managed (both active and resolved) keyed by their identifier. + +The list can be filtered via the use of the following query parameters: +- `priority`: return only alerts with the specified priority +- `unack`: return only unacknowledged alerts _(only applies to `emergency` or `alarm` priorities)_ +- `top`: return the x most recent alerts + +```typescript +HTTP GET "/signalk/v2/api/alerts?priority=alarm" +``` +_Example: List of alerts with a priority of `alarm`._ + +```JSON +{ + "0a8a1b07-8428-4e84-8259-1ddae5bf70de": { + "id": "0a8a1b07-8428-4e84-8259-1ddae5bf70de", + "created": "2025-01-02T08:19:58.676Z", + "priority": "alarm", + "process": "abnormal", + "alarmState": "active", + "acknowledged": false, + "silenced": false, + "metaData": { + "sourceRef": "alertsApi", + "name": "My Alert", + "message": "My alert message" + } + } +}, +{ + "74dbf514-ff33-4a3f-b212-fd28bd106a88": { + "id": "74dbf514-ff33-4a3f-b212-fd28bd106a88", + "created": "2025-01-02T08:19:58.676Z", + "resolved": "2025-01-02T08:21:41.996Z", + "priority": "alarm", + "process": "normal", + "alarmState": "inactive", + "acknowledged": false, + "silenced": false, + "metaData": { + "sourceRef": "alertsApi", + "name": "My other Alert", + "message": "My other alert message" + } + } +} +``` + +### Acknowledge ALL Alerts +To acknowledge an alert, submit an HTTP `POST` request to `/signalk/v2/api/alerts/{id}/ack` supplying th the id of the alert. + +_Example: Acknowledge alert._ +```typescript +HTTP PUT "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88/ack" +``` + +### Silence ALL Alerts +To silence an alert for 30 seconds, submit an HTTP `POST` request to `/signalk/v2/api/alerts/{id}/silence` supplying th the id of the alert. + +_Example: Silence alert._ +```typescript +HTTP PUT "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88/silence" +``` + +### Remove ALL Resolved Alerts +To resolve (set condition to normal), submit an HTTP `DELETE` request to `/signalk/v2/api/alerts/{id}` supplying th the id of the alert. + +_Example: Resolve alert._ +```typescript +HTTP DELETE "/signalk/v2/api/alerts/74dbf514-ff33-4a3f-b212-fd28bd106a88" +``` + +--- + +## Notifications emitted by Alerts + +Alerts will emit Notifications reflecting the current state that include both the alert identifier and metadata, whenever their state changes. + +Notifications are created under the path `notifications.{alertId}` by default. + +_Example:_ +```javascript +// alert +"0a8a1b07-8428-4e84-8259-1ddae5bf70de": { + ... + "metaData": { + "name": "My Alert" + } +} + +// notification path +notification.0a8a1b07-8428-4e84-8259-1ddae5bf70de +{ + "message": "My alert message!", + "state": "alarm", + "method": ["visual","sound"], + "id": "0a8a1b07-8428-4e84-8259-1ddae5bf70de", + "metaData": { + "name": "My alert" + } +} + +``` + +If a `path` is included in the Alert metadata then this is incluided in the Notification path. + +_Example:_ +```javascript +// alert +"0a8a1b07-8428-4e84-8259-1ddae5bf70de": { + "metaData": { + "path": "electrical.battery.1" + } + ... +} + +// notification path +notification.electrical.battery.1.0a8a1b07-8428-4e84-8259-1ddae5bf70de +``` diff --git a/docs/src/develop/rest-api/notifications_api.md b/docs/src/develop/rest-api/notifications_api.md deleted file mode 100644 index 49ded246c..000000000 --- a/docs/src/develop/rest-api/notifications_api.md +++ /dev/null @@ -1,18 +0,0 @@ -# Notifications API - -#### (Under Development) - -_Note: This API is currently under development and the information provided here is likely to change._ - -The Signal K server Notifications API will provide a set of operations for raising, actioning and clearing notifications. - -It will implement: - -- Both HTTP endpoints for interactive use (`/signalk/v2/api/notifications`) and an interface for use by plugins and connection handlers to ensure effective management of notifications. - -- The ability to action notifications (e.g. acknowledge, silence, etc) and preserve the resulting status so it is available to all connected devices. - -- A unique `id` for each notification which can then be used to action it, regardless of the notification source. - -[View the PR](https://github.com/SignalK/signalk-server/pull/1560) for more details. - diff --git a/package.json b/package.json index def1040de..97be89825 100644 --- a/package.json +++ b/package.json @@ -126,17 +126,17 @@ "ws": "^7.0.0" }, "optionalDependencies": { - "@signalk/freeboard-sk": "^2.0.0-beta.3", "@mxtommy/kip": "^2.9.1", + "@signalk/freeboard-sk": "^2.0.0-beta.3", "@signalk/instrumentpanel": "0.x", "@signalk/set-system-time": "^1.2.0", "@signalk/signalk-to-nmea0183": "^1.0.0", - "@signalk/vesselpositions": "^1.0.0", "@signalk/udp-nmea-plugin": "^2.0.0", - "signalk-to-nmea2000": "^2.16.0", - "signalk-n2kais-to-nmea0183": "^1.3.1", + "@signalk/vesselpositions": "^1.0.0", "mdns": "^2.5.1", - "serialport": "^11.0.0" + "serialport": "^11.0.0", + "signalk-n2kais-to-nmea0183": "^1.3.1", + "signalk-to-nmea2000": "^2.16.0" }, "devDependencies": { "@types/busboy": "^1.5.0", diff --git a/src/api/alerts/alertmanager.ts b/src/api/alerts/alertmanager.ts new file mode 100644 index 000000000..96ec1d1bc --- /dev/null +++ b/src/api/alerts/alertmanager.ts @@ -0,0 +1,312 @@ +import { v4 as uuidv4 } from 'uuid' +import { Path, Position, SourceRef } from '@signalk/server-api' +import { AlertsApplication } from '.' + +export type AlertPriority = 'emergency' | 'alarm' | 'warning' | 'caution' +export type AlertProcess = 'normal' | 'abnormal' +export type AlertAlarmState = 'active' | 'inactive' + +interface AlertAdditionalProperties { + name?: string + message?: string + position?: Position + path?: Path + sourceRef?: SourceRef +} +export interface AlertMetaData extends AlertAdditionalProperties { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [index: string]: any +} + +export interface AlertValue { + id: string + created: Date + resolved: Date + priority: AlertPriority + process: AlertProcess + alarmState: AlertAlarmState + acknowledged: boolean + silenced: boolean + metaData: AlertMetaData +} + +export interface AlertListParams { + priority: AlertPriority + top: number + unack: string +} + +export const isAlertPriority = (value: AlertPriority) => { + return ['emergency', 'alarm', 'warning', 'caution'].includes(value) +} + +const ALARM_SILENCE_TIME = 30000 // 30 secs + +// Class encapsulating an alert +export class Alert { + protected id: string + protected created: Date + protected resolved!: Date | undefined + protected priority: AlertPriority = 'caution' + protected process: AlertProcess = 'normal' + protected alarmState: AlertAlarmState = 'inactive' + protected acknowledged: boolean = false + protected silenced: boolean = false + protected metaData: AlertMetaData = {} + + private timer!: NodeJS.Timeout + private app: AlertsApplication + + constructor( + app: AlertsApplication, + priority?: AlertPriority, + metaData?: AlertMetaData + ) { + this.app = app + this.id = uuidv4() + this.created = new Date() + this.raise(priority, metaData) + } + + /** clean up */ + destroy() { + this.clearSilencer() + } + + /** return Alert value */ + get value(): AlertValue { + return { + id: this.id, + created: this.created, + resolved: this.resolved as Date, + priority: this.priority, + process: this.process, + alarmState: this.alarmState, + acknowledged: this.acknowledged, + silenced: this.silenced, + metaData: this.metaData + } + } + + get canRemove(): boolean { + return this.process === 'normal' + } + + /** Set / update Alert metadata */ + set properties(values: AlertMetaData) { + this.metaData = Object.assign({}, this.metaData, values) + this.notify() + } + + /** Update the Alert priority */ + updatePriority(value: AlertPriority) { + if (!isAlertPriority(value)) { + throw new Error('Invalid Alert Priority supplied!') + } else { + if (value === this.priority) return + // set the new Alert state for the supplied priority + this.priority = value + this.process = 'abnormal' + this.resolved = undefined + this.silenced = false + this.acknowledged = false + if (['emergency', 'alarm'].includes(value)) { + this.alarmState = 'active' + } else { + this.alarmState = 'inactive' + } + this.notify() + } + } + + /**set to abnormal condition */ + raise(priority?: AlertPriority, metaData?: AlertMetaData) { + this.clearSilencer() + this.metaData = metaData ?? { + sourceRef: 'alertsApi' as SourceRef, + message: `Alert created at ${this.created}` + } + this.updatePriority(priority as AlertPriority) + } + + /** return to normal condition */ + resolve() { + this.clearSilencer() + this.alarmState = 'inactive' + this.process = 'normal' + this.resolved = new Date() + this.notify() + } + + /** acknowledge alert */ + ack() { + this.clearSilencer() + this.alarmState = 'active' + this.acknowledged = true + this.notify() + } + + /** un-acknowledge alert */ + unAck() { + this.clearSilencer() + this.alarmState = ['emergency', 'alarm'].includes(this.priority) + ? 'active' + : 'inactive' + this.acknowledged = false + this.notify() + } + + /** temporarily silence alert */ + silence(): boolean { + if (this.priority === 'alarm' && this.process !== 'normal') { + this.silenced = true + this.notify() + this.timer = setTimeout(() => { + // unsilence after 30 secs + console.log( + `*** Alert ${this.metaData.name ?? 'id'} (${ + this.id + }) has been unsilenced.` + ) + this.silenced = false + this.notify() + }, ALARM_SILENCE_TIME) + console.log( + `*** Silence alert ${this.metaData.name ?? 'id'} (${this.id}) for ${ + ALARM_SILENCE_TIME / 1000 + } seconds.` + ) + return true + } else { + return false + } + } + + private clearSilencer() { + if (this.timer) { + clearTimeout(this.timer) + this.silenced = false + } + } + + /** Emit notification */ + private notify() { + const method = + this.alarmState === 'inactive' + ? [] + : this.silenced + ? ['visual'] + : ['visual', 'sound'] + const state = this.alarmState === 'inactive' ? 'normal' : this.priority + const meta: AlertMetaData = Object.assign({}, this.metaData) + delete meta.message + delete meta.sourceRef + meta['created'] = this.created + const msg = { + id: this.id, + method: method, + state: state, + message: this.metaData.message ?? '', + metaData: meta + } + const path = `notifications.${meta.path ? meta.path + '.' : ''}${msg.id}` + delete meta.path + this.app.handleMessage(this.metaData.sourceRef ?? 'alertsApi', { + updates: [ + { + values: [ + { + path: path, + value: msg + } + ] + } + ] + }) + } +} + +// Alert Manager +export class AlertManager { + private alerts: Map = new Map() + + constructor() {} + + list(params?: AlertListParams) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let r: { [key: string]: any } = {} + const hasParams = Object.keys(params ?? {}).length !== 0 + this.alerts.forEach((al: Alert, k: string) => { + // filter priority + if (hasParams && typeof params?.priority !== 'undefined') { + if (params?.priority === al.value.priority) { + r[k] = al.value + } + } + // filter unack + else if ( + hasParams && + typeof params?.unack !== 'undefined' && + params?.unack !== '0' + ) { + if ( + ['emergency', 'alarm'].includes(al.value.priority) && + !al.value.acknowledged + ) { + r[k] = al.value + } + } else { + r[k] = al.value + } + }) + // filter top x + if (hasParams && typeof params?.top !== 'undefined') { + const t = Number(params.top) + const ra = Object.entries(r) + if (ra.length > t) { + r = {} + ra.slice(0 - t).forEach((i) => { + r[i[0]] = i[1] + }) + } + } + return r + } + + add(alert: Alert) { + this.alerts.set(alert.value.id, alert) + } + + get(id: string) { + return this.alerts.get(id) + } + + delete(id: string) { + if (this.alerts.get(id)?.canRemove) { + this.alerts.get(id)?.destroy() + this.alerts.delete(id) + } + } + + ackAll() { + for (const al of this.alerts) { + if (al) al[1].value.acknowledged = true + } + } + + silenceAll() { + for (const al of this.alerts) { + if (al) al[1].silence() + } + } + + /** remove resolved alerts */ + clean() { + for (const al of this.alerts) { + if (al && al[1].canRemove) { + al[1].destroy() + this.alerts.delete(al[0]) + } + } + } +} diff --git a/src/api/alerts/index.ts b/src/api/alerts/index.ts new file mode 100644 index 000000000..7758504ee --- /dev/null +++ b/src/api/alerts/index.ts @@ -0,0 +1,406 @@ +/* + API for working with Alerts (Alarms & Notifications). +*/ + +import { createDebug } from '../../debug' +const debug = createDebug('signalk-server:api:alerts') +import { IRouter, Request, Response } from 'express' +import { SignalKMessageHub, WithConfig } from '../../app' +import { WithSecurityStrategy } from '../../security' + +import { + AlertManager, + Alert, + AlertMetaData, + isAlertPriority +} from './alertmanager' +import { Path } from '@signalk/server-api' + +const SIGNALK_API_PATH = `/signalk/v2/api` +const ALERTS_API_PATH = `${SIGNALK_API_PATH}/alerts` + +export interface AlertsApplication + extends IRouter, + WithConfig, + WithSecurityStrategy, + SignalKMessageHub {} + +export class AlertsApi { + private alertManager: AlertManager + + constructor(private app: AlertsApplication) { + this.alertManager = new AlertManager() + } + + async start() { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + this.initApiEndpoints() + resolve() + }) + } + + /** public interface methods */ + notify(path: string, value: Alert | null, source: string) { + debug(`** Interface:put(${path}, value, ${source})`) + } + + private updateAllowed(request: Request): boolean { + return this.app.securityStrategy.shouldAllowPut( + request, + 'vessels.self', + null, + 'alerts' + ) + } + + private initApiEndpoints() { + debug(`** Initialise ${ALERTS_API_PATH} path handlers **`) + + // List Alerts + this.app.get(`${ALERTS_API_PATH}`, (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path}`) + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + res.json(this.alertManager.list(req.query as any)) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // Fetch Alert + this.app.get(`${ALERTS_API_PATH}/:id`, (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path}`) + + try { + res.json(this.alertManager.get(req.params.id)) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // New Alert + this.app.post(`${ALERTS_API_PATH}`, (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + if (!isAlertPriority(req.body.priority)) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: 'Alert priority is invalid or not provided!' + }) + return + } + + // create alert & add to manager + const al = new Alert( + this.app, + req.body.priority, + req.body.properties ?? undefined + ) + try { + this.alertManager.add(al) + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: al.value.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // MOB Alert + this.app.post(`${ALERTS_API_PATH}/mob`, (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + const { name, message, sourceRef } = req.body + // create alert & add to manager + const al = new Alert(this.app, 'emergency', { + path: 'mob' as Path, + name, + message, + sourceRef + }) + try { + this.alertManager.add(al) + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: al.value.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // Acknowledge ALL Alerts + this.app.post(`${ALERTS_API_PATH}/ack`, (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + try { + this.alertManager.ackAll() + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // Acknowledge Alert + this.app.post( + `${ALERTS_API_PATH}/:id/ack`, + (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + try { + const al = this.alertManager.get(req.params.id) + al?.ack() + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + } + ) + + // Unacknowledge Alert + this.app.post( + `${ALERTS_API_PATH}/:id/unack`, + (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + try { + const al = this.alertManager.get(req.params.id) + al?.unAck() + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + } + ) + + // Silence ALL Alerts + this.app.post( + `${ALERTS_API_PATH}/silence`, + (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + try { + this.alertManager.silenceAll() + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + } + ) + + // Silence Alert + this.app.post( + `${ALERTS_API_PATH}/:id/silence`, + (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + try { + const al = this.alertManager.get(req.params.id) + if (al?.silence()) { + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } else { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: "Unable to silence alert! Priority <> 'alarm'" + }) + } + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + } + ) + + // Update Alert metadata + this.app.put( + `${ALERTS_API_PATH}/:id/properties`, + (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + if (Object.keys(req.body).length === 0) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: 'No properties have been provided!' + }) + return + } + + try { + const al = this.alertManager.get(req.params.id) + if (al) al.properties = req.body as AlertMetaData + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + } + ) + + // Update Alert priority + this.app.put( + `${ALERTS_API_PATH}/:id/priority`, + (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + if (!isAlertPriority(req.body.value)) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: 'Alert priority is invalid or not provided!' + }) + return + } + + try { + const al = this.alertManager.get(req.params.id) + if (al) al.updatePriority(req.body.value) + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + } + ) + + // Resolve Alert + this.app.post( + `${ALERTS_API_PATH}/:id/resolve`, + (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + try { + const al = this.alertManager.get(req.params.id) + al?.resolve() + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + } + ) + + // Clean / delete Alert + this.app.delete(`${ALERTS_API_PATH}/:id`, (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + try { + this.alertManager.delete(req.params.id) + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + + // Clean Alerts + this.app.delete(`${ALERTS_API_PATH}`, (req: Request, res: Response) => { + debug(`** ${req.method} ${req.path} ${JSON.stringify(req.body)}`) + + try { + this.alertManager.clean() + res.status(201).json({ + state: 'COMPLETED', + statusCode: 201, + id: req.params.id + }) + } catch (e) { + res.status(400).json({ + state: 'FAILED', + statusCode: 400, + message: (e as Error).message + }) + } + }) + } +} diff --git a/src/api/alerts/openApi.json b/src/api/alerts/openApi.json new file mode 100644 index 000000000..a4ca65a48 --- /dev/null +++ b/src/api/alerts/openApi.json @@ -0,0 +1,624 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "2.0.0", + "title": "Signal K Alerts API", + "termsOfService": "http://signalk.org/terms/", + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "description": "API for raising and managing Alerts which emit Notifications based on their current status." + }, + "externalDocs": { + "url": "http://signalk.org/specification/", + "description": "Signal K specification." + }, + "servers": [ + { + "url": "/signalk/v2/api/alerts" + } + ], + "tags": [ + { + "name": "alert", + "description": "Management and notification of abnormal condition that requires resolution." + }, + { + "name": "special", + "description": "Special alert types." + }, + { + "name": "alert list", + "description": "Alert list actions." + } + ], + "components": { + "schemas": { + "UuidDef": { + "type": "string", + "pattern": "[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$", + "example": "ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" + }, + "IsoTimeDef": { + "type": "string", + "pattern": "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2}(?:\\.\\d*)?)((-(\\d{2}):(\\d{2})|Z)?)$", + "example": "2022-04-22T05:02:56.484Z" + }, + "SignalKPositionDef": { + "type": "object", + "required": ["latitude", "longitude"], + "properties": { + "latitude": { + "type": "number", + "format": "float", + "example": 2.459786 + }, + "longitude": { + "type": "number", + "format": "float", + "example": 17.459786 + } + } + }, + "PriorityDef": { + "type": "string", + "description": "Priority / severity of the alert.", + "example": "alarm", + "enum": ["emergency", "alarm", "warning", "caution"] + }, + "ProcessDef": { + "type": "string", + "description": "State of the underlying process.", + "example": "abnormal", + "enum": ["normal", "abnormal"] + }, + "AlarmStateDef": { + "type": "string", + "description": "Alert alarm status.", + "example": "active", + "enum": ["active", "inactive"] + }, + "PathDef": { + "type": "string", + "description": "Signal K path associated with the alert.", + "example": "electrical.batteries.1" + }, + "NameDef": { + "type": "string", + "description": "Alert name.", + "example": "Battery Over Voltage" + }, + "MessageDef": { + "type": "string", + "description": "Alert message to display.", + "example": "My message!" + }, + "SourceRef": { + "type": "string", + "description": "Reference to the source of the Alert.", + "example": "alert-plugin" + }, + "MetaDataDef": { + "type": "object", + "description": "Data values associated with this alert.", + "additionalProperties": true, + "properties": { + "name": { + "$ref": "#/components/schemas/NameDef" + }, + "message": { + "$ref": "#/components/schemas/MessageDef" + }, + "path": { + "$ref": "#/components/schemas/PathDef" + }, + "position": { + "$ref": "#/components/schemas/SignalKPositionDef" + }, + "sourceRef": { + "$ref": "#/components/schemas/SourceRef" + } + } + }, + "AlertRequestModel": { + "description": "Alert request model", + "type": "object", + "required": ["priority"], + "properties": { + "priority": { + "$ref": "#/components/schemas/PriorityDef" + }, + "properties": { + "$ref": "#/components/schemas/MetaDataDef" + } + } + }, + "MOBRequestModel": { + "description": "Person overboard request model", + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "MOB message text. If not supplied then a system generated message is created.", + "example": "Person overboard!" + }, + "name": { + "type": "string", + "description": "MOB alert name. If not supplied then a system generated message is created.", + "example": "MOB Alert" + }, + "sourceRef": { + "$ref": "#/components/schemas/SourceRef" + } + } + }, + "ResponseModel": { + "description": "Alert information", + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/UuidDef" + }, + "created": { + "$ref": "#/components/schemas/IsoTimeDef" + }, + "resolved": { + "$ref": "#/components/schemas/IsoTimeDef" + }, + "priority": { + "$ref": "#/components/schemas/PriorityDef" + }, + "process": { + "$ref": "#/components/schemas/ProcessDef" + }, + "alarmState": { + "$ref": "#/components/schemas/AlarmStateDef" + }, + "acknowledged": { + "description": "Indicates if alert has been acknowledged.", + "type": "boolean" + }, + "silenced": { + "description": "Indicates if alert has been silenced.", + "type": "boolean" + }, + "metaData": { + "$ref": "#/components/schemas/MetaDataDef" + } + } + } + }, + "responses": { + "200Ok": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["COMPLETED"] + }, + "statusCode": { + "type": "number", + "enum": [200] + } + }, + "required": ["state", "statusCode"] + } + } + } + }, + "201ActionResponse": { + "description": "Action response - success.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": ["COMPLETED"] + }, + "statusCode": { + "type": "number", + "enum": [201] + }, + "id": { + "$ref": "#/components/schemas/UuidDef" + } + }, + "required": ["id", "statusCode", "state"] + } + } + } + }, + "ErrorResponse": { + "description": "Failed operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Request error response", + "properties": { + "state": { + "type": "string", + "enum": ["FAILED"] + }, + "statusCode": { + "type": "number", + "enum": [404] + }, + "message": { + "type": "string" + } + }, + "required": ["state", "statusCode", "message"] + } + } + } + } + }, + "parameters": { + "AlertId": { + "name": "id", + "description": "Alert identifier.", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/UuidDef" + } + } + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "JAUTHENTICATION" + } + } + }, + "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }], + "paths": { + "/": { + "get": { + "tags": ["alert list"], + "summary": "List of alerts keyed by id.", + "description": "Retrieve list of alerts.", + "parameters": [ + { + "name": "priority", + "description": "Filter results by alarm severity.", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PriorityDef" + } + }, + { + "name": "unack", + "description": "Return only unacknowleged alerts.", + "in": "query", + "required": false, + "schema": { + "type": "number", + "format": "int32", + "example": 1 + } + }, + { + "name": "top", + "description": "Return the last x alerts as specified.", + "in": "query", + "required": false, + "schema": { + "type": "number", + "format": "int32", + "example": 10 + } + } + ], + "responses": { + "default": { + "description": "An object containing alerts, keyed by their id.", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "allOf": [ + { + "$ref": "#/components/schemas/ResponseModel" + } + ] + } + } + } + } + } + } + }, + "post": { + "tags": ["alert"], + "summary": "Raise an alert.", + "description": "Raise a new alert with the specified priority and properties.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertRequestModel" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + }, + "delete": { + "tags": ["alert list"], + "summary": "Remove all resolved alerts from the alert list.", + "description": "Remove all alerts that have been resolved for a minimum of the specified time.", + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}": { + "parameters": [ + { + "$ref": "#/components/parameters/AlertId" + } + ], + "get": { + "tags": ["alert"], + "summary": "Return alert.", + "description": "Retrieve value of the alert with the supplied id.", + "responses": { + "default": { + "description": "An object containing alert key | value pairs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResponseModel" + } + } + } + } + } + }, + "delete": { + "tags": ["alert"], + "summary": "Clean / remove an alert from the alert list.", + "description": "Remove the alert with the specified id from the alert list.", + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/properties": { + "parameters": [ + { + "$ref": "#/components/parameters/AlertId" + } + ], + "put": { + "tags": ["alert"], + "summary": "Update the alert metadata.", + "description": "Update the alert metadata.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetaDataDef" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/priority": { + "parameters": [ + { + "$ref": "#/components/parameters/AlertId" + } + ], + "put": { + "tags": ["alert"], + "summary": "Change alert priority.", + "description": "Change the priority for the alert with the specified id.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["value"], + "properties": { + "value": { + "$ref": "#/components/schemas/PriorityDef" + } + } + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/resolve": { + "parameters": [ + { + "$ref": "#/components/parameters/AlertId" + } + ], + "post": { + "tags": ["alert"], + "summary": "Resolve an alert.", + "description": "Resolve the alert with the specified id (normal condition).", + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/ack": { + "parameters": [ + { + "$ref": "#/components/parameters/AlertId" + } + ], + "post": { + "tags": ["alert"], + "summary": "Acknowledge an alert.", + "description": "Acknowledge the alert with the specified id.", + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/unack": { + "parameters": [ + { + "$ref": "#/components/parameters/AlertId" + } + ], + "post": { + "tags": ["alert"], + "summary": "Unacknowledge an alert.", + "description": "Unacknowledge the alert with the specified id.", + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/{id}/silence": { + "parameters": [ + { + "$ref": "#/components/parameters/AlertId" + } + ], + "post": { + "tags": ["alert"], + "summary": "Temporarily silence an alert.", + "description": "Silence the alert with the specified id for 30 seconds.", + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/mob": { + "post": { + "tags": ["special"], + "summary": "Raise person overboard alarm.", + "description": "Raise a person overboard alarm which includes vessel position.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MOBRequestModel" + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/ack": { + "post": { + "tags": ["alert list"], + "summary": "Acknowledge ALL alerts.", + "description": "Acknowledge all of the unacknowledged alerts.", + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/silence": { + "post": { + "tags": ["alert list"], + "summary": "Temporarily silence ALL alerts.", + "description": "Silence all alerts for 30 seconds.", + "responses": { + "200": { + "$ref": "#/components/responses/201ActionResponse" + }, + "default": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + } + } +} diff --git a/src/api/alerts/openApi.ts b/src/api/alerts/openApi.ts new file mode 100644 index 000000000..a0abd0982 --- /dev/null +++ b/src/api/alerts/openApi.ts @@ -0,0 +1,8 @@ +import { OpenApiDescription } from '../swagger' +import alertsApiDoc from './openApi.json' + +export const alertsApiRecord = { + name: 'alerts', + path: '/signalk/v2/api/alerts', + apiDoc: alertsApiDoc as unknown as OpenApiDescription +} diff --git a/src/api/index.ts b/src/api/index.ts index 7a9c1a5de..a32814c6c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -6,7 +6,7 @@ import { FeaturesApi } from './discovery' import { ResourcesApi } from './resources' import { AutopilotApi } from './autopilot' import { SignalKApiId } from '@signalk/server-api' -import { NotificationsApi } from './notifications' +import { AlertsApi } from './alerts' export interface ApiResponse { state: 'FAILED' | 'COMPLETED' | 'PENDING' @@ -70,17 +70,16 @@ export const startApis = ( const featuresApi = new FeaturesApi(app) - const notificationsApi = new NotificationsApi(app) + const notificationsApi = new AlertsApi(app) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(app as any).notificationsApi = notificationsApi + apiList.push('autopilot') + Promise.all([ - resourcesApi.start(), - courseApi.start(), featuresApi.start(), - autopilotApi.start() - , + autopilotApi.start(), notificationsApi.start() ]) return apiList diff --git a/src/api/notifications/index.ts b/src/api/notifications/index.ts deleted file mode 100644 index 59c8e44b4..000000000 --- a/src/api/notifications/index.ts +++ /dev/null @@ -1,578 +0,0 @@ -/* - API for working with Notifications / Alarms. -*/ - -import { createDebug } from '../../debug' -const debug = createDebug('signalk-server:api:notifications') - -import { IRouter, Request, Response } from 'express' -import _ from 'lodash' -import { v4 as uuidv4 } from 'uuid' - -import { SignalKMessageHub, WithConfig } from '../../app' -import { WithSecurityStrategy } from '../../security' - -import { - ALARM_METHOD, - ALARM_STATE, - Notification, - SKVersion -} from '@signalk/server-api' - -import { buildSchemaSync } from 'api-schema-builder' -import notificationsApiDoc from './openApi.json' - -const NOTI_API_SCHEMA = buildSchemaSync(notificationsApiDoc) - -const SIGNALK_API_PATH = `/signalk/v2/api` -const NOTI_API_PATH = `${SIGNALK_API_PATH}/notifications` -const $SRC = 'notificationsApi' - -interface NotificationsApplication - extends IRouter, - WithConfig, - WithSecurityStrategy, - SignalKMessageHub {} - -export class NotificationsApi { - private idToPathMap: Map - - constructor(private server: NotificationsApplication) { - this.idToPathMap = new Map() - } - - async start() { - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve) => { - this.initApiEndpoints() - resolve() - }) - } - - /** public interface methods */ - notify(path: string, value: Notification | null, source: string) { - debug(`** Interface:put(${path}, value, ${source})`) - if (!path || !source) { - throw new Error('Path and source values must be specified!') - } - if (path.split('.')[0] !== 'notifications') { - throw new Error('Invalid notifications path!') - } - - try { - if (!value) { - this.clearNotificationAtPath(path, source) - } else { - return this.setNotificationAtPath(path, value, source) - } - } catch (e) { - debug((e as Error).message) - throw e - } - } - - private updateAllowed(request: Request): boolean { - return this.server.securityStrategy.shouldAllowPut( - request, - 'vessels.self', - null, - 'notifications' - ) - } - - private initApiEndpoints() { - debug(`** Initialise ${NOTI_API_PATH} path handlers **`) - - // Raise man overboard alarm - this.server.post(`${NOTI_API_PATH}/mob`, (req: Request, res: Response) => { - debug(`** POST ${NOTI_API_PATH}/mob`) - - const notiPath = `notifications.mob` - const pos = this.getSelfPath('navigation.position') - try { - const notiValue: Notification = { - message: 'Man Overboard!', - method: [ALARM_METHOD.sound, ALARM_METHOD.visual], - state: ALARM_STATE.emergency, - id: uuidv4(), - data: { - position: pos ? pos.value : 'No vessel position data.' - } - } - this.updateModel(notiPath, notiValue, $SRC) - - res.status(201).json({ - state: 'COMPLETED', - statusCode: 201, - id: notiValue.id - }) - } catch (e) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: (e as Error).message - }) - } - }) - - // Acknowledge notification - this.server.put(`${NOTI_API_PATH}/ack/*`, (req: Request, res: Response) => { - debug(`** PUT ${NOTI_API_PATH}/ack/${req.params[0]}`) - debug(`** params ${JSON.stringify(req.query)}`) - const source = (req.query.source as string) ?? $SRC - - try { - const id = this.pathIsUuid(req.params[0]) - let noti - if (id) { - debug(`** id detected: Fetch Notification with id = ${id}`) - noti = this.getNotificationById(id) - if (!noti) { - res.status(400).json({ - state: 'FAILED', - statusCode: 404, - message: `Notification with id = ${id} NOT found!` - }) - return - } - } else { - const notiPath = `notifications.` + req.params[0].split('/').join('.') - noti = this.getNotificationByPath(notiPath, source) - if (noti) { - res.status(200).json(noti) - } else { - res.status(400).json({ - state: 'FAILED', - statusCode: 404, - message: `Notification ${notiPath} NOT found!` - }) - return - } - } - if (noti.value.actions && Array.isArray(noti.value.actions)) { - if (!noti.value.actions.includes('ACK')) { - noti.value.actions.push('ACK') - } - } else { - noti.value.actions = ['ACK'] - } - } catch (e) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: (e as Error).message - }) - } - }) - - // Create / update notification - this.server.put(`${NOTI_API_PATH}/*`, (req: Request, res: Response) => { - debug(`** PUT ${NOTI_API_PATH}/${req.params[0]}`) - debug(JSON.stringify(req.body)) - - /*if (!this.updateAllowed(req)) { - res.status(403).json(Responses.unauthorised) - return - }*/ - if (!req.params[0]) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: 'No path provided!' - }) - } - - try { - /*const endpoint = - NOTI_API_SCHEMA[`${NOTI_API_PATH}/:standardAlarm`].put - if (!endpoint.body.validate(req.body)) { - res.status(400).json(endpoint.body.errors) - return - }*/ - let id = this.pathIsUuid(req.params[0]) - let notiPath: string - if (id) { - notiPath = this.idToPathMap.get(id) as string - debug(`** id supplied: PUT(${id}) ---> mapped to path ${notiPath}`) - if (!notiPath) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: `Supplied id is not mapped to a notification path!` - }) - return - } - } else { - notiPath = `notifications.` + req.params[0].split('/').join('.') - debug(`** path supplied: ${notiPath}`) - } - - const notiValue: Notification = { - message: req.body.message ?? '', - method: this.getNotificationMethod(), - state: req.body.state ?? ALARM_STATE.alert - } - if (req.body.data) { - notiValue.data = req.body.data - } - - id = this.setNotificationAtPath(notiPath, notiValue, $SRC) - - res.status(201).json({ - state: 'COMPLETED', - statusCode: 201, - id: id - }) - } catch (e) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: (e as Error).message - }) - } - }) - - // Clear notification - this.server.delete(`${NOTI_API_PATH}/*`, (req: Request, res: Response) => { - debug(`** DELETE ${NOTI_API_PATH}/${req.params[0]}`) - debug(`** params ${JSON.stringify(req.query)}`) - /* - if (!this.updateAllowed(req)) { - res.status(403).json(Responses.unauthorised) - return - } - */ - const source = (req.query.source as string) ?? $SRC - debug(`** source = ${source}`) - try { - const id = this.pathIsUuid(req.params[0]) - if (id) { - debug(`** id supplied: ${id}`) - this.clearNotificationWithId(id) - res.status(200).json({ - state: 'COMPLETED', - statusCode: 200 - }) - } else { - const notiPath = `notifications.` + req.params[0].split('/').join('.') - debug(`** path supplied: Clear ${notiPath} from $source= ${source}`) - this.clearNotificationAtPath(notiPath, source) - res.status(200).json({ - state: 'COMPLETED', - statusCode: 200 - }) - } - } catch (e) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: (e as Error).message - }) - } - }) - - // List notifications keyed by either path or id - this.server.get(`${NOTI_API_PATH}`, (req: Request, res: Response) => { - debug(`** GET ${NOTI_API_PATH}`) - debug(`** params ${JSON.stringify(req.query)}`) - const keyById = req.query.key === 'id' ? true : false - try { - const notiList: { [key: string]: Notification } = {} - this.idToPathMap.forEach((path, id) => { - const noti = this.getNotificationById(id, keyById) - if (noti) { - const key = keyById ? id : path - notiList[key] = noti - } - }) - res.status(200).json(notiList) - } catch (e) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: (e as Error).message - }) - } - }) - - // Return notification - this.server.get(`${NOTI_API_PATH}/*`, (req: Request, res: Response) => { - debug(`** GET ${NOTI_API_PATH}/*`) - debug(`** params ${JSON.stringify(req.query)}`) - const source = req.query.source as string - - try { - const id = this.pathIsUuid(req.params[0]) - if (id) { - debug(`** id detected: getNotificationById(${id})`) - const noti = this.getNotificationById(id, true) - if (noti) { - res.status(200).json(noti) - } else { - res.status(400).json({ - state: 'FAILED', - statusCode: 404, - message: `Notification with id = ${id} NOT found!` - }) - } - } else { - const notiPath = `notifications.` + req.params[0].split('/').join('.') - let noti - if (source) { - debug(`** filtering results by source: ${source}`) - noti = this.getNotificationByPath(notiPath, source) - } else { - noti = this.getSelfPath(notiPath) - } - if (noti) { - res.status(200).json(noti) - } else { - res.status(400).json({ - state: 'FAILED', - statusCode: 404, - message: `Notification ${notiPath} NOT found!` - }) - } - } - } catch (e) { - res.status(400).json({ - state: 'FAILED', - statusCode: 400, - message: (e as Error).message - }) - } - }) - } - - /** Clear Notification with provided id - * @param id: UUID of notification to clear - */ - private clearNotificationWithId(id: string) { - if (!this.idToPathMap.has(id)) { - throw new Error(`Notification with id = ${id} NOT found!`) - } - const path = this.idToPathMap.get(id) - // Get $source of notification - const noti = this.getSelfPath(path as string) - const source = this.sourceOfId(noti, id) ?? $SRC - this.updateModel(path as string, null, source) - this.idToPathMap.delete(id) - } - - /** Clear Notification at `path` raised by the specified $source - * @param path: signal k path in dot notation - * @param source: $source value to use. - */ - private clearNotificationAtPath(path: string, source: string) { - debug(`** path supplied: Clear ${path} from $source= ${source}`) - // Get notification value for the supplied source - const noti = this.getSelfPath(path) - const notiValue = this.valueWithSource(noti, source) - if (!notiValue) { - throw new Error( - `No notification found at ${path} that is from ${source}!` - ) - } - // Check notification for an id, if present then delete from map - if (notiValue.id && this.idToPathMap.has(notiValue.id)) { - debug(`** id detected..removing from map: ${notiValue.id}`) - this.idToPathMap.delete(notiValue.id) - } - this.updateModel(path, null, source) - } - - /** Set Notification value and $source at supplied path. - * @param path: signal k path in dot notation - * @param value: value to assign to path - * @param source: source identifier - * @returns id assigned to notification - */ - private setNotificationAtPath( - path: string, - value: Notification, - source: string - ): string { - debug(`** Set Notification at ${path} with $source= ${source}`) - // get id from existing value or generate id - const noti = this.getSelfPath(path) - const nv = noti ? this.valueWithSource(noti, source) : null - value.id = nv && nv.id ? noti.value.id : uuidv4() - debug(`** id = ${value.id}`) - - this.updateModel(path, value, source) - - return value.id as string - } - - /** TODO *** Get the Notification method for the supplied Notification type */ - private getNotificationMethod = (type?: string) => { - if (!type) { - return [ALARM_METHOD.sound, ALARM_METHOD.visual] - } else { - // return method for supplied type from settings - return [ALARM_METHOD.sound, ALARM_METHOD.visual] - } - } - - /** Maintain id mapping and send delta. - * @param path: signal k path in dot notation - * @param value: value to assign to path - * @param source: source identifier - */ - private updateModel = ( - path: string, - value: Notification | null, - source: string - ) => { - debug(`****** Sending ${path} Notification: ******`) - debug(`value: `, JSON.stringify(value)) - debug(`source: `, source ?? 'self (default)') - - if (value && value.id) { - debug(`ADDING to idToPathMap(${value.id})`) - this.idToPathMap.set(value.id, path) - } - - this.server.handleMessage( - source, - { - updates: [ - { - values: [ - { - path: path, - value: value - } - ] - } - ] - }, - SKVersion.v1 - ) - } - - /** Checks if path is a UUID - * @param path: UUID or signal k path in / notation - * @returns UUID value (or empty string - * */ - private pathIsUuid(path: string): string { - const testId = (id: string): boolean => { - const uuid = RegExp( - '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$' - ) - return uuid.test(id) - } - const p = path.indexOf('/') !== -1 ? path.split('/') : path.split('.') - if (p.length === 1 && testId(p[0])) { - return p[0] - } else { - return '' - } - } - - /** Get Signal K object from `self` at supplied path. - * @param path: signal k path in dot notation - * @returns signal k object - */ - private getSelfPath(path: string) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return _.get((this.server.signalk as any).self, path) - } - - /** Get Notification object with the supplied id. - * Note: values attribute (if present) is omitted from the returned object! - * @param id: notification id value to match. - @param incPath: If true includes a path attribute containing the signal k path. - @returns signal k object or null - */ - private getNotificationById(id: string, incPath?: boolean) { - if (this.idToPathMap.has(id)) { - const path = this.idToPathMap.get(id) - debug(`getNotificationById(${id}) => ${path}`) - const n = this.getSelfPath(path as string) - if (n['$source'] !== $SRC) { - const v = this.valueWithSource(n, $SRC) - if (!v) { - return null - } - n.value = v - n['$source'] !== $SRC - } - delete n.values - - const noti = Object.assign({}, n, incPath ? { path: path } : {}) - debug(`**NOTIFICATION with id = ${id}`, JSON.stringify(noti)) - return noti - } else { - debug(`idToPathMap(${id}) => NOT FOUND`) - return null - } - } - - /** Get Notification object at specified path with the value from the supplied $source. - * Note: values attribute (if present) is omitted from the returned object! - * @param path: signal k path in dot notation. - @param source: source identifier of the value to return - @returns signal k object or null - */ - private getNotificationByPath(path: string, source: string = $SRC) { - const n = this.getSelfPath(path as string) - if (n['$source'] !== source) { - const v = this.valueWithSource(n, source) - if (!v) { - console.log(`*** Couldn't find $source = ${source}`) - return null - } - n.value = v - n['$source'] = source - } - delete n.values - const noti = Object.assign({}, n) - debug(`**NOTIFICATION at ${path} from ${source}`, JSON.stringify(noti)) - return noti - } - - // returns $source value of supplied SK object with the specified id attribute value - /** Get the $source of the notification with the supplied id (including when multiple values are present). - * @param o: signal k object - * @param id: notification id - * @returns astring containing the value of $source | undefined - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private sourceOfId(o: any, id: string) { - let src - if (o.values) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Object.entries(o.values).forEach((e: Array) => { - if (e[1].value && e[1].value.id && e[1].value.id === id) { - src = e[0] - } - }) - } else { - if (o.value && o.value.id && o.value.id === id) { - src = o['$source'] - } - } - debug(`** sourceWithId(${id}) = ${src}`) - return src - } - - /** Get the value (including when multiple values are present) with the provided $source. - * @param o: signal k object - * @param source: $source identifier of desired value. - * @returns Notification | null - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private valueWithSource(o: any, source: string) { - let v - if (o.values && o.values[source]) { - v = Object.assign({}, o.values[source].value) - } else { - if (o['$source'] === source) { - v = Object.assign({}, o.value) - } - } - debug(`** valueWithSource(${source}) = ${JSON.stringify(v)}`) - return v - } -} diff --git a/src/api/notifications/openApi.json b/src/api/notifications/openApi.json deleted file mode 100644 index 344377992..000000000 --- a/src/api/notifications/openApi.json +++ /dev/null @@ -1,437 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "version": "2.0.0", - "title": "Signal K Notifications API", - "termsOfService": "http://signalk.org/terms/", - "license": { - "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" - }, - "description": "API for raising and actioning Notifications / Alarms. Notifications raised using this API are assigned an id which can be used for subsequent actions." - }, - "externalDocs": { - "url": "http://signalk.org/specification/", - "description": "Signal K specification." - }, - "servers": [ - { - "url": "/signalk/v2/api/notifications" - } - ], - "tags": [ - { - "name": "notifications", - "description": "General actions." - }, - { - "name": "via path", - "description": "Action notifications via specified path." - }, - { - "name": "via id", - "description": "Action notifications via supplied id." - }, - { - "name": "alarms", - "description": "Standard, pre-defined alarms." - } - ], - "components": { - "schemas": { - "UuidDef": { - "type": "string", - "pattern": "[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$", - "example": "ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" - }, - "PathDef": { - "type": "string", - "description": "Notification path.", - "example": "notifications.mob" - }, - "MethodDef": { - "type": "array", - "minimum": 0, - "maximum": 2, - "uniqueItems": true, - "description": "How the alarm is actioned.", - "example": ["sound"], - "items": { - "type": "string", - "enum": ["visual", "sound"] - } - }, - "SeverityDef": { - "type": "string", - "description": "Severity of the alarm.", - "example": "alert", - "enum": ["normal", "nominal", "alert", "warning", "alarm", "emergency"] - }, - "MessageDef": { - "type": "string", - "description": "Notification message to display.", - "example": "My message!" - }, - "TypeDef": { - "type": "string", - "description": "Type of notification.", - "example": "OverVoltage" - }, - "DataDef": { - "type": "object", - "additionalProperties": true, - "description": "Data values associated with this notification." - }, - "RequestModel": { - "type": "object", - "properties": { - "state": { - "$ref": "#/components/schemas/SeverityDef" - }, - "message": { - "$ref": "#/components/schemas/MessageDef" - }, - "data": { - "$ref": "#/components/schemas/DataDef" - }, - "type": { - "$ref": "#/components/schemas/TypeDef" - } - } - }, - "ResponseModel": { - "description": "Notification information", - "type": "object", - "required": ["timestamp", "$source"], - "properties": { - "timestamp": { - "type": "string" - }, - "$source": { - "type": "string" - }, - "value": { - "type": "object", - "required": ["method", "state", "message"], - "properties": { - "method": { - "$ref": "#/components/schemas/MethodDef" - }, - "state": { - "$ref": "#/components/schemas/SeverityDef" - }, - "message": { - "$ref": "#/components/schemas/MessageDef" - }, - "data": { - "$ref": "#/components/schemas/DataDef" - }, - "id": { - "$ref": "#/components/schemas/UuidDef" - }, - "type": { - "$ref": "#/components/schemas/TypeDef" - } - } - } - } - } - }, - "responses": { - "200Ok": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "state": { - "type": "string", - "enum": ["COMPLETED"] - }, - "statusCode": { - "type": "number", - "enum": [200] - } - }, - "required": ["state", "statusCode"] - } - } - } - }, - "201ActionResponse": { - "description": "Action response - success.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "state": { - "type": "string", - "enum": ["COMPLETED"] - }, - "statusCode": { - "type": "number", - "enum": [201] - }, - "id": { - "$ref": "#/components/schemas/UuidDef" - } - }, - "required": ["id", "statusCode", "state"] - } - } - } - }, - "ErrorResponse": { - "description": "Failed operation", - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "Request error response", - "properties": { - "state": { - "type": "string", - "enum": ["FAILED"] - }, - "statusCode": { - "type": "number", - "enum": [404] - }, - "message": { - "type": "string" - } - }, - "required": ["state", "statusCode", "message"] - } - } - } - }, - "NotificationResponse": { - "description": "Notification information response.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResponseModel" - } - } - } - } - }, - "parameters": { - "Source": { - "name": "source", - "description": "The source that raised the notification at the defined path.", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - }, - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" - }, - "cookieAuth": { - "type": "apiKey", - "in": "cookie", - "name": "JAUTHENTICATION" - } - } - }, - "security": [{ "cookieAuth": [] }, { "bearerAuth": [] }], - "paths": { - "/": { - "get": { - "tags": ["notifications"], - "summary": "Filtered notification list keyed by id.", - "description": "Retrieve list of notifications filtered by the specified parameter.", - "parameters": [ - { - "name": "type", - "description": "Filter results by notification type.", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/TypeDef" - } - }, - { - "name": "severity", - "description": "Filter results by alarm severity.", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/SeverityDef" - } - }, - { - "name": "key", - "description": "List results by provided key value.", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": ["path", "id"] - } - } - ], - "responses": { - "default": { - "description": "An object containing notifications, keyed by their id.", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "allOf": [ - { - "$ref": "#/components/schemas/ResponseModel" - } - ] - } - } - } - } - } - } - } - }, - "/mob": { - "post": { - "tags": ["alarms"], - "summary": "Man overboard alarm.", - "description": "Raise a Man overboard alarm with system generated message including vessel position.", - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } - }, - "/{id}": { - "parameters": [ - { - "name": "id", - "description": "Notification identifier.", - "in": "path", - "required": true, - "schema": { - "$ref": "#/components/schemas/UuidDef" - } - } - ], - "get": { - "tags": ["via id"], - "summary": "Retrieve notification information.", - "description": "Retrieve information for notification with supplied id.", - "responses": { - "default": { - "$ref": "#/components/responses/NotificationResponse" - } - } - }, - "put": { - "tags": ["via id"], - "summary": "Action a notification.", - "description": "Raise or action the notification with the specified id.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RequestModel" - } - } - } - }, - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - }, - "delete": { - "tags": ["via id"], - "summary": "Clear notification.", - "description": "Clear the notification with the supplied id.", - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } - }, - "/*": { - "get": { - "tags": ["via path"], - "summary": "Retrieve notification information.", - "description": "Retrieve information for notification with supplied id.", - "parameters": [ - { - "$ref": "#/components/parameters/Source" - } - ], - "responses": { - "default": { - "$ref": "#/components/responses/NotificationResponse" - } - } - }, - "put": { - "tags": ["via path"], - "summary": "Action a notification.", - "description": "Raise or action a notification at the specified path.", - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RequestModel" - } - } - } - }, - "responses": { - "201": { - "$ref": "#/components/responses/201ActionResponse" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - }, - "delete": { - "tags": ["via path"], - "summary": "Clear notification.", - "description": "Clear the specified notification.", - "parameters": [ - { - "$ref": "#/components/parameters/Source" - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/200Ok" - }, - "default": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } - } - } -} diff --git a/src/api/notifications/openApi.ts b/src/api/notifications/openApi.ts deleted file mode 100644 index 3de54479f..000000000 --- a/src/api/notifications/openApi.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { OpenApiDescription } from '../swagger' -import notificationsApiDoc from './openApi.json' - -export const notificationsApiRecord = { - name: 'notifications', - path: '/signalk/v2/api/notifications', - apiDoc: notificationsApiDoc as unknown as OpenApiDescription -} diff --git a/src/api/swagger.ts b/src/api/swagger.ts index cd875c698..0362fef13 100644 --- a/src/api/swagger.ts +++ b/src/api/swagger.ts @@ -3,7 +3,7 @@ import { IRouter, NextFunction, Request, Response } from 'express' import swaggerUi from 'swagger-ui-express' import { SERVERROUTESPREFIX } from '../constants' import { courseApiRecord } from './course/openApi' -import { notificationsApiRecord } from './notifications/openApi' +import { alertsApiRecord } from './alerts/openApi' import { resourcesApiRecord } from './resources/openApi' import { autopilotApiRecord } from './autopilot/openApi' import { securityApiRecord } from './security/openApi' @@ -26,10 +26,10 @@ interface ApiRecords { const apiDocs = [ discoveryApiRecord, + alertsApiRecord, appsApiRecord, autopilotApiRecord, courseApiRecord, - notificationsApiRecord, resourcesApiRecord, securityApiRecord ].reduce((acc, apiRecord: OpenApiRecord) => { diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts index d72fe2e5c..a5aaaccc4 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -39,7 +39,7 @@ import path from 'path' import { AutopilotApi } from '../api/autopilot' import { CourseApi } from '../api/course' import { ResourcesApi } from '../api/resources' -import { NotificationsApi } from '../api/notifications' +import { AlertsApi } from '../api/alerts' import { SERVERROUTESPREFIX } from '../constants' import { createDebug } from '../debug' import { listAllSerialPorts } from '../serialports' @@ -601,12 +601,12 @@ module.exports = (theApp: any) => { return courseApi.activeRoute(dest) } - const notificationsApi: NotificationsApi = app.notificationsApi - _.omit(appCopy, 'notificationsApi') // don't expose the actual notifications api manager + const alertsApi: AlertsApi = app.notificationsApi + _.omit(appCopy, 'alertsApi') // don't expose the actual alerts api manager // eslint-disable-next-line @typescript-eslint/no-explicit-any appCopy.notify = (path: string, value: any, source: string) => { - notificationsApi.notify(path, value, source) - + alertsApi.notify(path, value, source) + } try { const pluginConstructor: (