Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ dist/
.DS_Store
.vscode/
*.db
*.db-wal
*.db-shm
logs/*
bower_components
settings/ssl-key.pem
Expand Down
2 changes: 2 additions & 0 deletions docs/develop/rest-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
title: REST APIs
children:
- conventions.md
- alerts_api.md
- autopilot_api.md
- course_api.md
- history_api.md
Expand All @@ -26,6 +27,7 @@ APIs are available via `/signalk/v2/api/<endpoint>`

| API | Description | Endpoint |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| [`Alerts`](./alerts_api.md) | Manage the lifecycle of alerts: raise, acknowledge, silence, escalate, resolve. | `alerts` |
| [`Autopilot`](./autopilot_api.md) | Provide the ability to send common commands to an autopilot via a provider plugin. | `vessels/self/autopilot` |
| [Course](./course_api.md) | Set a course, follow a route, advance to next point, etc. | `vessels/self/navigation/course` |
| [History](./history_api.md) | Query historical data. | `history` |
Expand Down
118 changes: 118 additions & 0 deletions docs/develop/rest-api/alerts_api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
title: Alerts API
---

# Alerts API

The Alerts API manages the lifecycle of alerts: conditions that need an operator to notice, decide and act.

The exact request and response schemas are served by the running server at `/doc/openapi` and are generated from the same TypeBox definitions the API validates with. This page describes the model and the behaviour behind them.

## Overview

An alert names a condition and carries the state of the operator's response to it. The path identifies the alert and describes what is wrong — `propulsion.port.oilPressureLow`, not the sensor path that measured it — and there is one active alert per path and context. Data paths the condition concerns go in the optional `references` array, which is informational and never identity.

The server owns the lifecycle. A source describes a condition; the server decides what state its alert is in, what it takes to leave that state, and when it is over. That ownership is why the subsystem is in server core rather than a plugin: alerts arrive from deltas, from REST clients and from plugins, and a single owner is what keeps them from disagreeing.

Alerts are published as deltas at `alerts.<alert path>` carrying the whole alert as the value, so any Signal K client can mirror the active set without polling. The active set is republished when the server starts, so a client that connects after a restart sees restored alerts without waiting for something to happen.

### Lifecycle

States follow IEC 62682:

| State | Meaning |
| -------------------- | ------------------------------------------------------------ |
| `unacknowledged` | The condition is present and nobody has acknowledged it |
| `acknowledged` | The condition is present and an operator has acknowledged it |
| `rtn-unacknowledged` | The condition ended before anyone acknowledged it |
| `normal` | Terminal. The alert is resolved and has left the active set |

A `caution` whose condition ends resolves by itself. Anything more urgent waits for acknowledgment, because an alarm that goes away on its own is an alarm nobody saw. An alert marked `latching` waits for acknowledgment whatever its priority.

An unacknowledged `warning` escalates to an `alarm` after a configurable window. Escalation resets acknowledgment; there is no de-escalation.

Silencing is orthogonal to all of it: it quiets the annunciator for a bounded time and never changes state, never reorders the list, and never resolves anything. The bound depends on priority; the escalation window and both silence maxima are in `src/api/alerts/index.ts`.

### Staleness

An alert whose source stops re-emitting it is marked `stale` after the source timeout in `src/api/alerts/alertManager.ts`. It stays visible and stays actionable: a source going quiet is not evidence that the condition resolved. Staleness applies to alerts raised by delta, whose re-emission is the heartbeat. An alert raised through REST or the plugin API is raised once and never goes stale.

## REST endpoints

Under `/signalk/v2/api/alerts`:

| Method | Path | Purpose |
| ------ | ------------------- | ------------------------------------------------------ |
| GET | `/` | The active set, ordered as an operator reads it |
| POST | `/` | Raise an alert, or update the one already on that path |
| GET | `/{id}` | One alert |
| POST | `/{id}/acknowledge` | Acknowledge |
| POST | `/{id}/silence` | Silence, optionally for a given number of seconds |
| POST | `/{id}/escalate` | Raise to a higher priority |
| PUT | `/{id}/condition` | Report whether the condition is still present |
| POST | `/silence-all` | Silence every active alert |
| GET | `/history` | The audit trail, filterable and paged |
| GET | `/status` | Whether alert state is being persisted |

The list is ordered per IMO MSC.302(87) 9.16: emergencies first, then unacknowledged above returned-to-normal above acknowledged, most urgent and most recent first within each group.

Reading requires read access and every mutating call requires write access, inherited from the v2 API path prefix.

## Raising alerts from a delta

A device raises an alert by sending a value at its alert path:

```json
{
"context": "vessels.self",
"updates": [
{
"$source": "n2k-1",
"values": [
{
"path": "alerts.propulsion.port.oilPressureLow",
"value": { "priority": "alarm", "message": "Oil pressure low" }
}
]
}
]
}
```
Comment thread
mairas marked this conversation as resolved.

Only descriptive fields are read — `priority`, `message`, `group`, `latching`, `references`, `data`. Lifecycle fields in an incoming value are ignored: a device cannot declare its own alert acknowledged.

Sending `null` at the path, or a value with `state: normal`, reports that the condition ended. Any source may do this, not only the one that raised it; the audit trail records who did.

## The plugin API

`app.alerts` gives a plugin the same operations:

```javascript
const alert = await app.alerts.raise({
path: 'propulsion.port.oilPressureLow',
priority: 'alarm',
message: 'Oil pressure low'
})

await app.alerts.acknowledge(alert.id)
await app.alerts.silence(alert.id, 30) // seconds, as over REST
await app.alerts.clearCondition(alert.id)

app.alerts.list({ state: 'unacknowledged' })
```

Alerts raised this way are attributed to the plugin that raised them. `list`, `get` and `getByPath` read the same active set the REST API and the deltas carry.

## Limits

An alert is held in memory, written to the database and republished to every subscriber, so the path, message, group, `references` and `data` fields are each bounded at every ingress surface. The bounds themselves are in `src/api/alerts/description.ts` and `alertPath.ts`. REST rejects an oversized field with 400; a delta carrying one is dropped with a log line.

The active set is capped. When it is full, a more urgent alert displaces the least urgent one — the lowest priority, and among equals the one whose state changed longest ago — which is announced and recorded in the audit trail as a displacement. An alert no more urgent than everything active is refused.

## Persistence

Active alerts and the audit trail are stored in SQLite under `serverState/alerts/`, so alert state survives a restart. The audit trail is pruned against a retention window and the freed pages are returned to the filesystem over subsequent prunes.

A failed write does not stop an alert being raised or announced: annunciation never waits on the disk. `GET /status` reports `degraded` while the store and the active set disagree, which clears by itself once a write succeeds again. A database that cannot be opened at all stops the server with an error naming the file, rather than starting an alarm system that silently persists nothing.

A backup of the database has to include the `-wal` and `-shm` files beside it, or be taken while the server is stopped.
2 changes: 1 addition & 1 deletion docs/installation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ You do not have to install Signal K Server yourself. Several commercially availa
## Prerequisites

> [!NOTE]
> Signal K server requires [NodeJS](https://nodejs.org) version >= 22 (version 24 recommended) be installed on the target system.
> Signal K server requires [NodeJS](https://nodejs.org) `>=22.13 <23` or `>=23.4` (version 24 recommended) be installed on the target system. The alerts subsystem uses the built-in `node:sqlite` module, which needs a flag on 23.0 to 23.3, so those releases are not supported.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Getting Started

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
],
"license": "Apache-2.0",
"engines": {
"node": ">=22"
"node": ">=22.13.0 <23 || >=23.4.0"
},
"workspaces": [
"packages/server-admin-ui-dependencies",
Expand Down
1 change: 1 addition & 0 deletions packages/server-api/src/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,6 @@ export type SignalKApiId =
| 'historyplayback' //https://signalk.org/specification/1.7.0/doc/streaming_api.html#history-playback
| 'historysnapshot' //https://signalk.org/specification/1.7.0/doc/rest_api.html#history-snapshot-retrieval
| 'notifications'
| 'alerts'
| 'sensors'
| 'ble'
184 changes: 184 additions & 0 deletions packages/server-api/src/typebox/alerts-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* TypeBox Schema Definitions for the Signal K Alerts API
*
* These are the source of truth for the alert shapes: the plugin surface
* types are derived from them, and the OpenAPI document renders them.
*/

