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.
Files changed (3) hide show
  1. package/admin.js +129 -10
  2. package/core.js +8 -2
  3. 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", next: 'denied', hint: "Has its own broker credential. Click to deny it." },
319
- failed: { label: "Failed", next: 'approved', hint: "Asked and was refused. Click to let it enrol." },
320
- approved: { label: "Approved", next: 'denied', hint: "Its next request will be accepted. Click to deny it." },
321
- denied: { label: "Denied", next: 'cleared', hint: "Stopped, and its credential removed. Click to clear." },
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 connects."});
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
- // Subscribe to a topic (no wild cards as topic not passed to cb)
122
- function mqtt_subscribe(topic, cb) { // cb(message)
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": "https://github.com/mitra42/frugal-iot-client",
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.1"
44
+ "version": "2.0.2"
42
45
  }