tassello 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +112 -8
- package/dist/server/behavior_tree_files.d.ts +0 -1
- package/dist/server/behavior_tree_files.js +2 -25
- package/dist/server/diagnostics.d.ts +16 -0
- package/dist/server/diagnostics.js +200 -0
- package/dist/server/diagnostics_state.d.ts +23 -0
- package/dist/server/diagnostics_state.js +146 -0
- package/dist/server/disks.d.ts +14 -0
- package/dist/server/disks.js +152 -0
- package/dist/server/file_manager.d.ts +25 -0
- package/dist/server/file_manager.js +717 -0
- package/dist/server/http.js +248 -5
- package/dist/server/netpbm.d.ts +3 -0
- package/dist/server/netpbm.js +69 -0
- package/dist/server/pty.d.ts +26 -0
- package/dist/server/pty.js +150 -0
- package/dist/server/server.js +9 -2
- package/dist/server/session.d.ts +7 -1
- package/dist/server/session.js +67 -1
- package/dist/server/terminal_output.d.ts +25 -0
- package/dist/server/terminal_output.js +108 -0
- package/dist/server/terminals.d.ts +32 -0
- package/dist/server/terminals.js +366 -0
- package/dist/server/util.d.ts +4 -0
- package/dist/server/util.js +45 -1
- package/dist/shared/file_manager.d.ts +130 -0
- package/dist/shared/file_manager.js +7 -0
- package/dist/shared/protocol.d.ts +87 -2
- package/dist/shared/protocol.js +8 -0
- package/dist/types/client/api.d.ts +22 -0
- package/dist/types/client/connection.d.ts +22 -1
- package/dist/types/client/icons.d.ts +27 -0
- package/dist/types/client/sdk/index.d.ts +4 -3
- package/dist/types/shared/file_manager.d.ts +130 -0
- package/dist/types/shared/protocol.d.ts +87 -2
- package/dist/www/assets/Joystick.js +1 -1
- package/dist/www/assets/RobotModelView.js +2 -2
- package/dist/www/assets/TerminalView.css +1 -0
- package/dist/www/assets/TerminalView.js +17 -0
- package/dist/www/assets/dialogs.js +1 -0
- package/dist/www/assets/icons.js +1 -1
- package/dist/www/assets/index.css +1 -1
- package/dist/www/assets/index.js +59 -19
- package/dist/www/assets/keys.js +3 -0
- package/dist/www/assets/shared/tassello-sdk.js +1 -1
- package/dist/www/index.html +2 -0
- package/docs/plugins.md +20 -0
- package/package.json +6 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The robot's diagnostics as the server keeps them for the diagnostics widgets
|
|
2
|
+
// (diagnostics.ts): the latest status of each name, stale once no longer
|
|
3
|
+
// received, their latest changes of level, and what the pages have yet to get.
|
|
4
|
+
// diagnostic_msgs/msg/DiagnosticStatus's levels
|
|
5
|
+
const OK = 0;
|
|
6
|
+
const STALE = 3;
|
|
7
|
+
// A status not received for this long, nor for this many of its periods, is
|
|
8
|
+
// stale: the aggregator's default timeout, and room for slower publishers
|
|
9
|
+
// (diagnostic_updater publishes once a second by default)
|
|
10
|
+
export const STALE_AFTER_MS = 5000;
|
|
11
|
+
const STALE_PERIODS = 3;
|
|
12
|
+
function text(value) {
|
|
13
|
+
return typeof value === 'string' ? value : '';
|
|
14
|
+
}
|
|
15
|
+
function count(value) {
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
return Number.isFinite(n) ? n : 0;
|
|
18
|
+
}
|
|
19
|
+
function keyValues(value) {
|
|
20
|
+
if (!Array.isArray(value)) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
return value.map((pair) => {
|
|
24
|
+
const fields = (pair ?? {});
|
|
25
|
+
return { key: text(fields.key), value: text(fields.value) };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function sameValues(a, b) {
|
|
29
|
+
return a.length === b.length && a.every((pair, i) => pair.key === b[i].key && pair.value === b[i].value);
|
|
30
|
+
}
|
|
31
|
+
// The level a status is at: STALE when stale, else as published
|
|
32
|
+
export function levelOf(entry) {
|
|
33
|
+
return entry.stale ? STALE : entry.level;
|
|
34
|
+
}
|
|
35
|
+
export class DiagnosticsState {
|
|
36
|
+
capacity;
|
|
37
|
+
// By name
|
|
38
|
+
statuses = new Map();
|
|
39
|
+
// Oldest first, with up to an eighth of capacity more: dropped in chunks,
|
|
40
|
+
// not one by one
|
|
41
|
+
changes = [];
|
|
42
|
+
lastId = 0;
|
|
43
|
+
// The seq of the latest change, and of the latest taken in a batch
|
|
44
|
+
lastSeq = 0;
|
|
45
|
+
taken = 0;
|
|
46
|
+
// Changed since the last batch
|
|
47
|
+
dirty = new Set();
|
|
48
|
+
// Of the changes kept
|
|
49
|
+
constructor(capacity) {
|
|
50
|
+
this.capacity = capacity;
|
|
51
|
+
}
|
|
52
|
+
// A diagnostic_msgs/msg/DiagnosticArray, from rclnodejs, received at now
|
|
53
|
+
add(raw, now) {
|
|
54
|
+
const { status } = (raw ?? {});
|
|
55
|
+
if (Array.isArray(status)) {
|
|
56
|
+
for (const fields of status) {
|
|
57
|
+
if (fields && typeof fields === 'object') {
|
|
58
|
+
this.addStatus(fields, now);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Marks stale the statuses no longer received
|
|
64
|
+
checkStale(now) {
|
|
65
|
+
for (const kept of this.statuses.values()) {
|
|
66
|
+
const { entry } = kept;
|
|
67
|
+
if (entry.stale || now - entry.updated <= Math.max(STALE_AFTER_MS, STALE_PERIODS * kept.period)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const before = levelOf(entry);
|
|
71
|
+
entry.stale = true;
|
|
72
|
+
this.dirty.add(kept);
|
|
73
|
+
if (before !== STALE) {
|
|
74
|
+
this.change(entry, now);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// All the statuses and changes kept: what a new page gets
|
|
79
|
+
snapshot() {
|
|
80
|
+
return {
|
|
81
|
+
statuses: [...this.statuses.values()].map((kept) => ({ ...kept.entry })),
|
|
82
|
+
changes: this.changes.slice(-this.capacity),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// The statuses changed and the changes since the last batch; null if none
|
|
86
|
+
takeBatch() {
|
|
87
|
+
const fresh = this.lastSeq - this.taken;
|
|
88
|
+
if (this.dirty.size === 0 && fresh === 0) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const statuses = [...this.dirty].map((kept) => ({ ...kept.entry }));
|
|
92
|
+
this.dirty.clear();
|
|
93
|
+
this.taken = this.lastSeq;
|
|
94
|
+
const kept = Math.min(fresh, this.capacity);
|
|
95
|
+
return { statuses, changes: kept > 0 ? this.changes.slice(-kept) : [] };
|
|
96
|
+
}
|
|
97
|
+
addStatus(fields, now) {
|
|
98
|
+
const next = {
|
|
99
|
+
name: text(fields.name),
|
|
100
|
+
level: count(fields.level),
|
|
101
|
+
message: text(fields.message),
|
|
102
|
+
hardwareId: text(fields.hardware_id),
|
|
103
|
+
values: keyValues(fields.values),
|
|
104
|
+
};
|
|
105
|
+
const kept = this.statuses.get(next.name);
|
|
106
|
+
if (!kept) {
|
|
107
|
+
const entry = { id: ++this.lastId, ...next, stale: false, updated: now, since: now };
|
|
108
|
+
const added = { entry, period: 0 };
|
|
109
|
+
this.statuses.set(next.name, added);
|
|
110
|
+
this.dirty.add(added);
|
|
111
|
+
if (entry.level !== OK) {
|
|
112
|
+
this.change(entry, now);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const { entry } = kept;
|
|
117
|
+
kept.period = now - entry.updated;
|
|
118
|
+
entry.updated = now;
|
|
119
|
+
if (!entry.stale &&
|
|
120
|
+
entry.level === next.level &&
|
|
121
|
+
entry.message === next.message &&
|
|
122
|
+
entry.hardwareId === next.hardwareId &&
|
|
123
|
+
sameValues(entry.values, next.values)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const before = levelOf(entry);
|
|
127
|
+
Object.assign(entry, next, { stale: false });
|
|
128
|
+
this.dirty.add(kept);
|
|
129
|
+
if (levelOf(entry) !== before) {
|
|
130
|
+
this.change(entry, now);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
change(entry, now) {
|
|
134
|
+
entry.since = now;
|
|
135
|
+
this.changes.push({
|
|
136
|
+
seq: ++this.lastSeq,
|
|
137
|
+
time: now,
|
|
138
|
+
status: entry.id,
|
|
139
|
+
level: levelOf(entry),
|
|
140
|
+
message: entry.message,
|
|
141
|
+
});
|
|
142
|
+
if (this.changes.length >= this.capacity + Math.ceil(this.capacity / 8)) {
|
|
143
|
+
this.changes.splice(0, this.changes.length - this.capacity);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FsDisk, FsSpace } from '../shared/file_manager.ts';
|
|
2
|
+
export interface Mount {
|
|
3
|
+
device: string;
|
|
4
|
+
mount: string;
|
|
5
|
+
source: string;
|
|
6
|
+
type: string;
|
|
7
|
+
readOnly: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseMountInfo(text: string): Mount[];
|
|
10
|
+
export declare function storageMounts(mounts: Mount[]): Mount[];
|
|
11
|
+
export declare function mountOf(file: string, mounts: Mount[]): Mount | null;
|
|
12
|
+
export declare function readMounts(): Promise<Mount[]>;
|
|
13
|
+
export declare function spaceOf(folder: string): Promise<FsSpace | null>;
|
|
14
|
+
export declare function listDisks(): Promise<FsDisk[]>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The robot's file systems, for the file manager: the mounts of
|
|
2
|
+
// /proc/self/mountinfo that hold files (not proc, sysfs, cgroups, snaps...),
|
|
3
|
+
// with their size and free space, and the one a path is on.
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
// File systems that hold no files of the robot's
|
|
7
|
+
const PSEUDO = new Set([
|
|
8
|
+
'autofs',
|
|
9
|
+
'binfmt_misc',
|
|
10
|
+
'bpf',
|
|
11
|
+
'cgroup',
|
|
12
|
+
'cgroup2',
|
|
13
|
+
'configfs',
|
|
14
|
+
'debugfs',
|
|
15
|
+
'devpts',
|
|
16
|
+
'devtmpfs',
|
|
17
|
+
'efivarfs',
|
|
18
|
+
'fusectl',
|
|
19
|
+
'fuse.gvfsd-fuse',
|
|
20
|
+
'fuse.portal',
|
|
21
|
+
'fuse.snapfuse',
|
|
22
|
+
'hugetlbfs',
|
|
23
|
+
'mqueue',
|
|
24
|
+
'nsfs',
|
|
25
|
+
'proc',
|
|
26
|
+
'pstore',
|
|
27
|
+
'ramfs',
|
|
28
|
+
'rpc_pipefs',
|
|
29
|
+
'securityfs',
|
|
30
|
+
'selinuxfs',
|
|
31
|
+
'squashfs',
|
|
32
|
+
'sysfs',
|
|
33
|
+
'tracefs',
|
|
34
|
+
]);
|
|
35
|
+
// Mounts of the system's own, below these
|
|
36
|
+
const HIDDEN_UNDER = ['/proc', '/sys', '/dev', '/run', '/snap', '/var/snap', '/var/lib/docker', '/boot/efi'];
|
|
37
|
+
// statfs of a network share that went away hangs: given up after this
|
|
38
|
+
const STATFS_TIMEOUT_MS = 2000;
|
|
39
|
+
// The mount table changes rarely: read again after this
|
|
40
|
+
const MOUNTS_TTL_MS = 10_000;
|
|
41
|
+
// \040 for a space, as the kernel escapes them
|
|
42
|
+
function unescape(field) {
|
|
43
|
+
return field.replace(/\\([0-7]{3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8)));
|
|
44
|
+
}
|
|
45
|
+
// The lines of /proc/self/mountinfo:
|
|
46
|
+
// 36 35 98:0 /mnt1 /mnt2 rw,noatime master:1 - ext3 /dev/root rw,errors=continue
|
|
47
|
+
export function parseMountInfo(text) {
|
|
48
|
+
const mounts = [];
|
|
49
|
+
for (const line of text.split('\n')) {
|
|
50
|
+
const fields = line.trim().split(' ');
|
|
51
|
+
const separator = fields.indexOf('-', 6);
|
|
52
|
+
if (fields.length < 10 || separator < 0 || separator + 2 >= fields.length) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
mounts.push({
|
|
56
|
+
device: fields[2],
|
|
57
|
+
mount: unescape(fields[4]),
|
|
58
|
+
source: unescape(fields[separator + 2]),
|
|
59
|
+
type: fields[separator + 1],
|
|
60
|
+
readOnly: fields[5].split(',').includes('ro'),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return mounts;
|
|
64
|
+
}
|
|
65
|
+
function under(file, folder) {
|
|
66
|
+
return file === folder || file.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
|
67
|
+
}
|
|
68
|
+
// The mounts worth showing: not pseudo file systems, nor the system's own
|
|
69
|
+
// (/run, /snap), nor RAM (but /tmp), each device once, at its shortest mount
|
|
70
|
+
// point (others are bind mounts of it)
|
|
71
|
+
export function storageMounts(mounts) {
|
|
72
|
+
const kept = new Map();
|
|
73
|
+
for (const mount of mounts) {
|
|
74
|
+
const media = under(mount.mount, '/run/media') || under(mount.mount, '/media');
|
|
75
|
+
if (PSEUDO.has(mount.type) ||
|
|
76
|
+
(mount.type === 'tmpfs' && mount.mount !== '/tmp') ||
|
|
77
|
+
// A container's root
|
|
78
|
+
(mount.type === 'overlay' && mount.mount !== '/') ||
|
|
79
|
+
(!media && HIDDEN_UNDER.some((folder) => under(mount.mount, folder)))) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const known = kept.get(mount.device);
|
|
83
|
+
if (!known || mount.mount.length < known.mount.length) {
|
|
84
|
+
kept.set(mount.device, mount);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...kept.values()].sort((a, b) => a.mount.localeCompare(b.mount));
|
|
88
|
+
}
|
|
89
|
+
// The mount a path is on, of those given: the deepest that holds it
|
|
90
|
+
export function mountOf(file, mounts) {
|
|
91
|
+
let found = null;
|
|
92
|
+
for (const mount of mounts) {
|
|
93
|
+
if (under(file, mount.mount) && (!found || mount.mount.length >= found.mount.length)) {
|
|
94
|
+
found = mount;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
let cached = null;
|
|
100
|
+
export function readMounts() {
|
|
101
|
+
if (!cached || Date.now() - cached.at > MOUNTS_TTL_MS) {
|
|
102
|
+
cached = {
|
|
103
|
+
at: Date.now(),
|
|
104
|
+
mounts: fs.readFile('/proc/self/mountinfo', 'utf8').then(parseMountInfo, () => []),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return cached.mounts;
|
|
108
|
+
}
|
|
109
|
+
async function statfs(folder) {
|
|
110
|
+
let timer;
|
|
111
|
+
const timeout = new Promise((resolve) => (timer = setTimeout(() => resolve(null), STATFS_TIMEOUT_MS)));
|
|
112
|
+
try {
|
|
113
|
+
return await Promise.race([fs.statfs(folder).catch(() => null), timeout]);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Of the file system a folder is on; null if not known
|
|
120
|
+
export async function spaceOf(folder) {
|
|
121
|
+
const [stats, mounts] = await Promise.all([statfs(folder), readMounts()]);
|
|
122
|
+
if (!stats) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const real = await fs.realpath(folder).catch(() => folder);
|
|
126
|
+
return {
|
|
127
|
+
mount: mountOf(real, mounts)?.mount ?? path.parse(real).root,
|
|
128
|
+
total: stats.blocks * stats.bsize,
|
|
129
|
+
free: stats.bavail * stats.bsize,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// The robot's file systems, by mount point
|
|
133
|
+
export async function listDisks() {
|
|
134
|
+
const mounts = storageMounts(await readMounts());
|
|
135
|
+
const disks = await Promise.all(mounts.map(async (mount) => {
|
|
136
|
+
const stats = await statfs(mount.mount);
|
|
137
|
+
if (!stats || stats.blocks === 0) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
mount: mount.mount,
|
|
142
|
+
device: mount.source,
|
|
143
|
+
type: mount.type,
|
|
144
|
+
readOnly: mount.readOnly,
|
|
145
|
+
total: stats.blocks * stats.bsize,
|
|
146
|
+
used: (stats.blocks - stats.bfree) * stats.bsize,
|
|
147
|
+
free: stats.bavail * stats.bsize,
|
|
148
|
+
inodes: stats.files > 0 ? { total: stats.files, free: stats.ffree } : null,
|
|
149
|
+
};
|
|
150
|
+
}));
|
|
151
|
+
return disks.filter((disk) => disk !== null);
|
|
152
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FsAnalysis, type FsConflict, type FsDetails, type FsListing, type FsPlaces, type FsResult, type FsSaved, type FsText } from '../shared/file_manager.ts';
|
|
2
|
+
import { HttpError } from './panels.ts';
|
|
3
|
+
export declare const collator: Intl.Collator;
|
|
4
|
+
export declare function fsError(e: unknown, what: string): HttpError;
|
|
5
|
+
export declare function resolvePath(input: unknown, home?: string): string;
|
|
6
|
+
export declare function checkName(name: unknown): string;
|
|
7
|
+
export declare function numberedName(name: string, n: number): string;
|
|
8
|
+
export declare function listFolder(folder: string): Promise<FsListing>;
|
|
9
|
+
export declare function entryDetails(file: string): Promise<FsDetails>;
|
|
10
|
+
export declare function listPlaces(home?: string, prefixes?: string): FsPlaces;
|
|
11
|
+
export declare function makeFolder(folder: string): Promise<FsResult>;
|
|
12
|
+
export declare function makeFile(file: string): Promise<FsResult>;
|
|
13
|
+
export declare function renameEntry(file: string, name: string, home?: string): Promise<FsResult>;
|
|
14
|
+
export declare function transfer(op: 'move' | 'copy', sources: string[], to: string, conflict: FsConflict, home?: string): Promise<FsResult>;
|
|
15
|
+
export declare function deleteEntries(files: string[], home?: string): Promise<FsResult>;
|
|
16
|
+
export declare function changeMode(file: string, mode: unknown): Promise<FsResult>;
|
|
17
|
+
export declare function readText(file: string, known?: string | null): Promise<FsText | null>;
|
|
18
|
+
export declare function saveText(file: string, text: string, base: string | null, create: boolean): Promise<FsSaved>;
|
|
19
|
+
export declare function uploadTarget(folder: string, name: string, conflict: FsConflict): Promise<string | null>;
|
|
20
|
+
export declare function receiveFile(body: AsyncIterable<Buffer>, target: string, maxBytes: number): Promise<number>;
|
|
21
|
+
export declare function archiveArguments(files: string[]): string[];
|
|
22
|
+
export declare function analyzeFolder(root: string, limits?: {
|
|
23
|
+
ms: number;
|
|
24
|
+
entries: number;
|
|
25
|
+
}): Promise<FsAnalysis>;
|