frugal-iot-client 1.3.15 → 2.0.0

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 CHANGED
@@ -14,24 +14,32 @@ it loadable on its own, and it is maintained by reaching the display elements **
14
14
  | `widgets.js` | core | `mqtt-bar`, `mqtt-text`, `mqtt-toggle`, `mqtt-gauge`, `mqtt-slider`, `mqtt-color`, `mqtt-choosetopic` — all shadow DOM |
15
15
  | `graph.js` | core, widgets | `mqtt-graph`, `mqtt-graphdataset` — pulls Chart.js |
16
16
  | `cards.js` | core | the card UI: `mqtt-devicecard`, `mqtt-devicegrid`, `mqtt-projectback`, `mqtt-dashboard`, the layout store |
17
- | `nodeview.js` | core, widgets | the old node/group UI — **retires with `index-old.html`**; nothing new belongs here |
18
17
  | `flash.js` | core | `mqtt-flash` — pulls esptool-js |
19
- | `admin.js` | core | `mqtt-admin`, `tabbed-display` |
18
+ | `admin.js` | core | `mqtt-admin` — one admin section, rendered per card by `cards.js` |
20
19
  | `login.js` | core | `mqtt-login` — sign in, register, forgot/reset password; light DOM |
21
20
 
22
- Entry points: `index.html` → `dashboard.js` (the card UI, everything but `nodeview.js`);
23
- `index-old.html` → `webcomponents.js` (everything, and the only remaining user of it);
24
- `login.html` → `login.js`, which imports only `core.js` — that is why `mqtt-login` is not in
25
- `admin.js`; `index-embedded.html` needs only `core.js` + `widgets.js` and must keep working.
21
+ Every page loads the modules it needs; there is no file that loads everything.
22
+
23
+ | Page | Loads |
24
+ |---|---|
25
+ | `index.html` | `dashboard.js` — the card UI |
26
+ | `login.html` | `login.js`, which imports only `core.js` — that is why `mqtt-login` is not in `admin.js` |
27
+ | `index-embedded.html`, `dashboard_example.html` | `core.js` + `widgets.js` + `graph.js` |
28
+
29
+ Retired with the pre-cards UI: `index-old.html`, `index-project.html`, `nodeview.js` and
30
+ `webcomponents.js` (the shim that loaded everything), `mqtt-admin`'s tab strip, and the wrapper's
31
+ `headless` attribute — the data tree is all it builds now. `mqtt-admin` renders one section, chosen
32
+ by its `section` attribute; the cards on a project's back make one per card.
26
33
 
27
34
  Dashboards (e.g. `dashboard_example.html`) are thin HTML pages importing selectively.
28
35
 
29
36
  ## Design documents
30
37
 
31
- `CARDS_UX.md` is the design for the card UI — every decision is numbered (D-n) with its reasoning,
32
- deferred work is L-n, and it is the place to argue with a choice rather than re-deciding it in code.
33
- `CARDS_PLAN.md` is how it was built, including the bugs found on the way. `FLASH_PLAN.md` and
34
- `HEADLESS_PLAN.md` cover their own areas.
38
+ `docs/CARDS_UX.md` is the design for the card UI — every decision is numbered (D-n) with its
39
+ reasoning, deferred work is L-n, and it is the place to argue with a choice rather than re-deciding
40
+ it in code. `docs/CARDS_PLAN.md` is how it was built, including the bugs found on the way.
41
+ `docs/FLASH_PLAN.md` and `HEADLESS_PLAN.md` cover their own areas. Comments throughout the code cite
42
+ these by bare filename (`see CARDS_UX.md 4.7`); they all live in `docs/` except `HEADLESS_PLAN.md`.
35
43
 
36
44
  ## Code style
37
45
 
@@ -283,9 +291,9 @@ Each of these cost real time. They are not obvious from reading the code.
283
291
  `innerHTML`, `action`) as properties. **Any other function is silently put in `el.state`** and
284
292
  never wired up: `el({onpointerdown: fn})` does nothing at all. Use `addEventListener`.
285
293
  - **`@media (max-width: 1001px)` in `frugaliot.css`** inflates fonts and icons for the node/group UI,
286
- where a node is one wide block. Inside a card it makes everything wider than the card. Those rules
287
- read `var(--fi-chrome-font, …)`; the card page sets it to `1rem`. Anything new that is not
288
- `nodeview.js` should opt out the same way. Replace them properly when `nodeview.js` goes.
294
+ which is now retired — so these rules have no page they were drawn for. The card page opts out by
295
+ setting `--fi-chrome-font: 1rem`, which the rules read. They still reach the login page and the
296
+ embedded examples, so removing them is a visual change to those and wants looking at on a phone.
289
297
  - **`:host-context(details[open]) div { display: block }`** near the top of `frugaliot.css` has
