From 5c2fbce26e34f6ed679f0a3c9d83c8ab579eab5e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 12 Sep 2026 03:11:12 -0400 Subject: [PATCH] fix(control-pane): reject failed views and coalesce projection sampling --- docs/control-plane/VIEW-CONTRACT.md | 2 +- scripts/lib/agent-proximity/projection.js | 2 +- .../lib/control-pane/control-plane-view-ui.js | 16 +++++-- .../lib/control-pane/control-plane-view.js | 24 +++++++++- tests/lib/control-plane-view-ui.test.js | 39 +++++++++++++++ tests/lib/control-plane-view.test.js | 48 +++++++++++++++++++ 6 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 tests/lib/control-plane-view-ui.test.js diff --git a/docs/control-plane/VIEW-CONTRACT.md b/docs/control-plane/VIEW-CONTRACT.md index e8ac51e8d..8f6f00abb 100644 --- a/docs/control-plane/VIEW-CONTRACT.md +++ b/docs/control-plane/VIEW-CONTRACT.md @@ -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 diff --git a/scripts/lib/agent-proximity/projection.js b/scripts/lib/agent-proximity/projection.js index a81dbe037..08a62a685 100644 --- a/scripts/lib/agent-proximity/projection.js +++ b/scripts/lib/agent-proximity/projection.js @@ -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'; diff --git a/scripts/lib/control-pane/control-plane-view-ui.js b/scripts/lib/control-pane/control-plane-view-ui.js index 8dce6dedb..2fe9e95cd 100644 --- a/scripts/lib/control-pane/control-plane-view-ui.js +++ b/scripts/lib/control-pane/control-plane-view-ui.js @@ -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'; }); } diff --git a/scripts/lib/control-pane/control-plane-view.js b/scripts/lib/control-pane/control-plane-view.js index 5b9456f40..32f6a57ee 100644 --- a/scripts/lib/control-pane/control-plane-view.js +++ b/scripts/lib/control-pane/control-plane-view.js @@ -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 + }); } }; } diff --git a/tests/lib/control-plane-view-ui.test.js b/tests/lib/control-plane-view-ui.test.js new file mode 100644 index 000000000..142caad91 --- /dev/null +++ b/tests/lib/control-plane-view-ui.test.js @@ -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(/