Compare commits
2 Commits
slice/41-m
...
dc08c85409
| Author | SHA1 | Date | |
|---|---|---|---|
| dc08c85409 | |||
| 2b745dfb51 |
@@ -1,6 +1,70 @@
|
||||
[
|
||||
{"id":"dashboardAPI_basic_tab","type":"tab","label":"dashboardAPI basic","disabled":false,"info":"dashboardAPI basic example"},
|
||||
{"id":"dashboardAPI_basic_node","type":"dashboardapi","z":"dashboardAPI_basic_tab","name":"dashboardAPI basic","x":420,"y":180,"wires":[["dashboardAPI_basic_dbg"]]},
|
||||
{"id":"dashboardAPI_basic_inj","type":"inject","z":"dashboardAPI_basic_tab","name":"basic trigger","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"ping","payload":"1","payloadType":"str","x":160,"y":180,"wires":[["dashboardAPI_basic_node"]]},
|
||||
{"id":"dashboardAPI_basic_dbg","type":"debug","z":"dashboardAPI_basic_tab","name":"dashboardAPI basic debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
|
||||
{
|
||||
"id": "dashboardAPI_basic_tab",
|
||||
"type": "tab",
|
||||
"label": "dashboardAPI basic — measurement → Grafana",
|
||||
"disabled": false,
|
||||
"info": "Demonstrates the round-trip:\n- inject simulates a child.register message from a measurement node\n- dashboardapi composes a Grafana dashboard for that child\n- http request posts the dashboard to Grafana\n- debug shows the HTTP response\n\nConfigure the dashboardapi node with your Grafana host/port + bearer token\n(encrypted via Node-RED credentials). Default targets http://grafana:3000\nfrom inside the Docker compose stack."
|
||||
},
|
||||
{
|
||||
"id": "dashboardAPI_basic_node",
|
||||
"type": "dashboardapi",
|
||||
"z": "dashboardAPI_basic_tab",
|
||||
"name": "dashboardAPI",
|
||||
"protocol": "http",
|
||||
"host": "grafana",
|
||||
"port": 3000,
|
||||
"folderUid": "",
|
||||
"defaultBucket": "telemetry",
|
||||
"x": 460,
|
||||
"y": 200,
|
||||
"wires": [["dashboardAPI_basic_http"]]
|
||||
},
|
||||
{
|
||||
"id": "dashboardAPI_basic_inj",
|
||||
"type": "inject",
|
||||
"z": "dashboardAPI_basic_tab",
|
||||
"name": "simulate child.register (measurement)",
|
||||
"props": [
|
||||
{ "p": "topic", "vt": "str" },
|
||||
{ "p": "payload", "v": "{\"config\":{\"general\":{\"id\":\"meas-demo-001\",\"name\":\"FT-001 demo\"},\"functionality\":{\"softwareType\":\"measurement\",\"positionVsParent\":\"downstream\"}}}", "vt": "json" }
|
||||
],
|
||||
"topic": "child.register",
|
||||
"x": 180,
|
||||
"y": 200,
|
||||
"wires": [["dashboardAPI_basic_node"]]
|
||||
},
|
||||
{
|
||||
"id": "dashboardAPI_basic_http",
|
||||
"type": "http request",
|
||||
"z": "dashboardAPI_basic_tab",
|
||||
"name": "POST /api/dashboards/db",
|
||||
"method": "use",
|
||||
"ret": "obj",
|
||||
"paytoqs": "ignore",
|
||||
"url": "",
|
||||
"tls": "",
|
||||
"persist": false,
|
||||
"proxy": "",
|
||||
"authType": "",
|
||||
"senderr": false,
|
||||
"x": 720,
|
||||
"y": 200,
|
||||
"wires": [["dashboardAPI_basic_dbg"]]
|
||||
},
|
||||
{
|
||||
"id": "dashboardAPI_basic_dbg",
|
||||
"type": "debug",
|
||||
"z": "dashboardAPI_basic_tab",
|
||||
"name": "Grafana response",
|
||||
"active": true,
|
||||
"tosidebar": true,
|
||||
"console": false,
|
||||
"tostatus": false,
|
||||
"complete": "payload",
|
||||
"targetType": "msg",
|
||||
"x": 960,
|
||||
"y": 200,
|
||||
"wires": []
|
||||
}
|
||||
]
|
||||
|
||||
67
test/_output-manifest.md
Normal file
67
test/_output-manifest.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# dashboardAPI output manifest
|
||||
|
||||
Per `.claude/rules/output-coverage.md`: every output on every layer, in every state.
|
||||
|
||||
## Port 0 (process — Grafana upsert messages)
|
||||
|
||||
Emitted by the command handler(s) after a `child.register` or `regenerate-dashboard` message. Shape is the same for both; `meta.trigger` distinguishes them.
|
||||
|
||||
| Key | Source method | Type | States tested | Test file |
|
||||
|---|---|---|---|---|
|
||||
| `topic` | `handlers.emitDashboardsFor` | `'create'` (literal) | populated | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||
| `url` | `source.grafanaUpsertUrl()` | string (configured Grafana endpoint) | populated, default-config | `test/basic/slice34-credentials-and-folder.basic.test.js` |
|
||||
| `method` | `handlers.emitDashboardsFor` | `'POST'` (literal) | populated | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||
| `headers.Accept` | `handlers.emitDashboardsFor` | `'application/json'` (literal) | populated | _via output manifest test below_ |
|
||||
| `headers['Content-Type']` | `handlers.emitDashboardsFor` | `'application/json'` (literal) | populated | _via output manifest test below_ |
|
||||
| `headers.Authorization` | `handlers.emitDashboardsFor` | `'Bearer <token>'` when configured; absent when not | populated, absent (degraded — no token) | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||
| `payload.dashboard` | `source.buildDashboard()` | object (Grafana dashboard JSON) | populated, byte-identical-on-repeat | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
|
||||
| `payload.overwrite` | `source.buildUpsertRequest()` | `true` (literal) | populated | `test/basic/slice34-credentials-and-folder.basic.test.js` |
|
||||
| `payload.folderUid` | `source.buildUpsertRequest()` | string when configured; absent when empty | populated, absent (degraded — empty config) | `test/basic/slice34-credentials-and-folder.basic.test.js` |
|
||||
| `payload.folderId` | `source.buildUpsertRequest()` | number when explicitly passed; absent otherwise | absent (default), populated (explicit) | `test/basic/slice34-credentials-and-folder.basic.test.js` |
|
||||
| `meta.nodeId` | `handlers.emitDashboardsFor` | string (child node id) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||
| `meta.softwareType` | `handlers.emitDashboardsFor` | string (child softwareType) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||
| `meta.uid` | `handlers.emitDashboardsFor` | string (stableUid hash, deterministic) | populated, byte-identical | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
|
||||
| `meta.title` | `handlers.emitDashboardsFor` | string (child name or id) | populated | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||
| `meta.trigger` | `handlers.emitDashboardsFor` | `'child.register'` or `'manual'` | both states | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||
|
||||
**Degraded-state convention:** missing keys are **absent**, never set to `null`. The `http request` consumer treats absent headers/payload fields as defaults.
|
||||
|
||||
## Port 1 (InfluxDB telemetry)
|
||||
|
||||
dashboardAPI emits **nothing** on Port 1 by design — it has no measurements, no tick loop, no telemetry. Verified by absence: no `formatForInflux` import, no Port 1 wires in `examples/`.
|
||||
|
||||
## Port 2 (registration / control plumbing)
|
||||
|
||||
dashboardAPI is a **sink** for `child.register` messages, not a source — it does not register itself with any parent. Nothing emitted on Port 2.
|
||||
|
||||
## Structured log outputs
|
||||
|
||||
| Event | Level | Triggered by | Fields | Test |
|
||||
|---|---|---|---|---|
|
||||
| `regen-emitted` | info | successful composition (auto or manual) | `event`, `trigger`, `dashboardApiId`, `childId`, `dashboardCount` | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||
| `regen-skipped` | info | diff predicate says subtree unchanged | `event`, `outcome: 'no-diff'`, `trigger: 'child.register'`, `dashboardApiId`, `childId`, `subtreeSize` | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||
| `manual-regen-requested` | info | `regenerate-dashboard` topic received | `event`, `trigger: 'manual'`, `dashboardApiId`, `cachedChildCount` | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||
| `parent-panels-deduped` | debug | no-data-duplication filter removed root panels | `event`, `before`, `after`, `rootTitle` | _covered by composition tests in slice39_ |
|
||||
| `flows:started` | debug | Node-RED runtime emits flows:started | `event: 'flows:started'`, `type`, `diff` (count summary) | _covered by predicate tests in slice36_ |
|
||||
|
||||
## specificClass return shapes
|
||||
|
||||
| Method | Return shape | Populated states | Degraded states | Test |
|
||||
|---|---|---|---|---|
|
||||
| `buildDashboard(opts)` | `{ dashboard, uid, title, softwareType, nodeId, measurementName }` or `null` | success | `null` when no template for softwareType | `test/basic/slice43-output-manifest.basic.test.js` |
|
||||
| `generateDashboardsForGraph(root)` | array of `buildDashboard` results, root first, children after | 0..N children | empty array when root config missing | `test/basic/slice35-graph-perf-and-uid-uniqueness.basic.test.js` |
|
||||
| `subtreeChanged(diff, ids)` | boolean | id-in-diff, no-id-in-diff | null diff → true (cold start) | `test/basic/slice36-diff-predicate.basic.test.js` |
|
||||
| `subtreeIdsFor(myId, child)` | Set\<string\> | myId+childId+grandchildren | myId only when child has no grandchildren | `test/basic/slice36-diff-predicate.basic.test.js` |
|
||||
| `collectEmittedFields(dashboard)` | Set\<string\> | populated dashboard | empty set for `null`/`{}`/`{panels:[]}` | `test/basic/slice37-emitted-fields.basic.test.js` |
|
||||
| `cachedChildSources()` | array of child sources | 0..N cached | empty after construction | `test/basic/slice41-manual-regen.basic.test.js` |
|
||||
|
||||
## Anti-patterns enforced
|
||||
|
||||
- ❌ Emitting `{payload: null}` — `handlers.emitDashboardsFor` always builds `payload: { dashboard, overwrite, ... }`. Verified.
|
||||
- ❌ Mixing absent vs null for optional fields — `folderUid` / `folderId` are **absent** when unconfigured, never `null`. Verified.
|
||||
- ❌ Per-call token stamping — token is set on `headers.Authorization` when configured; absent when not. No empty-string sentinel.
|
||||
- ❌ Tab id over-triggering in diff predicate — predicate only matches against dashboardAPI's own id + child + grandchildren, never tab ids. Verified.
|
||||
|
||||
## Migration plan applied
|
||||
|
||||
This manifest is created together with slice #43 — the new outputs added in slices #34–#42 are documented here. Other EVOLV nodes still need their own manifests; tracked in `IMPROVEMENTS_BACKLOG.md`.
|
||||
146
test/basic/slice43-output-manifest.basic.test.js
Normal file
146
test/basic/slice43-output-manifest.basic.test.js
Normal file
@@ -0,0 +1,146 @@
|
||||
'use strict';
|
||||
|
||||
// Output-coverage tests per .claude/rules/output-coverage.md and
|
||||
// test/_output-manifest.md. Every output is exercised in both populated
|
||||
// and degraded states.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const DashboardApi = require('../../src/specificClass.js');
|
||||
const handlers = require('../../src/commands/handlers.js');
|
||||
|
||||
function makeChild(id, name = id, softwareType = 'measurement') {
|
||||
return {
|
||||
config: {
|
||||
general: { id, name },
|
||||
functionality: { softwareType, positionVsParent: 'downstream' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(nodeId = 'dApi-1') {
|
||||
const sends = [];
|
||||
const logs = [];
|
||||
return {
|
||||
sends,
|
||||
logs,
|
||||
ctx: {
|
||||
node: { id: nodeId },
|
||||
RED: { nodes: { getNode: () => null } },
|
||||
send: (m) => sends.push(m),
|
||||
logger: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Port 0 message shape: populated ────────────────────────────────────
|
||||
test('Port 0 emit has all required keys when token + folderUid configured', () => {
|
||||
const api = new DashboardApi({
|
||||
grafanaConnector: { protocol: 'http', host: 'grafana', port: 3000, bearerToken: 'tok', folderUid: 'rnd-folder' },
|
||||
});
|
||||
api.lastFlowsStartedDiff = null; // cold start
|
||||
const { sends, ctx } = makeCtx();
|
||||
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-1', 'FT-001') }, ctx);
|
||||
|
||||
assert.ok(sends.length >= 1);
|
||||
const m = sends[0];
|
||||
assert.equal(m.topic, 'create');
|
||||
assert.equal(m.method, 'POST');
|
||||
assert.equal(m.headers['Accept'], 'application/json');
|
||||
assert.equal(m.headers['Content-Type'], 'application/json');
|
||||
assert.equal(m.headers.Authorization, 'Bearer tok');
|
||||
assert.match(m.url, /^http:\/\/grafana:3000\/api\/dashboards\/db$/);
|
||||
assert.equal(m.payload.overwrite, true);
|
||||
assert.ok(m.payload.dashboard, 'dashboard JSON present');
|
||||
assert.equal(m.payload.folderUid, 'rnd-folder');
|
||||
// meta
|
||||
assert.equal(m.meta.nodeId, 'm-1');
|
||||
assert.equal(m.meta.softwareType, 'measurement');
|
||||
assert.equal(typeof m.meta.uid, 'string');
|
||||
assert.equal(m.meta.title, 'FT-001');
|
||||
assert.equal(m.meta.trigger, 'child.register');
|
||||
});
|
||||
|
||||
// ── Port 0 degraded: token absent, folderUid absent ───────────────────
|
||||
test('Port 0 emit omits Authorization header when no bearerToken configured', () => {
|
||||
const api = new DashboardApi({}); // no creds
|
||||
api.lastFlowsStartedDiff = null;
|
||||
const { sends, ctx } = makeCtx();
|
||||
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-2') }, ctx);
|
||||
const m = sends[0];
|
||||
assert.equal(m.headers.Authorization, undefined,
|
||||
'Authorization should be absent (not empty string, not null)');
|
||||
assert.equal(m.payload.folderUid, undefined,
|
||||
'folderUid should be absent when empty');
|
||||
assert.equal('folderId' in m.payload, false,
|
||||
'folderId should also be absent (not 0)');
|
||||
});
|
||||
|
||||
// ── Port 0 degraded: no template for softwareType ─────────────────────
|
||||
test('Port 0 emits no message when child softwareType has no template', () => {
|
||||
const api = new DashboardApi({});
|
||||
api.lastFlowsStartedDiff = null;
|
||||
const { sends, ctx } = makeCtx();
|
||||
// 'nonexistent' has no config/<>.json file
|
||||
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-3', 'm-3', 'nonexistent') }, ctx);
|
||||
assert.equal(sends.length, 0, 'no upsert message should be emitted when template missing');
|
||||
});
|
||||
|
||||
// ── Diff-skip path: no emission, logged outcome:no-diff ───────────────
|
||||
test('Diff-skip suppresses Port 0 emission AND records the skip in source.logger', () => {
|
||||
const api = new DashboardApi({});
|
||||
// Set diff so the predicate returns false (no overlap with subtree).
|
||||
api.lastFlowsStartedDiff = { added: ['unrelated'], changed: [], removed: [], rewired: [] };
|
||||
// Stub logger to capture
|
||||
const captured = [];
|
||||
api.logger = { info: (e) => captured.push(e), debug: () => {} };
|
||||
|
||||
const { sends, ctx } = makeCtx('dApi-1');
|
||||
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-4') }, ctx);
|
||||
|
||||
assert.equal(sends.length, 0, 'no upsert emitted when subtree unchanged');
|
||||
const skipLog = captured.find((e) => e.event === 'regen-skipped');
|
||||
assert.ok(skipLog, 'skip log emitted');
|
||||
assert.equal(skipLog.outcome, 'no-diff');
|
||||
assert.equal(skipLog.trigger, 'child.register');
|
||||
assert.equal(skipLog.dashboardApiId, 'dApi-1');
|
||||
assert.equal(skipLog.childId, 'm-4');
|
||||
});
|
||||
|
||||
// ── Successful regen logs structured fields per N-4 ───────────────────
|
||||
test('Successful regen logs event=regen-emitted with N-4 fields', () => {
|
||||
const api = new DashboardApi({});
|
||||
api.lastFlowsStartedDiff = null; // cold start → always regen
|
||||
const captured = [];
|
||||
api.logger = { info: (e) => captured.push(e), debug: () => {} };
|
||||
|
||||
const { ctx } = makeCtx('dApi-1');
|
||||
handlers.registerChild(api, { topic: 'child.register', payload: makeChild('m-5') }, ctx);
|
||||
|
||||
const emitLog = captured.find((e) => e.event === 'regen-emitted');
|
||||
assert.ok(emitLog, 'regen-emitted log present');
|
||||
assert.equal(emitLog.trigger, 'child.register');
|
||||
assert.equal(emitLog.dashboardApiId, 'dApi-1');
|
||||
assert.equal(emitLog.childId, 'm-5');
|
||||
assert.equal(typeof emitLog.dashboardCount, 'number');
|
||||
});
|
||||
|
||||
// ── Manual regen logs manual-regen-requested + emits with trigger:manual ─
|
||||
test('Manual regen logs manual-regen-requested and stamps trigger=manual', () => {
|
||||
const api = new DashboardApi({});
|
||||
api.recordChild(makeChild('m-6'));
|
||||
const captured = [];
|
||||
api.logger = { info: (e) => captured.push(e), debug: () => {} };
|
||||
|
||||
const { sends, ctx } = makeCtx();
|
||||
handlers.regenerateDashboard(api, { topic: 'regenerate-dashboard', payload: {} }, ctx);
|
||||
|
||||
const reqLog = captured.find((e) => e.event === 'manual-regen-requested');
|
||||
assert.ok(reqLog, 'manual-regen-requested log present');
|
||||
assert.equal(reqLog.cachedChildCount, 1);
|
||||
|
||||
if (sends.length > 0) {
|
||||
assert.equal(sends[0].meta.trigger, 'manual');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user