290
298
  specificity (0,2,2) and overrides most rules for any div in a shadow root inside an open
291
299
  `<details>` — which is every widget in the old UI.
@@ -340,9 +348,9 @@ regress unnoticed; regenerate deliberately with `npm run test:update` and read t
340
348
 
341
349
  ## Internationalisation
342
350
 
343
- The master language table is `const languages` in `webcomponents.js`. Dashboard-specific strings are added via `addVocabulary(yamlString)` in the dashboard's `<script>`.
351
+ The master language table is `const languages` in `core.js`. Dashboard-specific strings are added via `addVocabulary(yamlString)` in the dashboard's `<script>`.
344
352
 
345
- Every `addVocabulary` block must include **all four** language sections (EN, FR, HI, ID). Strings absent from `webcomponents.js` must be explicitly added in the dashboard block — do not assume the core file covers them.
353
+ Every `addVocabulary` block must include **all four** language sections (EN, FR, HI, ID). Strings absent from `core.js` must be explicitly added in the dashboard block — do not assume the core file covers them.
346
354
 
347
355
  ### How a string gets translated
348
356
 
package/admin.js CHANGED
@@ -1,74 +1,14 @@
1
1
  /*
2
- * Frugal IoT client - the tab strip, and the admin panel: OTA, permissions, projects, nodes
3
- * and the API tools. The login page moved to login.js, so that it does not have to load this.
2
+ * Frugal IoT client - the admin sections: OTA, permissions, projects, nodes and the API tools.
3
+ *
4
+ * One <mqtt-admin section="..."> renders one of them, and cards.js makes one per card on a
5
+ * project's back. The login page is in login.js, so that it does not have to load this.
4
6
  */
5
7
 
6
8
  import {EL, GET, HTMLElementExtended} from '/node_modules/html-element-extended/htmlelementextended.js';
7
9
  import mqtt from '/node_modules/mqtt/dist/mqtt.esm.js'; // https://www.npmjs.com/package/mqtt
8
- import { CssUrl, 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, configSet, el, getString, hasCapability, mqttTempConnect, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
9
11
 
10
- class TabbedDisplay extends HTMLElementExtended {
11
- constructor() {
12
- super();
13
- this.state = {tab: 0};
14
- this.tabs = [];
15
- }
16
- static get observedAttributes() { return ['tab']; }
17
- static get integerAttributes() { return ['tab']; }
18
-
19
- tabSelect(tab) {
20
- this.changeAttribute('tab', tab);
21
- this.renderAndReplace();
22
- // Let a parent lazily load a tab's data only once it's actually selected, rather than eagerly
23
- // for every tab whenever e.g. the organization changes - see MqttAdmin.onTabChange.
24
- const title = this.children[tab] && this.children[tab].getAttribute('title');
25
- this.dispatchEvent(new CustomEvent('tabchange', {detail: {tab, title}}));
26
- }
27
- updateActive(value) {
28
- // Note this may get called before children added, so careful not to change 'tab'
29
- if (this.children.length && this.tabs.length) {
30
- if (value < 0) value = 0;
31
- if (value >= this.children.length) value = this.children.length - 1;
32
- for (let i = 0; i < this.children.length; i++) {
33
- //if (this.children[i].tagName.toLowerCase() === 'section') {
34
- if (i === value) {
35
- // TODO could do this more easily with classList, but need to edit CSS
36
- this.children[i].className = "tabbed-section active";
37
- this.tabs[i].className = "tab active";
38
- } else {
39
- this.children[i].className = "tabbed-section inactive";
40
- this.tabs[i].className = "tab inactive";
41
- }
42
- //}
43
- }
44
- }
45
- }
46
- changeAttribute(name, value) {
47
- super.changeAttribute(name, value); // will set this.state.tab if name is "tab"
48
- if (name === "tab") {
49
- this.updateActive(this.state.tab);
50
- }
51
- }
52
- render() {
53
- //let contents = [];
54
- let i = 0;
55
- this.tabs = Array.from(this.children).map((c, i) =>
56
- el('button', {
57
- onclick: this.tabSelect.bind(this, i),
58
- textContent: c.getAttribute('title') })
59
- );
60
- this.updateActive(this.state.tab); // sets active/inactive on children and tabs
61
- this.classList.toggle('solo', this.tabs.length <= 1); // light-DOM class so CSS can suppress border
62
- return [
63
- el('link', {rel: 'stylesheet', href: CssUrl}),
64
- el('div', {class: 'tabbed-display'}, [
65
- this.tabs.length > 1 ? el('section', {class: 'tabs'}, this.tabs) : null,
66
- el('slot',{}), // Children are the sections i.e. each tabs content
67
- ]),
68
- ];
69
- }
70
- }
71
- customElements.define('tabbed-display', TabbedDisplay);
72
12
 
73
13
  // ---------- USB flashing over WebSerial ----------
74
14
  // The .bin files the OTA tab handles are application images. An ESP32 also needs a bootloader,
@@ -78,10 +18,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
78
18
  super(props);
79
19
  this.state = {register: false, ota_files: [], people_list: [], projects_list: [], platforms_list: [], farms_list: [], farm_nodes_list: [],
80
20
  selected_platform_id: null, selected_farm_id: null, selected_farm_node: null, device_schema: null, selected_action: null,
81
- // Default tab is "Dashboard" (index 0 in render()) - tabsNeedingLoad tracks which of the other,
82
- // network-backed tabs (OTA/Admin/API) still need their data (re-)fetched for the current
83
- // organization; see setOrganization and onTabChange.
84
- activeTabTitle: 'Dashboard', tabsNeedingLoad: new Set()};
21
+ // Which section this card is, and whether its data has been fetched for the current
22
+ // organization - the sections that need a server round trip only make it once. The section is
23
+ // set from the attribute (see changeAttribute); 'Dashboard' is the do-nothing default.
24
+ activeSectionTitle: 'Dashboard', sectionsNeedingLoad: new Set()};
85
25
  this.state.elements = {};
