Lattice Grid Buy a licence

api reference

The alarms module

createAlarms: levelled alarm events from KPI tiles, grid cells and router routes, raise and clear, with a hold timer that swallows a flapping feed.

API reference › The alarms module

All 14 pages Everything on one page → Developer guide →

The alarms module

modules/alarms turns data that is already graded into events an alarm system can consume. A KPI tile paints good/warn/critical from its thresholds and a conditional-formatting rule colours a cell - but nothing told the host that the breach began or ended. The operator saw red; the on-call rota, the ticketing system and the alarm grid on the same wall did not. This is the missing event, and nothing else: it owns no UI, touches no DOM, and adds no code to grid core. It is an opt-in bundle that adds no weight to a page that does not load it.

It is not a second alert engine. The data router's alert() has watched a slice and fired on a rising edge since v5, and the board's SLA module reuses it rather than growing its own. An alarm needs three things that route cannot give: a level (an alert is boolean), a clear (an alert re-arms silently), and per-key identity (an alert's condition is slice-wide, so two rows breaching at once fire it once). So the router gained a sibling route, monitor(), that shares every one of alert()'s internals - the partition, the row key, the seeding, and its independence from a time-travel scrub - and reports what the slice measures instead of a rising edge. The level ladder, the clear edge and the hold timer live in this module, once, shared by all three kinds of source.

import { createAlarms } from '@toclocoinc/lattice-grid/modules/alarms';

const alarms = createAlarms({ holdMs: 30000 });      // the default hold for every source

alarms.attach(kpi);                                  // every tile that grades itself
alarms.attach(grid, { columns: { cpu: { thresholds: { warn: 80, critical: 95, direction: 'lowerIsBetter' } } } });
alarms.attach(router, 'orders', { label: 'backlog', value: (rows) => rows.length,
  thresholds: { warn: 100, critical: 500, direction: 'lowerIsBetter' } });

alarms.on('alarm:raised', (a) => page(a.id, a.level, a.key, a.value));
alarms.on('alarm:cleared', (a) => resolve(a.id));
alarms.publish(router, 'alarms');                    // and feed the wall's own alarm grid

The transition contract. An alarm's identity is (source, key, level), so moving between two alarm levels is two events, in this order: good → critical raises critical; critical → warn clears critical and then raises warn; warn → good clears warn. The clear always precedes the raise, because an alarm system that sees the raise first has two alarms open on one key for the width of a callback. A source that goes unknown - a KPI panel whose feed went quiet, which is the KPI module's own signal for “measured nothing” - clears whatever was open and raises nothing: a dead feed is not a breach. A source's first known level raises if it is warn or critical, carrying previous: null, so active() is truthful from the moment you attach rather than only after something moves.

Every alarm has a documented id. <source>:<sourceId>:<key>:<level> - for example grid:grid#1:row-7/cpu:critical. It is public API from the moment active() ships, because a host keys its own incident records by it. source is kpi, grid or router; sourceId is what you named the source at attach, defaulting to kpi#1, grid#2, … in attach order; key is a KPI tile's id, a grid cell as <rowKey>/<column>, or a router route's label. Name the source yourself and the ids survive a reordering.

The hold timer is a hold-down, not a rate limit. holdMs is the owner's “a timer value to prevent emitting thousands of alarms if a threshold is crossed and cleared many times per second”. A new level must persist for holdMs before its transition is emitted, and a crossing back inside the window discards the pending transition - nothing is emitted late. A value flapping across a cut point a thousand times in two seconds with holdMs: 500 therefore emits nothing, and a breach that then settles emits exactly one raise. It is symmetric: a clear is held exactly as a raise is, so a breach that blinks off for a moment does not close and reopen the ticket. The window is fixed from the first observation and does not extend, which is the same shape alert()'s debounce already had. holdMs is set on the set, overridden per attach, and overridden again per column; pending() lists what is being held and active() lists what is open.

A grid's thresholds are the KPI tile's thresholds. Per column on the attach call, in exactly the shape a tile takes - two cut points and a direction, or an explicit bands list - evaluated per cell, so the alarm key is the row and the column. An undeclared column raises nothing. An optional when narrows it further, in the same shape a conditional-formatting rule takes and evaluated by the same code - so, like a formatting rule, it tests that cell's own value: a cell that fails it is not graded at all and clears whatever it had, which is how you keep an implausible reading from a broken agent off the on-call rota. A row that leaves the grid - deleted, or aged out of a rolling window - clears its cell alarms, because a row that is gone cannot still be breaching.

Grid cells, the transition pair, and the hold that swallows a flap

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createAlarms } = await import('../packages/modules/alarms/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'cpu', type: 'number' }, { field: 'state' }],
  rows: [{ id: 'web-1', cpu: 12, state: 'live' }],
});

