diff --git a/WORKING_WITH_NOTIFICATIONS_API.md b/WORKING_WITH_NOTIFICATIONS_API.md new file mode 100644 index 000000000..33627910d --- /dev/null +++ b/WORKING_WITH_NOTIFICATIONS_API.md @@ -0,0 +1,356 @@ +# Working with the Notifications API + + +## Overview + +The SignalK Notifications API provides the ability to raise and action notifications / alarms using `HTTP` requests. + +The API provides endpoints at the following path `/signalk/v2/api/notifications`. + +**See the [OpenAPI documentation](https://demo.signalk.io/admin/openapi/) in the Signal K server Admin UI (under Documentation) for details.** + +--- + +## Operation + +The Notifications API manages the raising, actioning and clearing of notifications. + +It does this by providing: +1. HTTP endpoints for interactive use +1. An Interface for use by plugins and connection handlers. + +In this way, notifications triggered by both stream data and client interactions are consistently represented in the Signal K data model and that actions (e.g. acknowledge, silence, etc) and their resulting status is preseved and available to all connected devices. + +Additionally, the Notifications API applies a unique `id` to each notification which can be used in as an alternative to the `path` and `$source` to identify a notification entry. + + +## Using the API Plugin Interface +--- + +The Notifications API exposes the `notify()` method for use by plugins for raising, updating and clearing notifications. + +**`app.notify(path, value, sourceId)`** + + - `path`: Signal K path of the notification + + - `value`: A valid `Notification` object or `null` if clearing a notification. + + - `sourceId` The source identifier associated with the notification. + + +To raise (create) a new notification or update and existing notification call the method with a valid `Notification` object as the `value`. + +- returns: `string` value containing the `id` of the new / updated notification. + +_Example: Raise notification_ +```javascript +const alarmId = app.notify( + 'myalarm', + { + message: 'My alarm text', + state: 'alert' + }, + 'myAlarmPlugin' +) + +// alarmId = "ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" +``` + +To clear (cancel) a notification call the method with `null` as the `value`. + +- returns: `void`. + +_Example: Clear notification_ +```javascript +const alarmId = app.notify( + 'myalarm', + null, + 'myAlarmPlugin' +) +``` + +## Using HTTP Endpoints +--- + +### Raising a Notification + +To create (or raise) a notification you submit a HTTP `PUT` request to the specified `path` under `/signalk/v2/api/notifications`. + +_Example: Raise notification_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/myalarm' { + "message": "My alarm text.", + "state": "alert" +} +``` + +You can also provide additional data values associated with the alarm. + +_Example: Raise notification with temperature values_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/myalarm' { + "message": "My alarm text.", + "state": "alert", + "data": { + "temperature": { + "outside": 293.5, + "inside": 289.7 + } + } +} +``` + +If the action is successful, a response containing the `id` of the notification is generated. + +_Example response:_ +```JSON +{ + "state": "COMPLETED", + "statusCode": 201, + "id": "ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a" +} +``` + +This `id` can be used to perform actions on the notification. + + +### Updating notification content +--- + +To update the information contained in a notification, you need to replace it by submitting another `HTTP PUT` request containing a the new values. + +You can either use the notification `path` or `id` to update it. + +_Example: Update notification by path_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/myalarm' { + "message": "New alarm text.", + "state": "warning" +} +``` + +_Example: Update notification by id_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' { + "message": "New alarm text.", + "state": "warning" +} +``` + +### Clear a notification +--- + +To clear or cancel a notification submit a `HTTP DELETE` request to either the notification `path` or `id`. + +_Example: Clear notification by path_ +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/notifications/myalarm' +``` + +_Example: Clear notification by id_ +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/notifications/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' +``` + +Additionally, you can clear a notification with a specific `$source` by providing the source value as a query parameter. + +_Example: Clear notification by path created by `zone-watch`_ +```typescript +HTTP DELETE 'http://hostname:3000/signalk/v2/api/notifications/enteredZone?source=zone-watch' +``` + +### Acknowledge a notification +--- + +To acknowledge a notification, submit a `HTTP PUT` request to `http://hostname:3000/signalk/v2/api/notifications/ack/`. + +This adds the **`actions`** property to the notification which holds a list of actions taken on the notification. "ACK" will be added to the list of actions when a notificationis acknowledged. + +``` +{ + ... + "actions": ["ACK"], + ... +} +``` + +_Example: Acknowledge notification using a path_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/ack/myalarm' +``` + +_Example: Acknowledge notification using an id_ +```typescript +HTTP PUT 'http://hostname:3000/signalk/v2/api/notifications/ack/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' +``` + +_Acknowledged notification response._ +```JSON +{ + "message": "Man Overboard!", + "method": [ + "sound", + "visual" + ], + "actions": ["ACK"], + "state": "emergency", + "id": "96171e52-38de-45d9-aa32-30633553f58d", + "data": { + "position": { + "longitude": -166.18340908333334, + "latitude": 60.03309133333333 + } + } +} +``` + +### Standard Alarms +--- + +Standard alarms, such as Man Overboard, can be raised submitting a HTTP `POST` request to the specified `alarm path`. + +These alarms will be raised by the server with pre-defined content. + + +_Example: Raise Man Overboard Alarm_ +```typescript +HTTP POST 'http://hostname:3000/signalk/v2/api/notifications/mob' +``` + +_Notification content._ +```JSON +{ + "message": "Man Overboard!", + "method": [ + "sound", + "visual" + ], + "state": "emergency", + "id": "96171e52-38de-45d9-aa32-30633553f58d", + "data": { + "position": { + "longitude": -166.18340908333334, + "latitude": 60.03309133333333 + } + } +} +``` + +## View / List notifications +--- + +### View a specified notification +To view a specific notification submit a `HTTP GET` request to either the notification `path` or `id`. + +_Example: Retrieve notification by path_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/notifications/myalarm' +``` + +_Example: Retrieve notification by id_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/notifications/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a' +``` + +_Response: Includes `path` attribute associated with the notification._ +```JSON +{ + "meta": {}, + "value": { + "message": "My test alarm!", + "method": ["sound", "visual"], + "state": "alert", + "id": "d3f1be57-2672-4c4d-8dc1-0978dea7a8d6", + "data": { + "position": { + "lat": 12, + "lon": 148 + } + } + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:52.459Z", + "path": "notifications.myalarm" + } +``` + +_Example: Retrieve notification by path with the specified $source_ +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/notifications/myalarm?source=zone-watch' +``` + +### View a list notifications +A list of notifications generated using the Notifications API and be retrieved by submitting a `HTTP GET` request to `http://hostname:3000/signalk/v2/api/notifications`. + +By default the list of notification objects will be keyed by their `path`. + +_Example: Notification list keyed by path (default)_ +```JSON +{ + "notifications.myalarm": { + "value": { + "message": "My test alarm!", + "method": ["sound", "visual"], + "state": "alert", + "id": "d3f1be57-2672-4c4d-8dc1-0978dea7a8d6", + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:52.459Z" + }, + "notifications.mob": { + "value": { + "message": "Man Overboard!", + "method": ["sound", "visual"], + "state": "emergency", + "id": "ff105ae9-43d5-4039-abaf-afeefb03566e", + "data": { + "position": "No vessel position data." + } + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:54.124Z" + } +} +``` + +To view a list of notifications keyed by their identifier, add `key=id` to the request. + +```typescript +HTTP GET 'http://hostname:3000/signalk/v2/api/notifications?key=id` +``` + +```JSON +{ + "d3f1be57-2672-4c4d-8dc1-0978dea7a8d6": { + "value": { + "message": "My test alarm!", + "method": ["sound", "visual"], + "state": "alert", + "id": "d3f1be57-2672-4c4d-8dc1-0978dea7a8d6", + "data": { + "position": { + "lat": 12, + "lon": 148 + } + } + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:52.459Z", + "path": "notifications.myalarm" + }, + "ff105ae9-43d5-4039-abaf-afeefb03566e": { + "value": { + "message": "Man Overboard!", + "method": ["sound", "visual"], + "state": "emergency", + "id": "ff105ae9-43d5-4039-abaf-afeefb03566e", + "data": { + "position": "No vessel position data." + } + }, + "$source": "notificationsApi", + "timestamp": "2023-06-08T07:52:54.124Z", + "path": "notifications.mob" + } +} +``` 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/packages/server-api/src/deltas.ts b/packages/server-api/src/deltas.ts index 456da98ab..604705f86 100644 --- a/packages/server-api/src/deltas.ts +++ b/packages/server-api/src/deltas.ts @@ -71,6 +71,8 @@ export interface Notification { state: ALARM_STATE method: ALARM_METHOD[] message: string + data?: { [key: string]: object | number | string | null } + id?: string } // MetaMessage diff --git a/packages/server-api/src/index.ts b/packages/server-api/src/index.ts index 87930f2be..c30a5091f 100644 --- a/packages/server-api/src/index.ts +++ b/packages/server-api/src/index.ts @@ -22,6 +22,7 @@ export enum SKVersion { export type Brand = K & { __brand: T } export * from './deltas' +import { Notification } from './deltas' export * from './coursetypes' export * from './resourcetypes' export * from './resourcesapi' @@ -151,6 +152,7 @@ export interface ServerAPI extends PluginServerApp { source: string // eslint-disable-next-line @typescript-eslint/no-explicit-any ) => Promise + notify: (path: string, value: Notification, source: string) => void //TSTODO convert queryRequest to ts // eslint-disable-next-line @typescript-eslint/no-explicit-any queryRequest: (requestId: string) => Promise 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 a68515464..a32814c6c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -6,11 +6,12 @@ import { FeaturesApi } from './discovery' import { ResourcesApi } from './resources' import { AutopilotApi } from './autopilot' import { SignalKApiId } from '@signalk/server-api' +import { AlertsApi } from './alerts' export interface ApiResponse { state: 'FAILED' | 'COMPLETED' | 'PENDING' statusCode: number - message: string + message?: string requestId?: string href?: string token?: string @@ -69,11 +70,17 @@ export const startApis = ( const featuresApi = new FeaturesApi(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/openApi.json b/src/api/notificationsv1/openApi.json similarity index 100% rename from src/api/notifications/openApi.json rename to src/api/notificationsv1/openApi.json diff --git a/src/api/notifications/openApi.ts b/src/api/notificationsv1/openApi.ts similarity index 100% rename from src/api/notifications/openApi.ts rename to src/api/notificationsv1/openApi.ts 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 a366365ca..a5aaaccc4 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -39,6 +39,7 @@ import path from 'path' import { AutopilotApi } from '../api/autopilot' import { CourseApi } from '../api/course' import { ResourcesApi } from '../api/resources' +import { AlertsApi } from '../api/alerts' import { SERVERROUTESPREFIX } from '../constants' import { createDebug } from '../debug' import { listAllSerialPorts } from '../serialports' @@ -600,6 +601,13 @@ module.exports = (theApp: any) => { return courseApi.activeRoute(dest) } + 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) => { + alertsApi.notify(path, value, source) + } + try { const pluginConstructor: ( app: ServerAPI