86
26
  }
87
27
  static get observedAttributes() { return ['register','message','url','lang','org','section']; }
@@ -137,12 +77,12 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
137
77
  //super.connectedCallback(); // Not doing as finishes with a re-render.
138
78
  }
139
79
  changeAttribute(name, value) {
140
- // Rendering a single section means there is no tab strip and so no tabchange event ever fires.
141
- // activeTabTitle would stay at its default of "Dashboard", setOrganization would load that
142
- // tab's data (there is none), and the section would sit there empty with no request made.
80
+ // Which section this is decides which data setOrganization goes on to fetch. Without this it
81
+ // would stay at the do-nothing default of "Dashboard" and the card would sit there empty with
82
+ // no request ever made.
143
83
  if (name === "section") {
144
84
  const section = this.adminSections().find((x) => x.key === value);
145
- if (section) this.state.activeTabTitle = section.title;
85
+ if (section) this.state.activeSectionTitle = section.title;
146
86
  }
147
87
  if (name === "lang") {
148
88
  if (value.includes(',')) {
@@ -171,8 +111,17 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
171
111
 
172
112
  // Content behind an organization dropdown (OTA/Admin/Nodes/API tabs) should not show until an
173
113
  // organization is selected. contentFn is a MqttAdmin method, called with `this` bound.
174
- gatedContent(contentFn) {
175
- return this.state.org ? contentFn.call(this) : el('p', {textContent: "Select an organization to continue."});
114
+ // A tab's content, or why there is none: no organization chosen, or one this tab is not about -
115
+ // the OTA tab needs OTAUPDATE and the others ADMIN, and the shared dropdown offers both.
116
+ gatedContent(section) {
117
+ if (!this.state.org) return el('p', {textContent: "Select an organization to continue."});
118
+ if (!this[section.orgs].some(([oid]) => oid === this.state.org)) {
119
+ // A colon and the name, so the translated part is a whole sentence in every language rather
120
+ // than a fragment that only reads correctly with English word order.
121
+ return el('p', {i8n: false, textContent:
122
+ `${getString("You do not have permission for this organization")}: ${this.state.org}`});
123
+ }
124
+ return section.content.call(this);
176
125
  }
177
126
  // An "Add X" button that expands into buildForm() once clicked, and stays expanded - used for the
178
127
  // "Add Project", "Add Platform" and "Add Farm" forms so they don't clutter the tab until the admin wants to add one.
@@ -247,6 +196,148 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
247
196
  }
248
197
  });
249
198
  }
