@tsplc/debug 0.0.0-stage → 0.2.17
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 +182 -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
package/src/ui/recent.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { AppRecord, TimelineEntry, TraceEvent } from "../api.ts";
|
|
2
|
+
import type { DebugEventSite, DebugManifest } from "../manifest.ts";
|
|
3
|
+
import { type View, empty, h, pathLink, recordSourceLink, sourceLink } from "./dom.ts";
|
|
4
|
+
import { formatClock, formatDuration, formatProperties, formatValue } from "./format.ts";
|
|
5
|
+
import { requestJobLink, requestStatus } from "./jobs.ts";
|
|
6
|
+
import type { Store } from "./store.ts";
|
|
7
|
+
|
|
8
|
+
type Source = TimelineEntry["source"];
|
|
9
|
+
/** App records are filtered by their own kind. */
|
|
10
|
+
type Kind = AppRecord["kind"] | Exclude<TimelineEntry["kind"], "app">;
|
|
11
|
+
|
|
12
|
+
const SOURCES: Source[] = ["plc", "server", "agent", "debugger"];
|
|
13
|
+
const KINDS: Kind[] = [
|
|
14
|
+
"event",
|
|
15
|
+
"log",
|
|
16
|
+
"exception",
|
|
17
|
+
"trace",
|
|
18
|
+
"request",
|
|
19
|
+
"deploy",
|
|
20
|
+
"audit",
|
|
21
|
+
"connection",
|
|
22
|
+
];
|
|
23
|
+
const PAGE = 300;
|
|
24
|
+
const RENDER_INTERVAL_MS = 300;
|
|
25
|
+
|
|
26
|
+
const hiddenSources = new Set<Source>();
|
|
27
|
+
const hiddenKinds = new Set<Kind>();
|
|
28
|
+
|
|
29
|
+
const kindOf = (entry: TimelineEntry): Kind =>
|
|
30
|
+
entry.kind === "app" ? entry.record.kind : entry.kind;
|
|
31
|
+
|
|
32
|
+
const sitesByManifest = new WeakMap<DebugManifest, Map<number, DebugEventSite>>();
|
|
33
|
+
const siteOf = (manifest: DebugManifest, id: number) => {
|
|
34
|
+
let sites = sitesByManifest.get(manifest);
|
|
35
|
+
if (!sites) {
|
|
36
|
+
sites = new Map(manifest.events.map((site) => [site.id, site]));
|
|
37
|
+
sitesByManifest.set(manifest, sites);
|
|
38
|
+
}
|
|
39
|
+
return sites.get(id);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A traced write, worded as what was observed: the value, the scan, and the guards captured with it. */
|
|
43
|
+
const traceBody = (store: Store, trace: TraceEvent) => {
|
|
44
|
+
const site = siteOf(store.manifest, trace.site);
|
|
45
|
+
if (!site)
|
|
46
|
+
return [
|
|
47
|
+
h(
|
|
48
|
+
"div",
|
|
49
|
+
{},
|
|
50
|
+
`Site ${trace.site} is not in this build's manifest; value ${JSON.stringify(trace.value)} in scan ${trace.scan}.`,
|
|
51
|
+
),
|
|
52
|
+
];
|
|
53
|
+
return [
|
|
54
|
+
h(
|
|
55
|
+
"div",
|
|
56
|
+
{},
|
|
57
|
+
h("span", { class: "tag" }, site.kind),
|
|
58
|
+
" ",
|
|
59
|
+
pathLink(site.path),
|
|
60
|
+
" was ",
|
|
61
|
+
h("b", { class: "val" }, formatValue(store.manifest, site.type, trace.value)),
|
|
62
|
+
` in scan ${trace.scan}`,
|
|
63
|
+
h("span", { class: "dim" }, ` · ${trace.task}`),
|
|
64
|
+
),
|
|
65
|
+
h("div", {}, h("code", {}, site.text), " ", sourceLink(store, site.range)),
|
|
66
|
+
trace.operands.length > 0 &&
|
|
67
|
+
h(
|
|
68
|
+
"div",
|
|
69
|
+
{ class: "chips" },
|
|
70
|
+
h("span", { class: "dim" }, "guards in that scan "),
|
|
71
|
+
...trace.operands.map((operand, index) => {
|
|
72
|
+
const declared = site.operands[index];
|
|
73
|
+
const value = declared
|
|
74
|
+
? formatValue(store.manifest, declared.type, operand.value)
|
|
75
|
+
: JSON.stringify(operand.value);
|
|
76
|
+
return h(
|
|
77
|
+
"span",
|
|
78
|
+
{
|
|
79
|
+
class: `chip${operand.stable ? "" : " unstable"}`,
|
|
80
|
+
title: operand.stable
|
|
81
|
+
? `${operand.text}, captured at the write`
|
|
82
|
+
: `${operand.text}, captured at the write; code between the guard and the write can change it, so the guard may have seen another value`,
|
|
83
|
+
},
|
|
84
|
+
declared
|
|
85
|
+
? sourceLink(store, declared.range, operand.text)
|
|
86
|
+
: h("code", {}, operand.text),
|
|
87
|
+
" = ",
|
|
88
|
+
operand.path ? pathLink(operand.path, value) : h("span", { class: "val" }, value),
|
|
89
|
+
operand.stable ? "" : h("span", { class: "dim" }, " ~"),
|
|
90
|
+
);
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** What the program reported: `bin scanned · barcode=CON-MD3… scanner=3 · route-lookup.ts:120`. */
|
|
97
|
+
const appBody = (store: Store, task: string, record: AppRecord) => {
|
|
98
|
+
const properties = Object.keys(record.properties).length
|
|
99
|
+
? h(
|
|
100
|
+
"span",
|
|
101
|
+
{ class: "mono", title: JSON.stringify(record.properties, null, 1) },
|
|
102
|
+
` · ${formatProperties(record.properties)}`,
|
|
103
|
+
)
|
|
104
|
+
: "";
|
|
105
|
+
const origin = [
|
|
106
|
+
" · ",
|
|
107
|
+
recordSourceLink(store, record.source),
|
|
108
|
+
h("span", { class: "dim" }, ` · ${task}`),
|
|
109
|
+
];
|
|
110
|
+
switch (record.kind) {
|
|
111
|
+
case "event":
|
|
112
|
+
return [h("div", {}, h("b", {}, record.event), properties, ...origin)];
|
|
113
|
+
case "log":
|
|
114
|
+
return [
|
|
115
|
+
h(
|
|
116
|
+
"div",
|
|
117
|
+
{},
|
|
118
|
+
h("span", { class: `tag lvl-${record.level}` }, record.level),
|
|
119
|
+
" ",
|
|
120
|
+
record.message,
|
|
121
|
+
properties,
|
|
122
|
+
...origin,
|
|
123
|
+
),
|
|
124
|
+
];
|
|
125
|
+
case "exception":
|
|
126
|
+
return [
|
|
127
|
+
h(
|
|
128
|
+
"div",
|
|
129
|
+
{},
|
|
130
|
+
h("b", { class: "err" }, record.name ?? `error ${record.code}`),
|
|
131
|
+
" · ",
|
|
132
|
+
record.handled
|
|
133
|
+
? h("span", { class: "dim" }, "handled")
|
|
134
|
+
: h("b", { class: "err" }, "unhandled"),
|
|
135
|
+
record.function ? h("span", { class: "mono" }, ` · ${record.function}`) : "",
|
|
136
|
+
record.instance ? h("span", {}, " on ", pathLink(record.instance)) : "",
|
|
137
|
+
properties,
|
|
138
|
+
...origin,
|
|
139
|
+
),
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const byWhom = (entry: { user?: string; host?: string }) =>
|
|
145
|
+
entry.user || entry.host
|
|
146
|
+
? h(
|
|
147
|
+
"span",
|
|
148
|
+
{ class: "dim" },
|
|
149
|
+
` by ${entry.user ?? "unknown user"}${entry.host ? ` on ${entry.host}` : ""}`,
|
|
150
|
+
)
|
|
151
|
+
: "";
|
|
152
|
+
|
|
153
|
+
const entryBody = (store: Store, entry: TimelineEntry) => {
|
|
154
|
+
switch (entry.kind) {
|
|
155
|
+
case "app":
|
|
156
|
+
return appBody(store, entry.task, entry.record);
|
|
157
|
+
case "trace":
|
|
158
|
+
return traceBody(store, entry.trace);
|
|
159
|
+
case "request": {
|
|
160
|
+
const { request } = entry;
|
|
161
|
+
return [
|
|
162
|
+
h(
|
|
163
|
+
"div",
|
|
164
|
+
{},
|
|
165
|
+
h("b", { class: "mono" }, request.service),
|
|
166
|
+
" ",
|
|
167
|
+
requestStatus(request),
|
|
168
|
+
request.durationMs === undefined ? "" : ` in ${formatDuration(request.durationMs)}`,
|
|
169
|
+
request.job === undefined
|
|
170
|
+
? ""
|
|
171
|
+
: h("span", {}, " · from ", requestJobLink(store, request.job, request.sequence)),
|
|
172
|
+
request.error ? h("div", { class: "err" }, request.error) : "",
|
|
173
|
+
),
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
case "deploy":
|
|
177
|
+
return [
|
|
178
|
+
h(
|
|
179
|
+
"div",
|
|
180
|
+
{},
|
|
181
|
+
`Release ${entry.release} · ${entry.operation}`,
|
|
182
|
+
entry.build ? h("span", { class: "mono dim" }, ` build ${entry.build}`) : "",
|
|
183
|
+
byWhom(entry),
|
|
184
|
+
),
|
|
185
|
+
];
|
|
186
|
+
case "audit":
|
|
187
|
+
return [
|
|
188
|
+
h(
|
|
189
|
+
"div",
|
|
190
|
+
{},
|
|
191
|
+
h("b", { class: "mono" }, `tsplc ${entry.command}`),
|
|
192
|
+
byWhom(entry),
|
|
193
|
+
entry.error ? h("span", { class: "err" }, ` · ${entry.error}`) : "",
|
|
194
|
+
),
|
|
195
|
+
h(
|
|
196
|
+
"div",
|
|
197
|
+
{ class: "chips" },
|
|
198
|
+
...Object.entries(entry.values).map(([path, value]) =>
|
|
199
|
+
h(
|
|
200
|
+
"span",
|
|
201
|
+
{ class: "chip" },
|
|
202
|
+
pathLink(path),
|
|
203
|
+
value === "" ? " unforced" : ` = ${value}`,
|
|
204
|
+
entry.previous?.[path] === undefined
|
|
205
|
+
? ""
|
|
206
|
+
: h("span", { class: "dim" }, ` (was ${entry.previous[path]})`),
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
),
|
|
210
|
+
];
|
|
211
|
+
case "connection":
|
|
212
|
+
return [
|
|
213
|
+
h(
|
|
214
|
+
"div",
|
|
215
|
+
{},
|
|
216
|
+
`Reading values: ${entry.state}`,
|
|
217
|
+
entry.error ? h("span", { class: "err" }, ` · ${entry.error}`) : "",
|
|
218
|
+
),
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const recentEntry = (store: Store, entry: TimelineEntry) =>
|
|
224
|
+
h(
|
|
225
|
+
"div",
|
|
226
|
+
{ class: `entry k-${kindOf(entry)}` },
|
|
227
|
+
h(
|
|
228
|
+
"span",
|
|
229
|
+
{ class: "when mono" },
|
|
230
|
+
formatClock(entry.at),
|
|
231
|
+
entry.approximate
|
|
232
|
+
? h(
|
|
233
|
+
"span",
|
|
234
|
+
{
|
|
235
|
+
class: "approx",
|
|
236
|
+
title:
|
|
237
|
+
"Placed through another clock, so its order against other sources is approximate",
|
|
238
|
+
},
|
|
239
|
+
"≈",
|
|
240
|
+
)
|
|
241
|
+
: "",
|
|
242
|
+
),
|
|
243
|
+
h("span", { class: `source src-${entry.source}` }, entry.source),
|
|
244
|
+
h("div", { class: "body" }, ...entryBody(store, entry)),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const clockNote = (store: Store) => {
|
|
248
|
+
const clock = store.session.clock;
|
|
249
|
+
return `PLC records are placed on the app server's clock from their age when it received them${
|
|
250
|
+
clock ? `, within ±${clock.uncertaintyMs.toFixed(1)} ms` : ""
|
|
251
|
+
}, so their order against other sources is approximate (≈).`;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export const recentView = (store: Store): View => {
|
|
255
|
+
let shown = PAGE;
|
|
256
|
+
let renderTimer: ReturnType<typeof setTimeout> | undefined;
|
|
257
|
+
const list = h("div", { class: "entries" });
|
|
258
|
+
const more = h("button", { onclick: () => ((shown += PAGE), render()) }, `Show ${PAGE} older`);
|
|
259
|
+
const note = h("p", { class: "hint" });
|
|
260
|
+
|
|
261
|
+
const toggles = <T extends string>(values: T[], hidden: Set<T>) =>
|
|
262
|
+
values.map((value) => {
|
|
263
|
+
const input = h("input", { type: "checkbox", checked: !hidden.has(value) });
|
|
264
|
+
input.addEventListener("change", () => {
|
|
265
|
+
if (input.checked) hidden.delete(value);
|
|
266
|
+
else hidden.add(value);
|
|
267
|
+
render();
|
|
268
|
+
});
|
|
269
|
+
return h("label", { class: "check" }, input, value);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const render = () => {
|
|
273
|
+
clearTimeout(renderTimer);
|
|
274
|
+
renderTimer = undefined;
|
|
275
|
+
note.textContent = clockNote(store);
|
|
276
|
+
const visible = store.timeline.filter(
|
|
277
|
+
(entry) => !hiddenSources.has(entry.source) && !hiddenKinds.has(kindOf(entry)),
|
|
278
|
+
);
|
|
279
|
+
list.replaceChildren(
|
|
280
|
+
...(visible.length
|
|
281
|
+
? visible
|
|
282
|
+
.slice(-shown)
|
|
283
|
+
.reverse()
|
|
284
|
+
.map((entry) => recentEntry(store, entry))
|
|
285
|
+
: [empty("Nothing in the recent window matches these filters.")]),
|
|
286
|
+
);
|
|
287
|
+
more.hidden = visible.length <= shown;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
render();
|
|
291
|
+
store.show();
|
|
292
|
+
return {
|
|
293
|
+
element: h(
|
|
294
|
+
"div",
|
|
295
|
+
{ class: "page recent" },
|
|
296
|
+
h("h1", {}, "Recent"),
|
|
297
|
+
h(
|
|
298
|
+
"p",
|
|
299
|
+
{ class: "hint" },
|
|
300
|
+
"The app server's recent window: what the program reported, its traced writes when the trace is on, and the requests it made, with the agent's deployments and audited commands. History lives in the app's telemetry sink, such as PostHog.",
|
|
301
|
+
),
|
|
302
|
+
h(
|
|
303
|
+
"div",
|
|
304
|
+
{ class: "toolbar" },
|
|
305
|
+
h("span", { class: "dim" }, "Source"),
|
|
306
|
+
...toggles(SOURCES, hiddenSources),
|
|
307
|
+
h("span", { class: "dim sep" }, "Kind"),
|
|
308
|
+
...toggles(KINDS, hiddenKinds),
|
|
309
|
+
),
|
|
310
|
+
note,
|
|
311
|
+
list,
|
|
312
|
+
more,
|
|
313
|
+
),
|
|
314
|
+
update(topics) {
|
|
315
|
+
if (!topics.has("timeline") && !topics.has("session")) return;
|
|
316
|
+
renderTimer ??= setTimeout(render, RENDER_INTERVAL_MS);
|
|
317
|
+
},
|
|
318
|
+
dispose() {
|
|
319
|
+
clearTimeout(renderTimer);
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type View, empty, h } from "./dom.ts";
|
|
2
|
+
import { formatClock, formatDuration } from "./format.ts";
|
|
3
|
+
import { requestJobLink, requestStatus } from "./jobs.ts";
|
|
4
|
+
import type { Store } from "./store.ts";
|
|
5
|
+
|
|
6
|
+
const LIMIT = 500;
|
|
7
|
+
|
|
8
|
+
export const requestsView = (store: Store, params: URLSearchParams): View => {
|
|
9
|
+
const highlighted = Number(params.get("seq") ?? Number.NaN);
|
|
10
|
+
const body = h("div");
|
|
11
|
+
const render = () => {
|
|
12
|
+
const requests = store.timeline
|
|
13
|
+
.flatMap((entry) => (entry.kind === "request" ? [entry.request] : []))
|
|
14
|
+
.sort((a, b) => b.seq - a.seq)
|
|
15
|
+
.slice(0, LIMIT);
|
|
16
|
+
body.replaceChildren(
|
|
17
|
+
requests.length
|
|
18
|
+
? h(
|
|
19
|
+
"table",
|
|
20
|
+
{ class: "grid" },
|
|
21
|
+
h(
|
|
22
|
+
"thead",
|
|
23
|
+
{},
|
|
24
|
+
h(
|
|
25
|
+
"tr",
|
|
26
|
+
{},
|
|
27
|
+
...["#", "Started", "Service", "Status", "Duration", "Job", "Error"].map((label) =>
|
|
28
|
+
h("th", {}, label),
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
),
|
|
32
|
+
h(
|
|
33
|
+
"tbody",
|
|
34
|
+
{},
|
|
35
|
+
...requests.map((request) =>
|
|
36
|
+
h(
|
|
37
|
+
"tr",
|
|
38
|
+
{ class: request.seq === highlighted ? "highlight" : undefined },
|
|
39
|
+
h("td", { class: "mono num" }, String(request.seq)),
|
|
40
|
+
h("td", { class: "mono" }, formatClock(request.startedAt)),
|
|
41
|
+
h("td", { class: "mono" }, request.service),
|
|
42
|
+
h("td", {}, requestStatus(request)),
|
|
43
|
+
h(
|
|
44
|
+
"td",
|
|
45
|
+
{ class: "mono num" },
|
|
46
|
+
request.durationMs === undefined ? "—" : formatDuration(request.durationMs),
|
|
47
|
+
),
|
|
48
|
+
h(
|
|
49
|
+
"td",
|
|
50
|
+
{},
|
|
51
|
+
request.job === undefined
|
|
52
|
+
? h("span", { class: "dim" }, "not from a job")
|
|
53
|
+
: requestJobLink(store, request.job, request.sequence),
|
|
54
|
+
),
|
|
55
|
+
h("td", { class: "err" }, request.error ?? ""),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
: empty("No service requests recorded yet."),
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
render();
|
|
64
|
+
store.show();
|
|
65
|
+
return {
|
|
66
|
+
element: h(
|
|
67
|
+
"div",
|
|
68
|
+
{ class: "page" },
|
|
69
|
+
h("h1", {}, "Requests"),
|
|
70
|
+
h(
|
|
71
|
+
"p",
|
|
72
|
+
{ class: "hint" },
|
|
73
|
+
`Service calls the app server traced, newest first (at most ${LIMIT}).`,
|
|
74
|
+
),
|
|
75
|
+
body,
|
|
76
|
+
),
|
|
77
|
+
update(topics) {
|
|
78
|
+
if (topics.has("timeline")) render();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
};
|
package/src/ui/store.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeviceState,
|
|
3
|
+
Interest,
|
|
4
|
+
JobState,
|
|
5
|
+
Sample,
|
|
6
|
+
Session,
|
|
7
|
+
StreamEvents,
|
|
8
|
+
TimelineEntry,
|
|
9
|
+
WatchGroups,
|
|
10
|
+
} from "../api.ts";
|
|
11
|
+
import type { DebugManifest } from "../manifest.ts";
|
|
12
|
+
import { apiUrl, getJson, putJson } from "./client.ts";
|
|
13
|
+
import { type ValueState, valueState } from "./format.ts";
|
|
14
|
+
import { createPaths, type Paths } from "../paths.ts";
|
|
15
|
+
import { ManifestTree } from "./tree.ts";
|
|
16
|
+
|
|
17
|
+
export type Topic =
|
|
18
|
+
"session" | "samples" | "jobs" | "devices" | "timeline" | "watch" | "stream" | "error" | "clock";
|
|
19
|
+
|
|
20
|
+
/** The browser's EventSource state, apart from the controller connection the host reports. */
|
|
21
|
+
type StreamState = "connecting" | "open" | "reconnecting";
|
|
22
|
+
|
|
23
|
+
interface LiveSample {
|
|
24
|
+
sample: Sample | undefined;
|
|
25
|
+
ageMs: number | undefined;
|
|
26
|
+
state: ValueState;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TIMELINE_LIMIT = 5000;
|
|
30
|
+
const REOPEN_DEBOUNCE_MS = 250;
|
|
31
|
+
|
|
32
|
+
// A restarted PLC numbers its records from the start again, so the time tells them apart.
|
|
33
|
+
const timelineKey = (entry: TimelineEntry) => {
|
|
34
|
+
switch (entry.kind) {
|
|
35
|
+
case "app":
|
|
36
|
+
return `app:${entry.task}:${entry.sequence}:${entry.at}`;
|
|
37
|
+
case "trace":
|
|
38
|
+
return `trace:${entry.trace.task}:${entry.trace.sequence}:${entry.at}`;
|
|
39
|
+
case "request":
|
|
40
|
+
return `request:${entry.request.seq}`;
|
|
41
|
+
default:
|
|
42
|
+
return `${entry.kind}:${entry.source}:${entry.at}`;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Everything the UI shows, kept current from one EventSource. Changes are batched per animation frame
|
|
48
|
+
* and handed to `onChange` by topic.
|
|
49
|
+
*/
|
|
50
|
+
export class Store {
|
|
51
|
+
readonly tree: ManifestTree;
|
|
52
|
+
readonly paths: Paths;
|
|
53
|
+
stream: StreamState = "connecting";
|
|
54
|
+
error: string | undefined;
|
|
55
|
+
samples = new Map<string, Sample>();
|
|
56
|
+
jobs = new Map<number, JobState>();
|
|
57
|
+
devices = new Map<string, DeviceState>();
|
|
58
|
+
/** Oldest first, by `at`. */
|
|
59
|
+
timeline: TimelineEntry[] = [];
|
|
60
|
+
watch: WatchGroups = { groups: {} };
|
|
61
|
+
|
|
62
|
+
private readonly timelineByKey = new Map<string, TimelineEntry>();
|
|
63
|
+
/** Browser clock minus host clock, from the fastest sample delivery seen. */
|
|
64
|
+
private hostSkewMs = Infinity;
|
|
65
|
+
private source: EventSource | undefined;
|
|
66
|
+
private showing: string | undefined;
|
|
67
|
+
private params: Record<string, string> = {};
|
|
68
|
+
private reopenTimer: ReturnType<typeof setTimeout> | undefined;
|
|
69
|
+
private pending = new Set<Topic>();
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
readonly manifest: DebugManifest,
|
|
73
|
+
public session: Session,
|
|
74
|
+
private readonly onChange: (topics: Set<Topic>) => void,
|
|
75
|
+
) {
|
|
76
|
+
this.tree = new ManifestTree(manifest);
|
|
77
|
+
this.paths = createPaths(manifest);
|
|
78
|
+
setInterval(() => this.notify("clock"), 1000);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
notify(topic: Topic) {
|
|
82
|
+
if (this.pending.size === 0) requestAnimationFrame(() => this.flush());
|
|
83
|
+
this.pending.add(topic);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private flush() {
|
|
87
|
+
const topics = this.pending;
|
|
88
|
+
this.pending = new Set();
|
|
89
|
+
this.onChange(topics);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
reportError(error: unknown) {
|
|
93
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
94
|
+
this.notify("error");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Host time now, so ages of host timestamps do not depend on the browser's clock. */
|
|
98
|
+
hostNow() {
|
|
99
|
+
return Date.now() - (Number.isFinite(this.hostSkewMs) ? this.hostSkewMs : 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ageOf(iso: string) {
|
|
103
|
+
return Math.max(0, this.hostNow() - Date.parse(iso));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get connected() {
|
|
107
|
+
return this.stream === "open" && this.session.connection.state === "connected";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
live(path: string): LiveSample {
|
|
111
|
+
const sample = this.samples.get(path);
|
|
112
|
+
if (!sample) return { sample, ageMs: undefined, state: "unavailable" };
|
|
113
|
+
const ageMs = this.ageOf(sample.receivedAt);
|
|
114
|
+
const state = valueState(sample.quality, ageMs, {
|
|
115
|
+
connected: this.connected,
|
|
116
|
+
intervalMs: this.session.reading.intervalMs,
|
|
117
|
+
});
|
|
118
|
+
return { sample, ageMs, state };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Streams exactly what the page shows, reopening the stream once it settles: the host reads
|
|
123
|
+
* only these paths, jobs and devices.
|
|
124
|
+
*/
|
|
125
|
+
show(interest: Partial<Interest> = {}) {
|
|
126
|
+
const params = Object.fromEntries(
|
|
127
|
+
Object.entries(interest).flatMap(([name, values]) => {
|
|
128
|
+
const list = [...new Set(values.map(String))].sort().join(",");
|
|
129
|
+
return list ? [[name, list]] : [];
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
const showing = JSON.stringify(Object.entries(params).sort());
|
|
133
|
+
if (showing === this.showing) return;
|
|
134
|
+
this.showing = showing;
|
|
135
|
+
this.params = params;
|
|
136
|
+
clearTimeout(this.reopenTimer);
|
|
137
|
+
this.reopenTimer = setTimeout(() => this.open(), this.source ? REOPEN_DEBOUNCE_MS : 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private open() {
|
|
141
|
+
this.source?.close();
|
|
142
|
+
const source = new EventSource(apiUrl("stream", this.params));
|
|
143
|
+
this.source = source;
|
|
144
|
+
source.onopen = () => {
|
|
145
|
+
this.stream = "open";
|
|
146
|
+
this.notify("stream");
|
|
147
|
+
void this.resync();
|
|
148
|
+
};
|
|
149
|
+
source.onerror = () => {
|
|
150
|
+
this.stream = "reconnecting";
|
|
151
|
+
this.notify("stream");
|
|
152
|
+
// The browser retries on its own unless the host refused the stream outright.
|
|
153
|
+
if (source.readyState === EventSource.CLOSED && this.source === source)
|
|
154
|
+
this.reopenTimer = setTimeout(() => this.open(), 3000);
|
|
155
|
+
};
|
|
156
|
+
const on = <K extends keyof StreamEvents>(name: K, handle: (data: StreamEvents[K]) => void) =>
|
|
157
|
+
source.addEventListener(name, (event) => handle(JSON.parse(event.data)));
|
|
158
|
+
on("session", (session) => {
|
|
159
|
+
this.session = session;
|
|
160
|
+
this.notify("session");
|
|
161
|
+
});
|
|
162
|
+
on("sample", (sample) => {
|
|
163
|
+
this.hostSkewMs = Math.min(this.hostSkewMs, Date.now() - Date.parse(sample.receivedAt));
|
|
164
|
+
this.samples.set(sample.path, sample);
|
|
165
|
+
this.notify("samples");
|
|
166
|
+
});
|
|
167
|
+
on("job", (job) => {
|
|
168
|
+
this.jobs.set(job.id, job);
|
|
169
|
+
this.notify("jobs");
|
|
170
|
+
});
|
|
171
|
+
on("device", (device) => {
|
|
172
|
+
this.devices.set(device.id, device);
|
|
173
|
+
this.notify("devices");
|
|
174
|
+
});
|
|
175
|
+
on("timeline", (entry) => this.addTimeline([entry]));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Catch up on what the stream does not replay: every (re)open may follow a gap. */
|
|
179
|
+
private async resync() {
|
|
180
|
+
try {
|
|
181
|
+
const since = this.timeline.at(-1)?.at;
|
|
182
|
+
const [session, jobs, devices, timeline, watch] = await Promise.all([
|
|
183
|
+
getJson("session"),
|
|
184
|
+
getJson("jobs"),
|
|
185
|
+
getJson("hardware"),
|
|
186
|
+
getJson("timeline", since ? { since } : {}),
|
|
187
|
+
getJson("watch"),
|
|
188
|
+
]);
|
|
189
|
+
this.session = session;
|
|
190
|
+
this.jobs = new Map(jobs.map((job) => [job.id, job]));
|
|
191
|
+
this.devices = new Map(devices.map((device) => [device.id, device]));
|
|
192
|
+
this.watch = watch;
|
|
193
|
+
this.addTimeline(timeline);
|
|
194
|
+
for (const topic of ["session", "jobs", "devices", "watch"] as const) this.notify(topic);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
this.reportError(error);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private addTimeline(entries: TimelineEntry[]) {
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
const key = timelineKey(entry);
|
|
203
|
+
const existing = this.timelineByKey.get(key);
|
|
204
|
+
this.timelineByKey.set(key, entry);
|
|
205
|
+
if (existing) {
|
|
206
|
+
this.timeline[this.timeline.indexOf(existing)] = entry;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const at = Date.parse(entry.at);
|
|
210
|
+
let index = this.timeline.length;
|
|
211
|
+
while (index > 0 && Date.parse(this.timeline[index - 1]!.at) > at) index--;
|
|
212
|
+
this.timeline.splice(index, 0, entry);
|
|
213
|
+
}
|
|
214
|
+
for (const dropped of this.timeline.splice(0, this.timeline.length - TIMELINE_LIMIT))
|
|
215
|
+
this.timelineByKey.delete(timelineKey(dropped));
|
|
216
|
+
this.notify("timeline");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async saveWatch(groups: Record<string, string[]>) {
|
|
220
|
+
try {
|
|
221
|
+
this.watch = await putJson("watch", { groups });
|
|
222
|
+
this.notify("watch");
|
|
223
|
+
} catch (error) {
|
|
224
|
+
this.reportError(error);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|