tassello 0.2.0 → 0.4.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +175 -8
  3. package/dist/server/behavior_tree_files.d.ts +0 -1
  4. package/dist/server/behavior_tree_files.js +2 -25
  5. package/dist/server/diagnostics.d.ts +16 -0
  6. package/dist/server/diagnostics.js +200 -0
  7. package/dist/server/diagnostics_state.d.ts +23 -0
  8. package/dist/server/diagnostics_state.js +146 -0
  9. package/dist/server/disks.d.ts +14 -0
  10. package/dist/server/disks.js +152 -0
  11. package/dist/server/file_manager.d.ts +25 -0
  12. package/dist/server/file_manager.js +717 -0
  13. package/dist/server/http.js +248 -5
  14. package/dist/server/netpbm.d.ts +3 -0
  15. package/dist/server/netpbm.js +69 -0
  16. package/dist/server/networking.d.ts +41 -0
  17. package/dist/server/networking.js +508 -0
  18. package/dist/server/networking_format.d.ts +38 -0
  19. package/dist/server/networking_format.js +651 -0
  20. package/dist/server/pty.d.ts +26 -0
  21. package/dist/server/pty.js +150 -0
  22. package/dist/server/server.js +13 -2
  23. package/dist/server/session.d.ts +10 -1
  24. package/dist/server/session.js +88 -1
  25. package/dist/server/terminal_output.d.ts +25 -0
  26. package/dist/server/terminal_output.js +108 -0
  27. package/dist/server/terminals.d.ts +32 -0
  28. package/dist/server/terminals.js +366 -0
  29. package/dist/server/util.d.ts +4 -0
  30. package/dist/server/util.js +45 -1
  31. package/dist/shared/file_manager.d.ts +130 -0
  32. package/dist/shared/file_manager.js +7 -0
  33. package/dist/shared/networking.d.ts +178 -0
  34. package/dist/shared/networking.js +248 -0
  35. package/dist/shared/protocol.d.ts +103 -2
  36. package/dist/shared/protocol.js +8 -0
  37. package/dist/types/client/api.d.ts +22 -0
  38. package/dist/types/client/connection.d.ts +30 -1
  39. package/dist/types/client/icons.d.ts +33 -0
  40. package/dist/types/client/sdk/index.d.ts +5 -3
  41. package/dist/types/shared/file_manager.d.ts +130 -0
  42. package/dist/types/shared/networking.d.ts +178 -0
  43. package/dist/types/shared/protocol.d.ts +103 -2
  44. package/dist/www/assets/Joystick.js +1 -1
  45. package/dist/www/assets/RobotModelView.js +2 -2
  46. package/dist/www/assets/TerminalView.css +1 -0
  47. package/dist/www/assets/TerminalView.js +17 -0
  48. package/dist/www/assets/dialogs.js +1 -0
  49. package/dist/www/assets/icons.js +1 -1
  50. package/dist/www/assets/index.css +1 -1
  51. package/dist/www/assets/index.js +59 -19
  52. package/dist/www/assets/keys.js +3 -0
  53. package/dist/www/assets/shared/tassello-sdk.js +1 -1
  54. package/dist/www/index.html +2 -0
  55. package/docs/plugins.md +22 -0
  56. package/package.json +6 -2
