@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.
- package/dist/format.js +121 -0
- package/dist/format.js.map +11 -0
- package/dist/index.js +1681 -0
- package/dist/index.js.map +24 -0
- package/dist/manifest.js +201 -0
- package/dist/manifest.js.map +10 -0
- package/dist/paths.js +138 -0
- package/dist/paths.js.map +10 -0
- package/package.json +46 -4
- package/src/adapter.ts +59 -0
- package/src/api.ts +183 -0
- package/src/debugger.ts +509 -0
- package/src/hardware.ts +125 -0
- package/src/http.ts +155 -0
- package/src/index.ts +8 -0
- package/src/jobs.ts +115 -0
- package/src/login.ts +284 -0
- package/src/manifest.ts +271 -0
- package/src/opcua.ts +69 -0
- package/src/paths.ts +185 -0
- package/src/telemetry.ts +110 -0
- package/src/timeline.ts +78 -0
- package/src/trace.ts +45 -0
- package/src/ui/bits.ts +41 -0
- package/src/ui/client.ts +48 -0
- package/src/ui/dev.ts +579 -0
- package/src/ui/dom.ts +168 -0
- package/src/ui/fixture.ts +548 -0
- package/src/ui/format.ts +125 -0
- package/src/ui/hardware.ts +368 -0
- package/src/ui/header.ts +179 -0
- package/src/ui/index.ts +20 -0
- package/src/ui/jobs.ts +249 -0
- package/src/ui/main.ts +74 -0
- package/src/ui/overview.ts +123 -0
- package/src/ui/page.ts +178 -0
- package/src/ui/recent.ts +322 -0
- package/src/ui/requests.ts +81 -0
- package/src/ui/store.ts +227 -0
- package/src/ui/tree.ts +156 -0
- package/src/ui/variables.ts +469 -0
- package/src/watch.ts +32 -0
- package/README.md +0 -3
|
@@ -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
|
+
};
|
package/src/ui/header.ts
ADDED
|
@@ -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
|
+
};
|
package/src/ui/index.ts
ADDED
|
@@ -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());
|