@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.
@@ -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
+ };
@@ -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
+ }