frugal-iot-client 2.0.1 → 2.0.3

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 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,157 @@ 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
+ /*
369
+ * Add a bridge, or re-issue an existing one's credentials.
370
+ *
371
+ * The result is shown once and is not stored anywhere it can be read back: the password is
372
+ * derived from a server secret and this row's timestamp, so the only copy is the one you put into
373
+ * the Pi's configuration. Re-running for the same site mints new ones and revokes the old, which
374
+ * is what makes it the way to recover from having lost them.
375
+ */
376
+ onBridgeAdd() {
377
+ const input = this.state.elements.bridge_site;
378
+ const site = (input && input.value || '').trim();
379
+ if (!site) { this.message(getString("Give the Pi a short name first")); return; }
380
+ if (this.state.bridges && this.state.bridges.some((b) => b.site === site)
381
+ && !window.confirm(
382
+ `"${site}" already exists.\n\n` +
383
+ `Adding it again issues a new broker password and a new replica token, and the ones that ` +
384
+ `Pi is using now stop working. It will stay disconnected until you put the new ones into ` +
385
+ `its configuration and restart its broker.`)) return;
386
+ POST(`/bridge_add/${this.state.org}`, { site }, (err, json) => {
387
+ if (err) { this.message(err.message); return; }
388
+ this.state.new_bridge = json; // shown once, below
389
+ this.getBridges(); // refreshes the table and re-renders the card
390
+ this.replaceElement("bridge_new", this.newBridgeDetails());
391
+ });
392
+ }
393
+ /*
394
+ * What the Pi needs, once. Deliberately not fetchable again: there is no route that reads a
395
+ * bridge's password back, so losing it means issuing a new one.
396
+ */
397
+ newBridgeDetails() {
398
+ const b = this.state.new_bridge;
399
+ if (!b) return el('span', {});
400
+ return el('div', {class: 'bridge-new'}, [
401
+ el('h4', {textContent: "Copy these now - they are not shown again"}),
402
+ el('p', {textContent:
403
+ "Run this on the Pi, in the directory the server is installed in. Replace the placeholder " +
404
+ "with this server's fully-qualified name - the one on its certificate, which you can check " +
405
+ "by opening https:// that name in a browser. It will then ask for the broker password and " +
406
+ "for the replica token, in that order, so have both of the values below to hand."}),
407
+ el('pre', {i8n: false, textContent: b.command}),
408
+ el('table', {class: 'nodestates'}, [
409
+ el('tr', {}, [el('th', {textContent: "Broker account"}), el('td', {i8n: false, textContent: b.account})]),
410
+ el('tr', {}, [el('th', {textContent: "Broker password"}), el('td', {i8n: false, textContent: b.password})]),
411
+ el('tr', {}, [el('th', {textContent: "Replica token"}), el('td', {i8n: false, textContent: b.replica_token})]),
412
+ ]),
413
+ el('p', {textContent:
414
+ "Two credentials for two different connections: the password is what the MQTT bridge logs " +
415
+ "in with on port 8883, and the token is what lets that Pi pull this organization's logins " +
416
+ "over HTTPS, so the same people can log in there too. The token can be skipped - press " +
417
+ "Enter at its prompt - and added later by doing this again. Neither is asked for on the " +
418
+ "command line, so that neither ends up in the Pi's shell history."}),
419
+ ]);
420
+ }
421
+ bridgesRestContent() {
422
+ return el('div', {}, [
423
+ this.refreshableHeading("Bridged Pis", this.getBridges),
424
+ el('p', {textContent:
425
+ "A bridge relays a Pi's readings to this server, so its nodes appear here too, while the " +
426
+ "Pi carries on recording and serving on its own whenever the link is down. \"Bridge\" is " +
427
+ "the broker's live view of the relay; \"Last check-in\" is when that Pi last collected " +
428
+ "this organization's logins, which it does over HTTP - so a Pi can be checking in while " +
429
+ "its bridge is down, and the two together say which half is broken."}),
430
+ this.state.elements.bridges_list = this.bridgesList(),
431
+ el('section', {}, [
432
+ el('h3', {textContent: "Add a bridge"}),
433
+ el('p', {textContent:
434
+ "Give the Pi a short name - it becomes its broker account and cannot be changed later " +
435
+ "without reconfiguring that Pi. Lower-case letters, digits and hyphens."}),
436
+ el('div', {}, [
437
+ this.state.elements.bridge_site = el('input',
438
+ {type: 'text', name: 'site', placeholder: 'pi4', size: 16}),
439
+ el('button', {textContent: "Add", onclick: this.onBridgeAdd.bind(this)}),
440
+ ]),
441
+ this.state.elements.bridge_new = this.newBridgeDetails(),
442
+ ]),
443
+ ]);
444
+ }
294
445
  onNodeReset(project, nodeid) {
295
446
  // Destructive and not obvious: forgetting a node that is running is safe (it notices its
296
447
  // credential being refused and enrols again) but it does cost that node a few minutes offline.
@@ -315,10 +466,32 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
315
466
  */
316
467
  nodeStates() {
317
468
  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." },
469
+ enrolled: { label: "Yes", next: 'denied', hint: "Has its own broker credential. Click to deny it." },
470
+ // "Failed" alone read as a status rather than a control, so the one action this card exists to
471
+ // offer - approving a node that cannot prove anything - was not findable by someone looking
472
+ // for it. The label says what clicking does.
473
+ failed: { label: "Failed - approve", next: 'approved', hint: "Asked and was refused. Click to let it enrol." },
474
+ approved: { label: "Approved", next: 'denied', hint: "Its next request will be accepted. Click to deny it." },
475
+ denied: { label: "Denied", next: 'cleared', hint: "Stopped, and its credential removed. Click to clear." },
476
+ };
477
+ }
478
+ /*
479
+ * Why the server turned a node away, in words rather than the wire code.
480
+ *
481
+ * Worth the table: the codes are deliberately vague to the NODE (so that probing an organization
482
+ * name learns nothing), but an administrator looking at their own organization needs the
483
+ * difference - "wrong secret" and "project not registered" have completely different fixes, and
484
+ * neither is fixed by approving the node.
485
+ */
486
+ nodeAskReasons() {
487
+ return {
488
+ refused: "enrolment secret wrong or missing",
489
+ no_such_project: "project is not registered here",
490
+ bad_request: "malformed org, project or node id",
491
+ needs_reset: "already enrolled, cannot prove it",
492
+ denied: "denied here",
493
+ rate_limited: "asking too often",
494
+ broker_unavailable: "broker was unavailable",
322
495
  };
323
496
  }
