frugal-iot-client 1.3.14 → 1.3.15
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/CARDS_UX.md +48 -0
- package/CLAUDE.md +35 -6
- package/admin.js +126 -152
- package/cards.js +38 -5
- package/core.js +287 -26
- package/frugaliot.css +151 -1
- package/graph.js +4 -11
- package/images/Battery0.png +0 -0
- package/images/Battery1.png +0 -0
- package/images/Battery2.png +0 -0
- package/images/Battery3.png +0 -0
- package/images/Battery4.png +0 -0
- package/images/Battery5.png +0 -0
- package/images/Battery6.png +0 -0
- package/index-embedded.html +5 -5
- package/login.html +9 -12
- package/login.js +224 -0
- package/package.json +1 -1
- package/scripts/make-battery-icons.js +110 -0
- package/test/cards.test.js +79 -0
- package/test/cardui.test.js +27 -0
- package/test/datatree.test.js +77 -0
- package/test/embedded.test.js +50 -0
- package/test/grid.test.js +19 -0
- package/test/login.test.js +134 -0
- package/test/mock.js +17 -2
- package/test/page.test.js +27 -3
- package/test/snapshots/card-one-device-summary.txt +1 -0
- package/test/snapshots/one-device.txt +33 -0
- package/webcomponents.js +2 -1
- package/widgets.js +19 -6
package/CARDS_UX.md
CHANGED
|
@@ -772,6 +772,11 @@ Reopening one means revisiting this document, not deciding it in code.
|
|
|
772
772
|
| D-42 | How the brand greens are used | A pale tint behind near-black text, never a fill under white — sunlight and dim sheds |
|
|
773
773
|
| D-43 | Where the connection details live | An ungated Info card on the project's back, not an expandable in the header |
|
|
774
774
|
| D-44 | How a module without a hand-written summary gets one | A generic default — its own readings, capped at two — not a list per module |
|
|
775
|
+
| D-45 | Login page: keep the Sign In / Register tab strip? | No — one card, four modes. Two tabs were chrome around one short form, and "forgot" and "reset" make four, which no longer reads as tabs |
|
|
776
|
+
| D-46 | Log in by username or by email? | One field, labelled "Username or email". The server tries username first, then email, so a username containing "@" still works |
|
|
777
|
+
| D-47 | How a forgotten password is reset | A stateless HMAC code — see §16. Nothing is stored, so there is no token table to expire, leak or clean up |
|
|
778
|
+
| D-48 | Six digits to type, or a link to click? | Both, from the same digest — see §16 |
|
|
779
|
+
| D-49 | Is `email` optional at registration? | No, required now. It was optional, and an account without one cannot use D-47 at all |
|
|
775
780
|
|
|
776
781
|
---
|
|
777
782
|
|
|
@@ -958,6 +963,49 @@ flickers.
|
|
|
958
963
|
|
|
959
964
|
---
|
|
960
965
|
|
|
966
|
+
---
|
|
967
|
+
|
|
968
|
+
## 16. Forgotten passwords — a code that is stored nowhere
|
|
969
|
+
|
|
970
|
+
The usual design writes a random token to a table with an expiry, and then needs the table cleaned
|
|
971
|
+
up, the token invalidated on use, and the row protected. None of that is wanted on a Pi in a shed.
|
|
972
|
+
|
|
973
|
+
Instead a code is **derived**, in `frugal-iot-server/lib/resetcode.js`:
|
|
974
|
+
|
|
975
|
+
```
|
|
976
|
+
HMAC(secret, user id + email + current hashed_password + five-minute slot)
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
- **`secret`** is 32 random bytes made at server start. A restart invalidates every outstanding
|
|
980
|
+
code, which is the right way round.
|
|
981
|
+
- **the five-minute slot** is `floor(now / 5 min)`. Verification accepts the current slot and the
|
|
982
|
+
one before, so a code lasts between 5 and 10 minutes. Nothing expires it; the arithmetic moves on.
|
|
983
|
+
- **`current hashed_password`** is what makes a code single-use, for free. Resetting the password
|
|
984
|
+
changes the hash, so every code derived from the old one stops matching the instant it is used.
|
|
985
|
+
- **Nothing is written down**, so there is no token table, no cleanup, and nothing to leak.
|
|
986
|
+
|
|
987
|
+
Two forms come out of one digest (D-48):
|
|
988
|
+
|
|
989
|
+
| Form | What it is | Why |
|
|
990
|
+
|---|---|---|
|
|
991
|
+
| code | six digits | short enough to read off a phone and type into the form |
|
|
992
|
+
| token | 32 hex characters | goes in the emailed link, where length costs nothing |
|
|
993
|
+
|
|
994
|
+
Six digits is a million guesses, and a code is alive for up to ten minutes, so the six-digit form
|
|
995
|
+
is only safe alongside the in-memory rate limiting in the same file — 3 sends and 10 verification
|
|
996
|
+
attempts per identifier per 15 minutes. The link form does not depend on that.
|
|
997
|
+
|
|
998
|
+
`POST /forgotpassword` answers identically whether or not the account exists, and identically when
|
|
999
|
+
the mail fails to send, so the form cannot be used to find out who has an account here. A failed
|
|
1000
|
+
send is logged loudly instead, because a server with broken SMTP looks from the outside exactly
|
|
1001
|
+
like one that is working.
|
|
1002
|
+
|
|
1003
|
+
Mail itself is configured in `config.d/email.yaml` and is **absent by default**. A server that
|
|
1004
|
+
cannot send mail says "Password reset is not available on this server" rather than accepting the
|
|
1005
|
+
request and quietly dropping it.
|
|
1006
|
+
|
|
1007
|
+
---
|
|
1008
|
+
|
|
961
1009
|
## Appendix — implementation notes parked for step 2
|
|
962
1010
|
|
|
963
1011
|
Not a plan; a scratchpad so nothing is lost while the UX is still moving.
|
package/CLAUDE.md
CHANGED
|
@@ -16,11 +16,13 @@ it loadable on its own, and it is maintained by reaching the display elements **
|
|
|
16
16
|
| `cards.js` | core | the card UI: `mqtt-devicecard`, `mqtt-devicegrid`, `mqtt-projectback`, `mqtt-dashboard`, the layout store |
|
|
17
17
|
| `nodeview.js` | core, widgets | the old node/group UI — **retires with `index-old.html`**; nothing new belongs here |
|
|
18
18
|
| `flash.js` | core | `mqtt-flash` — pulls esptool-js |
|
|
19
|
-
| `admin.js` | core | `mqtt-admin`, `
|
|
19
|
+
| `admin.js` | core | `mqtt-admin`, `tabbed-display` |
|
|
20
|
+
| `login.js` | core | `mqtt-login` — sign in, register, forgot/reset password; light DOM |
|
|
20
21
|
|
|
21
22
|
Entry points: `index.html` → `dashboard.js` (the card UI, everything but `nodeview.js`);
|
|
22
23
|
`index-old.html` → `webcomponents.js` (everything, and the only remaining user of it);
|
|
23
|
-
`
|
|
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.
|
|
24
26
|
|
|
25
27
|
Dashboards (e.g. `dashboard_example.html`) are thin HTML pages importing selectively.
|
|
26
28
|
|
|
@@ -93,7 +95,7 @@ Helper functions: `topicTwig(path)` → twig, `topicLeaf(path)` → leaf, `twigA
|
|
|
93
95
|
- `mt.twig` — stored twig string (e.g. `sht/temperature`)
|
|
94
96
|
- `mt.leaf` — getter, last segment of twig
|
|
95
97
|
- `mt.topicPath` — getter, full path including node prefix
|
|
96
|
-
- `mt.topicSetPath` — getter, write path (`…/set/…`); **throws when `this.
|
|
98
|
+
- `mt.topicSetPath` — getter, write path (`…/set/…`); **throws when `this.nodeMt` is null** — only call on topics that are part of the node hierarchy. Project-level topics have no node and must not reach this getter. Topics built by `standaloneTopic()` do have a `nodeMt` (a stub), so an embedded widget's set path resolves.
|
|
97
99
|
- `mt.element` — back-reference to the DOM element currently bound to this topic (null in headless mode or before binding)
|
|
98
100
|
- `el.mt` — forward-reference from a DOM element to its `MqttTopic`
|
|
99
101
|
|
|
@@ -159,10 +161,12 @@ HTMLElementExtended (from html-element-extended)
|
|
|
159
161
|
MqttGroupFrugalIot the frugal_iot system group (name, battery, OTA…)
|
|
160
162
|
MqttClient MQTT connection UI element
|
|
161
163
|
MqttWrapper top-level entry point; fetches config, builds tree
|
|
162
|
-
MqttLogin login/register form
|
|
163
164
|
MqttAdmin admin panel (OTA, permissions, node table)
|
|
164
165
|
LanguagePicker language selector
|
|
165
166
|
TabbedDisplay generic tab UI
|
|
167
|
+
HTMLElementExtendedMinimum (light DOM - no shadow root, so frugaliot.css reaches inside)
|
|
168
|
+
MqttLogin sign in / register / forgot / reset, one card and four modes
|
|
169
|
+
MqttDeviceCard, MqttDeviceGrid, MqttProjectBack, MqttDashboard the card UI
|
|
166
170
|
```
|
|
167
171
|
|
|
168
172
|
### Adding a new display element
|
|
@@ -186,8 +190,8 @@ a control shows a rule. Then:
|
|
|
186
190
|
|
|
187
191
|
### The card UI
|
|
188
192
|
|
|
189
|
-
- `mqtt-devicecard` and `mqtt-
|
|
190
|
-
`frugaliot.css` reaches them directly. That needs html-element-extended ≥ 0.1.7 (`renderRoot`).
|
|
193
|
+
- `mqtt-devicecard`, `mqtt-devicegrid` and `mqtt-login` are **light DOM**
|
|
194
|
+
(`HTMLElementExtendedMinimum`), so `frugaliot.css` reaches them directly. That needs html-element-extended ≥ 0.1.7 (`renderRoot`).
|
|
191
195
|
- A light-DOM element has no `<slot>`, and `renderAndReplace` **clears its own children** — so the
|
|
192
196
|
grid renders once with `render0()` and mutates in place, and a card builds every child itself.
|
|
193
197
|
- Widgets in a card are pre-bound (`el.mt = mt`) but deliberately **not** registered as `mt.element`:
|
|
@@ -233,6 +237,18 @@ if (prevBarMt) prevBarMt.element = null;
|
|
|
233
237
|
prevBarMt = sensorMt;
|
|
234
238
|
```
|
|
235
239
|
|
|
240
|
+
### Embedded widgets (no wrapper)
|
|
241
|
+
|
|
242
|
+
`index-embedded.html` puts an `mqtt-bar`/`mqtt-toggle`/`mqtt-graphdataset` on a page with nothing
|
|
243
|
+
else — no `mqtt-wrapper`, no discovery tree, no `/config.json`. `standaloneTopic(path, o)` in
|
|
244
|
+
`core.js` builds the one topic such a widget needs, giving it a stub `nodeMt` so `topicPath`,
|
|
245
|
+
`topicSetPath` and `groupMt` all resolve. It also sets `mt.standalone`, which is what makes
|
|
246
|
+
`canWrite` true: there is no permission list to consult, and the broker enforces the credentials on
|
|
247
|
+
the `mqtt-client` element instead. `test/embedded.test.js` guards this — it has broken twice
|
|
248
|
+
unnoticed.
|
|
249
|
+
|
|
250
|
+
Still not available on such a page: graph history (`addDataFrom` needs `server_config.logger.url`).
|
|
251
|
+
|
|
236
252
|
### Looking up topics
|
|
237
253
|
|
|
238
254
|
Use `wrapper.projectMt.findTopic(fullPath)` — never fabricate a new `MqttTopic` with partial paths:
|
|
@@ -273,8 +289,21 @@ Each of these cost real time. They are not obvious from reading the code.
|
|
|
273
289
|
- **`:host-context(details[open]) div { display: block }`** near the top of `frugaliot.css` has
|
|
274
290
|
specificity (0,2,2) and overrides most rules for any div in a shadow root inside an open
|
|
275
291
|
`<details>` — which is every widget in the old UI.
|
|
292
|
+
- **A card's own gestures eat keystrokes and drags meant for a widget inside it.** `mqtt-devicecard`
|
|
293
|
+
listens for `keydown` (space picks it up) and `pointerdown` (drag to reorder) on itself, so a text
|
|
294
|
+
field on the back had every space `preventDefault`ed and a mouse selection of its text lifted the
|
|
295
|
+
card. Both handlers now bail on `isEditableTarget(e)` from `core.js`, which reads
|
|
296
|
+
`composedPath()[0]` — the target a card sees has been retargeted to the widget host, so
|
|
297
|
+
`e.target.tagName` is `MQTT-TEXT`, never `INPUT`.
|
|
276
298
|
- **Assets resolve against the module, not the document**: `CssUrl` and `ImagesUrl` use
|
|
277
299
|
`import.meta.url`, or a page in a subdirectory 404s every stylesheet and icon.
|
|
300
|
+
- **A node reports in a burst**, publishing its whole topic set at once, so gaps between messages
|
|
301
|
+
are milliseconds. `MqttTopicNode.noteMessage` ignores anything closer than `REPORT_BURST_MS` when
|
|
302
|
+
learning the reporting interval — without that the interval collapses and every device reads as
|
|
303
|
+
offline seconds after reporting. Status is also floored by `MIN_REPORT_INTERVAL_MS`.
|
|
304
|
+
- **A device going quiet sends nothing**, so nothing prompts a re-render. `status` is derived rather
|
|
305
|
+
than timed, but a card still needs a slow interval to re-read it, or it shows whatever it showed
|
|
306
|
+
when the last message arrived.
|
|
278
307
|
- **Timestamps go through `nowMs()`**, never `Date.now()`, or a replayed history collapses onto one
|
|
279
308
|
instant and a graph has nothing to draw. `setClock()` is how tests decide what "now" is.
|
|
280
309
|
- **Caching will lie to you.** `/node_modules` and the client directory are served
|
package/admin.js
CHANGED
|
@@ -1,122 +1,12 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Frugal IoT client -
|
|
3
|
-
* and the API tools.
|
|
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.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import {EL, GET, HTMLElementExtended} from '/node_modules/html-element-extended/htmlelementextended.js';
|
|
7
7
|
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, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
|
|
8
|
+
import { CssUrl, POST, XXX, configSet, el, getString, hasCapability, mqttTempConnect, retainedPattern, locationParameterChange, mqtt_client, preferedLanguageSet, preferedLanguages, redirectToLogin, server_config } from './core.js';
|
|
9
9
|
|
|
10
|
-
class MqttLogin extends HTMLElementExtended { // TODO-89 may depend on organization
|
|
11
|
-
constructor(props) {
|
|
12
|
-
super(props);
|
|
13
|
-
this.state = {register: false};
|
|
14
|
-
}
|
|
15
|
-
static get observedAttributes() { return ['register','message','url','lang']; }
|
|
16
|
-
static get boolAttributes() { return ['register']; }
|
|
17
|
-
|
|
18
|
-
connectedCallback() {
|
|
19
|
-
this.loadAttributesFromURL();
|
|
20
|
-
super.connectedCallback();
|
|
21
|
-
}
|
|
22
|
-
changeAttribute(name, value) {
|
|
23
|
-
if (name === "lang") {
|
|
24
|
-
if (value.includes(',')) {
|
|
25
|
-
preferedLanguages = (value.split(',')).map(v => v.toUpperCase());
|
|
26
|
-
} else if (!value) {
|
|
27
|
-
preferedLanguageSet('EN');
|
|
28
|
-
locationParameterChange("lang", preferedLanguages.join(','));
|
|
29
|
-
} else {
|
|
30
|
-
preferedLanguageSet(value.toUpperCase());
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
super.changeAttribute(name, value);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
tabRegister(register) {
|
|
37
|
-
this.changeAttribute('register', register);
|
|
38
|
-
this.renderAndReplace();
|
|
39
|
-
}
|
|
40
|
-
// this.state.url (the page to return to after login) may already have its own query string, so set
|
|
41
|
-
// "lang" via the URL API rather than naively concatenating "?lang=...", which would produce a second
|
|
42
|
-
// "?" if one is already present.
|
|
43
|
-
urlWithLang(urlStr) {
|
|
44
|
-
if (!urlStr) { return urlStr; }
|
|
45
|
-
try {
|
|
46
|
-
const u = new URL(urlStr, window.location.origin);
|
|
47
|
-
u.searchParams.set('lang', preferedLanguages.join(','));
|
|
48
|
-
return u.toString();
|
|
49
|
-
} catch (e) {
|
|
50
|
-
return urlStr;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
render() { //TODO-89 needs styles
|
|
54
|
-
// TODO-89 organization should be dropdown
|
|
55
|
-
// TODO-89 merge login & register
|
|
56
|
-
if (preferedLanguages.length === 0) { XXX("Tracking down issue with lang"); }
|
|
57
|
-
return [
|
|
58
|
-
el('link', {rel: 'stylesheet', href: CssUrl}),
|
|
59
|
-
el('div', {class: 'mqtt-login'},[
|
|
60
|
-
// This is a top bar, holds message and language picker
|
|
61
|
-
el('div',{class: 'message'},[
|
|
62
|
-
el('span', {textContent: this.state.message}),
|
|
63
|
-
el('language-picker'),
|
|
64
|
-
]),
|
|
65
|
-
el('tabbed-display', {tab: this.state.register ? 1 : 0 }, [
|
|
66
|
-
el('section', {title: "Sign In"}, [
|
|
67
|
-
el('form', {action: '/login', method: "post"}, [
|
|
68
|
-
el('section', {}, [
|
|
69
|
-
el('label', {for: "username", textContent: 'Username'}),
|
|
70
|
-
el('input', {id: "username", name: "username", type: "text", autocomplete: "username", required: true, autofocus: true}),
|
|
71
|
-
]),
|
|
72
|
-
el('section', {}, [
|
|
73
|
-
el('label', {for: "password", textContent: "Password"}),
|
|
74
|
-
el('input', {id: "password", name: "password", type: "password", autocomplete: "current-password", required: true}),
|
|
75
|
-
]),
|
|
76
|
-
el('input', {id: "url", name: "url", type: "hidden", value: this.urlWithLang(this.state.url)}),
|
|
77
|
-
el('button', {class: "submit", type: "submit",
|
|
78
|
-
textContent: (this.state.register ? 'Submit' : 'Submit')}),
|
|
79
|
-
]),
|
|
80
|
-
]),
|
|
81
|
-
el('section', {title: "Register"}, [
|
|
82
|
-
el('form', {action: '/register', method: "post"}, [
|
|
83
|
-
el('section', {}, [
|
|
84
|
-
el('label', {for: "username", textContent: 'Username'}),
|
|
85
|
-
el('input', {id: "username", name: "username", type: "text", autocomplete: "username", required: true, autofocus: true}),
|
|
86
|
-
]),
|
|
87
|
-
el('section', {}, [
|
|
88
|
-
el('label', {for: "password", textContent: "Password"}),
|
|
89
|
-
el('input', {id: "password", name: "password", type: "password", autocomplete: "current-password", required: true}),
|
|
90
|
-
]),
|
|
91
|
-
// TODO-22 TODO-14 organization should be a drop-down
|
|
92
|
-
el('section', {}, [
|
|
93
|
-
el('label', {for: "organization", textContent: "Organization"}),
|
|
94
|
-
el('span', {textContent: "Note this is your organization - not the organizations whose devices you want to access." }),
|
|
95
|
-
el('br'),
|
|
96
|
-
el('input', {id: "organization", name: "organization", type: "text", autocomplete: "organization", required: false}),
|
|
97
|
-
]),
|
|
98
|
-
el('section', {}, [
|
|
99
|
-
el('label', {for: "name", textContent: "Name"}),
|
|
100
|
-
el('input', {id: "name", name: "name", type: "text", autocomplete: "name", required: true}),
|
|
101
|
-
]),
|
|
102
|
-
el('section', {}, [
|
|
103
|
-
el('label', {for: "email", textContent: "Email"}),
|
|
104
|
-
el('input', {id: "email", name: "email", type: "text", autocomplete: "email", required: true}),
|
|
105
|
-
]),
|
|
106
|
-
el('section', {}, [
|
|
107
|
-
el('label', {for: "phone", textContent: "Phone or Whatsapp"}),
|
|
108
|
-
el('input', {id: "phone", name: "phone", type: "text", autocomplete: "phone", required: true}),
|
|
109
|
-
]),
|
|
110
|
-
el('input', {id: "url", name: "url", type: "hidden", value: this.urlWithLang(this.state.url)}),
|
|
111
|
-
el('button', {class: "submit", type: "submit", textContent: 'Submit'}),
|
|
112
|
-
]),
|
|
113
|
-
]),
|
|
114
|
-
]),
|
|
115
|
-
]),
|
|
116
|
-
];
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
customElements.define('mqtt-login', MqttLogin);
|
|
120
10
|
class TabbedDisplay extends HTMLElementExtended {
|
|
121
11
|
constructor() {
|
|
122
12
|
super();
|
|
@@ -945,22 +835,17 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
945
835
|
this.state.farm_nodes_list = [];
|
|
946
836
|
// Rebuild the gated content of each tab first, since it re-creates the elements (e.g. ota_files,
|
|
947
837
|
// people_perms_list, platforms_list_display) that loadTabData() below then asynchronously replaces.
|
|
948
|
-
this.replaceElement(
|
|
949
|
-
this.replaceElement('admin_rest', this.gatedContent(this.adminRestContent));
|
|
950
|
-
this.replaceElement('nodes_rest', this.gatedContent(this.nodesRestContent));
|
|
951
|
-
this.replaceElement('api_rest', this.gatedContent(this.apiRestContent));
|
|
838
|
+
this.adminSections().forEach((section) => this.replaceElement(section.rest, this.gatedContent(section.content)));
|
|
952
839
|
// Only OTA/Admin/API tabs do their own network fetch (Nodes reads server_config, already loaded,
|
|
953
840
|
// and Dashboard is handled by mqtt-wrapper) - mark them all as needing a refetch for the new
|
|
954
841
|
// organization, but only actually fetch whichever tab is currently active; the rest load lazily
|
|
955
842
|
// if/when the user switches to them (see onTabChange), to avoid a server-side fetch per tab on
|
|
956
843
|
// every organization change.
|
|
957
|
-
this.state.tabsNeedingLoad = new Set(['OTA', 'Admin', 'API']);
|
|
844
|
+
this.state.tabsNeedingLoad = new Set(['OTA', 'Admin', 'Projects', 'API']);
|
|
958
845
|
this.loadTabIfNeeded(this.state.activeTabTitle);
|
|
959
846
|
// Note both these dropdowns are fine if this.state.org is undefined
|
|
960
|
-
this.
|
|
961
|
-
|
|
962
|
-
this.replaceElement("nodesorgsdropdown", this.orgDropdown(this.state.org, this.adminOrgs,"nodesorganizations"));
|
|
963
|
-
this.replaceElement("apiorgsdropdown", this.orgDropdown(this.state.org, this.adminOrgs,"apiorganizations"));
|
|
847
|
+
this.adminSections().forEach((section) =>
|
|
848
|
+
this.replaceElement(section.dropdown, this.orgDropdown(this.state.org, this[section.orgs], `${section.key}organizations`)));
|
|
964
849
|
// Keep the Dashboard tab's own organization in sync with the shared dropdown above.
|
|
965
850
|
if (this.state.elements.mqttWrapper) {
|
|
966
851
|
this.state.elements.mqttWrapper.setOrganization(org);
|
|
@@ -999,6 +884,7 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
999
884
|
this.getOtaFiles();
|
|
1000
885
|
} else if (title === 'Admin') {
|
|
1001
886
|
this.getPeopleList();
|
|
887
|
+
} else if (title === 'Projects') {
|
|
1002
888
|
this.getProjectsList();
|
|
1003
889
|
} else if (title === 'API') {
|
|
1004
890
|
this.getPlatformsList();
|
|
@@ -1189,6 +1075,14 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1189
1075
|
el('form', {action: '/ota_update', method: "post", enctype: "multipart/form-data"}, [
|
|
1190
1076
|
el('input', {id: "url2", name: "url", type: "hidden", value: `/dashboard/`}),
|
|
1191
1077
|
el('input', {id: "lang", name: "lang", type: "hidden", value: preferedLanguages.join(',')}),
|
|
1078
|
+
// The organization dropdown for this tab is a sibling of this form rather than inside it
|
|
1079
|
+
// (see renderSection), so the browser does not submit it. The server needs it: it checks
|
|
1080
|
+
// the OTAUPDATE permission against it and builds the upload directory from it, and
|
|
1081
|
+
// without it every upload is refused as "Permission denied to OTAUPDATE".
|
|
1082
|
+
// It must stay ahead of the file input. Multer fills req.body only from the parts that
|
|
1083
|
+
// arrive before the file, and the permission check runs while the file is being stored,
|
|
1084
|
+
// so a field placed after the file would not be there yet when it is read.
|
|
1085
|
+
el('input', {id: "otaorganization", name: "organization", type: "hidden", value: this.state.org}),
|
|
1192
1086
|
el('section', {}, [
|
|
1193
1087
|
el('label', {for: 'projects', textContent: "Project"}),
|
|
1194
1088
|
this.state.elements.projectdropdown = this.projectDropdown(this.state.org)
|
|
@@ -1227,6 +1121,99 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1227
1121
|
// and form to add (dropdown of people and permissions)
|
|
1228
1122
|
this.state.elements.people_list = this.peopleList(),
|
|
1229
1123
|
]),
|
|
1124
|
+
]);
|
|
1125
|
+
}
|
|
1126
|
+
// ---- Retained messages ----
|
|
1127
|
+
//
|
|
1128
|
+
// Nodes publish almost everything retained, so the broker keeps the last value of every topic and
|
|
1129
|
+
// hands it to each new subscriber - which is how a card shows a reading straight away. The cost is
|
|
1130
|
+
// that a topic published by mistake outlives the mistake: rename a module, misspell a field, test
|
|
1131
|
+
// with the wrong node id, and it stays on the broker for ever, on every dashboard. Fixing the node
|
|
1132
|
+
// does not clear it and neither does restarting the broker. The only way is to publish an empty
|
|
1133
|
+
// message to that exact topic.
|
|
1134
|
+
//
|
|
1135
|
+
// Same mechanics as frugal-iot-server/scripts/clearretained.js, which is the version to reach for
|
|
1136
|
+
// when there is no browser.
|
|
1137
|
+
retainedRestContent() {
|
|
1138
|
+
const r = this.state.retained || (this.state.retained = { status: null, topics: [], pattern: '#' });
|
|
1139
|
+
return el('div', {class: 'retained'}, [
|
|
1140
|
+
el('div', {class: 'formgroup'}, [
|
|
1141
|
+
el('label', {for: 'retained_pattern', textContent: "Pattern"}),
|
|
1142
|
+
el('span', {i8n: false, textContent: `${this.state.org}/`}),
|
|
1143
|
+
this.state.elements.retained_pattern = el('input', {id: 'retained_pattern', type: 'text',
|
|
1144
|
+
value: r.pattern, placeholder: 'lotus/+/sht30/#'}),
|
|
1145
|
+
el('button', {class: 'submit', type: 'button', textContent: "List",
|
|
1146
|
+
onclick: this.onRetainedList.bind(this)}),
|
|
1147
|
+
]),
|
|
1148
|
+
el('p', {class: 'retained__help', textContent:
|
|
1149
|
+
"+ matches one level, # matches the rest. Look before deleting."}),
|
|
1150
|
+
this.state.elements.retained_result = this.retainedResult(),
|
|
1151
|
+
]);
|
|
1152
|
+
}
|
|
1153
|
+
retainedResult() {
|
|
1154
|
+
const r = this.state.retained;
|
|
1155
|
+
if (r.status) return el('p', {i8n: false, textContent: r.status});
|
|
1156
|
+
if (!r.topics.length) return el('span', {});
|
|
1157
|
+
return el('div', {}, [
|
|
1158
|
+
el('p', {i8n: false, textContent: `${r.topics.length} retained topic(s)`}),
|
|
1159
|
+
el('ul', {class: 'retained__list'}, r.topics.map((t) =>
|
|
1160
|
+
el('li', {i8n: false, textContent: `${t.topic} = ${t.value}`}))),
|
|
1161
|
+
// Two steps on purpose: this cannot be undone, and a wildcard is easy to get wrong
|
|
1162
|
+
el('button', {class: 'submit', type: 'button',
|
|
1163
|
+
textContent: r.confirming ? `Yes - delete ${r.topics.length}` : "Delete these",
|
|
1164
|
+
onclick: this.onRetainedDelete.bind(this)}),
|
|
1165
|
+
]);
|
|
1166
|
+
}
|
|
1167
|
+
retainedShow(status, topics, confirming) {
|
|
1168
|
+
this.state.retained = { pattern: this.state.retained.pattern, status, topics: topics || [], confirming };
|
|
1169
|
+
this.replaceElement('retained_result', this.retainedResult());
|
|
1170
|
+
}
|
|
1171
|
+
onRetainedList() {
|
|
1172
|
+
const pattern = retainedPattern(this.state.org, this.state.elements.retained_pattern.value);
|
|
1173
|
+
this.state.retained.pattern = this.state.elements.retained_pattern.value;
|
|
1174
|
+
const client = mqttTempConnect(this.state.org);
|
|
1175
|
+
if (!client) { this.retainedShow("No broker credentials for this organization"); return; }
|
|
1176
|
+
this.retainedShow(`Listening on ${pattern} ...`);
|
|
1177
|
+
const found = new Map();
|
|
1178
|
+
client.on('message', (topic, payload, packet) => {
|
|
1179
|
+
// Only what the broker is holding. A live message arriving while we listen has the retain
|
|
1180
|
+
// flag clear, and clearing that would remove something nobody asked us to.
|
|
1181
|
+
if (packet.retain) found.set(topic, payload.toString());
|
|
1182
|
+
});
|
|
1183
|
+
client.on('error', (e) => { this.retainedShow(`Could not connect: ${e.message}`); client.end(true); });
|
|
1184
|
+
client.on('connect', () => {
|
|
1185
|
+
client.subscribe(pattern, {qos: 0}, (err) => {
|
|
1186
|
+
if (err) { this.retainedShow(`Could not subscribe: ${err.message}`); client.end(true); return; }
|
|
1187
|
+
// Retained messages all arrive at once on subscribe; this waits for them, not for news
|
|
1188
|
+
setTimeout(() => {
|
|
1189
|
+
client.end(true);
|
|
1190
|
+
const topics = [...found].map(([topic, value]) => ({topic, value})).sort((a, b) => a.topic.localeCompare(b.topic));
|
|
1191
|
+
this.retainedShow(topics.length ? null : "Nothing retained matches that pattern", topics);
|
|
1192
|
+
}, 3000);
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
onRetainedDelete() {
|
|
1197
|
+
const r = this.state.retained;
|
|
1198
|
+
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; }
|
|
1202
|
+
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
|
+
}));
|
|
1212
|
+
});
|
|
1213
|
+
client.on('error', (e) => { this.retainedShow(`Could not connect: ${e.message}`); client.end(true); });
|
|
1214
|
+
}
|
|
1215
|
+
projectsRestContent() {
|
|
1216
|
+
return el('div', {}, [
|
|
1230
1217
|
el('section', {}, [
|
|
1231
1218
|
el('h3', {textContent: "Projects"}),
|
|
1232
1219
|
// List of existing projects for this organization,
|
|
@@ -1234,11 +1221,13 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1234
1221
|
// and, once expanded, a form to add a new project (id and name)
|
|
1235
1222
|
this.state.elements.add_project = this.collapsibleArea('add_project', "Add Project", this.projectsAddForm),
|
|
1236
1223
|
]),
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1224
|
+
]);
|
|
1225
|
+
}
|
|
1226
|
+
// TODO-CSS cleanup - labels are too big
|
|
1227
|
+
// ADMIN, not WRITE: someone who can change things through the UI should not thereby get a raw
|
|
1228
|
+
// publish box - the UI constrains them to topics and values that mean something (D-32)
|
|
1229
|
+
messageRestContent() {
|
|
1230
|
+
return el('div', {}, [
|
|
1242
1231
|
el('section', {}, [
|
|
1243
1232
|
el('h3', {textContent: "Publish Message"}),
|
|
1244
1233
|
el('form', {}, [
|
|
@@ -1308,7 +1297,10 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1308
1297
|
adminSections() {
|
|
1309
1298
|
return [
|
|
1310
1299
|
{ key: 'ota', title: "OTA", orgs: 'otaOrgs', dropdown: 'otaorgsdropdown', rest: 'ota_rest', content: this.otaRestContent },
|
|
1311
|
-
{ key: 'admin',
|
|
1300
|
+
{ key: 'admin', title: "Admin", orgs: 'adminOrgs', dropdown: 'adminorgsdropdown', rest: 'admin_rest', content: this.adminRestContent },
|
|
1301
|
+
{ key: 'projects', title: "Projects", orgs: 'adminOrgs', dropdown: 'projectsorgsdropdown', rest: 'projects_rest', content: this.projectsRestContent },
|
|
1302
|
+
{ key: 'message', title: "Message", orgs: 'adminOrgs', dropdown: 'messageorgsdropdown', rest: 'message_rest', content: this.messageRestContent },
|
|
1303
|
+
{ key: 'retained', title: "Retained", orgs: 'adminOrgs', dropdown: 'retainedorgsdropdown', rest: 'retained_rest', content: this.retainedRestContent },
|
|
1312
1304
|
{ key: 'nodes', title: "Nodes", orgs: 'adminOrgs', dropdown: 'nodesorgsdropdown', rest: 'nodes_rest', content: this.nodesRestContent },
|
|
1313
1305
|
{ key: 'api', title: "API", orgs: 'adminOrgs', dropdown: 'apiorgsdropdown', rest: 'api_rest', content: this.apiRestContent },
|
|
1314
1306
|
];
|
|
@@ -1333,30 +1325,12 @@ class MqttAdmin extends HTMLElementExtended { // TODO-89 may depend on organizat
|
|
|
1333
1325
|
el('section', {title: "Dashboard"}, [
|
|
1334
1326
|
this.state.elements.mqttWrapper = el('mqtt-wrapper'),
|
|
1335
1327
|
]),
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
!this.adminOrgs.length ? null :
|
|
1344
|
-
el('section', {title: "Admin"}, [
|
|
1345
|
-
this.state.elements.adminorgsdropdown = el('span',{ textContent: "Waiting"}),
|
|
1346
|
-
this.state.elements.admin_rest = this.gatedContent(this.adminRestContent),
|
|
1347
|
-
]), // Admin tab
|
|
1348
|
-
|
|
1349
|
-
!this.adminOrgs.length ? null :
|
|
1350
|
-
el('section', {title: "Nodes"}, [
|
|
1351
|
-
this.state.elements.nodesorgsdropdown = el('span',{ textContent: "Waiting"}),
|
|
1352
|
-
this.state.elements.nodes_rest = this.gatedContent(this.nodesRestContent),
|
|
1353
|
-
]), // Nodes tab
|
|
1354
|
-
|
|
1355
|
-
!this.adminOrgs.length ? null :
|
|
1356
|
-
el('section', {title: "API"}, [
|
|
1357
|
-
this.state.elements.apiorgsdropdown = el('span',{ textContent: "Waiting"}),
|
|
1358
|
-
this.state.elements.api_rest = this.gatedContent(this.apiRestContent),
|
|
1359
|
-
]), // API tab
|
|
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
|
+
]))),
|
|
1360
1334
|
]);
|
|
1361
1335
|
// Lazily load a tab's data only once the user actually switches to it - see onTabChange.
|
|
1362
1336
|
tabbedDisplay.addEventListener('tabchange', (e) => this.onTabChange(e.detail));
|
package/cards.js
CHANGED
|
@@ -24,10 +24,13 @@ if (!('renderRoot' in HTMLElementExtendedMinimum.prototype)) {
|
|
|
24
24
|
+ 'elements cannot render. If it is linked, the browser is probably holding a cached copy - '
|
|
25
25
|
+ 'reload ignoring the cache.');
|
|
26
26
|
}
|
|
27
|
-
import { el, getString, hasCapability, ImagesUrl, relativeTime, server_config, XXX } from './core.js';
|
|
27
|
+
import { el, getString, hasCapability, ImagesUrl, isEditableTarget, relativeTime, server_config, XXX } from './core.js';
|
|
28
28
|
|
|
29
29
|
// Shape as well as colour, so status survives sunlight and colour blindness
|
|
30
30
|
const STATUS_MARK = { live: '●', stale: '◌', offline: '○', never: '·' };
|
|
31
|
+
// How often a card re-reads its own status and age. Nothing arrives to prompt it when a device
|
|
32
|
+
// stops reporting, which is exactly when the card most needs to change.
|
|
33
|
+
const STATUS_TICK_MS = 10000;
|
|
31
34
|
|
|
32
35
|
class MqttDeviceCard extends HTMLElementExtendedMinimum {
|
|
33
36
|
static get observedAttributes() { return ['mode', 'movable']; }
|
|
@@ -49,6 +52,13 @@ class MqttDeviceCard extends HTMLElementExtendedMinimum {
|
|
|
49
52
|
// Every message fires this, in both the element and the headless paths, so it is the one signal
|
|
50
53
|
// a card needs. Filtered to this device below.
|
|
51
54
|
document.addEventListener('frugaliot:groupchanged', this.onGroupChanged);
|
|
55
|
+
// A device going quiet sends nothing, so nothing would re-render to show it - status and "4s
|
|
56
|
+
// ago" would both sit at whatever they were when the last message arrived. This is the only
|
|
57
|
+
// clock the card needs; status itself is derived, not timed.
|
|
58
|
+
this.state.tick = setInterval(() => this.refresh(), STATUS_TICK_MS);
|
|
59
|
+
// A pending interval keeps node's event loop alive, so a card left attached by a test would
|
|
60
|
+
// hang the run. In a browser setInterval returns a number and this does nothing.
|
|
61
|
+
if (this.state.tick && (typeof this.state.tick.unref === 'function')) this.state.tick.unref();
|
|
52
62
|
this.addEventListener('keydown', this.onKeyDown);
|
|
53
63
|
// Not via el(): EL only assigns onclick, onchange and onsubmit as properties - any other
|
|
54
64
|
// function it is handed goes into el.state and is never wired up at all.
|
|
@@ -56,6 +66,7 @@ class MqttDeviceCard extends HTMLElementExtendedMinimum {
|
|
|
56
66
|
}
|
|
57
67
|
disconnectedCallback() {
|
|
58
68
|
document.removeEventListener('frugaliot:groupchanged', this.onGroupChanged);
|
|
69
|
+
clearInterval(this.state.tick);
|
|
59
70
|
this.removeEventListener('keydown', this.onKeyDown);
|
|
60
71
|
this.removeEventListener('pointerdown', this.onPointerDown);
|
|
61
72
|
// Deliberately not endDrag(): reordering re-appends the cards, so the one being dragged is
|
|
@@ -121,13 +132,27 @@ class MqttDeviceCard extends HTMLElementExtendedMinimum {
|
|
|
121
132
|
(e.widgets || []).forEach(({ row, widget }) => {
|
|
122
133
|
if (row.mt.state.value !== undefined) widget.valueSet(row.mt.state.value);
|
|
123
134
|
widget.parentElement?.classList.toggle('fi-row--outofrange', row.mt.outOfRange);
|
|
135
|
+
widget.parentElement?.classList.toggle('fi-row--invalid', row.mt.invalid);
|
|
124
136
|
});
|
|
125
137
|
// A row can appear or disappear - a sensor starts reporting, a control gets wired
|
|
126
138
|
if (e.rows && (e.widgets.length !== nodeMt.frontRows.filter((r) => r.mt).length)) this.renderAndReplace();
|
|
127
139
|
}
|
|
128
140
|
|
|
141
|
+
// A dot the colour the LED is showing, or dark when it is off - the same thing the board itself
|
|
142
|
+
// tells you from across the room
|
|
143
|
+
renderLed() {
|
|
144
|
+
const led = this.nodeMt.led;
|
|
145
|
+
if (!led) return null;
|
|
146
|
+
return el('span', {
|
|
147
|
+
class: `fi-led${led.on ? ' fi-led--on' : ''}`,
|
|
148
|
+
style: led.on ? `background:${led.color};` : '',
|
|
149
|
+
title: `${getString('Built in LED')}: ${led.on ? getString('On') : getString('Off')}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
renderMeta() {
|
|
130
154
|
return [
|
|
155
|
+
this.renderLed(),
|
|
131
156
|
this.renderBattery(),
|
|
132
157
|
(this.nodeMt.status === 'live') ? null : el('span', { class: 'fi-age', i8n: false, textContent: this.ageText }),
|
|
133
158
|
].filter(Boolean);
|
|
@@ -165,6 +190,9 @@ class MqttDeviceCard extends HTMLElementExtendedMinimum {
|
|
|
165
190
|
// someone who cannot make one.
|
|
166
191
|
onKeyDown(e) {
|
|
167
192
|
if (!this.state.movable) return;
|
|
193
|
+
// Space is a character in a text field before it is a pick-up gesture, and Escape/arrows belong
|
|
194
|
+
// to the input too
|
|
195
|
+
if (isEditableTarget(e)) return;
|
|
168
196
|
if (e.key === ' ') {
|
|
169
197
|
e.preventDefault();
|
|
170
198
|
this.setGrabbed(!this.state.grabbed);
|
|
@@ -186,6 +214,8 @@ class MqttDeviceCard extends HTMLElementExtendedMinimum {
|
|
|
186
214
|
// this has to work on. Touch needs a hold first, or the page cannot be scrolled.
|
|
187
215
|
onPointerDown(e) {
|
|
188
216
|
if (!this.state.movable || (e.button !== undefined && e.button !== 0)) return;
|
|
217
|
+
// Dragging across a text field selects text; six pixels of that must not lift the card
|
|
218
|
+
if (isEditableTarget(e)) return;
|
|
189
219
|
this.state.drag = { x: e.clientX, y: e.clientY, id: e.pointerId, active: false };
|
|
190
220
|
if (e.pointerType === 'touch') {
|
|
191
221
|
this.state.drag.timer = setTimeout(() => this.beginDrag(e), 400); // hold to lift
|
|
@@ -274,7 +304,7 @@ class MqttDeviceCard extends HTMLElementExtendedMinimum {
|
|
|
274
304
|
const widget = this.widgetFor(row);
|
|
275
305
|
this.state.elements.widgets.push({ row, widget });
|
|
276
306
|
return el('div', {
|
|
277
|
-
class: `fi-row fi-row--${row.kind}${row.mt.outOfRange ? ' fi-row--outofrange' : ''}`,
|
|
307
|
+
class: `fi-row fi-row--${row.kind}${row.mt.outOfRange ? ' fi-row--outofrange' : ''}${row.mt.invalid ? ' fi-row--invalid' : ''}`,
|
|
278
308
|
}, [widget]);
|
|
279
309
|
}
|
|
280
310
|
|
|
@@ -379,7 +409,7 @@ class MqttDeviceCard extends HTMLElementExtendedMinimum {
|
|
|
379
409
|
const widget = this.widgetForTopic(mt, mt.name);
|
|
380
410
|
this.state.elements.widgets.push({ row: { mt }, widget });
|
|
381
411
|
return el('div', {
|
|
382
|
-
class: `fi-field fi-field--reading${mt.outOfRange ? ' fi-row--outofrange' : ''}`,
|
|
412
|
+
class: `fi-field fi-field--reading${mt.outOfRange ? ' fi-row--outofrange' : ''}${mt.invalid ? ' fi-row--invalid' : ''}`,
|
|
383
413
|
}, [widget]);
|
|
384
414
|
}));
|
|
385
415
|
}
|
|
@@ -678,8 +708,11 @@ const ADMIN_CARDS = [
|
|
|
678
708
|
{ section: 'info', title: 'Info', capability: null, own: true },
|
|
679
709
|
{ section: 'ota', title: 'OTA', capability: 'OTAUPDATE' },
|
|
680
710
|
{ section: 'flash', title: 'Flash over USB', capability: 'OTAFLASH' },
|
|
681
|
-
{ section: 'admin',
|
|
682
|
-
{ section: '
|
|
711
|
+
{ section: 'admin', title: 'Permissions', capability: 'ADMIN' }, // people
|
|
712
|
+
{ section: 'projects', title: 'Projects', capability: 'ADMIN' },
|
|
713
|
+
{ section: 'message', title: 'Publish Message', capability: 'ADMIN' },
|
|
714
|
+
{ section: 'retained', title: 'Retained messages', capability: 'ADMIN' },
|
|
715
|
+
{ section: 'nodes', title: 'Nodes', capability: 'ADMIN' },
|
|
683
716
|
{ section: 'api', title: 'API', capability: 'ADMIN' },
|
|
684
717
|
];
|
|
685
718
|
|