frugal-iot-client 2.0.0 → 2.0.2
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/CLAUDE.md +4 -0
- package/admin.js +202 -34
- package/core.js +61 -12
- package/frugaliot.css +2 -0
- package/graph.js +85 -42
- package/package.json +5 -2
- package/test/cards.test.js +21 -0
- package/test/datatree.test.js +103 -0
- package/test/graph.test.js +69 -0
- package/test/page.test.js +46 -1
package/CLAUDE.md
CHANGED
|
@@ -48,6 +48,10 @@ these by bare filename (`see CARDS_UX.md 4.7`); they all live in `docs/` except
|
|
|
48
48
|
- **Comments**: explain WHY, not WHAT. One short line is enough; do not restate what the code clearly shows. No multi-line docstrings.
|
|
49
49
|
- Good: `// topicSetPath throws when this.node is null — guard before calling`
|
|
50
50
|
- Bad: `// Check if topic starts with the set path`
|
|
51
|
+
- **A comment says what is true now, not what used to break.** When fixing a bug, state the
|
|
52
|
+
constraint that still holds; the diagnosis — what it threw, where, why one deployment differed —
|
|
53
|
+
belongs in the pull request or the chat, not in the source, where it buries the one line that
|
|
54
|
+
still matters.
|
|
51
55
|
- **No trailing summaries** — do not add a comment block or prose after a method explaining what it does.
|
|
52
56
|
- **Getters**: prefer `get foo()` over plain property access whenever a value is derived or retrieved by traversing connected objects (e.g. `this.node.project`, `this.parentElement`, `this.mt.node.groups[this.group]`). Getters make the dependency chain explicit and let callers read `thing.project` naturally.
|
|
53
57
|
- **Dashboard functionality**: prefer adding reusable behaviour to the shared modules over implementing it inside a custom dashboard file. If a dashboard needs to traverse the topic tree, compute a list, or fire an event, that logic belongs on the relevant class as a getter or method.
|
package/admin.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {EL, GET, HTMLElementExtended} from '/node_modules/html-element-extended/htmlelementextended.js';
|
|
9
9
|
import mqtt from '/node_modules/mqtt/dist/mqtt.esm.js'; // https://www.npmjs.com/package/mqtt
|
|
10
|
-
import { CssUrl, DELETE, POST, XXX, configSet, el, getString, hasCapability, mqttTempConnect, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
|
|
10
|
+
import { CssUrl, DELETE, POST, XXX, brokerHost, configSet, el, getString, hasCapability, mqttTempConnect, mqtt_subscribe, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
// ---------- USB flashing over WebSerial ----------
|
|
@@ -36,10 +36,22 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
36
36
|
}
|
|
37
37
|
//this.append(el('div', {class: 'message', textContent: msg}));
|
|
38
38
|
}
|
|
39
|
+
// Only organizations server_config holds: a permission row can name one this server does not
|
|
40
|
+
// serve, and no card could act on it. Keyed by id so an organization-wide and a project-scoped
|
|
41
|
+
// row for the same capability list it once.
|
|
39
42
|
orgsByPerm(capability) {
|
|
40
|
-
|
|
43
|
+
const orgs = new Map();
|
|
44
|
+
server_config.user.permissions
|
|
41
45
|
.filter(o => o.capability === capability)
|
|
42
|
-
.
|
|
46
|
+
.forEach(o => {
|
|
47
|
+
const org = server_config.organizations[o.org];
|
|
48
|
+
if (org) orgs.set(o.org, org.name);
|
|
49
|
+
});
|
|
50
|
+
return [...orgs];
|
|
51
|
+
}
|
|
52
|
+
// An organization gains its projects block only once one exists, so this is not always there.
|
|
53
|
+
projectsOf(org) {
|
|
54
|
+
return (server_config.organizations[org] || {}).projects || {};
|
|
43
55
|
}
|
|
44
56
|
get adminOrgs() {
|
|
45
57
|
return this.orgsByPerm("ADMIN");
|
|
@@ -102,7 +114,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
102
114
|
return el('select', {id: 'projects', name: 'project' /*onchange: this.onOrganization.bind(this)*/}, [
|
|
103
115
|
//el('option', {value: "", textContent: "Not selected", selected: !this.state.value}),
|
|
104
116
|
el('option', {value: "+", textContent: "All", selected: true}),
|
|
105
|
-
Object.entries(
|
|
117
|
+
Object.entries(this.projectsOf(org))
|
|
106
118
|
.map(([pid, p]) => [ pid, p.name ])
|
|
107
119
|
.map(([pid, name]) =>
|
|
108
120
|
el('option', {value: pid, textContent: `${pid}: ${name}`, selected: false}))
|
|
@@ -238,18 +250,37 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
238
250
|
]);
|
|
239
251
|
}
|
|
240
252
|
return el('div', {}, [
|
|
241
|
-
...(e.secrets.length ?
|
|
242
|
-
el('
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
253
|
+
...(e.secrets.length ? [
|
|
254
|
+
el('p', {textContent: "Put this in platformio-secrets-local.ini, which is not committed:"}),
|
|
255
|
+
...e.secrets.map((secret) => el('p', {}, [
|
|
256
|
+
el('span', {class: 'pseudolink', textContent: ' 🗑 ',
|
|
257
|
+
onclick: this.onEnrolmentSecretDelete.bind(this, secret)}),
|
|
258
|
+
el('code', {i8n: false, class: 'enrolment__line', textContent:
|
|
259
|
+
`'-D SYSTEM_MQTT_ENROL_SECRET="${secret}"'`}),
|
|
260
|
+
])),
|
|
261
|
+
/*
|
|
262
|
+
* The host, and this server's own enrolment URL, for anyone building against something
|
|
263
|
+
* other than the production server the firmware defaults to.
|
|
264
|
+
*
|
|
265
|
+
* SYSTEM_MQTT_HOST is a HOST, derived from the broker URL rather than being it: that URL is
|
|
266
|
+
* the WebSocket address a browser uses through the reverse proxy, while the firmware
|
|
267
|
+
* resolves this string as a DNS name and connects to 1883. Handing over the URL gives
|
|
268
|
+
* "DNS Failed ... -54" on a node that has already enrolled, so it reads as an enrolment
|
|
269
|
+
* fault and is not one.
|
|
270
|
+
*/
|
|
271
|
+
el('p', {textContent:
|
|
272
|
+
"And these two, if you are not building against the production server:"}),
|
|
273
|
+
el('p', {}, [
|
|
274
|
+
el('code', {i8n: false, class: 'enrolment__line', textContent:
|
|
275
|
+
`'-D SYSTEM_MQTT_HOST="${brokerHost(server_config.mqtt && server_config.mqtt.broker)}"'`}),
|
|
276
|
+
el('br', {}),
|
|
277
|
+
el('code', {i8n: false, class: 'enrolment__line', textContent:
|
|
278
|
+
`'-D SYSTEM_MQTT_ENROL_URL="${e.enrol_url}"'`}),
|
|
279
|
+
]),
|
|
280
|
+
] : [el('p', {textContent:
|
|
248
281
|
"This organization has no enrolment secret, so no new node can enrol. Add one."})]),
|
|
249
282
|
el('button', {class: 'submit', type: 'button', textContent: "Add another enrolment secret",
|
|
250
283
|
onclick: this.onEnrolmentSecretAdd.bind(this)}),
|
|
251
|
-
el('p', {i8n: false, class: 'retained__help', textContent:
|
|
252
|
-
`${getString("Enrol URL")}: ${e.enrol_url}`}),
|
|
253
284
|
]);
|
|
254
285
|
}
|
|
255
286
|
getEnrolledNodes() {
|
|
@@ -260,6 +291,92 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
260
291
|
this.replaceElement("enrolled_nodes", this.enrolledNodesList());
|
|
261
292
|
});
|
|
262
293
|
}
|
|
294
|
+
/*
|
|
295
|
+
* The Pis bridging to this server, and whether each is currently connected.
|
|
296
|
+
*
|
|
297
|
+
* Two independent answers, which is the point of showing both. "Last check-in" is the server's
|
|
298
|
+
* own record of that Pi collecting replicated logins, so it says the Pi is alive and talking
|
|
299
|
+
* HTTP. "Bridge" is the broker's retained state for that account, so it says the MQTT relay
|
|
300
|
+
* itself is up. A Pi with a broken bridge still checks in, and that difference is exactly what
|
|
301
|
+
* is hard to see from production any other way.
|
|
302
|
+
*/
|
|
303
|
+
getBridges() {
|
|
304
|
+
if (!this.state.org) return;
|
|
305
|
+
GET(`/bridges_list/${this.state.org}`, {}, (err, json) => {
|
|
306
|
+
if (err) { this.message(err.message); return; }
|
|
307
|
+
this.state.bridges = json; // [{ site, account, created_at, last_pull }]
|
|
308
|
+
this.subscribeBridgeState();
|
|
309
|
+
this.replaceElement("bridges_list", this.bridgesList());
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/*
|
|
313
|
+
* Watch each bridge's connection state.
|
|
314
|
+
*
|
|
315
|
+
* One subscription per bridge rather than the "+" wildcard, because mqtt_deliver matches an
|
|
316
|
+
* incoming topic with topicMatches, which understands a trailing "/#" and nothing else - a "+" in
|
|
317
|
+
* the middle is compared literally, so the broker would send the messages and none would ever be
|
|
318
|
+
* delivered to this callback. Exact topics are narrower anyway, and the retained value arrives
|
|
319
|
+
* immediately, so a state appears without waiting for anything to change.
|
|
320
|
+
*
|
|
321
|
+
* Tracked in a Set because mqtt_subscribe does not de-duplicate: without this, every refresh and
|
|
322
|
+
* every organization change would add another copy of the same subscription.
|
|
323
|
+
*/
|
|
324
|
+
subscribeBridgeState() {
|
|
325
|
+
this.state.bridge_state = this.state.bridge_state || {};
|
|
326
|
+
this.state.bridge_subscribed = this.state.bridge_subscribed || new Set();
|
|
327
|
+
for (const b of (this.state.bridges || [])) {
|
|
328
|
+
const topic = `$SYS/broker/connection/${b.account}/state`;
|
|
329
|
+
if (this.state.bridge_subscribed.has(topic)) continue;
|
|
330
|
+
this.state.bridge_subscribed.add(topic);
|
|
331
|
+
// (topic, msg, retained) - the first argument is the topic, not the message
|
|
332
|
+
mqtt_subscribe(topic, (_topic, msg) => {
|
|
333
|
+
this.state.bridge_state[b.account] = String(msg);
|
|
334
|
+
this.replaceElement("bridges_list", this.bridgesList());
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
bridgesList() {
|
|
339
|
+
const bridges = this.state.bridges;
|
|
340
|
+
if (!bridges || !bridges.length) {
|
|
341
|
+
return el('p', {textContent:
|
|
342
|
+
"No Pi bridges to this server for this organization. A bridge relays a Pi's readings here " +
|
|
343
|
+
"while the Pi carries on working on its own - see INSTALLATION.md step 11."});
|
|
344
|
+
}
|
|
345
|
+
const state = this.state.bridge_state || {};
|
|
346
|
+
return el('table', {class: 'nodestates'}, [
|
|
347
|
+
el('tr', {}, [
|
|
348
|
+
el('th', {textContent: "Site"}), el('th', {textContent: "Bridge"}),
|
|
349
|
+
el('th', {textContent: "Last check-in"}), el('th', {textContent: "Added"}),
|
|
350
|
+
]),
|
|
351
|
+
...bridges.map((b) => {
|
|
352
|
+
// Three states, not two: nothing received yet is not the same as "down", and saying
|
|
353
|
+
// "Unknown" avoids reporting a healthy bridge as broken while the retained message is still
|
|
354
|
+
// in flight or when this login has not been granted the $SYS topic.
|
|
355
|
+
const raw = state[b.account];
|
|
356
|
+
const up = (raw === undefined) ? { label: "Unknown", hint: "No state received from the broker yet" }
|
|
357
|
+
: (raw === '1') ? { label: "Up", hint: "The broker reports this bridge connected" }
|
|
358
|
+
: { label: "DOWN", hint: "The broker reports this bridge not connected" };
|
|
359
|
+
return el('tr', {}, [
|
|
360
|
+
el('td', {i8n: false, textContent: b.site}),
|
|
361
|
+
el('td', {i8n: false, title: getString(up.hint), textContent: up.label}),
|
|
362
|
+
el('td', {i8n: false, textContent: b.last_pull ? this.formatLastSeen(new Date(b.last_pull).toISOString()) : getString("Never seen")}),
|
|
363
|
+
el('td', {i8n: false, textContent: b.created_at ? this.formatLastSeen(new Date(b.created_at).toISOString()) : ''}),
|
|
364
|
+
]);
|
|
365
|
+
}),
|
|
366
|
+
]);
|
|
367
|
+
}
|
|
368
|
+
bridgesRestContent() {
|
|
369
|
+
return el('div', {}, [
|
|
370
|
+
this.refreshableHeading("Bridged Pis", this.getBridges),
|
|
371
|
+
el('p', {textContent:
|
|
372
|
+
"A bridge relays a Pi's readings to this server, so its nodes appear here too, while the " +
|
|
373
|
+
"Pi carries on recording and serving on its own whenever the link is down. \"Bridge\" is " +
|
|
374
|
+
"the broker's live view of the relay; \"Last check-in\" is when that Pi last collected " +
|
|
375
|
+
"this organization's logins, which it does over HTTP - so a Pi can be checking in while " +
|
|
376
|
+
"its bridge is down, and the two together say which half is broken."}),
|
|
377
|
+
this.state.elements.bridges_list = this.bridgesList(),
|
|
378
|
+
]);
|
|
379
|
+
}
|
|
263
380
|
onNodeReset(project, nodeid) {
|
|
264
381
|
// Destructive and not obvious: forgetting a node that is running is safe (it notices its
|
|
265
382
|
// credential being refused and enrols again) but it does cost that node a few minutes offline.
|
|
@@ -284,10 +401,32 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
284
401
|
*/
|
|
285
402
|
nodeStates() {
|
|
286
403
|
return {
|
|
287
|
-
enrolled: { label: "Yes",
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
404
|
+
enrolled: { label: "Yes", next: 'denied', hint: "Has its own broker credential. Click to deny it." },
|
|
405
|
+
// "Failed" alone read as a status rather than a control, so the one action this card exists to
|
|
406
|
+
// offer - approving a node that cannot prove anything - was not findable by someone looking
|
|
407
|
+
// for it. The label says what clicking does.
|
|
408
|
+
failed: { label: "Failed - approve", next: 'approved', hint: "Asked and was refused. Click to let it enrol." },
|
|
409
|
+
approved: { label: "Approved", next: 'denied', hint: "Its next request will be accepted. Click to deny it." },
|
|
410
|
+
denied: { label: "Denied", next: 'cleared', hint: "Stopped, and its credential removed. Click to clear." },
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/*
|
|
414
|
+
* Why the server turned a node away, in words rather than the wire code.
|
|
415
|
+
*
|
|
416
|
+
* Worth the table: the codes are deliberately vague to the NODE (so that probing an organization
|
|
417
|
+
* name learns nothing), but an administrator looking at their own organization needs the
|
|
418
|
+
* difference - "wrong secret" and "project not registered" have completely different fixes, and
|
|
419
|
+
* neither is fixed by approving the node.
|
|
420
|
+
*/
|
|
421
|
+
nodeAskReasons() {
|
|
422
|
+
return {
|
|
423
|
+
refused: "enrolment secret wrong or missing",
|
|
424
|
+
no_such_project: "project is not registered here",
|
|
425
|
+
bad_request: "malformed org, project or node id",
|
|
426
|
+
needs_reset: "already enrolled, cannot prove it",
|
|
427
|
+
denied: "denied here",
|
|
428
|
+
rate_limited: "asking too often",
|
|
429
|
+
broker_unavailable: "broker was unavailable",
|
|
291
430
|
};
|
|
292
431
|
}
|
|
293
432
|
onNodeState(project, nodeid, state) {
|
|
@@ -304,7 +443,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
304
443
|
const nodes = this.state.enrolled_nodes;
|
|
305
444
|
if (!nodes || !nodes.length) {
|
|
306
445
|
return el('p', {textContent:
|
|
307
|
-
"No nodes have enrolled yet. A node enrols itself the first time it
|
|
446
|
+
"No nodes have enrolled yet, and none is asking to. A node enrols itself the first time it " +
|
|
447
|
+
"connects; one that asks and is refused is listed here too, so an empty list means nothing " +
|
|
448
|
+
"is reaching this server at all - check that the node's org and enrol URL are this one."});
|
|
308
449
|
}
|
|
309
450
|
const states = this.nodeStates();
|
|
310
451
|
return el('table', {class: 'nodestates'}, [
|
|
@@ -322,10 +463,14 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
322
463
|
el('span', {class: 'pseudolink', title: getString(st.hint), textContent: st.label,
|
|
323
464
|
onclick: this.onNodeState.bind(this, n.project, n.nodeid, st.next)}),
|
|
324
465
|
]),
|
|
325
|
-
// Untrusted, and shown as such: an address and a time for the admin to recognise
|
|
466
|
+
// Untrusted, and shown as such: an address and a time for the admin to recognise. The
|
|
467
|
+
// reason is here too - without it every refusal looks the same, and the ones an admin can
|
|
468
|
+
// do something about (a project that is not registered, a node id that is not a node id)
|
|
469
|
+
// are indistinguishable from the one that approving fixes.
|
|
326
470
|
el('td', {i8n: false, textContent: n.asked_at
|
|
327
471
|
? `${this.formatLastSeen(new Date(n.asked_at).toISOString())} from ${n.asked_from || '?'}` +
|
|
328
|
-
`${n.asked_count > 1 ? ` (${n.asked_count}x)` : ''}`
|
|
472
|
+
`${n.asked_count > 1 ? ` (${n.asked_count}x)` : ''}` +
|
|
473
|
+
`${n.asked_reason ? ` - ${this.nodeAskReasons()[n.asked_reason] || n.asked_reason}` : ''}`
|
|
329
474
|
: (n.enrolled_at ? new Date(n.enrolled_at).toISOString().slice(0, 10) : '')}),
|
|
330
475
|
el('td', {}, [
|
|
331
476
|
n.enrolled_at
|
|
@@ -911,6 +1056,24 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
911
1056
|
}
|
|
912
1057
|
return this.state.elements[name];
|
|
913
1058
|
}
|
|
1059
|
+
// Each of these lists is the result of one fetch made when the card opened - nothing pushes a
|
|
1060
|
+
// change, so the heading carries the way to ask again.
|
|
1061
|
+
refreshableHeading(title, refreshFn) {
|
|
1062
|
+
return el('h3', {}, [
|
|
1063
|
+
el('span', {textContent: title}),
|
|
1064
|
+
el('span', {class: 'pseudolink refresh', i8n: false, title: getString("Refresh"),
|
|
1065
|
+
textContent: ' \u27f3', onclick: refreshFn.bind(this)}),
|
|
1066
|
+
]);
|
|
1067
|
+
}
|
|
1068
|
+
// The organization's nodes are read out of server_config, so refreshing this table means fetching
|
|
1069
|
+
// /config.json again rather than re-rendering what is already here.
|
|
1070
|
+
refreshNodesTable() {
|
|
1071
|
+
GET("/config.json", {}, (err, json) => {
|
|
1072
|
+
if (err) { this.message(err.message); return; }
|
|
1073
|
+
configSet(json);
|
|
1074
|
+
this.replaceElement("nodes_table", this.nodesTable());
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
914
1077
|
setOrganization(org) {
|
|
915
1078
|
this.state.org = org;
|
|
916
1079
|
this.state.selected_platform_id = null;
|
|
@@ -933,7 +1096,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
933
1096
|
// the enrolled-node list as well as reading server_config - leaving it out was why that section
|
|
934
1097
|
// always said "No nodes have enrolled yet": loadSectionIfNeeded returns early for a tab that is not
|
|
935
1098
|
// in this set, so the fetch never happened.
|
|
936
|
-
this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'API']);
|
|
1099
|
+
this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'Bridges', 'API']);
|
|
937
1100
|
this.loadSectionIfNeeded(this.state.activeSectionTitle);
|
|
938
1101
|
// Only a card created without an organization has a dropdown of its own; replaceElement does
|
|
939
1102
|
// nothing for an element this rendering never made. Fine too if this.state.org is undefined.
|
|
@@ -971,6 +1134,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
971
1134
|
this.getProjectsList();
|
|
972
1135
|
} else if (title === 'Nodes') {
|
|
973
1136
|
this.getEnrolledNodes();
|
|
1137
|
+
} else if (title === 'Bridges') {
|
|
1138
|
+
this.getBridges();
|
|
974
1139
|
} else if (title === 'API') {
|
|
975
1140
|
this.getPlatformsList();
|
|
976
1141
|
this.getFarmsList();
|
|
@@ -1004,12 +1169,12 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1004
1169
|
|
|
1005
1170
|
// Flattens server_config's nodes-by-project data for one org into the row shape nodesTableFor() renders.
|
|
1006
1171
|
nodesForOrg(org) {
|
|
1007
|
-
const projects = (
|
|
1172
|
+
const projects = this.projectsOf(org);
|
|
1008
1173
|
return this.nodesForProjects(org, Object.keys(projects));
|
|
1009
1174
|
}
|
|
1010
1175
|
// Same, but restricted to a specific set of project ids (e.g. those api_farms maps to a farm).
|
|
1011
1176
|
nodesForProjects(org, projectIds) {
|
|
1012
|
-
const projects = (
|
|
1177
|
+
const projects = this.projectsOf(org);
|
|
1013
1178
|
let nodes = [];
|
|
1014
1179
|
projectIds.forEach(projectId => {
|
|
1015
1180
|
const project = projects[projectId];
|
|
@@ -1109,7 +1274,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1109
1274
|
// Create dropdown for selecting a different project for a node
|
|
1110
1275
|
if (!org) { return el('span', {textContent: "No organization"}); }
|
|
1111
1276
|
return el('select', {name: 'project'}, [
|
|
1112
|
-
Object.entries(
|
|
1277
|
+
Object.entries(this.projectsOf(org))
|
|
1113
1278
|
.map(([pid, p]) => [ pid, p.name ])
|
|
1114
1279
|
.map(([pid, name]) =>
|
|
1115
1280
|
el('option', {value: pid, textContent: `${pid}: ${name}`, selected: pid === currentProjectId}))
|
|
@@ -1188,7 +1353,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1188
1353
|
el('button', {class: "submit", type: "submit", textContent: 'Upload'}),
|
|
1189
1354
|
]), //form
|
|
1190
1355
|
el('section', {}, [
|
|
1191
|
-
|
|
1356
|
+
this.refreshableHeading("Existing OTA Files", this.getOtaFiles),
|
|
1192
1357
|
this.state.elements.ota_files = this.otaFilesList(),
|
|
1193
1358
|
]), // section ota
|
|
1194
1359
|
el('section', {}, [
|
|
@@ -1200,7 +1365,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1200
1365
|
adminRestContent() {
|
|
1201
1366
|
return el('div', {}, [
|
|
1202
1367
|
el('section', {}, [
|
|
1203
|
-
|
|
1368
|
+
this.refreshableHeading("Permissions", this.getPeopleList),
|
|
1204
1369
|
// List of people and their permissions, with option to delete,
|
|
1205
1370
|
this.state.elements.people_perms_list = this.peoplePermList(), // This gets replaced when actions taken
|
|
1206
1371
|
// and form to add (dropdown of people and permissions)
|
|
@@ -1303,7 +1468,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1303
1468
|
projectsRestContent() {
|
|
1304
1469
|
return el('div', {}, [
|
|
1305
1470
|
el('section', {}, [
|
|
1306
|
-
|
|
1471
|
+
this.refreshableHeading("Projects", this.getProjectsList),
|
|
1307
1472
|
// List of existing projects for this organization,
|
|
1308
1473
|
this.state.elements.projects_display_list = this.projectsDisplayList(),
|
|
1309
1474
|
// and, once expanded, a form to add a new project (id and name)
|
|
@@ -1348,14 +1513,16 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1348
1513
|
// Content of the Nodes tab below the organization dropdown - only rendered once an org is selected
|
|
1349
1514
|
nodesRestContent() {
|
|
1350
1515
|
return el('div', {}, [
|
|
1351
|
-
|
|
1516
|
+
this.refreshableHeading("Nodes in Organization", this.refreshNodesTable),
|
|
1352
1517
|
this.state.elements.nodes_table = this.nodesTable(),
|
|
1353
1518
|
el('section', {}, [
|
|
1354
|
-
|
|
1519
|
+
this.refreshableHeading("Enrolled nodes", this.getEnrolledNodes),
|
|
1355
1520
|
el('p', {textContent:
|
|
1356
1521
|
"Each node has its own broker credential, which it collects from this server the first " +
|
|
1357
1522
|
"time it connects. Forget one to have it issued a new credential - needed if its " +
|
|
1358
|
-
"filesystem has been erased, because it can then no longer prove which node it is."
|
|
1523
|
+
"filesystem has been erased, because it can then no longer prove which node it is. A " +
|
|
1524
|
+
"node that asked and was refused is listed here as well, with why: click Approve on it " +
|
|
1525
|
+
"to accept its next request whatever secret it presents. It retries every half hour."}),
|
|
1359
1526
|
this.state.elements.enrolled_nodes = this.enrolledNodesList(),
|
|
1360
1527
|
]),
|
|
1361
1528
|
el('section', {}, [
|
|
@@ -1373,25 +1540,25 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1373
1540
|
apiRestContent() {
|
|
1374
1541
|
return el('div', {}, [
|
|
1375
1542
|
el('section', {}, [
|
|
1376
|
-
|
|
1543
|
+
this.refreshableHeading("Registered Platforms", this.getPlatformsList),
|
|
1377
1544
|
this.state.elements.platforms_list_display = this.platformsListDisplay(),
|
|
1378
1545
|
]),
|
|
1379
1546
|
el('section', {}, [
|
|
1380
1547
|
this.state.elements.add_platform = this.collapsibleArea('add_platform', "Add Platform", this.platformRegisterForm),
|
|
1381
1548
|
]),
|
|
1382
1549
|
el('section', {}, [
|
|
1383
|
-
|
|
1550
|
+
this.refreshableHeading("Farms", this.getFarmsList),
|
|
1384
1551
|
this.state.elements.farms_list_display = this.farmsListDisplay(),
|
|
1385
1552
|
]),
|
|
1386
1553
|
el('section', {}, [
|
|
1387
1554
|
this.state.elements.add_farm = this.collapsibleArea('add_farm', "Add Farm", this.farmRegisterForm),
|
|
1388
1555
|
]),
|
|
1389
1556
|
el('section', {}, [
|
|
1390
|
-
|
|
1557
|
+
this.refreshableHeading("Nodes in Farm", this.getFarmNodesList),
|
|
1391
1558
|
this.state.elements.farm_nodes_table = this.farmNodesTable(),
|
|
1392
1559
|
]),
|
|
1393
1560
|
el('section', {}, [
|
|
1394
|
-
|
|
1561
|
+
this.refreshableHeading("Node Actions", this.getDeviceActionSchema),
|
|
1395
1562
|
this.state.elements.farm_node_actions = this.farmNodeActions(),
|
|
1396
1563
|
this.state.elements.action_section = this.actionSection(),
|
|
1397
1564
|
]),
|
|
@@ -1407,6 +1574,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1407
1574
|
{ key: 'message', title: "Message", orgs: 'adminOrgs', dropdown: 'messageorgsdropdown', rest: 'message_rest', content: this.messageRestContent },
|
|
1408
1575
|
{ key: 'retained', title: "Retained", orgs: 'adminOrgs', dropdown: 'retainedorgsdropdown', rest: 'retained_rest', content: this.retainedRestContent },
|
|
1409
1576
|
{ key: 'nodes', title: "Nodes", orgs: 'adminOrgs', dropdown: 'nodesorgsdropdown', rest: 'nodes_rest', content: this.nodesRestContent },
|
|
1577
|
+
{ key: 'bridges', title: "Bridges", orgs: 'adminOrgs', dropdown: 'bridgesorgsdropdown', rest: 'bridges_rest', content: this.bridgesRestContent },
|
|
1410
1578
|
{ key: 'api', title: "API", orgs: 'adminOrgs', dropdown: 'apiorgsdropdown', rest: 'api_rest', content: this.apiRestContent },
|
|
1411
1579
|
];
|
|
1412
1580
|
}
|
package/core.js
CHANGED
|
@@ -118,8 +118,14 @@ function unshiftUnique(arr, v) {
|
|
|
118
118
|
}
|
|
119
119
|
*/
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
/*
|
|
122
|
+
* Subscribe to a topic. cb(topic, message, retained) - see mqtt_deliver, which passes all three.
|
|
123
|
+
*
|
|
124
|
+
* A trailing "/#" works. A "+" anywhere does NOT: topicMatches understands only the trailing form,
|
|
125
|
+
* so the broker would deliver the messages and none of them would reach cb. Subscribe to each
|
|
126
|
+
* concrete topic instead, or teach topicMatches about "+".
|
|
127
|
+
*/
|
|
128
|
+
function mqtt_subscribe(topic, cb) { // cb(topic, message, retained)
|
|
123
129
|
console.log("Subscribing to ", topic);
|
|
124
130
|
mqtt_subscriptions.push({topic, cb});
|
|
125
131
|
// There may be no client yet - it only connects once it knows which organization's credentials to use
|
|
@@ -159,13 +165,29 @@ function retainedPattern(org, relative) {
|
|
|
159
165
|
return `${org}/${rest || '#'}`;
|
|
160
166
|
}
|
|
161
167
|
|
|
168
|
+
/*
|
|
169
|
+
* The broker's host name, out of the URL a browser connects with.
|
|
170
|
+
*
|
|
171
|
+
* They are not the same thing and a node needs the host. A browser is given something like
|
|
172
|
+
* "wss://frugaliot.naturalinnovation.org/wss" - a WebSocket URL, through the reverse proxy - while
|
|
173
|
+
* firmware opens a plain TCP connection to the host on 1883 and resolves that string as a DNS name.
|
|
174
|
+
* Handing the URL to configure_mqtt_enrolled() gets "DNS Failed ... error -54" and a node that
|
|
175
|
+
* enrols and then never connects, which is how this was found.
|
|
176
|
+
*/
|
|
177
|
+
function brokerHost(url) {
|
|
178
|
+
return String(url || '')
|
|
179
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // scheme
|
|
180
|
+
.replace(/\/.*$/, '') // path
|
|
181
|
+
.replace(/:\d+$/, ''); // port
|
|
182
|
+
}
|
|
183
|
+
|
|
162
184
|
// Route a received message to every matching subscription.
|
|
163
185
|
// Separate from the client's on('message') so a test or mock can inject messages with no broker.
|
|
164
|
-
function mqtt_deliver(topic, msg) {
|
|
186
|
+
function mqtt_deliver(topic, msg, retained) {
|
|
165
187
|
// The subscriptions are all going to be MqttNode which will then look at rest of topic
|
|
166
188
|
for (let o of mqtt_subscriptions) {
|
|
167
189
|
if (topicMatches(o.topic, topic)) { // Matches trailing wildcards, but not middle ones
|
|
168
|
-
o.cb(topic, msg);
|
|
190
|
+
o.cb(topic, msg, retained);
|
|
169
191
|
}
|
|
170
192
|
}
|
|
171
193
|
}
|
|
@@ -352,6 +374,7 @@ EN:
|
|
|
352
374
|
Advanced: Advanced
|
|
353
375
|
All: All
|
|
354
376
|
Already have an account?: Already have an account?
|
|
377
|
+
"And these two, if you are not building against the production server:": "And these two, if you are not building against the production server:"
|
|
355
378
|
API: API
|
|
356
379
|
Approved: Approved
|
|
357
380
|
AQI: AQI
|
|
@@ -524,12 +547,14 @@ EN:
|
|
|
524
547
|
Provision instead (erases config): Provision instead (erases config)
|
|
525
548
|
Publish Message: Publish Message
|
|
526
549
|
"Published to ": "Published to "
|
|
550
|
+
"Put this in platformio-secrets-local.ini, which is not committed:": "Put this in platformio-secrets-local.ini, which is not committed:"
|
|
527
551
|
QoS: QoS
|
|
528
552
|
reconnect: reconnect
|
|
529
553
|
Reconnect the board to flash again: Reconnect the board to flash again
|
|
530
554
|
Register: Register
|
|
531
555
|
Register a platform above before adding a farm.: Register a platform above before adding a farm.
|
|
532
556
|
Register Farm: Register Farm
|
|
557
|
+
Refresh: Refresh
|
|
533
558
|
Register Platform: Register Platform
|
|
534
559
|
Registered Platforms: Registered Platforms
|
|
535
560
|
Registration failed: Registration failed
|
|
@@ -624,6 +649,7 @@ FR:
|
|
|
624
649
|
Advanced: Avancé
|
|
625
650
|
All: Tous
|
|
626
651
|
Already have an account?: Vous avez déjà un compte ?
|
|
652
|
+
"And these two, if you are not building against the production server:": "Et ces deux-ci, si vous ne compilez pas pour le serveur de production :"
|
|
627
653
|
API: API
|
|
628
654
|
Approved: Approuvé
|
|
629
655
|
AQI: IQA
|
|
@@ -798,12 +824,14 @@ FR:
|
|
|
798
824
|
Provision instead (erases config): Provisionner plutôt (effacer la configuration)
|
|
799
825
|
Publish Message: Publier un message
|
|
800
826
|
"Published to ": "Publié sur "
|
|
827
|
+
"Put this in platformio-secrets-local.ini, which is not committed:": "Mettez ceci dans platformio-secrets-local.ini, qui n'est pas commité :"
|
|
801
828
|
QoS: QoS
|
|
802
829
|
reconnect: reconnecter
|
|
803
830
|
Reconnect the board to flash again: Reconnectez la carte pour flasher à nouveau
|
|
804
831
|
Register: Registre
|
|
805
832
|
Register a platform above before adding a farm.: Enregistrez une plateforme ci-dessus avant d'ajouter une ferme.
|
|
806
833
|
Register Farm: Enregistrer la ferme
|
|
834
|
+
Refresh: Actualiser
|
|
807
835
|
Register Platform: Enregistrer la plateforme
|
|
808
836
|
Registered Platforms: Plateformes enregistrées
|
|
809
837
|
Registration failed: L'inscription a échoué
|
|
@@ -898,6 +926,7 @@ HI:
|
|
|
898
926
|
Advanced: उन्नत
|
|
899
927
|
All: सभी
|
|
900
928
|
Already have an account?: पहले से खाता है?
|
|
929
|
+
"And these two, if you are not building against the production server:": "और ये दो, यदि आप उत्पादन सर्वर के लिए बिल्ड नहीं कर रहे हैं:"
|
|
901
930
|
API: एपीआई
|
|
902
931
|
Approved: स्वीकृत
|
|
903
932
|
AQI: वायु गुणवत्ता सूचकांक
|
|
@@ -1072,12 +1101,14 @@ HI:
|
|
|
1072
1101
|
Provision instead (erases config): इसके बजाय प्रोविजन करें (कॉन्फ़िगरेशन मिटेगी)
|
|
1073
1102
|
Publish Message: संदेश प्रकाशित करें
|
|
1074
1103
|
"Published to ": "पर प्रकाशित किया गया "
|
|
1104
|
+
"Put this in platformio-secrets-local.ini, which is not committed:": "इसे platformio-secrets-local.ini में रखें, जो कमिट नहीं किया जाता:"
|
|
1075
1105
|
QoS: QoS
|
|
1076
1106
|
reconnect: पुनः कनेक्ट करें
|
|
1077
1107
|
Reconnect the board to flash again: फिर से फ्लैश करने के लिए बोर्ड को दोबारा कनेक्ट करें
|
|
1078
1108
|
Register: पंजीकरण करें
|
|
1079
1109
|
Register a platform above before adding a farm.: फार्म जोड़ने से पहले ऊपर एक प्लेटफ़ॉर्म पंजीकृत करें।
|
|
1080
1110
|
Register Farm: फार्म पंजीकृत करें
|
|
1111
|
+
Refresh: ताज़ा करें
|
|
1081
1112
|
Register Platform: प्लेटफ़ॉर्म पंजीकृत करें
|
|
1082
1113
|
Registered Platforms: पंजीकृत प्लेटफ़ॉर्म
|
|
1083
1114
|
Registration failed: पंजीकरण विफल
|
|
@@ -1172,6 +1203,7 @@ ID:
|
|
|
1172
1203
|
Advanced: Lanjutan
|
|
1173
1204
|
All: Semua
|
|
1174
1205
|
Already have an account?: Sudah punya akun?
|
|
1206
|
+
"And these two, if you are not building against the production server:": "Dan dua ini, jika Anda tidak membangun untuk server produksi:"
|
|
1175
1207
|
API: API
|
|
1176
1208
|
Approved: Disetujui
|
|
1177
1209
|
AQI: Indeks Kualitas Udara
|
|
@@ -1346,12 +1378,14 @@ ID:
|
|
|
1346
1378
|
Provision instead (erases config): Provisioning saja (menghapus konfigurasi)
|
|
1347
1379
|
Publish Message: Publikasikan Pesan
|
|
1348
1380
|
"Published to ": "Dipublikasikan ke "
|
|
1381
|
+
"Put this in platformio-secrets-local.ini, which is not committed:": "Letakkan ini di platformio-secrets-local.ini, yang tidak di-commit:"
|
|
1349
1382
|
QoS: QoS
|
|
1350
1383
|
reconnect: sambungkan kembali
|
|
1351
1384
|
Reconnect the board to flash again: Hubungkan kembali papan untuk flash lagi
|
|
1352
1385
|
Register: Daftar
|
|
1353
1386
|
Register a platform above before adding a farm.: Daftarkan platform di atas sebelum menambahkan farm.
|
|
1354
1387
|
Register Farm: Daftarkan Farm
|
|
1388
|
+
Refresh: Segarkan
|
|
1355
1389
|
Register Platform: Daftarkan Platform
|
|
1356
1390
|
Registered Platforms: Platform Terdaftar
|
|
1357
1391
|
Registration failed: Pendaftaran gagal
|
|
@@ -2028,7 +2062,7 @@ class MqttTopic {
|
|
|
2028
2062
|
display: this.type !== 'bool',
|
|
2029
2063
|
title: {
|
|
2030
2064
|
// noinspection JSUnresolvedReference
|
|
2031
|
-
color: this.color, //
|
|
2065
|
+
color: this.color, // The family colour; the per-line variation happens in MqttGraph.addDataset
|
|
2032
2066
|
// noinspection JSUnresolvedReference
|
|
2033
2067
|
text: getString(this.name.replace(/[0-9]+$/,'')),
|
|
2034
2068
|
},
|
|
@@ -2064,7 +2098,6 @@ class MqttTopic {
|
|
|
2064
2098
|
|
|
2065
2099
|
// Create a graphdataset to put in the chart
|
|
2066
2100
|
if (!this.graphdataset) {
|
|
2067
|
-
let nodename = this.node ? this.node.usableName : "";
|
|
2068
2101
|
// noinspection JSUnresolvedReference
|
|
2069
2102
|
this.graphdataset = el('mqtt-graphdataset', {
|
|
2070
2103
|
// noinspection JSUnresolvedReference
|
|
@@ -2076,7 +2109,9 @@ class MqttTopic {
|
|
|
2076
2109
|
min: this.min,
|
|
2077
2110
|
max: this.max,
|
|
2078
2111
|
yaxisid: yaxisid,
|
|
2079
|
-
|
|
2112
|
+
// fullName, not name - the legend has to say which device a reading came from, and it goes
|
|
2113
|
+
// through nodeMt because this.node (the MqttNode element) is absent on a headless page.
|
|
2114
|
+
label: this.fullName,
|
|
2080
2115
|
});
|
|
2081
2116
|
this.graphdataset.mt = this;
|
|
2082
2117
|
}
|
|
@@ -2367,9 +2402,18 @@ class MqttTopicProject extends MqttTopic {
|
|
|
2367
2402
|
// Always created by MqttTopicProject.addNode. In normal mode MqttProject.addNode then adds the paired
|
|
2368
2403
|
// MqttNode element (this.element) and replaces _groups with elNode.topicGroups.
|
|
2369
2404
|
class MqttTopicNode extends MqttTopic {
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2405
|
+
/*
|
|
2406
|
+
* Route directly on the data-tree object rather than through the element.
|
|
2407
|
+
*
|
|
2408
|
+
* A RETAINED delivery updates the values but does not count as having heard from the node. Nodes
|
|
2409
|
+
* publish almost everything retained, so subscribing produces a flood of last-known values
|
|
2410
|
+
* whatever state the node is in - and treating those as "just seen" made a device that died
|
|
2411
|
+
* weeks ago read as live, on any server whose logger had restarted since. The honest answer for
|
|
2412
|
+
* such a node is that we do not know when it last spoke, which is what an unset lastMessageAt
|
|
2413
|
+
* already says ("never"); config.json's lastseen fills it in where the logger does know.
|
|
2414
|
+
*/
|
|
2415
|
+
message_received(topicPath, message, retained) {
|
|
2416
|
+
if (!retained) this.noteMessage();
|
|
2373
2417
|
this.topicValueSet(topicPath, message);
|
|
2374
2418
|
if (this.graphdataset) this.graphdataset.dataChanged();
|
|
2375
2419
|
}
|
|
@@ -2803,12 +2847,16 @@ class MqttClient extends HTMLElementExtended {
|
|
|
2803
2847
|
this.setStatus("Error:" + error.message);
|
|
2804
2848
|
}.bind(this));
|
|
2805
2849
|
// Message received, iterate over mqtt_subscriptions and call cb of subscription if matches
|
|
2806
|
-
mqtt_client.on('message', (topic, message) => {
|
|
2850
|
+
mqtt_client.on('message', (topic, message, packet) => {
|
|
2807
2851
|
// message is Buffer
|
|
2808
2852
|
// TODO - check whether topic is string or buffer.
|
|
2809
2853
|
let msg = message.toString();
|
|
2810
2854
|
console.log("Received", topic, " ", msg);
|
|
2811
|
-
|
|
2855
|
+
// packet.retain distinguishes the broker replaying its retained store, on subscribe, from
|
|
2856
|
+
// the node actually publishing. MQTT 3.1.1 requires the flag be cleared on a delivery to an
|
|
2857
|
+
// already-established subscription, so it is exactly "this arrived because we just
|
|
2858
|
+
// subscribed" - see MqttTopicNode.message_received for why that matters.
|
|
2859
|
+
mqtt_deliver(topic, msg, !!(packet && packet.retain));
|
|
2812
2860
|
});
|
|
2813
2861
|
} else {
|
|
2814
2862
|
// console.log("XXX already started connection") // We expect this, probably one time
|
|
@@ -3131,6 +3179,7 @@ export {
|
|
|
3131
3179
|
hasCapability,
|
|
3132
3180
|
redirectToLogin,
|
|
3133
3181
|
relativeTime,
|
|
3182
|
+
brokerHost,
|
|
3134
3183
|
retainedPattern,
|
|
3135
3184
|
SUMMARY_CHIP_LIMIT,
|
|
3136
3185
|
SUMMARY_READINGS_PER_MODULE,
|
package/frugaliot.css
CHANGED
|
@@ -200,6 +200,8 @@ div.mqtt-text {
|
|
|
200
200
|
text-decoration: underline;
|
|
201
201
|
cursor: pointer;
|
|
202
202
|
}
|
|
203
|
+
/* The \27f3 beside a heading: still a link, but underlining a glyph reads as a typo. */
|
|
204
|
+
.refresh { text-decoration: none; }
|
|
203
205
|
.message {margin: 0.5em; font-weight: bold; font-size: x-large; }
|
|
204
206
|
/* An enrolment secret is a long unbroken string; without this it runs off a phone. */
|
|
205
207
|
.enrolment__line { word-break: break-all; }
|
package/graph.js
CHANGED
|
@@ -273,9 +273,21 @@ class MqttGraph extends MqttElement {
|
|
|
273
273
|
this.chart.update();
|
|
274
274
|
}
|
|
275
275
|
addDataset(chartdataset) {
|
|
276
|
+
this.assignColor(chartdataset);
|
|
276
277
|
this.datasets.push(chartdataset);
|
|
277
278
|
this.makeChart();
|
|
278
279
|
}
|
|
280
|
+
// Only the graph knows what is already drawn, so it is the graph that decides how far a line has
|
|
281
|
+
// to move off its schema colour. Lowest free variant, so removing a line frees its colour again.
|
|
282
|
+
assignColor(chartdataset) {
|
|
283
|
+
const base = chartdataset.baseColor;
|
|
284
|
+
const used = this.datasets.filter(d => d.baseColor === base).map(d => d.colorVariant);
|
|
285
|
+
let n = 0;
|
|
286
|
+
while (used.includes(n)) n++;
|
|
287
|
+
chartdataset.colorVariant = n;
|
|
288
|
+
chartdataset.borderColor = variantColor(base, n); // also sets colour of point
|
|
289
|
+
chartdataset.backgroundColor = addAlpha(chartdataset.borderColor, 0.3); // 30% opacity
|
|
290
|
+
}
|
|
279
291
|
removeDataset(chartdataset) {
|
|
280
292
|
// Removes a single chartjs dataset object from this graph and redraws.
|
|
281
293
|
const idx = this.datasets.indexOf(chartdataset);
|
|
@@ -302,8 +314,75 @@ class MqttGraph extends MqttElement {
|
|
|
302
314
|
}
|
|
303
315
|
}
|
|
304
316
|
customElements.define('mqtt-graph', MqttGraph);
|
|
305
|
-
|
|
306
|
-
|
|
317
|
+
|
|
318
|
+
/* ===== Colour helpers ==========
|
|
319
|
+
The schema gives one colour per kind of reading, so two humidities on the same graph arrive the
|
|
320
|
+
same blue. variantColor spreads them apart while keeping them recognisably the same family. */
|
|
321
|
+
|
|
322
|
+
// Only the names likely to appear in a schema; anything else falls through and is used unchanged.
|
|
323
|
+
const namedColors = {
|
|
324
|
+
black: [0, 0, 0], gray: [128, 128, 128], grey: [128, 128, 128], white: [255, 255, 255],
|
|
325
|
+
red: [255, 0, 0], green: [0, 128, 0], blue: [0, 0, 255], purple: [128, 0, 128],
|
|
326
|
+
brown: [165, 42, 42], orange: [255, 165, 0], yellow: [255, 255, 0], pink: [255, 192, 203],
|
|
327
|
+
cyan: [0, 255, 255], magenta: [255, 0, 255],
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
function colorToRgb(color) {
|
|
331
|
+
if (!color) return null;
|
|
332
|
+
const c = color.toLowerCase().trim();
|
|
333
|
+
if (c.startsWith('#')) {
|
|
334
|
+
const hex = c.length === 4 ? c[1] + c[1] + c[2] + c[2] + c[3] + c[3] : c.slice(1, 7);
|
|
335
|
+
if (hex.length !== 6) return null;
|
|
336
|
+
return [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16));
|
|
337
|
+
}
|
|
338
|
+
if (c.startsWith('rgb')) {
|
|
339
|
+
const parts = c.match(/[\d.]+/g);
|
|
340
|
+
return parts && parts.length >= 3 ? parts.slice(0, 3).map(Number) : null;
|
|
341
|
+
}
|
|
342
|
+
return namedColors[c] || null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function addAlpha(color, alpha) {
|
|
346
|
+
if (!color) return color;
|
|
347
|
+
if (color.startsWith('rgba')) return color.replace(/[\d.]+\)$/, alpha + ')');
|
|
348
|
+
const rgb = colorToRgb(color);
|
|
349
|
+
return rgb ? `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})` : color;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function rgbToHsl([r, g, b]) {
|
|
353
|
+
r /= 255; g /= 255; b /= 255;
|
|
354
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2;
|
|
355
|
+
if (max === min) return [0, 0, l];
|
|
356
|
+
const d = max - min;
|
|
357
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
358
|
+
const h = (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60;
|
|
359
|
+
return [h, s, l];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function hslToRgb([h, s, l]) {
|
|
363
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
364
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
365
|
+
const m = l - c / 2;
|
|
366
|
+
const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
|
|
367
|
+
: h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
|
|
368
|
+
return [r, g, b].map(v => Math.round((v + m) * 255));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// n is how many lines already share this base colour: 0 keeps it, later ones step alternately either
|
|
372
|
+
// side in hue and lightness, so neighbours differ but all stay within a recognisable band.
|
|
373
|
+
function variantColor(baseColor, n) {
|
|
374
|
+
const rgb = n && colorToRgb(baseColor);
|
|
375
|
+
if (!rgb) return baseColor;
|
|
376
|
+
let [h, s, l] = rgbToHsl(rgb);
|
|
377
|
+
const step = (n % 2 ? 1 : -1) * Math.ceil(n / 2); // 1, -1, 2, -2 ...
|
|
378
|
+
if (s < 0.15) s = 0.45; // black and grey have no hue to rotate, so give the variants one
|
|
379
|
+
if (l < 0.2 || l > 0.8) l = 0.45; // and no room to move either, so start them mid-range
|
|
380
|
+
h = (h + step * 28 + 360) % 360;
|
|
381
|
+
l = Math.min(0.75, Math.max(0.25, l + step * 0.08));
|
|
382
|
+
const [vr, vg, vb] = hslToRgb([h, s, l]);
|
|
383
|
+
return `rgb(${vr}, ${vg}, ${vb})`;
|
|
384
|
+
}
|
|
385
|
+
|
|
307
386
|
class MqttGraphDataset extends MqttElement {
|
|
308
387
|
/*
|
|
309
388
|
chartdataset: { data[{value, time}], parsing: { xAixKey: 'time', yAxisKey: 'value' }
|
|
@@ -349,50 +428,14 @@ class MqttGraphDataset extends MqttElement {
|
|
|
349
428
|
};
|
|
350
429
|
}
|
|
351
430
|
// Things that are changed by attributes
|
|
352
|
-
this.chartdataset.label = this.state.label;
|
|
353
|
-
|
|
354
|
-
|
|
431
|
+
this.chartdataset.label = this.state.label;
|
|
432
|
+
// Only the schema colour is recorded here; MqttGraph.addDataset sets the line and fill colours,
|
|
433
|
+
// varying this one against whatever is already on the graph.
|
|
434
|
+
this.chartdataset.baseColor = this.state.color;
|
|
355
435
|
this.chartdataset.yAxisID = this.state.yaxisid;
|
|
356
436
|
// Should override display and position and grid of each axis used
|
|
357
437
|
}
|
|
358
438
|
|
|
359
|
-
// Helper method to add alpha transparency to a color
|
|
360
|
-
addAlpha(color, alpha) {
|
|
361
|
-
// Named color map for common colors
|
|
362
|
-
const namedColors = {
|
|
363
|
-
'purple': 'rgb(128, 0, 128)',
|
|
364
|
-
'brown': 'rgb(165, 42, 42)',
|
|
365
|
-
'red': 'rgb(255, 0, 0)',
|
|
366
|
-
'pink': 'rgb(255, 192, 203)',
|
|
367
|
-
'green': 'rgb(0, 128, 0)',
|
|
368
|
-
'blue': 'rgb(0, 0, 255)',
|
|
369
|
-
'black': 'rgb(0, 0, 0)'
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
// If it's already an rgba color, modify the alpha
|
|
373
|
-
if (color.startsWith('rgba')) {
|
|
374
|
-
return color.replace(/[\d.]+\)$/, alpha + ')');
|
|
375
|
-
}
|
|
376
|
-
// Convert hex or named color to rgba
|
|
377
|
-
if (color.startsWith('#')) {
|
|
378
|
-
const r = parseInt(color.slice(1, 3), 16);
|
|
379
|
-
const g = parseInt(color.slice(3, 5), 16);
|
|
380
|
-
const b = parseInt(color.slice(5, 7), 16);
|
|
381
|
-
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
382
|
-
}
|
|
383
|
-
// If it's rgb, convert to rgba
|
|
384
|
-
if (color.startsWith('rgb')) {
|
|
385
|
-
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
|
|
386
|
-
}
|
|
387
|
-
// Check for named colors
|
|
388
|
-
const lowerColor = color.toLowerCase().trim();
|
|
389
|
-
if (namedColors[lowerColor]) {
|
|
390
|
-
return namedColors[lowerColor].replace('rgb', 'rgba').replace(')', `, ${alpha})`);
|
|
391
|
-
}
|
|
392
|
-
// For unknown colors, return as-is
|
|
393
|
-
return color;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
439
|
// Normally the MqttTopic creates the MqttGraphDataset,
|
|
397
440
|
// However, in an embedded case, just the GraphDataset is created and has to create the topic.
|
|
398
441
|
makeTopic() {
|
package/package.json
CHANGED
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
"license": "MIT",
|
|
30
30
|
"main": "webcomponents.js",
|
|
31
31
|
"name": "frugal-iot-client",
|
|
32
|
-
"repository":
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/mitra42/frugal-iot-client.git"
|
|
35
|
+
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"fixtures": "node test/fixtures/regenerate.js",
|
|
35
38
|
"link:hee": "ln -sfn ../../html-element-extended node_modules/html-element-extended && ls -ld node_modules/html-element-extended",
|
|
@@ -38,5 +41,5 @@
|
|
|
38
41
|
"unlink:hee": "rm -f node_modules/html-element-extended && npm install html-element-extended"
|
|
39
42
|
},
|
|
40
43
|
"type": "module",
|
|
41
|
-
"version": "2.0.
|
|
44
|
+
"version": "2.0.2"
|
|
42
45
|
}
|
package/test/cards.test.js
CHANGED
|
@@ -75,6 +75,27 @@ describe('formatting a value', () => {
|
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
describe('the broker host a node is told to use', () => {
|
|
79
|
+
// A node resolves this as a DNS name and opens a plain connection to 1883. The browser's own
|
|
80
|
+
// broker address is a WebSocket URL through the reverse proxy, and pasting that into a sketch
|
|
81
|
+
// produced a node that enrolled and then reported "DNS Failed ... -54" for ever.
|
|
82
|
+
test('the host comes out of the websocket URL a browser is given', () => {
|
|
83
|
+
assert.equal(core.brokerHost('wss://frugaliot.naturalinnovation.org/wss'),
|
|
84
|
+
'frugaliot.naturalinnovation.org');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a port is not part of a host name', () => {
|
|
88
|
+
assert.equal(core.brokerHost('ws://frugaliot.local:9012'), 'frugaliot.local');
|
|
89
|
+
assert.equal(core.brokerHost('mqtt://192.168.1.107:1883'), '192.168.1.107');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a bare host is left alone, and nothing is not a crash', () => {
|
|
93
|
+
assert.equal(core.brokerHost('frugaliot.local'), 'frugaliot.local');
|
|
94
|
+
assert.equal(core.brokerHost(undefined), '');
|
|
95
|
+
assert.equal(core.brokerHost(null), '');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
78
99
|
describe('retained message patterns', () => {
|
|
79
100
|
test('a pattern is written relative to the organization', () => {
|
|
80
101
|
// "lotus/+/sht30" on org dev means dev/lotus/+/sht30 - nobody should have to type the org
|
package/test/datatree.test.js
CHANGED
|
@@ -16,6 +16,54 @@ before(async () => {
|
|
|
16
16
|
mock.loadConfig(config);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
/*
|
|
20
|
+
* What counts as having heard from a node.
|
|
21
|
+
*
|
|
22
|
+
* Nodes publish almost everything retained, so subscribing replays every last-known value whatever
|
|
23
|
+
* state the node is in. Counting that as contact made a device that died weeks ago read as "live"
|
|
24
|
+
* on any server whose logger had restarted since - the logger only learns lastseen from the
|
|
25
|
+
* non-retained discovery heartbeat, so the nodes with no lastseen are exactly the dead ones.
|
|
26
|
+
*/
|
|
27
|
+
describe('a retained message is the broker talking, not the node', () => {
|
|
28
|
+
const scenario = () => {
|
|
29
|
+
const { projectMt } = mock.runScenario('one-device');
|
|
30
|
+
return projectMt.nodes['esp8266-fb94bb'];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
test('a replayed value does not make a silent node look live', () => {
|
|
34
|
+
const node = scenario();
|
|
35
|
+
node.lastMessageAt = undefined; // as it is before anything has been heard
|
|
36
|
+
core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', true);
|
|
37
|
+
assert.equal(node.age, null, 'age is unknown, not zero');
|
|
38
|
+
assert.equal(node.status, 'never');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('but its value still arrives - the reading is current, the timestamp is not', () => {
|
|
42
|
+
const node = scenario();
|
|
43
|
+
node.lastMessageAt = undefined;
|
|
44
|
+
core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '23.5', true);
|
|
45
|
+
assert.equal(node.groups.sht.topics.temperature.state.value, 23.5);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a live publish does count', () => {
|
|
49
|
+
// The flag is clear on any delivery to an already-established subscription, so this is what a
|
|
50
|
+
// node actually reporting looks like
|
|
51
|
+
const node = scenario();
|
|
52
|
+
node.lastMessageAt = undefined;
|
|
53
|
+
core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', false);
|
|
54
|
+
assert.ok(node.age !== null && node.age < 1000);
|
|
55
|
+
assert.equal(node.status, 'live');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('config.json lastseen is what fills the gap for a node we have not heard', () => {
|
|
59
|
+
// The wrapper seeds it from the server's own record, which comes from the discovery heartbeat
|
|
60
|
+
const node = scenario();
|
|
61
|
+
node.lastMessageAt = Date.now() - 60 * 60 * 1000;
|
|
62
|
+
core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', true);
|
|
63
|
+
assert.ok(node.age > 59 * 60 * 1000, 'a replay must not reset it to now');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
19
67
|
describe('discovery', () => {
|
|
20
68
|
test('a node id on the project topic creates a node', () => {
|
|
21
69
|
const { projectMt } = mock.runScenario('one-device');
|
|
@@ -246,3 +294,58 @@ describe('two temperature sources', () => {
|
|
|
246
294
|
assert.equal(nodeMt.groups.ds18b20.state.name, 'Soil Temperature');
|
|
247
295
|
});
|
|
248
296
|
});
|
|
297
|
+
|
|
298
|
+
/*
|
|
299
|
+
* Seeding "last seen" from config.json.
|
|
300
|
+
*
|
|
301
|
+
* The server knows when it last saw each node - from the non-retained discovery heartbeat - and
|
|
302
|
+
* puts it in config.json. The wrapper uses that as its starting point, which is the only thing
|
|
303
|
+
* that tells a browser anything about a node that has not spoken since the page loaded. Load
|
|
304
|
+
* bearing since a retained delivery stopped counting as contact, and previously easy to lose: the
|
|
305
|
+
* seed is skipped if anything has already been heard, so a retained flood arriving first would
|
|
306
|
+
* silently replace an hour-old timestamp with "now".
|
|
307
|
+
*
|
|
308
|
+
* appender() is called directly rather than by appending the element: connectedCallback fetches
|
|
309
|
+
* /config.json unconditionally and there is no server here. Everything below it is the real path.
|
|
310
|
+
*/
|
|
311
|
+
describe('last seen, taken from config.json', () => {
|
|
312
|
+
const AN_HOUR = 60 * 60 * 1000;
|
|
313
|
+
|
|
314
|
+
const wrapperFor = (isoSeen) => {
|
|
315
|
+
const cfg = JSON.parse(JSON.stringify(config));
|
|
316
|
+
cfg.organizations.dev.projects.lotus.nodes = { 'esp8266-fb94bb': { lastseen: isoSeen } };
|
|
317
|
+
core.configSet(cfg);
|
|
318
|
+
core.mqtt_unsubscribe_organization('dev'); // module-level, so it leaks between tests
|
|
319
|
+
const w = document.createElement('mqtt-wrapper');
|
|
320
|
+
w.setAttribute('organization', 'dev');
|
|
321
|
+
w.setAttribute('project', 'lotus');
|
|
322
|
+
w.appender();
|
|
323
|
+
return w;
|
|
324
|
+
};
|
|
325
|
+
const tidy = () => { core.mqtt_unsubscribe_organization('dev'); core.configSet(config); };
|
|
326
|
+
|
|
327
|
+
test('a node nobody has heard from still shows when the server last saw it', () => {
|
|
328
|
+
const w = wrapperFor(new Date(Date.now() - AN_HOUR).toISOString());
|
|
329
|
+
const node = w.projectMt && w.projectMt.nodes['esp8266-fb94bb'];
|
|
330
|
+
assert.ok(node, 'the node should exist from the config alone');
|
|
331
|
+
assert.ok(node.age >= AN_HOUR - 5000 && node.age <= AN_HOUR + 5000, `age was ${node.age}`);
|
|
332
|
+
assert.equal(node.status, 'offline'); // an hour with nothing heard, not "live"
|
|
333
|
+
tidy();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('and a replayed retained value does not overwrite it', () => {
|
|
337
|
+
const w = wrapperFor(new Date(Date.now() - AN_HOUR).toISOString());
|
|
338
|
+
core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', true);
|
|
339
|
+
const node = w.projectMt.nodes['esp8266-fb94bb'];
|
|
340
|
+
assert.ok(node.age > AN_HOUR - 5000, `a replay reset it to ${node.age}`);
|
|
341
|
+
assert.equal(node.groups.sht.topics.temperature.state.value, 21.5, 'the value still arrives');
|
|
342
|
+
tidy();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('a live publish does replace it, because that is real contact', () => {
|
|
346
|
+
const w = wrapperFor(new Date(Date.now() - AN_HOUR).toISOString());
|
|
347
|
+
core.mqtt_deliver('dev/lotus/esp8266-fb94bb/sht/temperature', '21.5', false);
|
|
348
|
+
assert.ok(w.projectMt.nodes['esp8266-fb94bb'].age < 5000);
|
|
349
|
+
tidy();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What a graph line is called and what colour it is drawn in. Chart.js cannot draw in jsdom (no
|
|
2
|
+
// canvas), so these test the two decisions made before the chart is built.
|
|
3
|
+
import '../test/setup.js';
|
|
4
|
+
import { test, describe, before } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const config = JSON.parse(readFileSync(new URL('./fixtures/config.json', import.meta.url), 'utf8'));
|
|
9
|
+
let mock;
|
|
10
|
+
|
|
11
|
+
before(async () => {
|
|
12
|
+
mock = await import('./mock.js');
|
|
13
|
+
await import('../core.js');
|
|
14
|
+
await import('../graph.js');
|
|
15
|
+
mock.loadConfig(config);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('a legend entry names the device', () => {
|
|
19
|
+
test('the label a graphdataset is given says which node the reading came from', () => {
|
|
20
|
+
const { projectMt } = mock.runScenario('one-device');
|
|
21
|
+
const mt = projectMt.nodes['esp8266-fb94bb'].groups.sht.topics.temperature;
|
|
22
|
+
// The label createGraph passes; it went through node (the element), absent on the card UI
|
|
23
|
+
assert.equal(mt.fullName, 'Greenhouse North:SHT:Temperature');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('lines sharing a schema colour are told apart', () => {
|
|
28
|
+
const graph = () => new (customElements.get('mqtt-graph'))();
|
|
29
|
+
const added = (g, baseColor) => {
|
|
30
|
+
const ds = { baseColor };
|
|
31
|
+
g.assignColor(ds);
|
|
32
|
+
g.datasets.push(ds);
|
|
33
|
+
return ds;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
test('the first line keeps its colour, later ones differ from it and each other', () => {
|
|
37
|
+
const g = graph();
|
|
38
|
+
const colors = [0, 1, 2].map(() => added(g, '#008000').borderColor);
|
|
39
|
+
assert.equal(colors[0], '#008000');
|
|
40
|
+
assert.equal(new Set(colors).size, 3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a different base colour is unaffected by how many share the first', () => {
|
|
44
|
+
const g = graph();
|
|
45
|
+
added(g, '#008000');
|
|
46
|
+
added(g, '#008000');
|
|
47
|
+
assert.equal(added(g, '#0000ff').borderColor, '#0000ff');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('removing a line frees its colour for the next one', () => {
|
|
51
|
+
const g = graph();
|
|
52
|
+
added(g, 'brown');
|
|
53
|
+
const second = added(g, 'brown');
|
|
54
|
+
g.datasets.splice(g.datasets.indexOf(second), 1);
|
|
55
|
+
assert.equal(added(g, 'brown').borderColor, second.borderColor);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a fill is the line colour at 30%', () => {
|
|
59
|
+
const g = graph();
|
|
60
|
+
assert.equal(added(g, '#008000').backgroundColor, 'rgba(0, 128, 0, 0.3)');
|
|
61
|
+
assert.match(added(g, '#008000').backgroundColor, /^rgba\(\d+, \d+, \d+, 0\.3\)$/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('black has no hue to rotate, so its variants still differ', () => {
|
|
65
|
+
const g = graph();
|
|
66
|
+
const colors = [0, 1, 2].map(() => added(g, '#000000').borderColor);
|
|
67
|
+
assert.equal(new Set(colors).size, 3);
|
|
68
|
+
});
|
|
69
|
+
});
|
package/test/page.test.js
CHANGED
|
@@ -26,6 +26,50 @@ before(async () => {
|
|
|
26
26
|
});
|
|
27
27
|
beforeEach(() => { withCapabilities('READ'); try { localStorage.clear(); } catch (e) { /* none */ } });
|
|
28
28
|
|
|
29
|
+
// The permissions table outlives config.d - an organization dropped from it, or hosted on another
|
|
30
|
+
// server, keeps its rows, and buildConfigFor serves only the organizations the config has.
|
|
31
|
+
describe('a permission naming an organization that is not in the config', () => {
|
|
32
|
+
const withStaleOrg = () => mock.loadConfig({ ...base, user: { id: 2, name: 'test', permissions: [
|
|
33
|
+
{ org: 'dev', capability: 'ADMIN' },
|
|
34
|
+
{ org: 'dev', capability: 'OTAUPDATE' },
|
|
35
|
+
{ org: 'gone', capability: 'ADMIN' }, // no such organization in this config
|
|
36
|
+
{ org: 'gone', capability: 'OTAUPDATE' },
|
|
37
|
+
] } });
|
|
38
|
+
|
|
39
|
+
test('does not stop the section rendering', () => {
|
|
40
|
+
withStaleOrg();
|
|
41
|
+
const admin = document.createElement('mqtt-admin');
|
|
42
|
+
admin.setAttribute('section', 'admin');
|
|
43
|
+
document.body.append(admin); // connectedCallback used to throw here
|
|
44
|
+
assert.ok(admin.shadowRoot.querySelector('.mqtt-admin'), 'the section rendered nothing');
|
|
45
|
+
admin.remove();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('is left out, rather than offered and then failing on every card it opens', () => {
|
|
49
|
+
withStaleOrg();
|
|
50
|
+
const admin = document.createElement('mqtt-admin');
|
|
51
|
+
admin.setAttribute('section', 'admin');
|
|
52
|
+
document.body.append(admin);
|
|
53
|
+
assert.deepEqual(admin.adminOrgs, [['dev', 'Development']]);
|
|
54
|
+
assert.deepEqual(admin.otaOrgs, [['dev', 'Development']]);
|
|
55
|
+
// Only one left, so it is chosen without asking - which is the point of dropping the other
|
|
56
|
+
assert.equal(admin.state.org, 'dev');
|
|
57
|
+
admin.remove();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('an organization-wide and a project-scoped row list it once', () => {
|
|
61
|
+
mock.loadConfig({ ...base, user: { id: 2, name: 'test', permissions: [
|
|
62
|
+
{ org: 'dev', capability: 'ADMIN' },
|
|
63
|
+
{ org: 'dev', capability: 'ADMIN', project: 'lotus' },
|
|
64
|
+
] } });
|
|
65
|
+
const admin = document.createElement('mqtt-admin');
|
|
66
|
+
admin.setAttribute('section', 'admin');
|
|
67
|
+
document.body.append(admin);
|
|
68
|
+
assert.deepEqual(admin.adminOrgs, [['dev', 'Development']]);
|
|
69
|
+
admin.remove();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
29
73
|
describe('which admin cards a user gets', () => {
|
|
30
74
|
test('a reader gets Info and nothing else', () => {
|
|
31
75
|
// Info needs no capability: connection details are reference information, and having one card
|
|
@@ -143,8 +187,9 @@ describe('the project back', () => {
|
|
|
143
187
|
// Reach into the shadow root: the card's own textContent does not see the section's headings
|
|
144
188
|
const headings = (key) => {
|
|
145
189
|
back.state.elements[key].querySelector('.fi-admincard__head').click();
|
|
190
|
+
// The heading's first span is its title - the one beside it is the refresh control
|
|
146
191
|
return [...back.state.elements[key].querySelector('mqtt-admin')
|
|
147
|
-
.shadowRoot.querySelectorAll('h3')].map((h) => h.textContent);
|
|
192
|
+
.shadowRoot.querySelectorAll('h3')].map((h) => (h.querySelector('span') || h).textContent);
|
|
148
193
|
};
|
|
149
194
|
assert.deepEqual(headings('admin'), ['Permissions'], 'people only');
|
|
150
195
|
assert.deepEqual(headings('projects'), ['Projects']);
|