// A hand-wound clock, so a hold can be proved without sleeping for it.
let t = 0; let handle = 0; const timers = new Map();
const clock = {
  now: () => t,
  setTimeout: (fn, ms) => { handle += 1; timers.set(handle, { at: t + ms, fn }); return handle; },
  clearTimeout: (h) => timers.delete(h),
};
const advance = (ms) => {
  const end = t + ms;
  for (;;) {
    let next = null;
    for (const [h, e] of timers) if (e.at <= end && (next === null || e.at < timers.get(next).at)) next = h;
    if (next === null) break;
    const e = timers.get(next); timers.delete(next); t = e.at; e.fn();
  }
  t = end;
};

// No hold at all: every settled crossing is emitted.
const prompt = createAlarms({ holdMs: 0, clock });
const log = [];
prompt.on('alarm:raised', (a) => log.push(`raised ${a.level}`));
prompt.on('alarm:cleared', (a) => log.push(`cleared ${a.level}`));
prompt.attach(grid, {
  sourceId: 'hosts',
  holdMs: 0,
  columns: {
    cpu: {
      // The KPI tile's own shape. `bands` is the alternative to `thresholds`.
      thresholds: { warn: 80, critical: 95, direction: 'lowerIsBetter' },
      bands: undefined,
      // `when` tests the cell's own value, as a formatting rule does: a
      // reading of 999 is a broken agent, not a host at 999%.
      when: { op: 'lt', value: 200 },
      holdMs: 0,
    },
  },
});

grid.rows.apply({ update: [{ id: 'web-1', cpu: 99, state: 'live' }] });
const first = log.join(', ');                      // raised critical
log.length = 0;
grid.rows.apply({ update: [{ id: 'web-1', cpu: 85, state: 'live' }] });
const pair = log.join(', ');                       // the clear comes first
prompt.destroy();

// The same feed, held down for half a second.
const held = createAlarms({ holdMs: 500, clock });
let raises = 0;
held.on('alarm:raised', () => { raises += 1; });
held.attach(grid, { sourceId: 'hosts', columns: { cpu: { thresholds: { warn: 80, critical: 95, direction: 'lowerIsBetter' } } } });
for (let i = 0; i < 1000; i += 1) {
  grid.rows.apply({ update: [{ id: 'web-1', cpu: i % 2 === 0 ? 99 : 12, state: 'live' }] });
  advance(2);                                     // 1,000 crossings in 2 s
}
const flaps = raises;                              // 0 - none of them lasted 500 ms
grid.rows.apply({ update: [{ id: 'web-1', cpu: 99, state: 'live' }] });
advance(600);
const settled = raises;                            // 1 - this one did
held.destroy();
grid.destroy();

return `${first} | ${pair} | flaps ${flaps} | settled ${settled}`;

A router route is a levelled alert. attach(router, when, { value, thresholds }) takes the partition value or predicate exactly as router.attach does; value is what the slice measures (the row count unless you say otherwise) and label names the route, becoming the alarm's key. Because it rides monitor(), it keeps watching the live stream while the grids on the page are parked in the past by a time-travel scrub - the same property alert() has and a routed view does not.

Feeding the wall's own alarm grid. publish(router, kind) pushes every raise and every clear into a router feed as keyed rows, through addSource() - the router's ordinary public feed path, so buffering and time travel treat them as they treat any other feed. Each emission is its own row, so the raise and its later clear both stay on the wall rather than one overwriting the other; each carries kind, state ('raised' or 'cleared') and alarmId beside the alarm's own fields. Route them with a router keyed on kind, or with a predicate on it.

A router route, and the alarm grid it feeds

const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const { createAlarms } = await import('../packages/modules/alarms/index.js');

const router = createDataRouter({ rowKey: 'id', key: 'kind' });
const alarms = createAlarms();
const log = [];
alarms.on('alarm:raised', (a) => log.push(`raised ${a.level}`));
alarms.on('alarm:cleared', (a) => log.push(`cleared ${a.level}`));

alarms.attach(router, 'orders', {
  label: 'backlog',                               // becomes the alarm key
  value: (rows) => rows.length,                   // what the slice measures
  thresholds: { warn: 2, critical: 4, direction: 'lowerIsBetter' },
  bands: undefined,                               // the alternative to thresholds
});

router.load([{ id: 1, kind: 'orders' }, { id: 2, kind: 'orders' }, { id: 3, kind: 'orders' }]);
const open = alarms.active()[0];
const first = `${open.level} ${open.value} ${open.key}`;
log.length = 0;
router.push([{ op: 'upsert', row: { id: 4, kind: 'orders' } }, { op: 'upsert', row: { id: 5, kind: 'orders' } }]);
const pair = log.join(', ');                       // clear the warn, then raise the critical