324
497
  onNodeState(project, nodeid, state) {
@@ -335,7 +508,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
335
508
  const nodes = this.state.enrolled_nodes;
336
509
  if (!nodes || !nodes.length) {
337
510
  return el('p', {textContent:
338
- "No nodes have enrolled yet. A node enrols itself the first time it connects."});
511
+ "No nodes have enrolled yet, and none is asking to. A node enrols itself the first time it " +
512
+ "connects; one that asks and is refused is listed here too, so an empty list means nothing " +
513
+ "is reaching this server at all - check that the node's org and enrol URL are this one."});
339
514
  }
340
515
  const states = this.nodeStates();
341
516
  return el('table', {class: 'nodestates'}, [
@@ -353,10 +528,14 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
353
528
  el('span', {class: 'pseudolink', title: getString(st.hint), textContent: st.label,
354
529
  onclick: this.onNodeState.bind(this, n.project, n.nodeid, st.next)}),
355
530
  ]),
356
- // Untrusted, and shown as such: an address and a time for the admin to recognise
531
+ // Untrusted, and shown as such: an address and a time for the admin to recognise. The
532
+ // reason is here too - without it every refusal looks the same, and the ones an admin can
533
+ // do something about (a project that is not registered, a node id that is not a node id)
534
+ // are indistinguishable from the one that approving fixes.
357
535
  el('td', {i8n: false, textContent: n.asked_at
358
536
  ? `${this.formatLastSeen(new Date(n.asked_at).toISOString())} from ${n.asked_from || '?'}` +
359
- `${n.asked_count > 1 ? ` (${n.asked_count}x)` : ''}`
537
+ `${n.asked_count > 1 ? ` (${n.asked_count}x)` : ''}` +
538
+ `${n.asked_reason ? ` - ${this.nodeAskReasons()[n.asked_reason] || n.asked_reason}` : ''}`
360
539
  : (n.enrolled_at ? new Date(n.enrolled_at).toISOString().slice(0, 10) : '')}),
361
540
  el('td', {}, [
362
541
  n.enrolled_at
@@ -982,7 +1161,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
982
1161
  // the enrolled-node list as well as reading server_config - leaving it out was why that section
983
1162
  // always said "No nodes have enrolled yet": loadSectionIfNeeded returns early for a tab that is not
984
1163
  // in this set, so the fetch never happened.
985
- this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'API']);
1164
+ this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'Bridges', 'API']);
986
1165
  this.loadSectionIfNeeded(this.state.activeSectionTitle);
987
1166
  // Only a card created without an organization has a dropdown of its own; replaceElement does
988
1167
  // nothing for an element this rendering never made. Fine too if this.state.org is undefined.
@@ -1020,6 +1199,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1020
1199
  this.getProjectsList();