package/CHANGELOG.md CHANGED
@@ -28,6 +28,59 @@ change the plugin API (`tassello/sdk`, `tassello/server`, `tassello/vite`), as n
28
28
  *_server`). The server follows the log for every page and keeps its latest 10,000 messages.
29
29
  - Plugins: `connection.subscribeRosout`, and the `LogsIcon`, `NodesIcon`, `SearchIcon`,
30
30
  `BanIcon`, `FilterIcon`, `DownloadIcon` and `ArrowDownToLineIcon` icons.
31
+ - Diagnostics widget: the robot's diagnostics (`diagnostic_msgs/msg/DiagnosticArray`), from every
32
+ node's `diagnostic_updater` (`/diagnostics`, by node) or the aggregator's tree
33
+ (`/diagnostics_agg`): each status's level, message, hardware ID and values, the groups with the
34
+ worst under them, as a tree, as a list or as the latest changes of level, filtered by level and
35
+ text. A widget can be for some statuses only (`Sensors, motor_*`). The server follows the
36
+ diagnostics for every page, marks stale the statuses no longer received, and keeps their changes
37
+ of level.
38
+ - Plugins: `connection.subscribeDiagnostics`, and the `DiagnosticsIcon` and `UnfoldIcon` icons.
39
+ - File Manager widget: the robot's files, in the behavior tree editor's look. Folders listed with
40
+ their entries' sizes and times, unfolded in place, sorted and filtered; places (home, the ROS
41
+ workspaces sourced, bookmarks) and the disks with their free space; an entry's details, its
42
+ permissions changed there; an overview of the disks and of what takes a folder's space (its
43
+ largest entries, its files by type, the largest and latest files). Entries made, renamed in
44
+ place, cut, copied and pasted, moved or copied by dragging them onto folders, deleted,
45
+ downloaded (folders as `.tar.gz`), uploaded by dropping files and folders onto the widget, with
46
+ their progress; names taken are asked about. Text files are edited in tabs, as gedit does: line
47
+ numbers, syntax highlighting (YAML, XML, Python, C++, CMake, JSON, shell, INI, Markdown, ROS
48
+ interfaces), search and replace, indentation, line ends kept, undo and redo, never saved over
49
+ changes made on the robot since they were read. Images are shown, PGM maps too. A widget can
50
+ stay in its folder, or be read-only. The server polls the folders and files shown.
51
+ - Plugins: `fsApi` and `ApiError`, the `Fs*` types, and the `FolderIcon`, `FolderPlusIcon`,
52
+ `FileIcon`, `FileTextIcon`, `FilePlusIcon`, `UploadIcon`, `ArrowUpIcon`, `ArrowLeftIcon`,
53
+ `HomeIcon`, `HardDriveIcon`, `BookmarkIcon`, `SidebarIcon`, `PieChartIcon`, `InfoIcon`,
54
+ `WrapIcon`, `LinkIcon`, `LockIcon` and `ArchiveIcon` icons.
55
+ - REST errors of the API reject with an `ApiError`, which has the HTTP status.
56
+ - Terminal widget: a shell on the robot (the login shell of the server's user), or a command, in
57
+ a full terminal (xterm.js): colours, full screen programs and their mouse, copy and paste. It
58
+ outlives the page: reloaded, reconnected or shown again, it is as it was, full screen programs
59
+ included; hung up an hour after no page shows it. On touch screens, Termux's extra keys (Esc,
60
+ Tab, Ctrl, Alt, Home, End, the arrows, Page Up and Down): sticky modifiers that also apply to the
61
+ on-screen keyboard, keys swiped up for another character, a layout of one's own; the terminal
62
+ fills the screen above the on-screen keyboard while typing; one finger scrolls, two pinch the
63
+ text's size. Needs Python 3 (ROS 2's), no native module. Terminals open from the dashboard's own
64
+ pages only.
65
+ - Plugins: `connection.subscribeTerminal`, `terminalInput`, `resizeTerminal`, `ackTerminal`,
66
+ `restartTerminal`, the `TerminalOutput` and `TerminalExit` types, and the `ShellIcon`,
67
+ `KeyboardIcon`, `MaximizeIcon`, `MinimizeIcon`, `ClipboardIcon`, `TextSmallerIcon` and
68
+ `TextLargerIcon` icons.
69
+ - Networking widget: the robot's networks, through NetworkManager (`nmcli`), as a phone's settings
70
+ show them. The Wi-Fi networks in range, joined with their password (WPA, WPA2 and WPA3 Personal,
71
+ WEP, WPA Enterprise with PEAP), hidden ones by name; the connections saved, connected and
72
+ disconnected, edited (password, interface, IPv4 by DHCP or static with gateway and DNS, shared,
73
+ connecting automatically and with which priority) and forgotten; wired connections with an
74
+ address of their own; the robot as an access point (WPA2, band, channel, its address), started
75
+ now or at boot, and a network shared by its password and a QR code; the interfaces, their state,
76
+ addresses, gateway, DNS, MAC address and speed. The server tells each page which interface it
77
+ reaches the robot through, and the widget asks before a change that would cut it off. The server
78
+ follows NetworkManager for every page (`nmcli monitor`), shows every page what is being done and
79
+ how it ended, and says when its user may not change the networks (a polkit rule, see the
80
+ README). Networks change from the dashboard's own pages only.
81
+ - Plugins: `connection.subscribeNetwork`, `networkAction`, the `Network*`, `WifiNetwork`,
82
+ `WifiSecurity` and `ConnectionKind` types, and the `NetworkIcon`, `WifiIcon`, `WifiOffIcon`,
83
+ `EthernetIcon`, `HotspotIcon` and `ShieldIcon` icons.
31
84
 
32
85
  ## 0.1.0
33
86
 
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Tassello
2
2
 
3
3
  **A web dashboard for ROS 2 robots.** Drive the robot, send it navigation goals, see it move in 3D,
4
- watch its cameras, run commands on it and manage its services from any browser on the network,
5
- phones included. Arrange it all in the browser, as panels of widgets, and extend it with plugins.
4
+ watch its cameras, run commands on it, open a terminal on it and manage its services from any
5
+ browser on the network, phones included. Arrange it all in the browser, as panels of widgets, and extend it with plugins.
6
6
 
7
7
  *Tassello* is Italian for a mosaic tile, and for a wall plug: the dashboard is a mosaic of widgets,
8
8
  and plugins add more.
@@ -11,7 +11,8 @@ and plugins add more.
11
11
  - **Every device**: a desktop canvas, and layouts of their own for tablets and phones.
12
12
  - **Built in**: teleop pads, a Nav2 map with goals, the robot's URDF in 3D, an arm jogger, cameras,
13
13
  images, command buttons, a systemd application manager, a behavior tree inspector and
14
- editor, and the ROS log of every node.
14
+ editor, the ROS log of every node, the robot's diagnostics, a file manager, a terminal, and
15
+ its networks: Wi-Fi joined, connections switched, the robot as an access point.
15
16
  - **Plugins**: widgets of your own, with server-side code if they need it, as npm or ROS packages.
16
17
  - **One process**: a ROS 2 node in Node.js. Nothing to install on the robot but Node.js.
17
18
 
@@ -21,7 +22,9 @@ Tassello runs on the robot (or any machine on its ROS network) and needs:
21
22
 
22
23
  - ROS 2: Humble, Jazzy, Kilted or Lyrical, on x64 or arm64 (e.g. a Raspberry Pi), for which
23
24
  [rclnodejs](https://github.com/RobotWebTools/rclnodejs) ships prebuilt binaries;
24
- - Node.js 22 or newer, and npm.
25
+ - Node.js 22 or newer, and npm;
26
+ - Python 3, which ROS 2 comes with: the Terminal widget's pseudo-terminals;
27
+ - NetworkManager, which Ubuntu runs, for the Networking widget.
25
28
 
26
29
  ```bash
