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 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`, `mqtt-login`, `tabbed-display` |
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
- `index-embedded.html` needs only `core.js` + `widgets.js` and must keep working.
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.node` is null** — only call on topics that are known to be part of the node hierarchy (i.e. `this.node` is set). Project-level or standalone topics (e.g. those created by an embedded `mqtt-graphdataset`) have no real node and must not reach this getter.
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-devicegrid` are **light DOM** (`HTMLElementExtendedMinimum`), so
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 - login, the tab strip, and the admin panel: OTA, permissions, projects, nodes
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('ota_rest', this.gatedContent(this.otaRestContent));
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.replaceElement("otaorgsdropdown", this.orgDropdown(this.state.org, this.otaOrgs,"otaorganizations"));
961
- this.replaceElement("adminorgsdropdown", this.orgDropdown(this.state.org, this.adminOrgs,"adminorganizations"));
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
- // TODO-CSS cleanup - labels are too big
1238
- // This should really use a superuser permission but for now its just the super admin can do this
1239
- // ADMIN, not WRITE: someone who can change things through the UI should not thereby get a
1240
- // raw publish box - the UI constrains them to topics and values that mean something (D-32)
1241
- !hasCapability(this.state.org, 'ADMIN') ? null :
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', title: "Admin", orgs: 'adminOrgs', dropdown: 'adminorgsdropdown', rest: 'admin_rest', content: this.adminRestContent },
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
- !this.otaOrgs.length ? null :
1337
- el('section', {title: "OTA"}, [
1338
- this.state.elements.otaorgsdropdown = el('span',{ textContent: "Waiting"}),
1339
- this.state.elements.ota_rest = this.gatedContent(this.otaRestContent),
1340
- ]
1341
- ), // OTA tab
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', title: 'Permissions', capability: 'ADMIN' },
682
- { section: 'nodes', title: 'Nodes', capability: 'ADMIN' },
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