Skip to content

Commit aadd08a

Browse files
mairasclaude
andcommitted
feat(alerts): give plugins an alerts surface
Each plugin gets its own shallow copy of the server app, so a surface a plugin publishes on its own copy is invisible to the others. The subsystem lives in core for that reason, and app.alerts delegates to the one manager, attributing each raise and clear to the plugin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UmEL91uGc7sNotczyGihFx
1 parent 6debe5b commit aadd08a

11 files changed

Lines changed: 360 additions & 1 deletion

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type {
2+
Alert,
3+
AlertPriority,
4+
HistoryEntry,
5+
RaiseAlertRequest,
6+
TransitionResult
7+
} from './typebox/alerts-schemas'
8+
import type { Context, Path } from '.'
9+
10+
export type {
11+
Alert,
12+
AlertPriority,
13+
AlertState,
14+
RaiseAlertRequest,
15+
TransitionResult,
16+
HistoryEntry as AlertHistoryEntry
17+
} from './typebox/alerts-schemas'
18+
19+
/**
20+
* Query against the alert audit trail.
21+
*
22+
* @category Alerts API
23+
*/
24+
export interface AlertHistoryQuery {
25+
/** Earliest entry to return, as an ISO 8601 timestamp */
26+
from?: string
27+
/** Latest entry to return, as an ISO 8601 timestamp */
28+
to?: string
29+
/** Only entries belonging to this alert */
30+
alertId?: string
31+
/** Only entries about this alert path */
32+
path?: Path
33+
/** Only entries about alerts in this context */
34+
context?: Context
35+
/** Only these kinds of event */
36+
eventType?: HistoryEntry['eventType'] | HistoryEntry['eventType'][]
37+
/** Maximum entries to return */
38+
limit?: number
39+
/** Entries to skip */
40+
offset?: number
41+
}
42+
43+
/**
44+
* Which alerts to list.
45+
*
46+
* @category Alerts API
47+
*/
48+
export interface AlertFilter {
49+
state?: Alert['state'] | Alert['state'][]
50+
priority?: AlertPriority | AlertPriority[]
51+
group?: string
52+
stale?: boolean
53+
}
54+
55+
/**
56+
* Plugin interface to the alerts subsystem.
57+
*
58+
* The server owns alert lifecycle: a plugin describes a condition and the
59+
* server decides what state the alert is in. Raising an alert on a path that
60+
* already has one updates that alert rather than creating a second.
61+
*
62+
* @category Alerts API
63+
*/
64+
export interface AlertsApi {
65+
/**
66+
* Raise an alert, or update the alert already on that path.
67+
*
68+
* @example
69+
* ```typescript
70+
* await app.alerts.raise({
71+
* path: 'propulsion.port.oilPressureLow',
72+
* priority: 'alarm',
73+
* message: 'Oil pressure low'
74+
* })
75+
* ```
76+
*/
77+
raise(request: RaiseAlertRequest): Promise<Alert>
78+
79+
/**
80+
* Acknowledge an alert.
81+
*
82+
* The audit trail records the plugin as the actor; a plugin cannot
83+
* acknowledge on behalf of a name it chooses.
84+
*/
85+
acknowledge(alertId: string): Promise<TransitionResult>
86+
87+
/**
88+
* Silence an alert for `durationSeconds`, or for the configured maximum.
89+
* The maximum is shorter for an emergency.
90+
*/
91+
silence(alertId: string, durationSeconds?: number): Promise<Alert>
92+
93+
/** Silence every active alert. */
94+
silenceAll(): Promise<void>
95+
96+
/** Raise an alert to a higher priority. */
97+
escalate(alertId: string, priority: AlertPriority): Promise<Alert>
98+
99+
/**
100+
* Report that the condition ended. Whether the alert resolves or waits for
101+
* acknowledgment depends on its priority and whether it latches.
102+
*/
103+
clearCondition(alertId: string): Promise<TransitionResult>
104+
105+
/** The active alerts, optionally filtered. */
106+
list(filter?: AlertFilter): Alert[]
107+
108+
/** One active alert, or null. */
109+
get(alertId: string): Alert | null
110+
111+
/** The active alert on a path, or null. */
112+
getByPath(path: Path, context?: Context): Alert | null
113+
114+
/** The audit trail. */
115+
history(
116+
query?: AlertHistoryQuery
117+
): Promise<{ entries: HistoryEntry[]; total: number }>
118+
}
119+
120+
/** @category Alerts API */
121+
export interface WithAlertsApi {
122+
alerts: AlertsApi
123+
}