27
30
  npm install -g tassello
@@ -117,6 +120,10 @@ after the others on the devices already arranged.
117
120
  | **Behavior Tree Inspector** | What a BT.CPP behavior tree is doing, from its log (Nav2's `/behavior_tree_log`): the tree of its XML file with the running branch lit, each node's status, last result, counts and ports, and the latest transitions. As a graph (pan, zoom, follow the running nodes) or a list. Records the log into rosbags, and plays rosbags back at any speed |
118
121
  | **Behavior Tree Editor** | Edits a BT.CPP behavior tree's XML file of the robot as a graph: nodes from a palette (BT.CPP's, Nav2's, your libraries'), dragged where they go, moved with their children, their names and ports edited in a side panel. Subtrees, undo and redo, copy and paste, an XML view. Saves only what changed, never over changes made meanwhile |
119
122
  | **Logs** | The ROS log (`/rosout`), as rqt_console and swri_console show it: every node's messages coloured by level, the nodes running and those that logged with their counts, filtered by node, by level and by text (terms or a regular expression, to show or to hide). Pause, follow the newest, clear, download, and a message's details: where it was logged |
123
+ | **Diagnostics** | The robot's diagnostics (`/diagnostics`, or the aggregator's `/diagnostics_agg`), as rqt_robot_monitor shows them: each status's level, the groups with the worst under them, those with problems unfolded. As a tree, as a list (the worst first) or as the latest changes of level, filtered by level and by text; a widget can be for some statuses only. A status's details: its message, hardware ID, values and latest changes. Pause, download, and forget the statuses no longer published |
124
+ | **File Manager** | The robot's files: folders unfolded in place, places (home, the ROS workspaces, bookmarks) and the disks' free space, an entry's details and permissions, what takes a folder's space. Make, rename, cut, copy, paste, drag onto folders, delete, download (folders as `.tar.gz`) and upload (drop files and folders onto it). Text files edited in tabs as gedit does: highlighting, search and replace, undo; images and PGM maps shown |
125
+ | **Terminal** | A shell on the robot, or a command (`htop`, `tmux attach`), in a full terminal: colours, full screen programs, the mouse. It outlives the page: reloaded, reconnected or shown again, it is as it was. On touch screens, the keys phones lack, as Termux has them: Esc, Tab, Ctrl, Alt, Home, End, the arrows, Page Up and Down, and while typing the terminal fills the screen above the on-screen keyboard |
126
+ | **Networking** | The robot's networks, through NetworkManager, as a phone's settings show them: the Wi-Fi networks in range, joined with their password; the connections saved, switched between, edited (their addresses by DHCP or static), forgotten; the robot as an access point, its network shared by a QR code; the interfaces, their state and addresses. Asks before a change that would cut the page off from the robot |
120
127
 
121
128
  Their properties (topics, frames, speeds, colours, ...) are edited in the side panel of edit mode.
122
129
 
@@ -209,6 +216,143 @@ Notes on some of them:
209
216
  log for every page, keeping its latest 10,000 messages, and for 10 minutes after the last page
210
217
  closes: a page opened later sees what was logged meanwhile, and when first opened, what each
