@tsplc/debug 0.0.0-stage → 0.2.16

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.
@@ -0,0 +1,368 @@
1
+ import type { DebugDevice } from "../manifest.ts";
2
+ import { mapBits, readBits } from "./bits.ts";
3
+ import type { Quality } from "../api.ts";
4
+ import {
5
+ type View,
6
+ deviceHealth,
7
+ empty,
8
+ fill,
9
+ h,
10
+ healthBadge,
11
+ pathLink,
12
+ sourceLink,
13
+ } from "./dom.ts";
14
+ import { formatScalar, formatValue, valueState, valueStateLabels } from "./format.ts";
15
+ import type { Store } from "./store.ts";
16
+
17
+ const deviceHref = (id: string) => `#/hardware/${encodeURIComponent(id)}`;
18
+
19
+ const assemblySummary = (device: DebugDevice) =>
20
+ device.assemblies
21
+ .map((assembly) => `${assembly.direction === "input" ? "in" : "out"} ${assembly.bytes} B`)
22
+ .join(" · ");
23
+
24
+ const deviceList = (store: Store): View => {
25
+ const body = h("tbody");
26
+ const render = () =>
27
+ body.replaceChildren(
28
+ ...store.manifest.devices.map((device) =>
29
+ h(
30
+ "tr",
31
+ { class: "link-row", onclick: () => (location.hash = deviceHref(device.id)) },
32
+ h("td", {}, healthBadge(deviceHealth(store, device))),
33
+ h("td", {}, h("a", { href: deviceHref(device.id), class: "mono" }, device.id)),
34
+ h("td", {}, [device.vendor, device.product].filter(Boolean).join(" ")),
35
+ h(
36
+ "td",
37
+ { class: "mono" },
38
+ device.network
39
+ ? `${device.network.address}${device.network.rpiMs === undefined ? "" : ` · RPI ${device.network.rpiMs} ms`}`
40
+ : "",
41
+ ),
42
+ h("td", {}, device.connection ?? ""),
43
+ h("td", { class: "mono" }, assemblySummary(device)),
44
+ h("td", { class: "num" }, String(device.channels.length)),
45
+ ),
46
+ ),
47
+ );
48
+ render();
49
+ store.show({
50
+ health: store.manifest.devices.filter((device) => device.health).map(({ id }) => id),
51
+ });
52
+ return {
53
+ element: h(
54
+ "div",
55
+ { class: "page" },
56
+ h("h1", {}, "Hardware"),
57
+ store.manifest.devices.length
58
+ ? h(
59
+ "table",
60
+ { class: "grid" },
61
+ h(
62
+ "thead",
63
+ {},
64
+ h(
65
+ "tr",
66
+ {},
67
+ ...[
68
+ "Health",
69
+ "Device",
70
+ "Product",
71
+ "Network",
72
+ "Connection",
73
+ "Assemblies",
74
+ "Channels",
75
+ ].map((label) => h("th", {}, label)),
76
+ ),
77
+ ),
78
+ body,
79
+ )
80
+ : empty("This build configures no devices."),
81
+ ),
82
+ update(topics) {
83
+ if (topics.has("devices") || topics.has("stream") || topics.has("session")) render();
84
+ },
85
+ };
86
+ };
87
+
88
+ const deviceDetail = (store: Store, device: DebugDevice): View => {
89
+ const io = store.manifest.io.filter((entry) => entry.device === device.id);
90
+ const badge = h("span");
91
+ const status = h("dl", { class: "facts readings" });
92
+ const grids = h("div", { class: "assemblies" });
93
+ const channelBody = h("tbody");
94
+
95
+ /** The state for the manifest's `index`th assembly: the host lists them in the same order per direction. */
96
+ const bytesOf = (index: number) => {
97
+ const assembly = device.assemblies[index];
98
+ if (!assembly) return undefined;
99
+ const sameDirection = device.assemblies
100
+ .slice(0, index)
101
+ .filter((other) => other.direction === assembly.direction).length;
102
+ return store.devices
103
+ .get(device.id)
104
+ ?.assemblies.filter((state) => state.direction === assembly.direction)[sameDirection];
105
+ };
106
+
107
+ const renderHealth = () => {
108
+ const health = deviceHealth(store, device);
109
+ badge.replaceChildren(
110
+ healthBadge(health),
111
+ health.detail ? h("span", { class: "dim" }, ` ${health.detail}`) : "",
112
+ );
113
+ };
114
+
115
+ /** Device states carry no sample time, so only the connection marks their values stale. */
116
+ const reading = (quality: Quality, value: string) => {
117
+ const state = valueState(quality, 0, {
118
+ connected: store.connected,
119
+ intervalMs: store.session.reading.intervalMs,
120
+ });
121
+ return [
122
+ h("span", { class: `value s-${state}` }, value),
123
+ h("span", { class: `meta s-${state}` }, valueStateLabels[state]),
124
+ ];
125
+ };
126
+
127
+ const renderStatus = () => {
128
+ const values = store.devices.get(device.id)?.status ?? {};
129
+ fill(
130
+ status,
131
+ ...Object.keys(device.status ?? {}).flatMap((label) => {
132
+ const entry = values[label];
133
+ return [
134
+ h("dt", {}, label),
135
+ h(
136
+ "dd",
137
+ {},
138
+ ...(entry
139
+ ? reading(entry.quality, formatScalar(entry.value))
140
+ : [h("span", { class: "value s-unavailable" }, "not read")]),
141
+ ),
142
+ ];
143
+ }),
144
+ );
145
+ };
146
+
147
+ const renderGrids = () =>
148
+ grids.replaceChildren(
149
+ ...device.assemblies.map((assembly, index) => {
150
+ const state = bytesOf(index);
151
+ const bytes = state?.bytes ?? null;
152
+ const bits = mapBits(device.id, assembly.direction, assembly.bytes, device.channels, io);
153
+ const quality = !store.connected ? "stale" : state ? state.quality : "unavailable";
154
+ return h(
155
+ "section",
156
+ { class: `panel assembly q-${quality}` },
157
+ h(
158
+ "h3",
159
+ {},
160
+ `${assembly.direction === "input" ? "Input" : "Output"} assembly · ${assembly.bytes} bytes`,
161
+ assembly.address ? h("span", { class: "mono dim" }, ` ${assembly.address}`) : "",
162
+ h(
163
+ "span",
164
+ { class: "dim" },
165
+ bytes
166
+ ? ` · ${quality === "good" ? "raw bytes" : `raw bytes, ${quality}`}`
167
+ : assembly.raw
168
+ ? " · raw bytes not read yet"
169
+ : " · no raw bytes in this build: it has no generated device tree",
170
+ ),
171
+ ),
172
+ h(
173
+ "div",
174
+ { class: "bitgrid" },
175
+ h("span", { class: "bh" }, "byte"),
176
+ ...Array.from({ length: 8 }, (_, bit) => h("span", { class: "bh" }, `.${bit}`)),
177
+ h("span", { class: "bh" }, "hex"),
178
+ h("span", { class: "bh legend-head" }, "mapped"),
179
+ ...Array.from({ length: assembly.bytes }, (_, byte) => {
180
+ const value = bytes?.[byte];
181
+ const starts = device.channels.filter(
182
+ (channel) =>
183
+ channel.direction === assembly.direction && channel.offsetBits >> 3 === byte,
184
+ );
185
+ return [
186
+ h("span", { class: "byte mono" }, `+${byte}`),
187
+ ...Array.from({ length: 8 }, (_, bit) => {
188
+ const mapped = bits[byte * 8 + bit];
189
+ const on = value === undefined ? undefined : (value >> bit) & 1;
190
+ const where = `byte ${byte} bit ${bit}`;
191
+ return h(
192
+ "span",
193
+ {
194
+ class: `bit${mapped ? " mapped" : ""}${on === 1 ? " on" : ""}${on === undefined ? " none" : ""}`,
195
+ title: mapped
196
+ ? `${where} · ${mapped.channel.name} (${mapped.channel.id})${mapped.paths.map((path) => `\n${path}`).join("")}`
197
+ : `${where} · not mapped`,
198
+ },
199
+ on === undefined ? "·" : String(on),
200
+ mapped && mapped.channel.offsetBits === byte * 8 + bit
201
+ ? h("small", {}, mapped.channel.id)
202
+ : undefined,
203
+ );
204
+ }),
205
+ h(
206
+ "span",
207
+ { class: "hex mono" },
208
+ value === undefined ? "··" : value.toString(16).padStart(2, "0").toUpperCase(),
209
+ ),
210
+ h(
211
+ "span",
212
+ { class: "legend" },
213
+ ...starts.map((channel) =>
214
+ h(
215
+ "span",
216
+ { class: "legend-item" },
217
+ h("b", {}, channel.id),
218
+ ...io
219
+ .filter((entry) => entry.channel === channel.id)
220
+ .map((entry) =>
221
+ pathLink(entry.path, entry.path.split(".").slice(-3).join(".")),
222
+ ),
223
+ ),
224
+ ),
225
+ ),
226
+ ];
227
+ }).flat(),
228
+ ),
229
+ );
230
+ }),
231
+ );
232
+
233
+ const renderChannels = () =>
234
+ channelBody.replaceChildren(
235
+ ...device.channels.map((channel) => {
236
+ const index = device.assemblies.findIndex(
237
+ (assembly) => assembly.direction === channel.direction,
238
+ );
239
+ const bytes = index >= 0 ? bytesOf(index)?.bytes : undefined;
240
+ const raw = bytes ? readBits(bytes, channel.offsetBits, channel.bits) : null;
241
+ const mapped = io.filter((entry) => entry.channel === channel.id);
242
+ return h(
243
+ "tr",
244
+ {},
245
+ h("td", { class: "mono" }, channel.id),
246
+ h(
247
+ "td",
248
+ {},
249
+ channel.name,
250
+ channel.description ? h("div", { class: "dim" }, channel.description) : "",
251
+ ),
252
+ h("td", {}, channel.direction),
253
+ h(
254
+ "td",
255
+ { class: "mono" },
256
+ `${channel.offsetBits >> 3}.${channel.offsetBits & 7} · ${channel.bits} bit${channel.bits === 1 ? "" : "s"}`,
257
+ ),
258
+ h("td", { class: "mono" }, channel.address ?? ""),
259
+ h(
260
+ "td",
261
+ { class: "mono num", title: "Decoded from the raw assembly bytes" },
262
+ raw === null ? "—" : String(raw),
263
+ channel.unit ? ` ${channel.unit}` : "",
264
+ ),
265
+ h(
266
+ "td",
267
+ {},
268
+ ...(mapped.length
269
+ ? mapped.map((entry) => {
270
+ const bound = store.devices
271
+ .get(device.id)
272
+ ?.channels.find(
273
+ (candidate) => candidate.id === channel.id && candidate.path === entry.path,
274
+ );
275
+ const node = store.tree.resolve(entry.path);
276
+ return h(
277
+ "div",
278
+ { class: "mapped-path" },
279
+ pathLink(entry.path),
280
+ entry.inverted ? h("span", { class: "tag" }, "inverted") : "",
281
+ ...(bound
282
+ ? reading(bound.quality, formatValue(store.manifest, entry.type, bound.value))
283
+ : []),
284
+ node?.declaration ? sourceLink(store, node.declaration) : "",
285
+ );
286
+ })
287
+ : [h("span", { class: "dim" }, "not mapped")]),
288
+ ),
289
+ );
290
+ }),
291
+ );
292
+
293
+ renderHealth();
294
+ renderStatus();
295
+ renderGrids();
296
+ renderChannels();
297
+ store.show({ devices: [device.id] });
298
+
299
+ const identity: [string, string | undefined][] = [
300
+ ["Vendor", device.vendor],
301
+ ["Product", device.product],
302
+ ["Product code", device.productCode],
303
+ ["Address", device.network?.address],
304
+ ["RPI", device.network?.rpiMs === undefined ? undefined : `${device.network.rpiMs} ms`],
305
+ ["Connection", device.connection],
306
+ ];
307
+ return {
308
+ element: h(
309
+ "div",
310
+ { class: "page" },
311
+ h("div", { class: "crumbs" }, h("a", { href: "#/hardware" }, "Hardware"), " / "),
312
+ h("h1", { class: "mono" }, device.id, " ", badge),
313
+ h(
314
+ "dl",
315
+ { class: "facts" },
316
+ ...identity.flatMap(([label, value]) =>
317
+ value === undefined ? [] : [h("dt", {}, label), h("dd", { class: "mono" }, value)],
318
+ ),
319
+ ),
320
+ device.status && h("h2", {}, "Status"),
321
+ status,
322
+ grids,
323
+ h("h2", {}, "Channels"),
324
+ device.channels.length
325
+ ? h(
326
+ "table",
327
+ { class: "grid" },
328
+ h(
329
+ "thead",
330
+ {},
331
+ h(
332
+ "tr",
333
+ {},
334
+ ...["Id", "Name", "Dir", "Bits", "Address", "Raw", "TypeScript"].map((label) =>
335
+ h("th", {}, label),
336
+ ),
337
+ ),
338
+ ),
339
+ channelBody,
340
+ )
341
+ : empty("No channels configured."),
342
+ ),
343
+ update(topics) {
344
+ if (topics.has("devices") || topics.has("stream") || topics.has("session")) {
345
+ renderHealth();
346
+ renderStatus();
347
+ renderGrids();
348
+ renderChannels();
349
+ }
350
+ },
351
+ };
352
+ };
353
+
354
+ export const hardwareView = (store: Store, id: string | undefined): View => {
355
+ if (id === undefined) return deviceList(store);
356
+ const device = store.manifest.devices.find((candidate) => candidate.id === id);
357
+ if (device) return deviceDetail(store, device);
358
+ store.show();
359
+ return {
360
+ element: h(
361
+ "div",
362
+ { class: "page" },
363
+ h("h1", {}, "Hardware"),
364
+ empty(`No device ${id} in this build.`),
365
+ ),
366
+ update() {},
367
+ };
368
+ };
@@ -0,0 +1,179 @@
1
+ import { h } from "./dom.ts";
2
+ import { formatAge, formatClock, formatInterval } from "./format.ts";
3
+ import type { Store, Topic } from "./store.ts";
4
+
5
+ export const ROUTES = ["overview", "variables", "hardware", "jobs", "recent", "requests"] as const;
6
+ export type Route = (typeof ROUTES)[number];
7
+
8
+ const shortId = (id: string | null) => (id === null ? "unknown" : id.slice(0, 10));
9
+
10
+ const readout = (label: string, ...value: (Node | string)[]) =>
11
+ h(
12
+ "div",
13
+ { class: "readout" },
14
+ h("span", { class: "label" }, label),
15
+ h("span", { class: "reading" }, ...value),
16
+ );
17
+
18
+ /** Always visible: which build runs, how values are read and whether they can be, and how far to trust times. */
19
+ export const header = (store: Store) => {
20
+ const readouts = h("div", { class: "readouts" });
21
+ const banners = h("div", { class: "banners" });
22
+ const links = new Map(ROUTES.map((route) => [route, h("a", { href: `#/${route}` }, route)]));
23
+ const element = h(
24
+ "header",
25
+ {},
26
+ h(
27
+ "div",
28
+ { class: "bar" },
29
+ h(
30
+ "div",
31
+ { class: "brand" },
32
+ h("b", {}, "tsPLC"),
33
+ h("span", {}, "debug"),
34
+ h(
35
+ "span",
36
+ { class: "ro", title: "The debugger never writes or forces values" },
37
+ "read-only",
38
+ ),
39
+ ),
40
+ readouts,
41
+ ),
42
+ h("nav", {}, ...links.values()),
43
+ banners,
44
+ );
45
+
46
+ const render = () => {
47
+ const { session } = store;
48
+ const { build, connection, reading } = session;
49
+ const buildState = build.matches ? "match" : "mismatch";
50
+ readouts.replaceChildren(
51
+ readout(
52
+ "build",
53
+ h(
54
+ "span",
55
+ {
56
+ class: `build ${buildState}`,
57
+ title: `manifest ${build.manifest ?? "none"}\ncontroller ${build.controller ?? "unknown"}`,
58
+ },
59
+ `manifest ${shortId(build.manifest)}`,
60
+ h("i", {}, build.matches ? " = " : " ≠ "),
61
+ `controller ${shortId(build.controller)}`,
62
+ ),
63
+ ),
64
+ readout(
65
+ "values",
66
+ h("span", { class: `dot c-${connection.state}`, title: connection.endpoint }),
67
+ `${reading.method} · every ${formatInterval(reading.intervalMs)}`,
68
+ h(
69
+ "span",
70
+ { class: "dim" },
71
+ ` · ${connection.state} since ${formatClock(connection.since).slice(0, 8)}`,
72
+ ` (${formatAge(store.ageOf(connection.since)).replace(" ago", "")})`,
73
+ ),
74
+ ),
75
+ // The PLC clock counts from the runtime's start, so only the precision of the mapping means anything.
76
+ readout(
77
+ "plc clock",
78
+ session.clock
79
+ ? `±${session.clock.uncertaintyMs.toFixed(1)} ms against the app server's`
80
+ : "not estimated",
81
+ ),
82
+ readout(
83
+ "scans",
84
+ ...session.tasks.map((task) => {
85
+ const interval = store.manifest.tasks.find(
86
+ (candidate) => candidate.name === task.name,
87
+ )?.intervalMs;
88
+ return h(
89
+ "span",
90
+ {
91
+ class: "task",
92
+ title:
93
+ interval === undefined
94
+ ? task.name
95
+ : `${task.name}: configured every ${interval} ms`,
96
+ },
97
+ `${task.name} `,
98
+ h("b", {}, task.scansPerSecond === null ? "—" : task.scansPerSecond.toFixed(1)),
99
+ "/s",
100
+ task.dropped > 0
101
+ ? h(
102
+ "span",
103
+ {
104
+ class: "err",
105
+ title:
106
+ "Telemetry records overwritten before the PLC sent them: Recent is missing these",
107
+ },
108
+ ` ${task.dropped} dropped`,
109
+ )
110
+ : "",
111
+ );
112
+ }),
113
+ ),
114
+ readout(
115
+ "release",
116
+ session.release
117
+ ? h(
118
+ "span",
119
+ { title: `deployed ${session.release.deployedAt}` },
120
+ `${session.release.id} · ${session.release.operation} · ${formatAge(store.ageOf(session.release.deployedAt))}`,
121
+ )
122
+ : "not reported",
123
+ ),
124
+ );
125
+
126
+ const notices: [string, string][] = [];
127
+ if (!build.matches)
128
+ notices.push([
129
+ "danger",
130
+ build.controller === null
131
+ ? "The controller's build id could not be read, so this manifest may not describe what is running."
132
+ : `Build mismatch: the controller runs ${build.controller}, but this manifest is ${build.manifest ?? "missing"}. Paths, values and source links may be wrong.`,
133
+ ]);
134
+ if (store.stream === "reconnecting")
135
+ notices.push([
136
+ "warn",
137
+ "Lost the stream from the debugger host; reconnecting. Every value is stale until it resumes.",
138
+ ]);
139
+ else if (connection.state !== "connected")
140
+ notices.push([
141
+ "warn",
142
+ `${connection.error ?? `Reading values: ${connection.state} since ${formatClock(connection.since)}.`} Values shown are the last read and marked stale.`,
143
+ ]);
144
+ if (store.error) notices.push(["error", store.error]);
145
+ banners.replaceChildren(
146
+ ...notices.map(([tone, text]) =>
147
+ h(
148
+ "div",
149
+ { class: `banner ${tone}` },
150
+ text,
151
+ tone === "error"
152
+ ? h(
153
+ "button",
154
+ { onclick: () => ((store.error = undefined), store.notify("error")) },
155
+ "dismiss",
156
+ )
157
+ : "",
158
+ ),
159
+ ),
160
+ );
161
+ };
162
+
163
+ render();
164
+ return {
165
+ element,
166
+ update(topics: Set<Topic>) {
167
+ if (
168
+ topics.has("session") ||
169
+ topics.has("stream") ||
170
+ topics.has("error") ||
171
+ topics.has("clock")
172
+ )
173
+ render();
174
+ },
175
+ setRoute(route: Route) {
176
+ for (const [name, link] of links) link.classList.toggle("on", name === route);
177
+ },
178
+ };
179
+ };
@@ -0,0 +1,20 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, resolve } from "node:path";
3
+ import { html } from "./page.ts";
4
+
5
+ let ui: Promise<{ html: string; script: string }> | undefined;
6
+
7
+ const build = async () => {
8
+ const result = await Bun.build({
9
+ entrypoints: [
10
+ resolve(dirname(createRequire(import.meta.url).resolve("@tsplc/debug")), "../src/ui/main.ts"),
11
+ ],
12
+ target: "browser",
13
+ minify: true,
14
+ });
15
+ if (!result.success) throw new AggregateError(result.logs, "Bundling the debugger UI failed");
16
+ return { html, script: await result.outputs[0]!.text() };
17
+ };
18
+
19
+ /** The browser UI: serve `html` at `GET /` and `script` at `GET /ui.js`. Bundled on first call, then cached. */
20
+ export const loadUi = () => (ui ??= build());