Files
generalFunctions/src/domain/HealthStatus.js
znetsixe 47faf94048 Phase 1 wave 1: domain + nodered + stats infra (additive)
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>
2026-05-10 18:27:29 +02:00

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 };