@tsplc/debug 0.2.16 → 0.2.18

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.
@@ -1,4 +1,4 @@
1
- import { type View, empty, h } from "./dom.ts";
1
+ import { type View, empty, h, table } from "./dom.ts";
2
2
  import { formatClock, formatDuration } from "./format.ts";
3
3
  import { requestJobLink, requestStatus } from "./jobs.ts";
4
4
  import type { Store } from "./store.ts";
@@ -6,76 +6,72 @@ import type { Store } from "./store.ts";
6
6
  const LIMIT = 500;
7
7
 
8
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
- };
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
+ ? table(
19
+ { class: "grid" },
20
+ ["#", "Started", "Service", "Status", "Duration", "Job", "Error"],
21
+ h(
22
+ "tbody",
23
+ {},
24
+ ...requests.map((request) =>
25
+ h(
26
+ "tr",
27
+ {
28
+ class:
29
+ request.seq === highlighted ? "highlight" : undefined,
30
+ },
31
+ h("td", { class: "mono num" }, String(request.seq)),
32
+ h("td", { class: "mono" }, formatClock(request.startedAt)),
33
+ h("td", { class: "mono" }, request.service),
34
+ h("td", {}, requestStatus(request)),
35
+ h(
36
+ "td",
37
+ { class: "mono num" },
38
+ request.durationMs === undefined
39
+ ? "—"
40
+ : formatDuration(request.durationMs),
41
+ ),
42
+ h(
43
+ "td",
44
+ {},
45
+ request.job === undefined
46
+ ? h("span", { class: "dim" }, "not from a job")
47
+ : requestJobLink(store, request.job, request.sequence),
48
+ ),
49
+ h("td", { class: "err" }, request.error ?? ""),
50
+ ),
51
+ ),
52
+ ),
53
+ )
54
+ : empty("No service requests recorded yet."),
55
+ );
56
+ };
57
+ render();
58
+ store.show();
59
+ return {
60
+ element: h(
61
+ "div",
62
+ { class: "page" },
63
+ h("h1", {}, "Requests"),
64
+ h(
65
+ "p",
66
+ { class: "hint" },
67
+ `Service calls the app server traced, newest first (at most ${LIMIT}).`,
68
+ ),
69
+ body,
70
+ ),
71
+ update(topics) {
72
+ if (topics.has("timeline")) {
73
+ render();
74
+ }
75
+ },
76
+ };
81
77
  };
package/src/ui/store.ts CHANGED
@@ -1,29 +1,39 @@
1
1
  import type {
2
- DeviceState,
3
- Interest,
4
- JobState,
5
- Sample,
6
- Session,
7
- StreamEvents,
8
- TimelineEntry,
9
- WatchGroups,
2
+ DeviceState,
3
+ Interest,
4
+ JobState,
5
+ Sample,
6
+ Session,
7
+ StreamEvents,
8
+ TimelineEntry,
9
+ WatchGroups,
10
10
  } from "../api.ts";
11
+ import { errorMessage } from "../errors.ts";
11
12
  import type { DebugManifest } from "../manifest.ts";
12
13
  import { apiUrl, getJson, putJson } from "./client.ts";
13
14
  import { type ValueState, valueState } from "./format.ts";
14
15
  import { createPaths, type Paths } from "../paths.ts";
16
+ import { insertInTimeOrder } from "../timeline.ts";
15
17
  import { ManifestTree } from "./tree.ts";
16
18
 
17
19
  export type Topic =
18
- "session" | "samples" | "jobs" | "devices" | "timeline" | "watch" | "stream" | "error" | "clock";
20
+ | "session"
21
+ | "samples"
22
+ | "jobs"
23
+ | "devices"
24
+ | "timeline"
25
+ | "watch"
26
+ | "stream"
27
+ | "error"
28
+ | "clock";
19
29
 
20
30
  /** The browser's EventSource state, apart from the controller connection the host reports. */
21
31
  type StreamState = "connecting" | "open" | "reconnecting";
22
32
 
23
33
  interface LiveSample {
24
- sample: Sample | undefined;
25
- ageMs: number | undefined;
26
- state: ValueState;
34
+ sample: Sample | undefined;
35
+ ageMs: number | undefined;
36
+ state: ValueState;
27
37
  }
28
38
 
29
39
  const TIMELINE_LIMIT = 5000;