199
+ // Which nodes have enrolled, and forgetting one so it can enrol again.
200
+ /*
201
+ * The organization's enrolment secrets (SECURITY.md S10).
202
+ *
203
+ * Its own request, not part of /config.json, which is served to every logged-in user. Fetched
204
+ * only when the admin asks to see them, so an open Nodes card does not put a secret on screen.
205
+ */
206
+ getEnrolmentSecrets() {
207
+ if (!this.state.org) return;
208
+ GET(`/enrolment_secret/${this.state.org}`, {}, (err, json) => {
209
+ if (err) { this.message(err.message); return; }
210
+ this.state.enrolment = json; // {org, secrets, enrol_url}
211
+ this.replaceElement("enrolment_secrets", this.enrolmentSecretsList());
212
+ });
213
+ }
214
+ onEnrolmentSecretAdd() {
215
+ POST(`/enrolment_secret/${this.state.org}`, {}, (err, json) => {
216
+ this.message(err ? err.message : (json && json.message) || getString("Added"), false);
217
+ this.getEnrolmentSecrets();
218
+ });
219
+ }
220
+ onEnrolmentSecretDelete(secret) {
221
+ // Irreversible for any node still holding it, and those nodes cannot be told - they are the
222
+ // ones not yet enrolled, so nothing on the server knows they exist.
223
+ if (!window.confirm(
224
+ `Withdraw this enrolment secret?\n\n` +
225
+ `Any node flashed with it that has not enrolled yet will be refused and will need ` +
226
+ `reflashing. Nodes that have already enrolled are unaffected - they never present it again.`)) return;
227
+ DELETE(`/enrolment_secret/${this.state.org}`, {secret}, (err, json) => {
228
+ this.message(err ? err.message : (json && json.message) || getString("Withdrawn"), false);
229
+ this.getEnrolmentSecrets();
230
+ });
231
+ }
232
+ enrolmentSecretsList() {
233
+ const e = this.state.enrolment;
234
+ if (!e) {
235
+ return el('p', {}, [
236
+ el('span', {class: 'pseudolink', textContent: "Show enrolment secrets",
237
+ onclick: this.getEnrolmentSecrets.bind(this)}),
238
+ ]);
239
+ }
240
+ return el('div', {}, [
241
+ ...(e.secrets.length ? e.secrets.map((secret, i) => el('p', {}, [
242
+ el('span', {class: 'pseudolink', textContent: ' 🗑 ',
243
+ onclick: this.onEnrolmentSecretDelete.bind(this, secret)}),
244
+ // The line to paste into a sketch, so nobody has to work out the argument order
245
+ el('code', {i8n: false, class: 'enrolment__line', textContent:
246
+ `frugal_iot.configure_mqtt_enrolled("${(server_config.mqtt && server_config.mqtt.broker) || ''}", "${secret}");`}),
247
+ ])) : [el('p', {textContent:
248
+ "This organization has no enrolment secret, so no new node can enrol. Add one."})]),
249
+ el('button', {class: 'submit', type: 'button', textContent: "Add another enrolment secret",
250
+ onclick: this.onEnrolmentSecretAdd.bind(this)}),
251
+ el('p', {i8n: false, class: 'retained__help', textContent:
252
+ `${getString("Enrol URL")}: ${e.enrol_url}`}),
253
+ ]);
254
+ }
255
+ getEnrolledNodes() {
256
+ if (!this.state.org) return;
257
+ GET(`/nodes_list/${this.state.org}`, {}, (err, json) => {
258
+ if (err) { this.message(err.message); return; }
259
+ this.state.enrolled_nodes = json; // [{ project, nodeid, lora, enrolled_at }]
260
+ this.replaceElement("enrolled_nodes", this.enrolledNodesList());
261
+ });
262
+ }
263
+ onNodeReset(project, nodeid) {
264
+ // Destructive and not obvious: forgetting a node that is running is safe (it notices its
265
+ // credential being refused and enrols again) but it does cost that node a few minutes offline.
266
+ // Worth asking, unlike the OTA deletes beside it where the file can simply be uploaded again.
267
+ if (!window.confirm(
268
+ `Forget ${project}/${nodeid}?\n\n` +
269
+ `It will be issued a new broker credential the next time it asks. A node that is running ` +
270
+ `will be offline for a minute or two while it notices; a node whose filesystem was erased ` +
271
+ `needs this before it can rejoin at all.`)) return;
272
+ POST(`/node_reset/${this.state.org}`, { project, nodeid }, (err, json) => {
273
+ this.message(err ? err.message : (json && json.message) || "Forgotten");
274
+ this.getEnrolledNodes();
275
+ });
276
+ }
277
+ /*
278
+ * What each state means, what clicking it does next, and how it reads.
279
+ *
280
+ * A node id cannot be discovered from the server, so a node that keeps asking and being refused
281
+ * is how an admin learns it exists - which is why "failed" is a state here rather than an error
282
+ * somewhere in a log. Those rows are untrusted: anyone can make an id appear by attempting
283
+ * enrolment, so the row shows when it asked and from where, for the admin to recognise.
284
+ */
285
+ nodeStates() {
286
+ return {
287
+ enrolled: { label: "Yes", next: 'denied', hint: "Has its own broker credential. Click to deny it." },
288
+ failed: { label: "Failed", next: 'approved', hint: "Asked and was refused. Click to let it enrol." },
289
+ approved: { label: "Approved", next: 'denied', hint: "Its next request will be accepted. Click to deny it." },
290
+ denied: { label: "Denied", next: 'cleared', hint: "Stopped, and its credential removed. Click to clear." },
291
+ };
292
+ }
293
+ onNodeState(project, nodeid, state) {
294
+ if (state === 'denied' && !window.confirm(
295
+ `Deny ${nodeid}?\n\n` +
296
+ `Its broker account is deleted, so it stops being able to publish or subscribe, and it ` +
297
+ `cannot enrol again until you clear this. Use it for a node sending bad readings.`)) return;
298
+ POST(`/node_state/${this.state.org}`, {project, nodeid, state}, (err, json) => {
299
+ this.message(err ? err.message : (json && json.message) || getString("Done"), false);
300
+ this.getEnrolledNodes();
301
+ });
302
+ }
303
+ enrolledNodesList() {
304
+ const nodes = this.state.enrolled_nodes;
305
+ if (!nodes || !nodes.length) {
306
+ return el('p', {textContent:
307
+ "No nodes have enrolled yet. A node enrols itself the first time it connects."});
308
+ }
309
+ const states = this.nodeStates();
310
+ return el('table', {class: 'nodestates'}, [
311
+ el('tr', {}, [
312
+ el('th', {textContent: "Project"}), el('th', {textContent: "Node"}),
313
+ el('th', {textContent: "Enrolled"}), el('th', {textContent: "Last asked"}),
314
+ el('th', {textContent: "Forget"}),
315
+ ]),
316
+ ...nodes.map((n) => {
317
+ const st = states[n.state] || states.failed;
318
+ return el('tr', {}, [
319
+ el('td', {i8n: false, textContent: n.project || ''}),
320
+ el('td', {i8n: false, textContent: `${n.nodeid}${n.lora ? ' (LoRa)' : ''}`}),
321
+ el('td', {}, [
322
+ el('span', {class: 'pseudolink', title: getString(st.hint), textContent: st.label,
323
+ onclick: this.onNodeState.bind(this, n.project, n.nodeid, st.next)}),
324
+ ]),
325
+ // Untrusted, and shown as such: an address and a time for the admin to recognise
326
+ el('td', {i8n: false, textContent: n.asked_at
327
+ ? `${this.formatLastSeen(new Date(n.asked_at).toISOString())} from ${n.asked_from || '?'}` +
328
+ `${n.asked_count > 1 ? ` (${n.asked_count}x)` : ''}`
329
+ : (n.enrolled_at ? new Date(n.enrolled_at).toISOString().slice(0, 10) : '')}),
330
+ el('td', {}, [
331
+ n.enrolled_at
332
+ ? el('span', {class: 'pseudolink', textContent: ' 🗑 ',
333
+ title: getString("Forget this node, so it is issued a new credential"),
334
+ onclick: this.onNodeReset.bind(this, n.project, n.nodeid)})
335
+ : el('span', {}),
336
+ ]),
337
+ ]);
338
+ }),
339
+ ]);
340
+ }
250
341
  getPeopleList() {
251
342
  if (this.state.org) {
252
343
  this.getOrChangeAdminPeople(`/people_list/${this.state.org}`);
@@ -565,6 +656,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
565
656
  const projectIds = this.selectedFarmProjectIds();
566
657
  if (!org || projectIds.length === 0) {
567
658
  this.state.farm_nodes_list = [];
659
+ this.state.enrolled_nodes = null;
568
660
  this.replaceElement("farm_nodes_table", this.farmNodesTable());
569
661
  return;
570
662
  }
@@ -834,22 +926,19 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
834
926
  this.state.farms_list = [];
835
927
  this.state.farm_nodes_list = [];
836
928
  // Rebuild the gated content of each tab first, since it re-creates the elements (e.g. ota_files,
837
- // people_perms_list, platforms_list_display) that loadTabData() below then asynchronously replaces.
838
- this.adminSections().forEach((section) => this.replaceElement(section.rest, this.gatedContent(section.content)));
839
- // Only OTA/Admin/API tabs do their own network fetch (Nodes reads server_config, already loaded,
840
- // and Dashboard is handled by mqtt-wrapper) - mark them all as needing a refetch for the new
841
- // organization, but only actually fetch whichever tab is currently active; the rest load lazily
842
- // if/when the user switches to them (see onTabChange), to avoid a server-side fetch per tab on
843
- // every organization change.
844
- this.state.tabsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'API']);
845
- this.loadTabIfNeeded(this.state.activeTabTitle);
846
- // Note both these dropdowns are fine if this.state.org is undefined
929
+ // people_perms_list, platforms_list_display) that loadSectionData() below then asynchronously replaces.
930
+ this.adminSections().forEach((section) => this.replaceElement(section.rest, this.gatedContent(section)));
931
+ // Which tabs do their own network fetch, and so need one again for a new organization. Only
932
+ // Dashboard does not (mqtt-wrapper handles it). "Nodes" is in the list because it now fetches
933
+ // the enrolled-node list as well as reading server_config - leaving it out was why that section
934
+ // always said "No nodes have enrolled yet": loadSectionIfNeeded returns early for a tab that is not
935
+ // in this set, so the fetch never happened.
936
+ this.state.sectionsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'Nodes', 'API']);
937
+ this.loadSectionIfNeeded(this.state.activeSectionTitle);
938
+ // Only a card created without an organization has a dropdown of its own; replaceElement does
939
+ // nothing for an element this rendering never made. Fine too if this.state.org is undefined.
847
940
  this.adminSections().forEach((section) =>
848
941
  this.replaceElement(section.dropdown, this.orgDropdown(this.state.org, this[section.orgs], `${section.key}organizations`)));
