Adds platform infrastructure used by the upcoming refactor of
nodeClass / specificClass across all 12 nodes:
- src/domain/UnitPolicy.js — extracted from rotatingMachine/MGC
- src/domain/ChildRouter.js — declarative event routing on top of childRegistrationUtils
- src/domain/LatestWinsGate.js — extracted from MGC dispatch gate
- src/domain/HealthStatus.js — standardised {level, flags, message, source}
- src/nodered/statusBadge.js — compose / error / idle / byState / text helpers
- src/stats/index.js — mean / stdDev / median / mad / lerp
All additive — no existing exports change shape.
56 unit tests pass under node:test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
2.9 KiB
JavaScript
103 lines
2.9 KiB
JavaScript
/**
|
|
* HealthStatus — standardised health/quality datum.
|
|
* Contract: see .claude/refactor/CONTRACTS.md §9.
|
|
*
|
|
* Shape (always frozen):
|
|
* { level: 0|1|2|3, flags: string[], message: string, source: string|null }
|
|
*
|
|
* level 0 = nominal, 3 = unusable. Returned objects are frozen plain
|
|
* objects (not class instances) so they round-trip cleanly through
|
|
* JSON / InfluxDB serialisation.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const LABELS = ['nominal', 'minor', 'major', 'critical'];
|
|
|
|
function _freeze(level, flags, message, source) {
|
|
return Object.freeze({
|
|
level,
|
|
flags: Object.freeze(flags.slice()),
|
|
message,
|
|
source: source == null ? null : String(source),
|
|
});
|
|
}
|
|
|
|
function _coerceDegradedLevel(level) {
|
|
const n = Math.trunc(Number(level));
|
|
if (!Number.isFinite(n) || n < 1) return 1;
|
|
if (n > 3) return 3;
|
|
return n;
|
|
}
|
|
|
|
function _coerceFlags(flags) {
|
|
if (!Array.isArray(flags)) return [];
|
|
const out = [];
|
|
for (const f of flags) {
|
|
if (f == null) continue;
|
|
out.push(String(f));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function ok(message, source) {
|
|
return _freeze(
|
|
0,
|
|
[],
|
|
typeof message === 'string' && message.length > 0 ? message : 'nominal',
|
|
source != null ? source : null,
|
|
);
|
|
}
|
|
|
|
function degraded(level, flags, message, source) {
|
|
const lvl = _coerceDegradedLevel(level);
|
|
const f = _coerceFlags(flags);
|
|
const m = typeof message === 'string' && message.length > 0
|
|
? message
|
|
: LABELS[lvl];
|
|
return _freeze(lvl, f, m, source != null ? source : null);
|
|
}
|
|
|
|
// Merge multiple statuses into one node-level status. Worst level wins
|
|
// for level/message/source; flags are concatenated and de-duped.
|
|
function compose(statuses) {
|
|
if (!Array.isArray(statuses) || statuses.length === 0) return ok();
|
|
|
|
let worst = null;
|
|
const seen = new Set();
|
|
const flags = [];
|
|
|
|
for (const s of statuses) {
|
|
if (!s || typeof s !== 'object') continue;
|
|
const lvl = Number.isFinite(s.level) ? s.level : 0;
|
|
if (worst === null || lvl > worst.level) {
|
|
worst = { level: lvl, message: s.message, source: s.source ?? null };
|
|
}
|
|
if (Array.isArray(s.flags)) {
|
|
for (const f of s.flags) {
|
|
if (f == null) continue;
|
|
const k = String(f);
|
|
if (!seen.has(k)) {
|
|
seen.add(k);
|
|
flags.push(k);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (worst === null) return ok();
|
|
|
|
const message = typeof worst.message === 'string' && worst.message.length > 0
|
|
? worst.message
|
|
: LABELS[Math.max(0, Math.min(3, worst.level))];
|
|
return _freeze(worst.level, flags, message, worst.source);
|
|
}
|
|
|
|
function label(level) {
|
|
const n = Math.trunc(Number(level));
|
|
if (!Number.isFinite(n) || n < 0 || n > 3) return 'unknown';
|
|
return LABELS[n];
|
|
}
|
|
|
|
module.exports = { ok, degraded, compose, label };
|