frugal-iot-client 2.0.1 → 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/admin.js +129 -10
- package/core.js +8 -2
- package/package.json +5 -2
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, brokerHost, 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 ----------
|
|
@@ -291,6 +291,92 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
291
291
|
this.replaceElement("enrolled_nodes", this.enrolledNodesList());
|
|
292
292
|
});
|
|
293
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
|
+
}
|
|
294
380
|
onNodeReset(project, nodeid) {
|
|
295
381
|
// Destructive and not obvious: forgetting a node that is running is safe (it notices its
|
|
296
382
|
// credential being refused and enrols again) but it does cost that node a few minutes offline.
|
|
@@ -315,10 +401,32 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
315
401
|
*/
|
|
316
402
|
nodeStates() {
|
|
317
403
|
return {
|
|
318
|
-
enrolled: { label: "Yes",
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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",
|
|
322
430
|
};
|
|
323
431
|
}
|
|
324
432
|
onNodeState(project, nodeid, state) {
|
|
@@ -335,7 +443,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
335
443
|
const nodes = this.state.enrolled_nodes;
|
|
336
444
|
if (!nodes || !nodes.length) {
|
|
337
445
|
return el('p', {textContent:
|
|
338
|
-
"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."});
|
|
339
449
|
}
|
|
340
450
|
const states = this.nodeStates();
|
|
341
451
|
return el('table', {class: 'nodestates'}, [
|
|
@@ -353,10 +463,14 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
353
463
|
el('span', {class: 'pseudolink', title: getString(st.hint), textContent: st.label,
|
|
354
464
|
onclick: this.onNodeState.bind(this, n.project, n.nodeid, st.next)}),
|
|
355
465
|
]),
|
|
356
|
-
// 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.
|
|
357
470
|
el('td', {i8n: false, textContent: n.asked_at
|
|
358
471
|
? `${this.formatLastSeen(new Date(n.asked_at).toISOString())} from ${n.asked_from || '?'}` +
|
|
359
|
-
`${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}` : ''}`
|
|
360
474
|
: (n.enrolled_at ? new Date(n.enrolled_at).toISOString().slice(0, 10) : '')}),
|
|
361
475
|
el('td', {}, [
|
|
362
476
|
n.enrolled_at
|
|
@@ -982,7 +1096,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
982
1096
|
// the enrolled-node list as well as reading server_config - leaving it out was why that section
|
|
983
1097
|
// always said "No nodes have enrolled yet": loadSectionIfNeeded returns early for a tab that is not
|
|
984
1098
|
// in this set, so the fetch never happened.
|
|
985
|
-
this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'API']);
|
|
1099
|
+
this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'Bridges', 'API']);
|
|
986
1100
|
this.loadSectionIfNeeded(this.state.activeSectionTitle);
|
|
987
1101
|
// Only a card created without an organization has a dropdown of its own; replaceElement does
|
|
988
1102
|
// nothing for an element this rendering never made. Fine too if this.state.org is undefined.
|
|
@@ -1020,6 +1134,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1020
1134
|
this.getProjectsList();
|
|
1021
1135
|
} else if (title === 'Nodes') {
|
|
1022
1136
|
this.getEnrolledNodes();
|
|
1137
|
+
} else if (title === 'Bridges') {
|
|
1138
|
+
this.getBridges();
|
|
1023
1139
|
} else if (title === 'API') {
|
|
1024
1140
|
this.getPlatformsList();
|
|
1025
1141
|
this.getFarmsList();
|
|
@@ -1404,7 +1520,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1404
1520
|
el('p', {textContent:
|
|
1405
1521
|
"Each node has its own broker credential, which it collects from this server the first " +
|
|
1406
1522
|
"time it connects. Forget one to have it issued a new credential - needed if its " +
|
|
1407
|
-
"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."}),
|
|
1408
1526
|
this.state.elements.enrolled_nodes = this.enrolledNodesList(),
|
|
1409
1527
|
]),
|
|
1410
1528
|
el('section', {}, [
|
|
@@ -1456,6 +1574,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1456
1574
|
{ key: 'message', title: "Message", orgs: 'adminOrgs', dropdown: 'messageorgsdropdown', rest: 'message_rest', content: this.messageRestContent },
|
|
1457
1575
|
{ key: 'retained', title: "Retained", orgs: 'adminOrgs', dropdown: 'retainedorgsdropdown', rest: 'retained_rest', content: this.retainedRestContent },
|
|
1458
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 },
|
|
1459
1578
|
{ key: 'api', title: "API", orgs: 'adminOrgs', dropdown: 'apiorgsdropdown', rest: 'api_rest', content: this.apiRestContent },
|
|
1460
1579
|
];
|
|
1461
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
|
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
|
}
|