849
- // Keep the Dashboard tab's own organization in sync with the shared dropdown above.
850
- if (this.state.elements.mqttWrapper) {
851
- this.state.elements.mqttWrapper.setOrganization(org);
852
- }
853
942
  }
854
943
  setDefaultOrganization() {
855
944
  let org = this.state.org;
@@ -867,25 +956,21 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
867
956
  onOrganization(e) {
868
957
  this.setOrganization(e.target.value);
869
958
  }
870
- // Fired by tabbed-display's 'tabchange' event (see TabbedDisplay.tabSelect) whenever the user
871
- // switches tabs - lazily loads that tab's data if it hasn't been fetched yet for the current org.
872
- onTabChange({title}) {
873
- this.state.activeTabTitle = title;
874
- this.loadTabIfNeeded(title);
875
- }
876
- loadTabIfNeeded(title) {
877
- if (!this.state.tabsNeedingLoad.has(title)) { return; }
878
- this.state.tabsNeedingLoad.delete(title);
879
- this.loadTabData(title);
959
+ loadSectionIfNeeded(title) {
960
+ if (!this.state.sectionsNeedingLoad.has(title)) { return; }
961
+ this.state.sectionsNeedingLoad.delete(title);
962
+ this.loadSectionData(title);
880
963
  }
881
- // Only OTA/Admin/API have their own server round trip - Nodes and Dashboard need nothing here.
882
- loadTabData(title) {
964
+ // OTA/Admin/Projects/Nodes/API each have their own server round trip; Dashboard needs nothing.
965
+ loadSectionData(title) {
883
966
  if (title === 'OTA') {
884
967
  this.getOtaFiles();
885
968
  } else if (title === 'Admin') {
886
969
  this.getPeopleList();
887
970
  } else if (title === 'Projects') {
888
971
  this.getProjectsList();
972
+ } else if (title === 'Nodes') {
973
+ this.getEnrolledNodes();
889
974
  } else if (title === 'API') {
890
975
  this.getPlatformsList();
891
976
  this.getFarmsList();
@@ -1139,7 +1224,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1139
1224
  return el('div', {class: 'retained'}, [
1140
1225
  el('div', {class: 'formgroup'}, [
1141
1226
  el('label', {for: 'retained_pattern', textContent: "Pattern"}),
1142
- el('span', {i8n: false, textContent: `${this.state.org}/`}),
1227
+ el('span', {class: 'retained__prefix', i8n: false, textContent: `${this.state.org}/`}),
1143
1228
  this.state.elements.retained_pattern = el('input', {id: 'retained_pattern', type: 'text',
1144
1229
  value: r.pattern, placeholder: 'lotus/+/sht30/#'}),
1145
1230
  el('button', {class: 'submit', type: 'button', textContent: "List",
@@ -1171,7 +1256,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1171
1256
  onRetainedList() {
1172
1257
  const pattern = retainedPattern(this.state.org, this.state.elements.retained_pattern.value);
1173
1258
  this.state.retained.pattern = this.state.elements.retained_pattern.value;
1174
- const client = mqttTempConnect(this.state.org);
1259
+ const client = mqttTempConnect();
1175
1260
  if (!client) { this.retainedShow("No broker credentials for this organization"); return; }
1176
1261
  this.retainedShow(`Listening on ${pattern} ...`);
1177
1262
  const found = new Map();
@@ -1196,21 +1281,24 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1196
1281
  onRetainedDelete() {
1197
1282
  const r = this.state.retained;
1198
1283
  if (!r.confirming) { this.retainedShow(null, r.topics, true); return; }
1199
- const topics = r.topics;
1200
- const client = mqttTempConnect(this.state.org);
1201
- if (!client) { this.retainedShow("No broker credentials for this organization"); return; }
1284
+ const topics = r.topics.map((t) => t.topic);
1202
1285
  this.retainedShow(`Deleting ${topics.length} ...`);
1203
- client.on('connect', () => {
1204
- // An empty retained message is how MQTT spells "forget this topic"
1205
- let left = topics.length;
1206
- topics.forEach((t) => client.publish(t.topic, '', {retain: true, qos: 1}, () => {
1207
- if (--left === 0) {
1208
- client.end();
1209
- this.retainedShow(`Deleted ${topics.length}. If one comes back, a node is still publishing it.`);
1210
- }
1211
- }));
1286
+ /*
1287
+ * The server does the publishing, not this browser.
1288
+ *
1289
+ * A retained topic is removed by publishing an empty payload to it, and this used to do that
1290
+ * directly. It cannot any more: a user's broker credential may publish to "set/" topics only,
1291
+ * so that a dashboard cannot invent a reading. Worse, it failed with no sign of failing - the
1292
+ * broker acknowledges a QoS 1 publish before deciding whether it is allowed - which is how
1293
+ * "Deleted 23" came to be printed while all 23 were still there.
1294
+ */
1295
+ POST(`/retained_delete/${this.state.org}`, {topics}, (err, json) => {
1296
+ if (err) { this.retainedShow(`Could not delete: ${err.message}`); return; }
1297
+ const failed = (json && json.failed) || 0;
1298
+ this.retainedShow(
1299
+ `Deleted ${(json && json.deleted) || 0}${failed ? `, ${failed} failed` : ''}. ` +
1300
+ `If one comes back, a node is still publishing it.`);
1212
1301
  });
1213
- client.on('error', (e) => { this.retainedShow(`Could not connect: ${e.message}`); client.end(true); });
1214
1302
  }
1215
1303
  projectsRestContent() {
1216
1304
  return el('div', {}, [
@@ -1262,6 +1350,23 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1262
1350
  return el('div', {}, [
1263
1351
  el('h3', {textContent: "Nodes in Organization"}),
1264
1352
  this.state.elements.nodes_table = this.nodesTable(),
1353
+ el('section', {}, [
1354
+ el('h3', {textContent: "Enrolled nodes"}),
1355
+ el('p', {textContent:
1356
+ "Each node has its own broker credential, which it collects from this server the first " +
1357
+ "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."}),
1359
+ this.state.elements.enrolled_nodes = this.enrolledNodesList(),
1360
+ ]),
1361
+ el('section', {}, [
1362
+ el('h3', {textContent: "Enrolment secret"}),
1363
+ el('p', {textContent:
1364
+ "Compiled into a node's firmware, this is what lets it ask this server for its own " +
1365
+ "broker credential. It grants nothing else - no reading and no publishing. Add a second " +
1366
+ "one to change it: both are accepted, so nodes already flashed keep working until you " +
1367
+ "withdraw the old one."}),
1368
+ this.state.elements.enrolment_secrets = this.enrolmentSecretsList(),
1369
+ ]),
1265
1370
  ]);
1266
1371
  }
1267
1372
  // Content of the API tab below the organization dropdown - only rendered once an org is selected
@@ -1292,8 +1397,8 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1292
1397
  ]),
1293
1398
  ]);
1294
1399
  }
1295
- // The admin functions, described once. The tabbed view below shows them all; the project back
1296
- // (CARDS_UX.md 11) puts each in its own card, by setting section="ota" and so on.
1400
+ // The admin functions, described once. The project back (CARDS_UX.md 11) puts each in its own
1401
+ // card, by setting section="ota" and so on.
1297
1402
  adminSections() {
1298
1403
  return [
1299
1404
  { key: 'ota', title: "OTA", orgs: 'otaOrgs', dropdown: 'otaorgsdropdown', rest: 'ota_rest', content: this.otaRestContent },
@@ -1306,45 +1411,38 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
1306
1411
  ];
1307
1412
  }
1308
1413
  // Just one section, with no tab strip around it - what an admin card holds
1414
+ /*
1415
+ * One section on its own, which is how the project's back shows each admin card (cards.js).
1416
+ *
1417
+ * No organization dropdown when the card was given one. The page's own selector - in the wrapper
1418
+ * above the cards - has already decided, and cards.js passes it as the `org` attribute, so a
1419
+ * dropdown here would be a second control for a choice already made. It is still offered when
1420
+ * the element was created without an org, the only case where the card could not otherwise be
1421
+ * used at all; `el` omits an undefined attribute, so the two cases are distinguishable.
1422
+ */
1309
1423
  renderSection(key) {
1310
1424
  if (key === 'flash') return el('div', {class: 'mqtt-admin'}, [el('mqtt-flash', {})]);
1311
1425
  const section = this.adminSections().find((s) => s.key === key);
1312
1426
  if (!section) { XXX(["No such admin section", key]); return null; }
1313
1427
  if (!this[section.orgs].length) return null; // no permission for it
1314
1428
  return el('div', {class: 'mqtt-admin'}, [
1315
- this.state.elements[section.dropdown] = el('span', {textContent: "Waiting"}),
1316
- this.state.elements[section.rest] = this.gatedContent(section.content),
1429
+ this.state.org ? null
1430
+ : (this.state.elements[section.dropdown] = el('span', {textContent: "Waiting"})),
1431
+ this.state.elements[section.rest] = this.gatedContent(section),
1317
1432
  ]);
1318
1433
  }
1319
1434
 
1320
- render() { //TODO-89 needs styles
1321
- if (this.state.section) {
1322
- return [el('link', {rel: 'stylesheet', href: CssUrl}), this.renderSection(this.state.section)];
1435
+ /*
1436
+ * One admin section, always. This element is created by cards.js, once per card on the project's
1437
+ * back, with a `section` attribute saying which - there is no longer a tabbed page that shows
1438
+ * them all at once.
1439
+ */
1440
+ render() {
1441
+ if (!this.state.section) {
1442
+ XXX(["mqtt-admin needs a section attribute - it renders one admin card, not a page"]);
1443
+ return null;
1323
1444
  }
1324
- const tabbedDisplay = el('tabbed-display', {tab: 0}, [
1325
- el('section', {title: "Dashboard"}, [
1326
- this.state.elements.mqttWrapper = el('mqtt-wrapper'),
1327
- ]),
1328
- // From adminSections, so a section added for the cards gets a tab here too
1329
- ...this.adminSections().map((section) => (!this[section.orgs].length ? null :
1330
- el('section', {title: section.title}, [
1331
- this.state.elements[section.dropdown] = el('span', { textContent: "Waiting"}),
1332
- this.state.elements[section.rest] = this.gatedContent(section.content),
1333
- ]))),
1334
- ]);
1335
- // Lazily load a tab's data only once the user actually switches to it - see onTabChange.
1336
- tabbedDisplay.addEventListener('tabchange', (e) => this.onTabChange(e.detail));
1337
- return [
1338
- el('link', {rel: 'stylesheet', href: CssUrl}),
1339
- el('div', {class: 'mqtt-admin'},[
1340
- // This is a top bar, holds message and language picker
1341
- el('div',{class: 'message'},[
1342
- this.state.elements.message = el('span', {textContent: this.state.message}),
1343
- el('language-picker'),
1344
- ]),
1345
- tabbedDisplay,
1346
- ]),
1347
- ];
1445
+ return [el('link', {rel: 'stylesheet', href: CssUrl}), this.renderSection(this.state.section)];
1348
1446
  }
1349
1447
  }
1350
1448
  customElements.define('mqtt-admin', MqttAdmin);