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 +23 -15
- package/admin.js +245 -147
- package/cards.js +27 -8
- package/core.js +293 -107
- package/dashboard_example.html +8 -4
- package/{CARDS_UX.md → docs/CARDS_UX.md} +33 -0
- package/frugaliot.css +23 -67
- package/index-embedded.html +6 -4
- package/index.html +2 -3
- package/package.json +1 -1
- package/test/cards.test.js +46 -1
- package/test/cardui.test.js +20 -0
- package/test/fixtures/config.json +574 -55
- package/test/grid.test.js +4 -0
- package/test/mock.html +4 -5
- package/test/mock.js +19 -13
- package/test/modules.test.js +1 -2
- package/test/page.test.js +10 -4
- package/test/permissions.test.js +34 -4
- package/test/smoke.test.js +15 -9
- package/test/snapshots/card-control-wired-back.txt +5 -5
- package/test/snapshots/card-control-wired-front.txt +6 -3
- package/test/snapshots/control-wired.txt +11 -11
- package/test/snapshots/default-front.txt +8 -8
- package/test/snapshots/mixed-sensors.txt +7 -7
- package/test/snapshots/one-device.txt +6 -6
- package/test/snapshots/unknown-module.txt +3 -3
- package/index-old.html +0 -37
- package/index-project.html +0 -27
- package/nodeview.js +0 -433
- package/test/oldui.test.js +0 -40
- package/test/snapshots/no-readings.txt +0 -54
- package/webcomponents.js +0 -20
- /package/{CARDS_PLAN.md → docs/CARDS_PLAN.md} +0 -0
- /package/{FLASH_PLAN.md → docs/FLASH_PLAN.md} +0 -0
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
32
|
-
deferred work is L-n, and it is the place to argue with a choice rather than re-deciding
|
|
33
|
-
`CARDS_PLAN.md` is how it was built, including the bugs found on the way.
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|
3
|
-
*
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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.
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
838
|
-
this.adminSections().forEach((section) => this.replaceElement(section.rest, this.gatedContent(section
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
this.state.
|
|
845
|
-
this.
|
|
846
|
-
//
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
this.
|
|
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
|
-
//
|
|
882
|
-
|
|
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(
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
|
1296
|
-
//
|
|
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.
|
|
1316
|
-
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
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);
|