import { Type, type Static } from '@sinclair/typebox'
import { IsoTimeSchema } from './shared-schemas'

export const AlertPrioritySchema = Type.Union(
[
Type.Literal('emergency'),
Type.Literal('alarm'),
Type.Literal('warning'),
Type.Literal('caution')
],
{
$id: 'AlertPriority',
description:
'Urgency of the condition, from an emergency down to a caution.'
}
)

export const AlertStateSchema = Type.Union(
[
Type.Literal('normal'),
Type.Literal('unacknowledged'),
Type.Literal('acknowledged'),
Type.Literal('rtn-unacknowledged')
],
{
$id: 'AlertState',
description:
'Lifecycle state. `rtn-unacknowledged` is a condition that ended ' +
'before anyone acknowledged it.'
}
)

export const HistoryEventTypeSchema = Type.Union(
[
Type.Literal('raise'),
Type.Literal('acknowledge'),
Type.Literal('silence'),
Type.Literal('unsilence'),
Type.Literal('clear'),
Type.Literal('escalate')
],
{ $id: 'HistoryEventType', description: 'What happened to an alert.' }
)

const AlertPathSchema = Type.String({
description:
'The condition this alert names, and its identity. One active alert ' +
'per path and context.',
examples: ['propulsion.port.oilPressureLow']
})

