margin-ts 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/nextjs.d.ts +65 -0
- package/dist/adapters/nextjs.d.ts.map +1 -0
- package/dist/adapters/nextjs.js +233 -0
- package/dist/adapters/nextjs.js.map +1 -0
- package/dist/adapters/stream.d.ts +77 -0
- package/dist/adapters/stream.d.ts.map +1 -0
- package/dist/adapters/stream.js +197 -0
- package/dist/adapters/stream.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -1
- package/dist/index.js.map +1 -1
- package/dist/observation.d.ts +15 -0
- package/dist/observation.d.ts.map +1 -1
- package/dist/observation.js +58 -2
- package/dist/observation.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/nextjs.ts +290 -0
- package/src/adapters/stream.ts +241 -0
- package/src/index.ts +17 -1
- package/src/observation.ts +56 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming health updates via Server-Sent Events (SSE).
|
|
3
|
+
*
|
|
4
|
+
* Push health/drift/anomaly updates to browser dashboards in real-time.
|
|
5
|
+
*
|
|
6
|
+
* Express:
|
|
7
|
+
* app.get('/margin/stream', marginSSE(monitor));
|
|
8
|
+
*
|
|
9
|
+
* Next.js Pages Router:
|
|
10
|
+
* export default marginSSEHandler(monitor);
|
|
11
|
+
*
|
|
12
|
+
* Browser client:
|
|
13
|
+
* const source = new EventSource('/margin/stream');
|
|
14
|
+
* source.onmessage = (e) => {
|
|
15
|
+
* const health = JSON.parse(e.data);
|
|
16
|
+
* updateDashboard(health);
|
|
17
|
+
* };
|
|
18
|
+
*
|
|
19
|
+
* Zero dependencies. Works with any framework that supports
|
|
20
|
+
* writable response streams (Express, Fastify, Next.js, raw Node http).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
Confidence,
|
|
25
|
+
Health,
|
|
26
|
+
Absence,
|
|
27
|
+
Observation,
|
|
28
|
+
Expression,
|
|
29
|
+
Parser,
|
|
30
|
+
isAbsent,
|
|
31
|
+
createExpression,
|
|
32
|
+
expressionToString,
|
|
33
|
+
expressionToDict,
|
|
34
|
+
classifyDrift,
|
|
35
|
+
DriftClassification,
|
|
36
|
+
DriftState,
|
|
37
|
+
DriftDirection,
|
|
38
|
+
classifyAnomaly,
|
|
39
|
+
AnomalyClassification,
|
|
40
|
+
AnomalyState,
|
|
41
|
+
} from '../index.js';
|
|
42
|
+
|
|
43
|
+
// -----------------------------------------------------------------------
|
|
44
|
+
// StreamingMonitor — the TS equivalent of Python's Monitor
|
|
45
|
+
// -----------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
interface ComponentWindow {
|
|
48
|
+
values: number[];
|
|
49
|
+
observations: Observation[];
|
|
50
|
+
maxWindow: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class StreamingMonitor {
|
|
54
|
+
private parser: Parser;
|
|
55
|
+
private windows: Map<string, ComponentWindow> = new Map();
|
|
56
|
+
private _expression: Expression | null = null;
|
|
57
|
+
private _step = 0;
|
|
58
|
+
private maxWindow: number;
|
|
59
|
+
private minAnomalyRef: number;
|
|
60
|
+
|
|
61
|
+
constructor(parser: Parser, options: { window?: number; minAnomalyRef?: number } = {}) {
|
|
62
|
+
this.parser = parser;
|
|
63
|
+
this.maxWindow = options.window ?? 100;
|
|
64
|
+
this.minAnomalyRef = options.minAnomalyRef ?? 10;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get expression(): Expression | null { return this._expression; }
|
|
68
|
+
get step(): number { return this._step; }
|
|
69
|
+
|
|
70
|
+
update(
|
|
71
|
+
values: Record<string, number>,
|
|
72
|
+
nowOrOptions?: Date | { now?: Date; absences?: Record<string, Absence> },
|
|
73
|
+
): Expression {
|
|
74
|
+
let now: Date;
|
|
75
|
+
let absences: Record<string, Absence> | undefined;
|
|
76
|
+
if (nowOrOptions instanceof Date) {
|
|
77
|
+
now = nowOrOptions;
|
|
78
|
+
} else {
|
|
79
|
+
now = nowOrOptions?.now ?? new Date();
|
|
80
|
+
absences = nowOrOptions?.absences;
|
|
81
|
+
}
|
|
82
|
+
this._expression = this.parser.parse(values, { step: this._step, absences });
|
|
83
|
+
this._step++;
|
|
84
|
+
|
|
85
|
+
// Update per-component windows (skip absent observations —
|
|
86
|
+
// they carry no measured value and would corrupt drift/anomaly windows)
|
|
87
|
+
for (const obs of this._expression.observations) {
|
|
88
|
+
if (isAbsent(obs)) continue;
|
|
89
|
+
|
|
90
|
+
if (!this.windows.has(obs.name)) {
|
|
91
|
+
this.windows.set(obs.name, { values: [], observations: [], maxWindow: this.maxWindow });
|
|
92
|
+
}
|
|
93
|
+
const w = this.windows.get(obs.name)!;
|
|
94
|
+
w.values.push(obs.value);
|
|
95
|
+
const stamped = { ...obs, measuredAt: now };
|
|
96
|
+
w.observations.push(stamped);
|
|
97
|
+
if (w.values.length > this.maxWindow) {
|
|
98
|
+
w.values.shift();
|
|
99
|
+
w.observations.shift();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return this._expression;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
drift(component: string): DriftClassification | null {
|
|
107
|
+
const w = this.windows.get(component);
|
|
108
|
+
if (!w || w.observations.length < 3) return null;
|
|
109
|
+
return classifyDrift(w.observations);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
anomaly(component: string): AnomalyClassification | null {
|
|
113
|
+
const w = this.windows.get(component);
|
|
114
|
+
if (!w || w.values.length < this.minAnomalyRef + 1) return null;
|
|
115
|
+
const ref = w.values.slice(0, -1);
|
|
116
|
+
const current = w.values[w.values.length - 1];
|
|
117
|
+
return classifyAnomaly(current, ref, { component });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
status(): Record<string, unknown> {
|
|
121
|
+
const drift: Record<string, unknown> = {};
|
|
122
|
+
const anomaly: Record<string, unknown> = {};
|
|
123
|
+
|
|
124
|
+
for (const [name] of this.windows) {
|
|
125
|
+
const dc = this.drift(name);
|
|
126
|
+
if (dc) drift[name] = { state: dc.state, direction: dc.direction, rate: dc.rate };
|
|
127
|
+
const ac = this.anomaly(name);
|
|
128
|
+
if (ac) anomaly[name] = { state: ac.state, zScore: ac.zScore };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
step: this._step,
|
|
133
|
+
expression: this._expression ? expressionToString(this._expression) : null,
|
|
134
|
+
health: this._expression ? expressionToDict(this._expression) : null,
|
|
135
|
+
drift,
|
|
136
|
+
anomaly,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
reset(): void {
|
|
141
|
+
this.windows.clear();
|
|
142
|
+
this._expression = null;
|
|
143
|
+
this._step = 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -----------------------------------------------------------------------
|
|
148
|
+
// SSE helpers
|
|
149
|
+
// -----------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/** Connected SSE client. */
|
|
152
|
+
interface SSEClient {
|
|
153
|
+
id: string;
|
|
154
|
+
res: any;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let _clients: SSEClient[] = [];
|
|
158
|
+
let _clientIdCounter = 0;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Broadcast a status update to all connected SSE clients.
|
|
162
|
+
* Call this after each monitor.update().
|
|
163
|
+
*/
|
|
164
|
+
export function broadcast(monitor: StreamingMonitor): void {
|
|
165
|
+
const data = JSON.stringify(monitor.status());
|
|
166
|
+
const message = `data: ${data}\n\n`;
|
|
167
|
+
_clients = _clients.filter(client => {
|
|
168
|
+
try {
|
|
169
|
+
client.res.write(message);
|
|
170
|
+
return true;
|
|
171
|
+
} catch {
|
|
172
|
+
return false; // client disconnected
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Express/Fastify route handler for SSE streaming.
|
|
179
|
+
*
|
|
180
|
+
* app.get('/margin/stream', marginSSE(monitor));
|
|
181
|
+
*
|
|
182
|
+
* Each connected browser receives real-time health updates.
|
|
183
|
+
* Call broadcast(monitor) after each monitor.update() to push.
|
|
184
|
+
*/
|
|
185
|
+
export function marginSSE(monitor: StreamingMonitor): (req: any, res: any) => void {
|
|
186
|
+
return (req: any, res: any) => {
|
|
187
|
+
res.writeHead(200, {
|
|
188
|
+
'Content-Type': 'text/event-stream',
|
|
189
|
+
'Cache-Control': 'no-cache',
|
|
190
|
+
'Connection': 'keep-alive',
|
|
191
|
+
'Access-Control-Allow-Origin': '*',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Send current state immediately
|
|
195
|
+
const initial = JSON.stringify(monitor.status());
|
|
196
|
+
res.write(`data: ${initial}\n\n`);
|
|
197
|
+
|
|
198
|
+
const client: SSEClient = { id: String(++_clientIdCounter), res };
|
|
199
|
+
_clients.push(client);
|
|
200
|
+
|
|
201
|
+
req.on('close', () => {
|
|
202
|
+
_clients = _clients.filter(c => c.id !== client.id);
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Next.js Pages Router SSE handler.
|
|
209
|
+
*
|
|
210
|
+
* // pages/api/margin/stream.ts
|
|
211
|
+
* export default marginSSEHandler(monitor);
|
|
212
|
+
*/
|
|
213
|
+
export function marginSSEHandler(monitor: StreamingMonitor): (req: any, res: any) => void {
|
|
214
|
+
return marginSSE(monitor);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Create a polling endpoint (for environments that don't support SSE).
|
|
219
|
+
* Returns the current status as JSON on each request.
|
|
220
|
+
*
|
|
221
|
+
* app.get('/margin/poll', marginPoll(monitor));
|
|
222
|
+
*/
|
|
223
|
+
export function marginPoll(monitor: StreamingMonitor): (req: any, res: any) => void {
|
|
224
|
+
return (_req: any, res: any) => {
|
|
225
|
+
res.json(monitor.status());
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Number of connected SSE clients. */
|
|
230
|
+
export function connectedClients(): number {
|
|
231
|
+
return _clients.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Disconnect all SSE clients (for testing). */
|
|
235
|
+
export function resetClients(): void {
|
|
236
|
+
for (const client of _clients) {
|
|
237
|
+
try { client.res.end(); } catch {}
|
|
238
|
+
}
|
|
239
|
+
_clients = [];
|
|
240
|
+
_clientIdCounter = 0;
|
|
241
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -15,10 +15,12 @@ export {
|
|
|
15
15
|
} from './health.js';
|
|
16
16
|
|
|
17
17
|
export {
|
|
18
|
+
Absence,
|
|
18
19
|
Op, Observation, Correction, Expression,
|
|
20
|
+
isAbsent,
|
|
19
21
|
observationSigma, observationToAtom, observationToDict, observationFromDict,
|
|
20
22
|
correctionIsActive,
|
|
21
|
-
createExpression, healthOf, degraded, expressionToString, expressionToDict,
|
|
23
|
+
createExpression, healthOf, degraded, absent, intact, expressionToString, expressionToDict,
|
|
22
24
|
Parser,
|
|
23
25
|
} from './observation.js';
|
|
24
26
|
|
|
@@ -44,3 +46,17 @@ export {
|
|
|
44
46
|
DEFAULT_SUITE_THRESHOLDS,
|
|
45
47
|
type SuiteResults, type SuiteThresholds,
|
|
46
48
|
} from './adapters/vitest.js';
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
withMargin, withMarginApp,
|
|
52
|
+
marginHealthHandler, marginHealthAppHandler,
|
|
53
|
+
resetRoutes,
|
|
54
|
+
DEFAULT_NEXTJS_THRESHOLDS,
|
|
55
|
+
type WithMarginOptions, type NextjsThresholds,
|
|
56
|
+
} from './adapters/nextjs.js';
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
StreamingMonitor,
|
|
60
|
+
broadcast, marginSSE, marginSSEHandler, marginPoll,
|
|
61
|
+
connectedClients, resetClients,
|
|
62
|
+
} from './adapters/stream.js';
|
package/src/observation.ts
CHANGED
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
import { Confidence, minConfidence } from './confidence.js';
|
|
6
6
|
import { Health, Thresholds, classify, createThresholds, isIntact, SEVERITY } from './health.js';
|
|
7
7
|
|
|
8
|
+
// -----------------------------------------------------------------------
|
|
9
|
+
// Absence — why a value is missing
|
|
10
|
+
// -----------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export enum Absence {
|
|
13
|
+
NOT_MEASURED = 'not_measured',
|
|
14
|
+
BELOW_DETECTION = 'below_detection',
|
|
15
|
+
ABOVE_RANGE = 'above_range',
|
|
16
|
+
SENSOR_FAILED = 'sensor_failed',
|
|
17
|
+
REDACTED = 'redacted',
|
|
18
|
+
NOT_APPLICABLE = 'not_applicable',
|
|
19
|
+
PENDING = 'pending',
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
// -----------------------------------------------------------------------
|
|
9
23
|
// Observation
|
|
10
24
|
// -----------------------------------------------------------------------
|
|
@@ -18,6 +32,12 @@ export interface Observation {
|
|
|
18
32
|
higherIsBetter: boolean;
|
|
19
33
|
provenance: string[];
|
|
20
34
|
measuredAt?: Date;
|
|
35
|
+
absence?: Absence;
|
|
36
|
+
absenceDetail?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isAbsent(obs: Observation): boolean {
|
|
40
|
+
return obs.absence !== undefined;
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
export function observationSigma(obs: Observation): number {
|
|
@@ -27,6 +47,7 @@ export function observationSigma(obs: Observation): number {
|
|
|
27
47
|
}
|
|
28
48
|
|
|
29
49
|
export function observationToAtom(obs: Observation): string {
|
|
50
|
+
if (obs.absence !== undefined) return `${obs.name}:ABSENT(${obs.absence})`;
|
|
30
51
|
if (obs.health === Health.OOD) return `${obs.name}:${obs.health}`;
|
|
31
52
|
const sigma = observationSigma(obs);
|
|
32
53
|
const sign = sigma >= 0 ? '+' : '';
|
|
@@ -45,11 +66,13 @@ export function observationToDict(obs: Observation): Record<string, unknown> {
|
|
|
45
66
|
provenance: obs.provenance,
|
|
46
67
|
};
|
|
47
68
|
if (obs.measuredAt) d.measuredAt = obs.measuredAt.toISOString();
|
|
69
|
+
if (obs.absence !== undefined) d.absence = obs.absence;
|
|
70
|
+
if (obs.absenceDetail !== undefined) d.absenceDetail = obs.absenceDetail;
|
|
48
71
|
return d;
|
|
49
72
|
}
|
|
50
73
|
|
|
51
74
|
export function observationFromDict(d: Record<string, unknown>): Observation {
|
|
52
|
-
|
|
75
|
+
const obs: Observation = {
|
|
53
76
|
name: d.name as string,
|
|
54
77
|
health: d.health as Health,
|
|
55
78
|
value: d.value as number,
|
|
@@ -59,6 +82,9 @@ export function observationFromDict(d: Record<string, unknown>): Observation {
|
|
|
59
82
|
provenance: (d.provenance as string[]) ?? [],
|
|
60
83
|
measuredAt: d.measuredAt ? new Date(d.measuredAt as string) : undefined,
|
|
61
84
|
};
|
|
85
|
+
if (d.absence !== undefined) obs.absence = d.absence as Absence;
|
|
86
|
+
if (d.absenceDetail !== undefined) obs.absenceDetail = d.absenceDetail as string;
|
|
87
|
+
return obs;
|
|
62
88
|
}
|
|
63
89
|
|
|
64
90
|
// -----------------------------------------------------------------------
|
|
@@ -119,6 +145,14 @@ export function degraded(expr: Expression): Observation[] {
|
|
|
119
145
|
);
|
|
120
146
|
}
|
|
121
147
|
|
|
148
|
+
export function absent(expr: Expression): Observation[] {
|
|
149
|
+
return expr.observations.filter(o => isAbsent(o));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function intact(expr: Expression): Observation[] {
|
|
153
|
+
return expr.observations.filter(o => o.health === Health.INTACT);
|
|
154
|
+
}
|
|
155
|
+
|
|
122
156
|
export function expressionToString(expr: Expression): string {
|
|
123
157
|
if (expr.observations.length === 0) return '[∅]';
|
|
124
158
|
const parts = expr.observations.map(o => {
|
|
@@ -174,12 +208,15 @@ export class Parser {
|
|
|
174
208
|
label?: string;
|
|
175
209
|
step?: number;
|
|
176
210
|
confidences?: Record<string, Confidence>;
|
|
211
|
+
absences?: Record<string, Absence>;
|
|
177
212
|
} = {},
|
|
178
213
|
): Expression {
|
|
179
214
|
const confidences = options.confidences ?? {};
|
|
215
|
+
const absences = options.absences ?? {};
|
|
180
216
|
const observations: Observation[] = [];
|
|
181
217
|
|
|
182
218
|
for (const [name, val] of Object.entries(values)) {
|
|
219
|
+
if (name in absences) continue; // handled below
|
|
183
220
|
const baseline = this.baselines[name] ?? val;
|
|
184
221
|
const conf = confidences[name] ?? Confidence.MODERATE;
|
|
185
222
|
const t = this.thresholdsFor(name);
|
|
@@ -196,6 +233,24 @@ export class Parser {
|
|
|
196
233
|
});
|
|
197
234
|
}
|
|
198
235
|
|
|
236
|
+
// Emit absent observations
|
|
237
|
+
for (const [name, reason] of Object.entries(absences)) {
|
|
238
|
+
const baseline = this.baselines[name] ?? 0;
|
|
239
|
+
const t = this.thresholdsFor(name);
|
|
240
|
+
const conf = confidences[name] ?? Confidence.INDETERMINATE;
|
|
241
|
+
observations.push({
|
|
242
|
+
name,
|
|
243
|
+
health: Health.OOD,
|
|
244
|
+
value: baseline,
|
|
245
|
+
baseline,
|
|
246
|
+
confidence: conf,
|
|
247
|
+
higherIsBetter: t.higherIsBetter !== false,
|
|
248
|
+
provenance: [],
|
|
249
|
+
measuredAt: undefined,
|
|
250
|
+
absence: reason,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
199
254
|
return createExpression(observations, [], options.label ?? '', options.step);
|
|
200
255
|
}
|
|
201
256
|
}
|