// The wall's alarm grid, fed by the router.
const wall = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'id' }, { field: 'state' }, { field: 'level' }],
  rows: [],
});
router.attach(wall, 'alarms');
const cell = createAlarms();
cell.publish(router, 'alarms');
const host = createHeadlessGrid({
  rowKey: 'id', columns: [{ field: 'id' }, { field: 'cpu', type: 'number' }], rows: [{ id: 'db-1', cpu: 10 }],
});
cell.attach(host, { columns: { cpu: { thresholds: { warn: 80, critical: 95, direction: 'lowerIsBetter' } } } });
host.rows.apply({ update: [{ id: 'db-1', cpu: 99 }] });
host.rows.apply({ update: [{ id: 'db-1', cpu: 4 }] });
const shown = [];
wall.rows.forEachAll((row) => shown.push(`${row.data.state} ${row.data.level}`));

cell.destroy(); alarms.destroy(); router.destroy(); wall.destroy(); host.destroy();
return `${first} | ${pair} | wall ${shown.join(', ')}`;

From a framework. An alarm set attaches to instances, and every adapter already hands the host the instance it built - so createAlarms needs no wrapper anywhere. In React, read ref.current.instance off a viewer (or ref.current.grid off <LatticeGrid>) and call attach in an effect; the router comes straight out of useLatticeRouter(). In Vue, a template ref exposes instance() on a viewer and grid() on the grid, and the router provider exposes router. In Svelte, each component exports instance() (or grid()), and Router.svelte exports router(). In Angular, a template reference variable gives you the component's instance or grid getter, and LatticeRouter is injectable with a router getter. As a web component, every element exposes el.instance (and el.grid / el.router), and the ready event carries the instance in its detail. Build the alarm set once, attach on mount, and call destroy() when the host unmounts.

Type reference

Generated from the type declarations, so it always matches the release. Each surface lists its properties, its methods and the events it raises as three tables; an option or value type lists its members once.

The alarms module

AlarmsEventPayloads

What a handler receives, per alarm event.

PropertyTypeDescription
alarm:raisedAlarmEventThe alarm that opened.
alarm:clearedAlarmEventThe alarm that closed.

AlarmEvent

One alarm, as `alarm:raised` and `alarm:cleared` carry it and as {@link Alarms.active} lists it.

PropertyTypeDescription
idstringThe alarm's stable identity, `<source>:<sourceId>:<key>:<level>` - for example `grid:grid#1:row-7/cpu:critical`. **This format is public API**: a host keys its own ticket or incident records by it, so it is documented rather than left to change.
sourceAlarmSource 'kpi' | 'grid' | 'router'Which kind of source raised it.
sourceIdstringWhich source of that kind: the `sourceId` given at attach, else `kpi#1`, `grid#2`, …
keystringWhat inside the source is alarming: a KPI tile's id, a grid cell as `<rowKey>/<column>` (the row key JSON-encoded when it is not a string), or a router route's `label`.
levelAlarmLevelThe level this event is about - the one raised, or the one cleared.
previousKpiRollupStatus | nullThe level the source held before this transition, or null when it held none - the first reading of a source. On a clear it is the level being cleared, because that is what the source held.
valueunknownThe reading at the transition.
thresholdKPIThresholds | nullThe cut points it was graded against, or null when it was graded by `bands`.
bandKPIBand[] | nullThe bands it was graded against, or null when it was graded by `thresholds`.
atnumberWhen the transition was emitted, as a millisecond timestamp from the set's clock.
holdMsnumberThe hold this transition had to survive before it was believed.

PendingAlarm

A transition that has been observed but not yet held long enough to be believed.

PropertyTypeDescription
idstringThe id the alarm will carry if the level survives its hold.
sourceAlarmSource 'kpi' | 'grid' | 'router'Which kind of source is holding it.
sourceIdstringWhich source of that kind.
keystringWhat inside the source is holding.
levelKpiRollupStatus | nullThe level being held.
previousKpiRollupStatus | nullThe level still in force until the hold elapses.
valueunknownThe reading that started the hold.
atnumberWhen the hold started, as a millisecond timestamp.
holdMsnumberHow long the level must persist.

AlarmsClock

The clock the hold timer runs on, injected so a test can drive a thousand crossings through a `holdMs` window without sleeping for it.