211
218
  node logged in its last 10 seconds (rosout's own history).
219
+ - **Diagnostics** follows a `diagnostic_msgs/msg/DiagnosticArray` topic: `/diagnostics`, on which
220
+ every node's `diagnostic_updater` publishes its statuses (named `node: task`, grouped by node),
221
+ or `/diagnostics_agg`, the aggregator's tree of them (`/Robot/Sensors/Lidar`). A group shows the
222
+ worst level under it; one the aggregator puts in error only because some statuses under it are
223
+ stale shows as stale. A status not received for 5 s, or for 3 of its periods if it is slower, is
224
+ stale: what published it stopped. The level buttons show and hide each level (Shift+click: that
225
+ level only), counting the statuses, not the groups that sum them up; *Filter* keeps the statuses
226
+ whose name, message or hardware ID contain any of its terms, separated by `;`, or match a
227
+ regular expression, and a group's with all those under it. The groups with problems unfold, and
228
+ stay unfolded once these are gone, until folded; the arrow keys go through the tree and fold
229
+ and unfold it. A status clicked shows its message, hardware ID, values and latest changes of
230
+ level. Pausing holds the statuses as they are; *Forget* hides those no longer published, until
231
+ they are again; *Download* saves the statuses shown, the worst first, with their values (or the
232
+ changes shown). The widget's properties set its statuses (names, paths or globs, e.g.
233
+ `Sensors, motor_*`: from the root if they start with `/`, else at any depth), and the levels,
234
+ filter and view it opens with. The server follows the diagnostics for every page, and for 10
235
+ minutes after the last page closes, keeping the latest status of each name and their latest
236
+ 1,000 changes of level.
237
+ - **File Manager** opens its *folder* (`~` by default; `/…`, `~/…`, `package://…`) and lists it
238
+ with its entries' sizes and times, sorted by name (naturally: `run_2` before `run_10`), size or
239
+ time, folders first; the chevrons unfold folders in place. Its places are home, the root, the
240
+ ROS installations and workspaces sourced (`AMENT_PREFIX_PATH`), its *bookmarks* (the widget's,
241
+ and those made in the page, kept by the browser) and the disks, with their free space. Click to
242
+ select (Ctrl, Shift: several), double click (on touch screens tap) to open: folders, text files
243
+ into the editor, images (PNG, JPEG, SVG…, and PGM maps, converted on the robot) into a viewer.
244
+ Right click, or the ⋯ of a row, for the rest: download (a folder, or several entries, as one
245
+ `.tar.gz`), cut, copy and paste (between file managers too), duplicate, move or copy to a folder
246
+ picked, rename (F2, in place), copy the path, details, delete (there is no trash: it asks
247
+ first). Drag entries onto a folder, a place or the address bar's folders to move them (Ctrl:
248
+ copy them); drop files or folders from the computer to upload them, with their progress. Where
249
+ names are taken, it asks: replace, keep both (`map (2).yaml`) or skip. *Details* show an entry's
250
+ kind, size (a folder's computed on demand), times, owner, mode, link and disk, an image's
251
+ preview, and change its permissions; *what takes the space* shows the disks (size, use, free
252
+ space, inodes when short) and analyzes the folder: its largest entries, its files by type, the
253
+ largest and the latest files under it, on its disk only, for 15 s at most. Keys: the arrows
254
+ (Left and Right fold), Enter, Backspace, Alt+arrows (up, back, forward), Delete, F2, Ctrl+A,
255
+ Ctrl+C, Ctrl+X, Ctrl+V, Ctrl+F (filter), Ctrl+L (type a path), Ctrl+H (hidden files), and typing
256
+ a name's first letters.
257
+
258
+ Text files (UTF-8, 5 MB at most) open in tabs, edited as with gedit: line numbers, the line of
259
+ the cursor lit, syntax highlighting (YAML, XML for URDF, xacro, launch and SDF, Python, C and
260
+ C++, CMake, JSON, shell, INI, TOML and systemd units, Markdown, ROS interfaces), search and
261
+ replace (case, whole words, regular expressions with `$1`), Tab and Shift+Tab indenting the lines
262
+ selected, new lines indented as the one before, Ctrl+/ commenting lines out, go to line (Ctrl+I),
263
+ undo and redo a word at a time, long lines wrapped on demand (then not highlighted). The status
264
+ line shows the cursor's line and column, and switches the highlighting, the indentation and the
265
+ line ends (kept as the file has them, LF or CRLF). As the behavior tree editor, it saves only
266
+ over the version read: a file changed on the robot is shown at once without changes in the
267
+ editor, with changes the editor asks which to keep, and a save over a file changed since it was
268
+ read is refused. *Save as* writes elsewhere, e.g. a file the server's user cannot write. Changes
269
+ are kept while the page is open, and the page asks before closing with changes not saved.
270
+
271
+ The folders shown and the files open are read again every 3 s. A widget can *stay in its
272
+ folder* (e.g. one for the maps, or the rosbags) or be *read-only*: these are the page's choices,
273
+ not a protection (see Security).
274
+ - **Terminal** runs the login shell of the server's user (`$SHELL -l`, as SSH does: its
275
+ `~/.bashrc` and the ROS setup Tassello was started with), or the widget's *command* through it,
276
+ in its *folder* (`~` by default; `/…`, `~/…`, `package://…`), on a pseudo-terminal of its own:
277
+ `TERM=xterm-256color`, true colour, full screen programs (vim, htop, less) and their mouse. A
278
+ terminal belongs to the browser's tab: reloading the page, losing the connection or showing
279
+ another panel and coming back finds it as it was, what it printed meanwhile included, screens of
280
+ full screen programs too. A terminal no page shows is hung up after an hour (its programs get
281
+ SIGHUP, as when a terminal window is closed), as is the one unseen the longest when 16 are open:
282
+ for jobs that should outlive it, `tmux` or `nohup`. A *command* such as `tmux new -A -s robot` gives every page, and every
283
+ device, the same session. When the program ends, Enter or *Restart* starts it again; *Restart*
284
+ also hangs up a program that hangs. Changing the widget's command or folder starts it anew.
285
+
286
+ Copy with Ctrl+C while text is selected (it interrupts otherwise) or Ctrl+Shift+C, paste with
287
+ Ctrl+Shift+V or the toolbar's button (pages served over http cannot read the clipboard: it opens
288
+ a box to paste into, on phones too). The toolbar changes the size of the text for this device, as
289
+ pinching does, and fills the window with the terminal.
290
+
291
+ On touch screens (or always, or never: *Extra keys*) the keys phones lack are under the
292
+ terminal, as Termux has them: Esc, `/`, `-`, Home, ↑, End, Page Up, then Tab, Ctrl, Alt, ←, ↓,
293
+ →, Page Down. Tapped, they type without closing the on-screen keyboard. Ctrl, Alt and Shift
294
+ apply to the next key, of the row or of the keyboard (Ctrl, then `c`: interrupt); tapped twice,
295
+ or held, they stay until tapped again. Swiping up on a key, or holding it, types what its corner
296
+ shows (`\`, `|`, `~`); the arrows repeat while held. Their *layout* is a property: a line per
297
+ row, keys by name (`ESC TAB CTRL ALT SHIFT HOME END PGUP PGDN UP DOWN LEFT RIGHT INS DEL BKSP
298
+ ENTER SPACE F1`…`F12 KEYBOARD PASTE`) or what they type (`~`, `|`, `ros2`), what swiping up
299
+ types after a colon (`-:|`). While the on-screen keyboard is open, the terminal fills the screen
300
+ above it, the keys right above the keyboard (*Full screen while typing*). One finger scrolls (the
301
+ scrollback, or the program: less, htop), flung or not; two pinch the text's size.
302
+ - **Networking** changes the networks through NetworkManager (`nmcli`), which Ubuntu runs (a
303
+ robot whose netplan renders with systemd-networkd shows its interfaces as not managed). Its
304
+ **Wi-Fi** view lists the networks in range of each Wi-Fi interface, the one connected to first,
305
+ then those saved, then by signal. *Join* asks for the password (WPA and WPA2 Personal, WPA3
306
+ Personal, WEP, and WPA Enterprise with PEAP and MSCHAPv2), *Connect* uses the one saved, and
307
+ asks for it again if it is found wrong; *Join a hidden network…* looks for one by name. *New
308
+ access point…* makes the robot a Wi-Fi network of its own: WPA2 with a password made for it,
309
+ 2.4 or 5 GHz, a channel, the robot's address on it (10.42.0.1 if not given), giving addresses to
310
+ who joins (NetworkManager's shared mode, with `dnsmasq-base`). *Start automatically* makes it
311
+ the robot's network from when it starts, wherever it is. *Share…* shows a network's name and
312
+ password, and the QR code phones join it by. The **Saved** view lists the connections saved, by
313
+ kind (Wi-Fi networks, access points, wired, VPN), the active then the latest used first: connect
314
+ and disconnect them, edit them (their name, password, interface, IPv4: DHCP, static with a
315
+ gateway and DNS servers, shared or disabled, whether they connect by themselves and with which
316
+ priority) and forget them; *New wired connection…* gives a cable an address of its own, e.g. on
317
+ a lidar's network. The **Interfaces** view shows each interface's state, addresses, gateway, DNS
318
+ servers, MAC address and speed, and the connections it may connect with. A widget can be for
319
+ some interfaces only (`wlan0, eth*`), or read-only. Those other programs configure (docker's
320
+ bridges, VPN clients' tunnels) are left out, unless *Show every interface*.
321
+
322
+ The server tells each page which interface it reaches the robot through: a change that would
323
+ cut the page off (disconnecting that interface, switching its Wi-Fi to another network, making
324
+ it an access point, turning Wi-Fi off) is asked about first, saying so. What is being done shows
325
+ on every page, and how it ended, also to a page that lost the robot meanwhile: after switching
326
+ networks, the robot is reached on the new one; if it could not join it, NetworkManager connects
327
+ by itself to one of those saved that connect automatically. A Wi-Fi network or an access point
328
+ that fails to start is not kept; a cable's waits for its cable.
329
+
330
+ A desktop's NetworkManager lets the users of its session change the networks, not a service's:
331
+ give the server's user the right with a polkit rule. On Ubuntu 22.04 (polkit 0.105),
332
+ `/etc/polkit-1/localauthority/50-local.d/tassello-networking.pkla`, for the user `robot`:
333
+
334
+ ```ini
335
+ [Let the dashboard change the networks]
336
+ Identity=unix-user:robot
337
+ Action=org.freedesktop.NetworkManager.*
338
+ ResultAny=yes
339
+ ResultInactive=yes
340
+ ResultActive=yes
341
+ ```
342
+
343
+ From Ubuntu 24.04 (and Debian 12), `/etc/polkit-1/rules.d/50-tassello-networking.rules`:
344
+
345
+ ```js
346
+ polkit.addRule(function (action, subject) {
347
+ if (action.id.indexOf('org.freedesktop.NetworkManager.') === 0 && subject.user === 'robot') {
348
+ return polkit.Result.YES;
349
+ }
350
+ });
351
+ ```
352
+
353
+ Without it, the widget says the networks cannot be changed. *Share…* reads the passwords with
354
+ `nmcli --show-secrets`, which needs the same right; those a desktop's keyring keeps cannot be
355
+ read.
212
356
 
213
357
  ## Plugins
214
358
 
@@ -236,10 +380,15 @@ and ship example panels. See **[docs/plugins.md](docs/plugins.md)**.
236
380
  ## Security
237
381
 
238
382
  Tassello has no login. **Anyone who can open the page can drive the robot, run any command as
239
- the server's user, read its images, behavior trees and the behavior tree logs of its rosbags,
240
- record rosbags into new folders, and write behavior tree files (XML files whose root is
241
- `<root>`, over others of the same kind only)**: keep it on a network you trust, or behind a reverse proxy
242
- with authentication (it serves everything under `/`, its WebSocket at `/ws`).
383
+ the server's user (the Terminal, a shell), read, write, upload, move and delete any of the
384
+ files that user can (the File Manager), and change the robot's networks and read their passwords
385
+ (Networking)**: keep it on a network you trust, or behind a reverse proxy
386
+ with authentication (it serves everything under `/`, its WebSocket at `/ws`). Changes to the files
387
+ are accepted from the dashboard's own pages only (they send a header that another site's page
388
+ cannot), and the root, the home folder and the system's top folders (`/usr`, `/etc`…) are never
389
+ deleted nor moved. Terminals open, and networks change, from the dashboard's own pages only too:
390
+ those whose origin is the host they reached (the `Host` header, or the `X-Forwarded-Host` a
391
+ reverse proxy sets), which another site's page cannot pretend.
243
392
 
244
393
  ## How it works
245
394
 
@@ -250,6 +399,8 @@ with authentication (it serves everything under `/`, its WebSocket at `/ws`).
250
399
  ├─ rclnodejs: ROS
251
400
  ├─ sharp: raw images to JPEG, maps to PNG
252
401
  ├─ bash: the buttons' commands
402
+ ├─ python3: the terminals' pseudo-terminals
403
+ ├─ nmcli: the networks (NetworkManager)
253
404
  └─ the plugins' server sides
254
405
  ```
255
406
 
@@ -270,7 +421,23 @@ Safety and bandwidth are the server's concern:
270
421
  - The ROS log is followed once for every page: each gets the new messages at most 10 times a
271
422
  second, at most 500 at a time (a node logging faster has some skipped, which the page shows).
272
423
  Only the rows in sight are drawn.
424
+ - Diagnostics are followed once for every page: each gets the statuses that changed, at most 4
425
+ times a second, not those received the same again. Subscribed best effort, they come from
426
+ every publisher, whatever its QoS.
273
427
  - A button runs the command saved in its panel, never one sent by the page.
428
+ - A terminal's pseudo-terminal is opened by a few lines of Python (ROS 2's own), no native module
429
+ to build. Its output is also played on a terminal of the server's own (xterm.js, headless), which
430
+ paces the program, never the pages: a page gets at most 256 KB ahead of what it has drawn, and a
431
+ page further behind (a slow phone, a hidden tab, a flood of output) gets the screen as it is
432
+ instead of all that led to it. A page that reconnects gets what it missed, from the last 1 MB.
433
+ - The networks are read once for every page: again as soon as `nmcli monitor` reports a change,
434
+ and every 3 s for the signals of the Wi-Fi networks in range, which are scanned for when a page
435
+ opens the widget (at most every 30 s) or asks. Passwords are on `nmcli`'s command line only for
436
+ the moment it takes to save them, activating a connection being another command.
437
+ - The File Manager's folders are read again every 3 s, and answered "unchanged" while they are;
438
+ the largest are listed in part (5,000 entries), and only the rows in sight are drawn. Uploads
439
+ and downloads are streamed, folders archived by `tar` as they are sent; saves replace files at
440
+ once (a temporary file renamed over them).
274
441
 