packages/server-api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export * as history from './history'
2525
export * as typebox from './typebox'
2626
/** @category Notifications API */
2727
export * from './notificationsapi'
28+
export * from './alertsapi'
2829
export { FullSignalK, SourceMetaEntry } from './fullsignalk'
2930
export { getSourceId, fillIdentity, fillIdentityField } from './sourceutil'
3031

packages/server-api/src/serverapi.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
Delta,
1010
MetaValue,
1111
WithResourcesApi,
12-
WithNotificationsApi
12+
WithNotificationsApi,
13+
WithAlertsApi
1314
} from '.'
1415
import { RadarProviderRegistry, WithRadarApi } from './radarapi'
1516
import { CourseApi } from './course'
@@ -88,6 +89,7 @@ export interface ServerAPI
8889
WithFeatures,
8990
CourseApi,
9091
WithNotificationsApi,
92+
WithAlertsApi,
9193
SelfIdentity {
9294
/**
9395
* Returns the entry for the provided path starting from `vessels.self` in the full data model.

src/interfaces/plugins.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ import {
4343
NotificationId,
4444
AlarmRaiseOptions,
4545
AlarmUpdateOptions,
46+
AlertFilter,
47+
AlertHistoryQuery,
48+
AlertPriority,
49+
RaiseAlertRequest,
4650
AccessScopedRouter,
4751
PluginRouter,
4852
RouteAccessLevel,
@@ -1036,6 +1040,30 @@ module.exports = (theApp: any) => {
10361040
return courseApi.activeRoute(dest)
10371041
}
10381042

1043+
// Each plugin gets its own shallow copy of `app`, so a surface one plugin
1044+
// attaches to its own copy is invisible to every other plugin. The alerts
1045+
// subsystem lives in core for exactly that reason.
1046+
appCopy.alerts = {
1047+
raise: (request: RaiseAlertRequest) =>
1048+
app.alertsApi.raise(request, plugin.id as SourceRef),
1049+
// Attributed to the plugin, not to a name the plugin picked: the audit
1050+
// trail has to say who acted, and a plugin is what acted.
1051+
acknowledge: (alertId: string) =>
1052+
app.alertsApi.acknowledge(alertId, plugin.id),
1053+
silence: (alertId: string, durationSeconds?: number) =>
1054+
app.alertsApi.silence(alertId, durationSeconds),
1055+
silenceAll: () => app.alertsApi.silenceAll(),
1056+
escalate: (alertId: string, priority: AlertPriority) =>
1057+
app.alertsApi.escalate(alertId, priority),
1058+
clearCondition: (alertId: string) =>
1059+
app.alertsApi.clearCondition(alertId, plugin.id as SourceRef),
1060+
list: (filter?: AlertFilter) => app.alertsApi.list(filter),
1061+
get: (alertId: string) => app.alertsApi.get(alertId),
1062+
getByPath: (path: Path, context?: Context) =>
1063+
app.alertsApi.getByPath(path, context),
1064+
history: (query?: AlertHistoryQuery) => app.alertsApi.history(query)
1065+
}
1066+
10391067
appCopy.notifications = {
10401068
list: () => app.notificationApi.list(),
10411069
getId: (id: NotificationId) => app.notificationApi.getId(id),
@@ -1050,6 +1078,7 @@ module.exports = (theApp: any) => {
10501078
acknowledge: (id: NotificationId) => app.notificationApi.acknowledge(id),
10511079
acknowledgeAll: () => app.notificationApi.acknowledgeAll()
10521080
}
1081+
delete (appCopy as any).alertsApi // expose only the plugin-specific methods
10531082
delete (appCopy as any).notificationApi // expose only the plugin-specific methods
10541083

10551084
try {

test/api/alerts/pluginApi.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { expect } from 'chai'
2+
import * as fs from 'fs'
3+
import * as path from 'path'
4+
import { freeport } from '../../ts-servertestutilities'
5+
6+
// eslint-disable-next-line @typescript-eslint/no-require-imports
7+
const Server = require('../../../dist/')
8+
9+
const CONFIG_DIR = path.join(__dirname, '..', '..', 'plugin-test-config')
10+
11+
/**
12+
* Two plugins, two copies of `app`, one alert subsystem.
13+
*
14+
* A plugin that attaches an API to its own `app` copy is invisible to every
15+
* other plugin — the failure this subsystem is in core to avoid. These plugins
16+
* only raise; the surface reaching them at all is the assertion.
17+
*/
18+
/** Only what this suite uses of a started server. */
19+
interface RunningServer {
20+
start: () => Promise<unknown>
21+
stop: () => Promise<unknown>
22+
}
23+
24+
/** An alert as the list endpoint serves it. */
25+
interface ListedAlert {
26+
path: string
27+
$source: string
28+
message: string
29+
}
30+
31+
/** How long to wait for both plugins to have raised before giving up. */
32+
const RAISE_DEADLINE_MS = 20_000
33+
34+
const POLL_INTERVAL_MS = 25
35+
36+
describe('alerts plugin API', function () {
37+
let server: RunningServer
38+
let url: string
39+
40+
async function listAlerts(): Promise<ListedAlert[]> {
41+
const response = await fetch(`${url}/signalk/v2/api/alerts`)
42+
if (!response.ok) {
43+
throw new Error(
44+
`GET /signalk/v2/api/alerts answered ${String(response.status)}: ` +
45+
`${await response.text()}`
46+
)
47+
}
48+
return (await response.json()) as ListedAlert[]
49+
}
50+
51+
/**
52+
* Wait until both plugins' alerts are in the active set.
53+
*
54+
* The plugin loader does not await `start()`, so there is no event to hang
55+
* this on; the active set is the only thing that says the raises landed.
56+
*/
57+
async function bothPluginsHaveRaised(): Promise<void> {
58+
const deadline = Date.now() + RAISE_DEADLINE_MS
59+
for (;;) {
60+
const paths = new Set((await listAlerts()).map((alert) => alert.path))
61+
if (
62+
paths.has('test.plugina.condition') &&
63+
paths.has('test.pluginb.condition')
64+
) {
65+
return
66+
}
67+
if (Date.now() > deadline) {
68+
throw new Error(
69+
`Neither plugin raised within ${String(RAISE_DEADLINE_MS)}ms; ` +
70+
`the active set holds ${[...paths].join(', ') || 'nothing'}.`
71+
)
72+
}
73+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
74+
}
75+
}
76+
77+
before(async function () {
78+
// The store survives a run, so a stale alert from an earlier one would
79+
// let this pass without the plugins raising anything.
80+
fs.rmSync(path.join(CONFIG_DIR, 'serverState', 'alerts'), {
81+
recursive: true,
82+
force: true
83+
})
84+
process.env.SIGNALK_NODE_CONFIG_DIR = CONFIG_DIR
85+
const port = await freeport()
86+
url = `http://127.0.0.1:${port}`
87+
server = new Server({ config: { settings: { port } } })
88+
await server.start()
89+
// The plugin loader does not await start(), so the raises land some time
90+
// after the server answers. A fixed wait is a bet on how long that takes.
91+
await bothPluginsHaveRaised()
92+
})
93+
94+
after(async function () {
95+
await server.stop()
96+
delete process.env.SIGNALK_NODE_CONFIG_DIR
97+
// The boot writes these into the shared fixture directory; they are
98+
// runtime state, not fixtures.
99+
for (const artefact of ['settings.json', 'priorities.json']) {
100+
fs.rmSync(path.join(CONFIG_DIR, artefact), { force: true })
101+
}
102+
fs.rmSync(path.join(CONFIG_DIR, 'serverState', 'alerts'), {
103+
recursive: true,
104+
force: true
105+
})
106+
})
107+
108+
it('gives every plugin a working surface, each alert attributed to its plugin', async function () {
109+
const alerts = await listAlerts()
110+
111+
const raised = Object.fromEntries(
112+
alerts.map((alert) => [alert.path, alert])
113+
)
114+
expect(Object.keys(raised)).to.include.members([
115+
'test.plugina.condition',
116+
'test.pluginb.condition'
117+
])
118+
expect(raised['test.plugina.condition'].$source).to.equal('alertsplugin-a')
119+
expect(raised['test.pluginb.condition'].$source).to.equal('alertsplugin-b')
120+
})
121+
})

test/plugin-test-config/node_modules/alertsplugin-a/index.js

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/plugin-test-config/node_modules/alertsplugin-a/package.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/plugin-test-config/node_modules/alertsplugin-b/index.js

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)