MethodSignatureParametersReturnsDescription
now(): number - numberThe current time, in milliseconds.
setTimeout(fn: () => void, ms: number): unknownfn: () => void, ms: numberunknownSchedule a hold; returns whatever handle `clearTimeout` will be given.
clearTimeout(handle: unknown): voidhandle: unknownvoidCancel a hold.

AlarmsConfig

An alarm set's configuration.

PropertyTypeDescription
holdMsnumberThe default hold, in milliseconds, for every source attached to this set. A new level must persist this long before its transition is emitted, and a crossing back inside the window discards the pending transition entirely. `0` (the default) emits on every settled crossing. A hold-down, not a rate limit: a sustained breach emits once. (optional)
clockAlarmsClockThe clock the hold timer runs on; the real one unless a test supplies its own. (optional)

AlarmsColumnOptions

One watched column of an attached grid.

PropertyTypeDescription
thresholdsKPIThresholdsThe cut points the cell is graded against - the KPI tile's own shape. (optional)
bandsKPIBand[]Explicit bands, as an alternative to `thresholds`; the first matching band wins. (optional)
whenFormattingConditionAn extra condition the cell must satisfy before it is graded at all, in the same shape a conditional-formatting rule takes and evaluated by the same code. A cell that fails it raises nothing and clears whatever it had. (optional)
holdMsnumberThis column's own hold, overriding the attach's and the set's. (optional)

AlarmsAttachOptions

What one `attach` call takes. Which keys apply depends on the kind of source.

PropertyTypeDescription
sourceIdstringThis source's id, as it appears in every alarm's `sourceId` and `id`. Defaults to `kpi#1`, `grid#2`, … in attach order; give it a name and the ids stay stable across a reordering. (optional)
holdMsnumberThis source's hold, overriding the set's `holdMs`. (optional)
columnsRecord<string, AlarmsColumnOptions>**Grids only.** The columns to watch, keyed by column id; an undeclared column raises nothing. (optional)
thresholdsKPIThresholds**Routers only.** The cut points the route's value is graded against. (optional)
bandsKPIBand[]**Routers only.** Explicit bands, as an alternative to `thresholds`. (optional)
labelstring**Routers only.** The route's name, used as the alarm `key`; defaults to the partition value. (optional)
MethodSignatureParametersReturnsDescription
value(rows: unknown[]) => unknownrows: unknown[]=> unknown**Routers only.** What the slice measures; defaults to the number of rows in it. (optional)

Alarms

A set of levelled alarms over data that is already graded. It owns no UI and touches no DOM: it observes KPI tiles, grid cells and router routes, holds a changed level for `holdMs` before believing it, and emits `alarm:raised` / `alarm:cleared`. An alarm's identity is `(source, key, level)`, so moving between two alarm levels CLEARS the level being left before it RAISES the level being entered.

Properties

No properties.

Methods
MethodSignatureParametersReturnsDescription
attach(source: unknown, whenOrOpts?: unknown, opts?: AlarmsAttachOptions): Alarmssource: unknown
whenOrOpts?: unknown
opts?: AlarmsAttachOptions
AlarmsAttach a source. A KPI panel grades every tile that declares thresholds or bands; a grid grades the cells of the columns named in `columns`; a router grades what its route measures. A router takes the partition value or predicate as the second argument, exactly as `router.attach` does.
on(name: AlarmsEventName, fn: (event: AlarmsEventPayloads[AlarmsEventName]) => void): () => voidname: AlarmsEventName
fn: (event: AlarmsEventPayloads[AlarmsEventName]) => void
() => voidSubscribe to `alarm:raised` or `alarm:cleared`; any other name is warned about once.
active(): AlarmEvent[] - AlarmEvent[]Every alarm currently open, as the payloads that raised them. Truthful from attach.
pending(): PendingAlarm[] - PendingAlarm[]Every transition being held - observed, but not yet believed.
publish(router: unknown, kind?: string): Alarmsrouter: unknown
kind?: string
AlarmsPublish raised and cleared alarms into a router feed as keyed rows, so a NOC alarm grid shows them live. Each emission is its own row - a raise and its later clear are two rows, not one overwritten - and every row carries `kind`, `state` (`'raised'`/`'cleared'`) and `alarmId` beside the alarm's own fields. Route them with a router keyed on `kind`, or with a predicate on it.
destroy(): void - voidRelease every listener, every hold timer and every source.
Events
EventWhenPayloadCancellable
alarm:raisedA level became true and stayed true for its `holdMs` - the alarm is now open. Within a transition between two alarm levels this fires after the clear of the level being left.AlarmEventno
alarm:clearedAn open alarm is no longer true: the level moved, the source went silent, or the row it was measured on left the grid. Within a transition between two alarm levels this fires before the raise of the level being entered.AlarmEventno