275
442
  ## Developing
276
443
 
@@ -6,7 +6,6 @@ export interface BehaviorTreeFileWatcher {
6
6
  onFile: (file: FileState) => void;
7
7
  onModels: (models: ModelsState) => void;
8
8
  }
9
- export declare function contentVersion(text: string): string;
10
9
  export declare class BehaviorTreeFiles {
11
10
  private readonly uploadsDir;
12
11
  private readonly logger;
@@ -3,14 +3,13 @@
3
3
  // the robot), with the nodes they can use: Nav2's, those of their includes
4
4
  // and of the libraries the pages name (TreeNodesModel). And saved, only over
5
5
  // behavior trees, and only if unchanged since the page read them.
6
- import { createHash, randomBytes } from 'node:crypto';
7
6
  import { promises as fs } from 'node:fs';
8
7
  import path from 'node:path';
9
8
  import { readModels } from '../shared/bt_nodes.js';
10
9
  import { parseXmlSource, sourceText } from '../shared/xml.js';
11
10
  import { resolveTreeFile } from './behavior_tree_file.js';
12
11
  import { isBehaviorTreeXml } from './files.js';
13
- import { errorMessage, packageShareDirectory } from './util.js';
12
+ import { contentVersion, errorMessage, packageShareDirectory, replaceFile } from './util.js';
14
13
  // How often the files are checked for changes
15
14
  const POLL_MS = 2000;
16
15
  const MAX_FILE_BYTES = 5 * 1024 * 1024;
@@ -18,10 +17,6 @@ const MAX_FILE_BYTES = 5 * 1024 * 1024;
18
17
  const MAX_INCLUDES = 50;
19
18
  // A file of models kept parsed while unchanged; this many
20
19
  const MODEL_CACHE = 64;
21
- // Of a file's content
22
- export function contentVersion(text) {
23
- return createHash('sha256').update(text).digest('hex').slice(0, 16);
24
- }
25
20
  // Size, time and inode of a file, '' if gone
26
21
  async function stamp(file) {
27
22
  const stat = await fs.stat(file).catch(() => null);
@@ -297,24 +292,6 @@ class ModelsWatch {
297
292
  }
298
293
  }
299
294
  }