@@ -31,16 +41,16 @@ const REOPEN_DEBOUNCE_MS = 250;
31
41
 
32
42
  // A restarted PLC numbers its records from the start again, so the time tells them apart.
33
43
  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
+ switch (entry.kind) {
45
+ case "app":
46
+ return `app:${entry.task}:${entry.sequence}:${entry.at}`;
47
+ case "trace":
48
+ return `trace:${entry.trace.task}:${entry.trace.sequence}:${entry.at}`;
49
+ case "request":
50
+ return `request:${entry.request.seq}`;
51
+ default:
52
+ return `${entry.kind}:${entry.source}:${entry.at}`;
53
+ }
44
54
  };
45
55
 
46
56
  /**
@@ -48,180 +58,208 @@ const timelineKey = (entry: TimelineEntry) => {
48
58
  * and handed to `onChange` by topic.
49
59
  */
50
60
  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
- }
61
+ readonly tree: ManifestTree;
62
+ readonly paths: Paths;
63
+ stream: StreamState = "connecting";
64
+ error: string | undefined;
65
+ samples = new Map<string, Sample>();
66
+ jobs = new Map<number, JobState>();
67
+ devices = new Map<string, DeviceState>();
68
+ /** Oldest first, by `at`. */
69
+ timeline: TimelineEntry[] = [];
70
+ watch: WatchGroups = { groups: {} };
71
+
72
+ private readonly timelineByKey = new Map<string, TimelineEntry>();
73
+ /** Browser clock minus host clock, from the fastest sample delivery seen. */
74
+ private hostSkewMs = Infinity;
75
+ private source: EventSource | undefined;
76
+ private showing: string | undefined;
77
+ private params: Record<string, string> = {};
78
+ private reopenTimer: ReturnType<typeof setTimeout> | undefined;
79
+ private pending = new Set<Topic>();
80
+
81
+ constructor(
82
+ readonly manifest: DebugManifest,
83
+ public session: Session,
84
+ private readonly onChange: (topics: Set<Topic>) => void,
85
+ ) {
86
+ this.tree = new ManifestTree(manifest);
87
+ this.paths = createPaths(manifest);
88
+ setInterval(() => this.notify("clock"), 1000);
89
+ }
90
+
91
+ notify(topic: Topic) {
92
+ if (this.pending.size === 0) {
93
+ requestAnimationFrame(() => this.flush());
94
+ }
95
+ this.pending.add(topic);
96
+ }
97
+
98
+ private flush() {
99
+ const topics = this.pending;
100
+ this.pending = new Set();
101
+ this.onChange(topics);
102
+ }
103
+
104
+ reportError(error: unknown) {
105
+ this.error = errorMessage(error);
106
+ this.notify("error");
107
+ }
108
+
109
+ clearError() {
110
+ this.error = undefined;
111
+ this.notify("error");
112
+ }
113
+
114
+ /** Host time now, so ages of host timestamps do not depend on the browser's clock. */
115
+ hostNow() {
116
+ return (
117
+ Date.now() - (Number.isFinite(this.hostSkewMs) ? this.hostSkewMs : 0)
118
+ );
119
+ }
120
+
121
+ ageOf(iso: string) {
122
+ return Math.max(0, this.hostNow() - Date.parse(iso));
123
+ }
124
+
125
+ get connected() {
126
+ return (
127
+ this.stream === "open" && this.session.connection.state === "connected"
128
+ );
129
+ }
130
+
131
+ live(path: string): LiveSample {
132
+ const sample = this.samples.get(path);
133
+ if (!sample) {
134
+ return { sample, ageMs: undefined, state: "unavailable" };
135
+ }
136
+ const ageMs = this.ageOf(sample.receivedAt);
137
+ const state = valueState(sample.quality, ageMs, {
138
+ connected: this.connected,
139
+ intervalMs: this.session.reading.intervalMs,
140
+ });
141
+ return { sample, ageMs, state };
142
+ }
143
+
144
+ /**
145
+ * Streams exactly what the page shows, reopening the stream once it settles: the host reads
146
+ * only these paths, jobs and devices.
147
+ */
148
+ show(interest: Partial<Interest> = {}) {
149
+ const params = Object.fromEntries(
150
+ Object.entries(interest).flatMap(([name, values]) => {
151
+ const list = [...new Set(values.map(String))].sort().join(",");
152
+ return list ? [[name, list]] : [];
153
+ }),
154
+ );
155
+ const showing = JSON.stringify(Object.entries(params).sort());
156
+ if (showing === this.showing) {
157
+ return;
158
+ }
159
+ this.showing = showing;
160
+ this.params = params;
161
+ clearTimeout(this.reopenTimer);
162
+ this.reopenTimer = setTimeout(
163
+ () => this.open(),
164
+ this.source ? REOPEN_DEBOUNCE_MS : 0,
165
+ );
166
+ }
167
+
168
+ private open() {
169
+ this.source?.close();
170
+ const source = new EventSource(apiUrl("stream", this.params));
171
+ this.source = source;
172
+ source.onopen = () => {
173
+ this.stream = "open";
174
+ this.notify("stream");
175
+ void this.resync();
176
+ };
177
+ source.onerror = () => {
178
+ this.stream = "reconnecting";
179
+ this.notify("stream");
180
+ // The browser retries on its own unless the host refused the stream outright.
181
+ if (source.readyState === EventSource.CLOSED && this.source === source) {
182
+ this.reopenTimer = setTimeout(() => this.open(), 3000);
183
+ }
184
+ };
185
+ const on = <K extends keyof StreamEvents>(
186
+ name: K,
187
+ handle: (data: StreamEvents[K]) => void,
188
+ ) =>
189
+ source.addEventListener(name, (event) => handle(JSON.parse(event.data)));
190
+ on("session", (session) => {
191
+ this.session = session;
192
+ this.notify("session");
193
+ });
194
+ on("sample", (sample) => {
195
+ this.hostSkewMs = Math.min(
196
+ this.hostSkewMs,
197
+ Date.now() - Date.parse(sample.receivedAt),
198
+ );
199
+ this.samples.set(sample.path, sample);
200
+ this.notify("samples");
201
+ });
202
+ on("job", (job) => {
203
+ this.jobs.set(job.id, job);
204
+ this.notify("jobs");
205
+ });
206
+ on("device", (device) => {
207
+ this.devices.set(device.id, device);
208
+ this.notify("devices");
209
+ });
210
+ on("timeline", (entry) => this.addTimeline([entry]));
211
+ }
212
+
213
+ /** Catch up on what the stream does not replay: every (re)open may follow a gap. */
214
+ private async resync() {
215
+ try {
216
+ const since = this.timeline.at(-1)?.at;
217
+ const [session, jobs, devices, timeline, watch] = await Promise.all([
218
+ getJson("session"),
219
+ getJson("jobs"),
220
+ getJson("hardware"),
221
+ getJson("timeline", since ? { since } : {}),
222
+ getJson("watch"),
223
+ ]);
224
+ this.session = session;
225
+ this.jobs = new Map(jobs.map((job) => [job.id, job]));
226
+ this.devices = new Map(devices.map((device) => [device.id, device]));
227
+ this.watch = watch;
228
+ this.addTimeline(timeline);
229
+ for (const topic of ["session", "jobs", "devices", "watch"] as const) {
230
+ this.notify(topic);
231
+ }
232
+ } catch (error) {
233
+ this.reportError(error);
234
+ }
235
+ }
236
+
237
+ private addTimeline(entries: TimelineEntry[]) {
238
+ for (const entry of entries) {
239
+ const key = timelineKey(entry);
240
+ const existing = this.timelineByKey.get(key);
241
+ this.timelineByKey.set(key, entry);
242
+ if (existing) {
243
+ this.timeline[this.timeline.indexOf(existing)] = entry;
244
+ continue;
245
+ }
246
+ insertInTimeOrder(this.timeline, entry);
247
+ }
248
+ for (const dropped of this.timeline.splice(
249
+ 0,
250
+ this.timeline.length - TIMELINE_LIMIT,
251
+ )) {
252
+ this.timelineByKey.delete(timelineKey(dropped));
253
+ }
254
+ this.notify("timeline");
255
+ }
256
+
257
+ async saveWatch(groups: Record<string, string[]>) {
258
+ try {
259
+ this.watch = await putJson("watch", { groups });
260
+ this.notify("watch");
261
+ } catch (error) {
262
+ this.reportError(error);
263
+ }
264
+ }
227
265
  }