export const AlertSchema = Type.Object(
{
id: Type.String(),
path: AlertPathSchema,
references: Type.Optional(
Type.Array(Type.String(), {
description:
'Data paths the condition concerns. Informational, never identity.'
})
),
$source: Type.String(),
source: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
priority: AlertPrioritySchema,
state: AlertStateSchema,
condition: Type.Boolean({
description: 'Whether the underlying condition is still present.'
}),
latching: Type.Boolean({
description:
'A latched alert is held until acknowledged, even once the ' +
'condition ends.'
}),
silenced: Type.Boolean(),
silencedUntil: Type.Optional(IsoTimeSchema),
message: Type.String(),
group: Type.Optional(Type.String()),
data: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
raisedAt: IsoTimeSchema,
stateChangedAt: IsoTimeSchema,
acknowledgedAt: Type.Optional(IsoTimeSchema),
acknowledgedBy: Type.Optional(Type.String()),
clearedAt: Type.Optional(IsoTimeSchema),
sourceOnline: Type.Boolean(),
lastSourceUpdate: IsoTimeSchema,
stale: Type.Boolean({
description:
'The source stopped reporting. A stale alert stays visible and ' +
'actionable.'
}),
context: Type.Optional(Type.String())
},
{ $id: 'Alert', description: 'An alert and its lifecycle state.' }
)

export const RaiseAlertRequestSchema = Type.Object(
{
path: AlertPathSchema,
priority: AlertPrioritySchema,
message: Type.String(),
references: Type.Optional(Type.Array(Type.String())),
context: Type.Optional(Type.String()),
group: Type.Optional(Type.String()),
latching: Type.Optional(Type.Boolean()),
data: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
},
{ $id: 'RaiseAlertRequest', description: 'What it takes to raise an alert.' }
)

export const TransitionResultSchema = Type.Object(
{
alert: Type.Union([AlertSchema, Type.Null()], {
description: 'The alert after the transition, or null once it resolved.'
}),
cleared: Type.Boolean({
description: 'Whether the alert left the active set.'
}),
previousState: AlertStateSchema
},
{
$id: 'TransitionResult',
description: 'The outcome of a transition.',
required: ['alert', 'cleared', 'previousState']
}
)

export const HistoryEntrySchema = Type.Object(
{
id: Type.String(),
alertId: Type.String(),
path: AlertPathSchema,
context: Type.Optional(Type.String()),
priority: AlertPrioritySchema,
message: Type.String(),
$source: Type.String(),
eventType: HistoryEventTypeSchema,
timestamp: IsoTimeSchema,
userId: Type.Optional(Type.String()),
previousState: Type.Optional(AlertStateSchema),
newState: Type.Optional(AlertStateSchema),
previousPriority: Type.Optional(AlertPrioritySchema),
newPriority: Type.Optional(AlertPrioritySchema),
details: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
},
{ $id: 'HistoryEntry', description: 'One entry in the audit trail.' }
)

export const HistoryQueryResultSchema = Type.Object(
{
entries: Type.Array(HistoryEntrySchema),
total: Type.Integer({
description: 'Entries matching the query, before paging.'
})
},
{ $id: 'HistoryQueryResult' }
)

export const StoreStatusSchema = Type.Object(
{
store: Type.Object({
degraded: Type.Boolean({
description:
'True when a write failed. Alerts are still raised and announced, ' +
'but state may not survive a restart.'
})
})
},
{ $id: 'StoreStatus' }
)

export type Alert = Static<typeof AlertSchema>
export type AlertPriority = Static<typeof AlertPrioritySchema>
export type AlertState = Static<typeof AlertStateSchema>
export type RaiseAlertRequest = Static<typeof RaiseAlertRequestSchema>
export type TransitionResult = Static<typeof TransitionResultSchema>
export type HistoryEntry = Static<typeof HistoryEntrySchema>
export type HistoryEventType = Static<typeof HistoryEventTypeSchema>
1 change: 1 addition & 0 deletions packages/server-api/src/typebox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './course-schemas'
export * from './discovery-schemas'
export * from './history-schemas'
export * from './notifications-schemas'
export * from './alerts-schemas'
export * from './radar-schemas'
export * from './resources-schemas'
export * from './weather-schemas'
Expand Down
Loading
Loading