mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
fix(control-pane): reject failed views and coalesce projection sampling
This commit is contained in:
@@ -21,7 +21,7 @@ Served by `node scripts/control-pane.js` (loopback only, same Host and Origin ga
|
||||
| `GET /api/control-plane` | The full view document below. |
|
||||
| `GET /api/control-plane/events` | `{ schemaVersion, generatedAt, thresholds, events, counts }` only, for hooks and pollers. |
|
||||
|
||||
The server keeps one projection window per process, so z-scores roll across polls. Options on `createControlPaneServer`: `projection` (`windowSize`, `clipPercentiles`, `minWindowForZscore`), `viewOptions` (`thresholds`, `manifest`, `channelWeights`), `proximityOptions` (passed to the scan).
|
||||
The server keeps one projection window per process. Both API routes share a snapshot cached for five seconds, and concurrent refresh requests are coalesced. Reads within that interval do not add samples. After expiry, the next read refreshes the snapshot once; idle intervals do not generate synthetic samples. Failed refreshes return errors rather than healthy empty data. The page rejects failed HTTP responses and invalid view envelopes and shows `offline`. Options on `createControlPaneServer`: `projection` (`windowSize`, `clipPercentiles`), `viewOptions` (`thresholds`, `manifest`, `channelWeights`, `minWindowForZscore`), `proximityOptions` (passed to the scan).
|
||||
|
||||
## Document
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ function projectPairs(links, options = {}) {
|
||||
const minWindow = Number.isFinite(options.minWindowForZscore) ? options.minWindowForZscore : PROJECTION_DEFAULTS.minWindowForZscore;
|
||||
|
||||
const raw = list.map(l => channelVector(l.channels));
|
||||
if (window) for (const vec of raw) window.push(vec);
|
||||
if (window && options.sample !== false) for (const vec of raw) window.push(vec);
|
||||
|
||||
let stats = null;
|
||||
let normalization = 'raw';
|
||||
|
||||
@@ -207,10 +207,13 @@ function renderControlPlaneViewHtml() {
|
||||
}
|
||||
|
||||
function apply(data) {
|
||||
view = data || view;
|
||||
view.tasks = view.tasks || []; view.lanes = view.lanes || []; view.pairs = view.pairs || [];
|
||||
view.events = view.events || []; view.projection = view.projection || { agents: [] };
|
||||
view.thresholds = view.thresholds || { ta: 0.35, ra: 0.7 };
|
||||
if (!data || data.schemaVersion !== 'ecc.control-plane.view.v1' ||
|
||||
!['tasks', 'lanes', 'pairs', 'events'].every(function (key) { return Array.isArray(data[key]); }) ||
|
||||
!data.projection || !Array.isArray(data.projection.agents) || !data.thresholds ||
|
||||
!Number.isFinite(data.thresholds.ta) || !Number.isFinite(data.thresholds.ra)) {
|
||||
throw new Error('Invalid control-plane view');
|
||||
}
|
||||
view = Object.assign({}, data);
|
||||
renderEvents(); renderLanes(); draw();
|
||||
var c = view.counts || {};
|
||||
document.getElementById('status').textContent =
|
||||
@@ -220,7 +223,10 @@ function renderControlPlaneViewHtml() {
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/api/control-plane').then(function (r) { return r.json(); }).then(apply).catch(function () {
|
||||
fetch('/api/control-plane').then(function (r) {
|
||||
if (!r.ok) throw new Error('Control-plane request failed');
|
||||
return r.json();
|
||||
}).then(apply).catch(function () {
|
||||
document.getElementById('status').textContent = 'offline';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,6 +225,7 @@ function buildControlPlaneView(snapshot, options = {}) {
|
||||
const projection = projectPairs(prox.links || [], {
|
||||
window: options.window,
|
||||
channelWeights: options.channelWeights,
|
||||
sample: options.sample,
|
||||
minWindowForZscore: options.minWindowForZscore
|
||||
});
|
||||
const pointByAgent = new Map(projection.agents.map(a => [a.agentId, a]));
|
||||
@@ -319,11 +320,30 @@ function buildControlPlaneView(snapshot, options = {}) {
|
||||
*/
|
||||
function createControlPlaneViewSource(deps = {}) {
|
||||
const window = deps.window || createProjectionWindow(deps.projection || {});
|
||||
const clock = deps.clock || Date.now;
|
||||
const interval = deps.sampleIntervalMs === undefined ? 5000 : deps.sampleIntervalMs;
|
||||
if (!Number.isFinite(interval) || interval <= 0) throw new Error('sampleIntervalMs must be positive and finite');
|
||||
let cached = null;
|
||||
let pending = null;
|
||||
let expiresAt = 0;
|
||||
async function refresh() {
|
||||
const snapshot = await deps.buildSnapshot();
|
||||
const view = buildControlPlaneView(snapshot, { ...deps.viewOptions, window });
|
||||
cached = { snapshot, view };
|
||||
expiresAt = clock() + interval;
|
||||
return cached;
|
||||
}
|
||||
return {
|
||||
window,
|
||||
async build(extra = {}) {
|
||||
const snapshot = await deps.buildSnapshot();
|
||||
return buildControlPlaneView(snapshot, { ...deps.viewOptions, ...extra, window });
|
||||
if (!cached || clock() >= expiresAt) {
|
||||
if (!pending) pending = refresh().finally(() => { pending = null; });
|
||||
await pending;
|
||||
}
|
||||
if (Object.keys(extra).length === 0) return cached.view;
|
||||
return buildControlPlaneView(cached.snapshot, {
|
||||
...deps.viewOptions, ...extra, now: extra.now || cached.view.generatedAt, window, sample: false
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const vm = require('vm');
|
||||
const { renderControlPlaneViewHtml } = require('../../scripts/lib/control-pane/control-plane-view-ui');
|
||||
|
||||
async function renderResponse(ok, data) {
|
||||
const elements = new Map();
|
||||
const context = new Proxy({}, { get: () => () => {} });
|
||||
function element() {
|
||||
return { textContent: '', style: {}, appendChild() {}, getContext: () => context,
|
||||
clientWidth: 640, clientHeight: 480,
|
||||
parentElement: { getBoundingClientRect: () => ({ width: 640, height: 480 }) } };
|
||||
}
|
||||
const document = {
|
||||
getElementById(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); },
|
||||
createElement: element
|
||||
};
|
||||
const code = renderControlPlaneViewHtml().match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
vm.runInNewContext(code, {
|
||||
document, window: { addEventListener() {}, devicePixelRatio: 1 }, setInterval() {},
|
||||
fetch: async () => ({ ok, json: async () => data })
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return elements;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const failed = await renderResponse(false, { ok: false, error: 'snapshot unavailable' });
|
||||
assert.strictEqual(failed.get('status').textContent, 'offline', 'HTTP errors must not display a healthy empty view');
|
||||
const malformed = await renderResponse(true, { schemaVersion: 'wrong' });
|
||||
assert.strictEqual(malformed.get('status').textContent, 'offline', 'invalid schemas must be rejected');
|
||||
const valid = await renderResponse(true, {
|
||||
schemaVersion: 'ecc.control-plane.view.v1', tasks: [], lanes: [], pairs: [], events: [],
|
||||
projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 }, counts: {}
|
||||
});
|
||||
assert.ok(valid.get('status').textContent.includes('0 tasks'));
|
||||
console.log('PASS control-plane UI error and schema handling');
|
||||
})().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
@@ -264,7 +264,9 @@ function snapshotFor(sessions, extra = {}) {
|
||||
const sessions = [session('a'), session('b'), session('c'), session('d')];
|
||||
const snapshot = snapshotFor(sessions);
|
||||
let builds = 0;
|
||||
let clock = 1000;
|
||||
const source = createControlPlaneViewSource({
|
||||
clock: () => clock,
|
||||
buildSnapshot: async () => {
|
||||
builds += 1;
|
||||
return snapshot;
|
||||
@@ -273,6 +275,7 @@ function snapshotFor(sessions, extra = {}) {
|
||||
viewOptions: { now: NOW }
|
||||
});
|
||||
const first = await source.build();
|
||||
clock += 5000;
|
||||
const second = await source.build();
|
||||
assert.strictEqual(builds, 2);
|
||||
assert.strictEqual(first.projection.window.samples, 6);
|
||||
@@ -281,6 +284,51 @@ function snapshotFor(sessions, extra = {}) {
|
||||
assert.strictEqual(second.generatedAt, NOW);
|
||||
});
|
||||
|
||||
await test('view source samples once per interval despite repeated and concurrent reads', async () => {
|
||||
let clock = 1000;
|
||||
let builds = 0;
|
||||
const source = createControlPlaneViewSource({
|
||||
clock: () => clock,
|
||||
buildSnapshot: async () => { builds += 1; return snapshotFor([session('a'), session('b')]); },
|
||||
viewOptions: { now: NOW }
|
||||
});
|
||||
const views = await Promise.all(Array.from({ length: 10 }, () => source.build()));
|
||||
assert.strictEqual(builds, 1);
|
||||
assert.strictEqual(source.window.length, 1);
|
||||
assert.ok(views.every(view => view.generatedAt === views[0].generatedAt));
|
||||
await source.build();
|
||||
await source.build({ thresholds: { ta: 0.2, ra: 1.5 } });
|
||||
assert.strictEqual(source.window.length, 1, 'alternate read options must not resample');
|
||||
clock += 5000;
|
||||
await source.build();
|
||||
assert.strictEqual(builds, 2);
|
||||
assert.strictEqual(source.window.length, 2);
|
||||
});
|
||||
|
||||
await test('view source rejects failed refreshes and retries without false healthy data', async () => {
|
||||
let fail = true;
|
||||
let clock = 0;
|
||||
const source = createControlPlaneViewSource({
|
||||
clock: () => clock,
|
||||
buildSnapshot: async () => {
|
||||
if (fail) throw new Error('snapshot unavailable');
|
||||
return snapshotFor([session('a'), session('b')]);
|
||||
}
|
||||
});
|
||||
await assert.rejects(source.build(), /snapshot unavailable/);
|
||||
assert.strictEqual(source.window.length, 0);
|
||||
fail = false;
|
||||
await source.build();
|
||||
assert.strictEqual(source.window.length, 1);
|
||||
clock += 5000;
|
||||
fail = true;
|
||||
await assert.rejects(source.build(), /snapshot unavailable/);
|
||||
assert.strictEqual(source.window.length, 1);
|
||||
fail = false;
|
||||
await source.build();
|
||||
assert.strictEqual(source.window.length, 2);
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
if (failed > 0) process.exit(1);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user