1021
1200
  } else if (title === 'Nodes') {
1022
1201
  this.getEnrolledNodes();
1202
+ } else if (title === 'Bridges') {
1203
+ this.getBridges();
1023
1204
  } else if (title === 'API') {
1024
1205
  this.getPlatformsList();
1025
1206
  this.getFarmsList();
@@ -1404,7 +1585,9 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1404
1585
  el('p', {textContent:
1405
1586
  "Each node has its own broker credential, which it collects from this server the first " +
1406
1587
  "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."}),
1588
+ "filesystem has been erased, because it can then no longer prove which node it is. A " +
1589
+ "node that asked and was refused is listed here as well, with why: click Approve on it " +
1590
+ "to accept its next request whatever secret it presents. It retries every half hour."}),
1408
1591
  this.state.elements.enrolled_nodes = this.enrolledNodesList(),
1409
1592
  ]),
1410
1593
  el('section', {}, [
@@ -1456,6 +1639,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1456
1639
  { key: 'message', title: "Message", orgs: 'adminOrgs', dropdown: 'messageorgsdropdown', rest: 'message_rest', content: this.messageRestContent },
1457
1640
  { key: 'retained', title: "Retained", orgs: 'adminOrgs', dropdown: 'retainedorgsdropdown', rest: 'retained_rest', content: this.retainedRestContent },
1458
1641
  { key: 'nodes', title: "Nodes", orgs: 'adminOrgs', dropdown: 'nodesorgsdropdown', rest: 'nodes_rest', content: this.nodesRestContent },
1642
+ { key: 'bridges', title: "Bridges", orgs: 'adminOrgs', dropdown: 'bridgesorgsdropdown', rest: 'bridges_rest', content: this.bridgesRestContent },
1459
1643
  { key: 'api', title: "API", orgs: 'adminOrgs', dropdown: 'apiorgsdropdown', rest: 'api_rest', content: this.apiRestContent },
1460
1644
  ];
1461
1645
  }
package/cards.js CHANGED
@@ -733,6 +733,7 @@ const ADMIN_CARDS = [
733
733
  { section: 'message', title: 'Publish Message', capability: 'ADMIN' },
734
734
  { section: 'retained', title: 'Retained messages', capability: 'ADMIN' },
735
735
  { section: 'nodes', title: 'Nodes', capability: 'ADMIN' },
736
+ { section: 'bridges', title: 'Bridges', capability: 'ADMIN' },
736
737
  { section: 'api', title: 'API', capability: 'ADMIN' },
737
738
  ];
738
739
 
package/core.js CHANGED
@@ -118,15 +118,38 @@ 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
+ * Say what the broker made of a subscription.
123
+ *
124
+ * A broker that REFUSES one does not report an error: MQTT 3.1.1 answers with SUBACK return code
125
+ * 0x80, which mqtt.js hands back as granted qos 128 in the second argument. Checking only `err`
126
+ * therefore made a refusal completely silent - "Subscribing to X" and then nothing, for ever,
127
+ * looking exactly like a topic nobody publishes to. That is the difference between "no permission"
128
+ * and "no data", and it is the first thing you want to know.
129
+ */
130
+ function subscribeResult(topic, err, granted) {
131
+ if (err) { console.error("Subscribe failed", topic, err); return; }
132
+ const refused = (granted || []).filter((g) => g.qos === 128);
133
+ if (refused.length) {
134
+ console.error(`Subscribe REFUSED by the broker: ${topic} - this login's broker account has no `
135
+ + `permission for it. Its rights are set when it logs in; a permission added since then `
136
+ + `needs a fresh login, or "frugal-iot-rebuild-dynsec" on the server.`);
137
+ }
138
+ }
139
+
140
+ /*
141
+ * Subscribe to a topic. cb(topic, message, retained) - see mqtt_deliver, which passes all three.
142
+ *
143
+ * A trailing "/#" works. A "+" anywhere does NOT: topicMatches understands only the trailing form,
144
+ * so the broker would deliver the messages and none of them would reach cb. Subscribe to each
145
+ * concrete topic instead, or teach topicMatches about "+".
146
+ */
147
+ function mqtt_subscribe(topic, cb) { // cb(topic, message, retained)
123
148
  console.log("Subscribing to ", topic);
124
149
  mqtt_subscriptions.push({topic, cb});
125
150
  // There may be no client yet - it only connects once it knows which organization's credentials to use
126
151
  if (mqtt_client && mqtt_client.connected) {
127
- mqtt_client.subscribe(topic, (err) => {
128
- if (err) console.error(err);
129
- })
152
+ mqtt_client.subscribe(topic, (err, granted) => subscribeResult(topic, err, granted))
130
153
  } else {
131
154
  console.log("Delaying till connected"); // It will resubscribe from "subscriptions"
132
155
  }
@@ -2055,10 +2078,13 @@ class MqttTopic {
2055
2078
  type: this.type === 'exponential' ? 'logarithmic' : 'linear',
2056
2079
  display: this.type !== 'bool',
2057
2080
  title: {
2081
+ display: true, // Chart.js hides a scale title unless asked - without this the axis has no label at all
2058
2082
  // noinspection JSUnresolvedReference
2059
2083
  color: this.color, // The family colour; the per-line variation happens in MqttGraph.addDataset
2084
+ font: { size: 20 },
2085
+ // The unit is what tells two axes apart when both read as a number - "Temperature (°C)"
2060
2086
  // noinspection JSUnresolvedReference
2061
- text: getString(this.name.replace(/[0-9]+$/,'')),
2087
+ text: getString(this.name.replace(/[0-9]+$/,'')) + (unitSymbol(this.units) ? ` (${unitSymbol(this.units)})` : ''),
2062
2088
  },
2063
2089
  // 0 is not valid on a logarithmic axis, so fall back to auto-scaling (undefined) rather than 0
2064
2090
  // noinspection JSUnresolvedReference
@@ -2823,9 +2849,7 @@ class MqttClient extends HTMLElementExtended {
2823
2849
  mqtt_subscriptions.forEach((s) => {
2824
2850
  if (!mqtt_client._resubscribeTopics[s.topic]) { // Not really public interface but cleaner console as not needed
2825
2851
  console.log("Now connected, subscribing to", s.topic);
2826
- mqtt_client.subscribe(s.topic, (err) => {
2827
- if (err) console.error(err);
2828
- });
2852
+ mqtt_client.subscribe(s.topic, (err, granted) => subscribeResult(s.topic, err, granted));
2829
2853
  }
2830
2854
  })
2831
2855
  } else {
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.3"
42
45
  }
@@ -67,3 +67,24 @@ describe('lines sharing a schema colour are told apart', () => {
67
67
  assert.equal(new Set(colors).size, 3);
68
68
  });
69
69
  });