300
- // Writes the file anew, at once: a temporary file renamed over it, keeping
301
- // its mode; in place if that cannot be (a folder the server cannot write in,
302
- // a file of another user's)
303
- async function writeFile(file, text) {
304
- const stat = await fs.stat(file);
305
- if (stat.uid === process.getuid?.()) {
306
- const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(4).toString('hex')}.tmp`);
307
- try {
308
- await fs.writeFile(temporary, text, { mode: stat.mode & 0o7777, flag: 'wx' });
309
- await fs.rename(temporary, file);
310
- return;
311
- }
312
- catch {
313
- await fs.rm(temporary, { force: true });
314
- }
315
- }
316
- await fs.writeFile(file, text);
317
- }
318
295
  export class BehaviorTreeFiles {
319
296
  uploadsDir;
320
297
  logger;
@@ -390,7 +367,7 @@ export class BehaviorTreeFiles {
390
367
  if (base !== null && contentVersion(current) !== base) {
391
368
  return { version: contentVersion(current), conflict: true };
392
369
  }
393
- await writeFile(real, text);
370
+ await replaceFile(real, text);
394
371
  }
395
372
  this.logger.info(`Behavior tree saved: ${file}`);
396
373
  await this.watched.get(file)?.refresh();
@@ -0,0 +1,16 @@
1
+ import rclnodejs from 'rclnodejs';
2
+ import { type DiagnosticsUpdate } from '../shared/protocol.ts';
3
+ import type { StreamState } from './frame_stream.ts';
4
+ import { type Logger } from './util.ts';
5
+ export interface DiagnosticsWatcher {
6
+ onStatus: (state: StreamState, message: string) => void;
7
+ onUpdate: (update: Omit<DiagnosticsUpdate, 'type' | 'id'>) => void;
8
+ }
9
+ export declare class Diagnostics {
10
+ private readonly node;
11
+ private readonly logger;
12
+ private readonly topics;
13
+ constructor(node: rclnodejs.Node, logger: Logger);
14
+ watch(topic: string, watcher: DiagnosticsWatcher): () => void;
15
+ private forget;
16
+ }
@@ -0,0 +1,200 @@
1
+ // The robot's diagnostics for the diagnostics widgets: the
2
+ // diagnostic_msgs/msg/DiagnosticArray of /diagnostics, which every node's
3
+ // diagnostic_updater publishes, or of /diagnostics_agg, the aggregator's tree.
4
+ //
5
+ // A topic is followed once for every page, like the ROS log (rosout.ts). The
6
+ // server keeps the latest status of each name, marks stale those no longer
7
+ // received, and logs their changes of level (diagnostics_state.ts); its pages
8
+ // get the statuses that changed at most DIAGNOSTICS_RATE times a second.
9
+ // Diagnostics are followed for LINGER_MS after the last page: a page opened
10
+ // later sees what changed meanwhile.
11
+ import rclnodejs from 'rclnodejs';
12
+ import { DIAGNOSTICS_CHANGES, DIAGNOSTICS_RATE } from '../shared/protocol.js';
13
+ import { DiagnosticsState, STALE_AFTER_MS } from './diagnostics_state.js';
14
+ import { absoluteTopic, errorMessage } from './util.js';
15
+ const { HistoryPolicy, ReliabilityPolicy, DurabilityPolicy } = rclnodejs.QoS;
16
+ const ARRAY_TYPE = 'diagnostic_msgs/msg/DiagnosticArray';
17
+ // Arrays queued while the server is busy: every node publishes its own
18
+ const QUEUE_DEPTH = 100;
19
+ // How often the statuses are checked for staleness, and the topic for
20
+ // publishers until it has messages
21
+ const CHECK_PERIOD_MS = 1000;
22
+ const LINGER_MS = 10 * 60_000;
23
+ // A diagnostics topic: its subscription, its statuses, its pages
24
+ class DiagnosticsTopic {
25
+ watchers = new Set();
26
+ lingerTimer = null;
27
+ node;
28
+ logger;
29
+ topic;
30
+ started = Date.now();
31
+ statuses = new DiagnosticsState(DIAGNOSTICS_CHANGES);
32
+ subscription = null;
33
+ checkTimer;
34
+ state = 'waiting';
35
+ message = '';
36
+ // When the latest array came, 0 before the first
37
+ lastMessage = 0;
38
+ flushTimer = null;
39
+ lastFlush = 0;
40
+ constructor(node, logger, topic) {
41
+ this.node = node;
42
+ this.logger = logger;
43
+ this.topic = topic;
44
+ this.check();
45
+ this.checkTimer = setInterval(() => this.check(), CHECK_PERIOD_MS);
46
+ }
47
+ add(watcher) {
48
+ if (this.lingerTimer) {
49
+ clearTimeout(this.lingerTimer);
50
+ this.lingerTimer = null;
51
+ }
52
+ // What the others have not had yet, which the new one gets with the rest
53
+ this.flush();
54
+ this.watchers.add(watcher);
55
+ watcher.onStatus(this.state, this.message);
56
+ watcher.onUpdate({ reset: true, started: this.started, ...this.statuses.snapshot() });
57
+ }
58
+ remove(watcher) {
59
+ this.watchers.delete(watcher);
60
+ }
61
+ destroy() {
62
+ clearInterval(this.checkTimer);
63
+ for (const timer of [this.flushTimer, this.lingerTimer]) {
64
+ if (timer) {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+ if (this.subscription) {
69
+ this.node.destroySubscription(this.subscription);
70
+ }
71
+ }
72
+ // Best effort and volatile, which every publisher matches, reliable or not,
73
+ // latched or not: unlike most topics, /diagnostics has many publishers, and
74
+ // those that come later may not have the first ones' QoS. A lost array is
75
+ // followed by another a second later. Whether it could.
76
+ subscribe() {
77
+ const qos = new rclnodejs.QoS(HistoryPolicy.RMW_QOS_POLICY_HISTORY_KEEP_LAST, QUEUE_DEPTH, ReliabilityPolicy.RMW_QOS_POLICY_RELIABILITY_BEST_EFFORT, DurabilityPolicy.RMW_QOS_POLICY_DURABILITY_VOLATILE);
78
+ try {
79
+ this.subscription = this.node.createSubscription(ARRAY_TYPE, this.topic, { qos }, (raw) => this.onMessage(raw));
80
+ return true;
81
+ }
82
+ catch (e) {
83
+ this.setState('error', `Cannot subscribe to ${this.topic}: ${errorMessage(e)}`);
84
+ return false;
85
+ }
86
+ }
87
+ onMessage(raw) {
88
+ this.lastMessage = Date.now();
89
+ this.statuses.add(raw, this.lastMessage);
90
+ this.setState('streaming', this.topic);
91
+ this.scheduleFlush();
92
+ }
93
+ // Marks the stale statuses, and until diagnostics come, subscribes when the
94
+ // topic is not of another type (which the middleware would refuse), and
95
+ // says what they wait for
96
+ check() {
97
+ const now = Date.now();
98
+ this.statuses.checkStale(now);
99
+ this.scheduleFlush();
100
+ if (this.lastMessage > 0 && now - this.lastMessage <= STALE_AFTER_MS) {
101
+ return;
102
+ }
103
+ let types;
104
+ try {
105
+ // Which says what is wrong with it, unlike the lookup
106
+ rclnodejs.validator.validateFullTopicName(this.topic);
107
+ const publishers = this.node.getPublishersInfoByTopic(this.topic, false);
108
+ types = [...new Set(publishers.map((p) => p.topic_type))];
109
+ }
110
+ catch (e) {
111
+ this.setState('error', errorMessage(e));
112
+ return;
113
+ }
114
+ if (types.length > 0 && !types.includes(ARRAY_TYPE)) {
115
+ this.setState('error', `${this.topic} is a ${types.join(', ')}, not a ${ARRAY_TYPE}`);
116
+ return;
117
+ }
118
+ if (!this.subscription && !this.subscribe()) {
119
+ return;
120
+ }
121
+ if (this.lastMessage > 0) {
122
+ this.setState('waiting', `No diagnostics on ${this.topic} lately: their statuses went stale`);
123
+ }
124
+ else if (types.length === 0) {
125
+ this.setState('waiting', `Waiting for ${this.topic} to be published`);
126
+ }
127
+ else {
128
+ this.setState('waiting', `Waiting for diagnostics on ${this.topic}`);
129
+ }
130
+ }
131
+ scheduleFlush() {
132
+ if (this.flushTimer || this.watchers.size === 0) {
133
+ return;
134
+ }
135
+ const wait = Math.max(0, this.lastFlush + 1000 / DIAGNOSTICS_RATE - Date.now());
136
+ this.flushTimer = setTimeout(() => this.flush(), wait);
137
+ }
138
+ flush() {
139
+ if (this.flushTimer) {
140
+ clearTimeout(this.flushTimer);
141
+ this.flushTimer = null;
142
+ }
143
+ this.lastFlush = Date.now();
144
+ // With no page left, what changed waits for the next one, which gets it
145
+ // with the rest
146
+ const batch = this.statuses.takeBatch();
147
+ if (!batch) {
148
+ return;
149
+ }
150
+ const update = { reset: false, started: this.started, ...batch };
151
+ for (const watcher of this.watchers) {
152
+ watcher.onUpdate(update);
153
+ }
154
+ }
155
+ setState(state, message) {
156
+ if (state === this.state && message === this.message) {
157
+ return;
158
+ }
159
+ if (state === 'error') {
160
+ this.logger.warn(message);
161
+ }
162
+ this.state = state;
163
+ this.message = message;
164
+ for (const watcher of this.watchers) {
165
+ watcher.onStatus(state, message);
166
+ }
167
+ }
168
+ }
169
+ export class Diagnostics {
170
+ node;
171
+ logger;
172
+ topics = new Map();
173
+ constructor(node, logger) {
174
+ this.node = node;
175
+ this.logger = logger;
176
+ }
177
+ // Until the returned function is called
178
+ watch(topic, watcher) {
179
+ const name = absoluteTopic(topic);
180
+ let diagnostics = this.topics.get(name);
181
+ if (!diagnostics) {
182
+ diagnostics = new DiagnosticsTopic(this.node, this.logger, name);
183
+ this.topics.set(name, diagnostics);
184
+ }
185
+ const shared = diagnostics;
186
+ shared.add(watcher);
187
+ return () => {
188
+ shared.remove(watcher);
189
+ if (shared.watchers.size === 0 && !shared.lingerTimer) {
190
+ shared.lingerTimer = setTimeout(() => this.forget(name, shared), LINGER_MS);
191
+ }
192
+ };
193
+ }
194
+ forget(topic, diagnostics) {
195
+ diagnostics.destroy();
196
+ if (this.topics.get(topic) === diagnostics) {
197
+ this.topics.delete(topic);
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,23 @@
1
+ import type { DiagnosticChange, DiagnosticEntry } from '../shared/protocol.ts';
2
+ export declare const STALE_AFTER_MS = 5000;
3
+ export declare function levelOf(entry: Pick<DiagnosticEntry, 'level' | 'stale'>): number;
4
+ export interface DiagnosticsBatch {
5
+ statuses: DiagnosticEntry[];
6
+ changes: DiagnosticChange[];
7
+ }
8
+ export declare class DiagnosticsState {
9
+ private readonly capacity;
10
+ private readonly statuses;
11
+ private changes;
12
+ private lastId;
13
+ private lastSeq;
14
+ private taken;
15
+ private readonly dirty;
16
+ constructor(capacity: number);
17
+ add(raw: unknown, now: number): void;
18
+ checkStale(now: number): void;
19
+ snapshot(): DiagnosticsBatch;
20
+ takeBatch(): DiagnosticsBatch | null;
21
+ private addStatus;
22
+ private change;
23
+ }