70
+
71
+ describe('an axis says what it measures', () => {
72
+ // graphdataset.graph is the route to a graph that does not need a connected element or a canvas
73
+ const axisOf = (mt, g) => { mt.graphdataset = { graph: g }; const id = mt.yaxisid; return g.state.scales[id]; };
74
+
75
+ test('the title is drawn, and carries the unit symbol', () => {
76
+ const { projectMt } = mock.runScenario('one-device');
77
+ const g = new (customElements.get('mqtt-graph'))();
78
+ const axis = axisOf(projectMt.nodes['esp8266-fb94bb'].groups.sht.topics.temperature, g);
79
+ assert.equal(axis.title.display, true);
80
+ assert.equal(axis.title.text, 'Temperature (°C)');
81
+ });
82
+
83
+ test('a reading with no unit in the schema gets a bare name', () => {
84
+ const { projectMt } = mock.runScenario('one-device');
85
+ const g = new (customElements.get('mqtt-graph'))();
86
+ const mt = projectMt.nodes['esp8266-fb94bb'].groups.sht.topics.temperature;
87
+ mt.units = undefined;
88
+ assert.equal(axisOf(mt, g).title.text, 'Temperature');
89
+ });
90
+ });
package/test/page.test.js CHANGED
@@ -84,7 +84,7 @@ describe('which admin cards a user gets', () => {
84
84
  assert.deepEqual(cards.adminCardsFor('dev').map((c) => c.section), ['info', 'flash']);
85
85
  withCapabilities('READ', 'ADMIN');
86
86
  assert.deepEqual(cards.adminCardsFor('dev').map((c) => c.section),
87
- ['info', 'admin', 'projects', 'message', 'retained', 'nodes', 'api']);
87
+ ['info', 'admin', 'projects', 'message', 'retained', 'nodes', 'bridges', 'api']);
88
88
  });
89
89
 
90
90
  test('flashing and pushing an OTA binary are separate capabilities', () => {
@@ -108,7 +108,7 @@ describe('the project back', () => {
108
108
  const back = document.createElement('mqtt-projectback');
109
109
  back.setAttribute('organization', 'dev');
110
110
  document.body.append(back);
111
- assert.equal(back.querySelectorAll('.fi-admincard').length, 7);
111
+ assert.equal(back.querySelectorAll('.fi-admincard').length, 8);
112
112
  back.remove();
113
113
  });
114
114
 
@@ -228,7 +228,7 @@ describe('the project back', () => {
228
228
  document.body.append(back);
229
229
  back.querySelectorAll('.fi-admincard__head').forEach((h) => h.click());
230
230
  const sections = [...back.querySelectorAll('mqtt-admin')].map((a) => a.getAttribute('section'));
231
- assert.deepEqual(sections, ['admin', 'projects', 'message', 'retained', 'nodes', 'api']);
231
+ assert.deepEqual(sections, ['admin', 'projects', 'message', 'retained', 'nodes', 'bridges', 'api']);
232
232
  back.remove();
233
233
  });
234
234
  });