@jsenv/core 41.2.13 → 41.3.0
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/html/client_monitor_page.html +508 -0
- package/dist/html/clients_page.html +660 -0
- package/dist/js/client_reporter.js +502 -0
- package/dist/start_dev_server/start_dev_server.js +731 -143
- package/package.json +17 -10
- package/src/dev/start_dev_server.js +37 -13
- package/src/plugins/client_monitoring/client/client_monitor_page.html +487 -0
- package/src/plugins/client_monitoring/client/client_reporter.js +502 -0
- package/src/plugins/client_monitoring/client/clients_page.html +634 -0
- package/src/plugins/client_monitoring/jsenv_plugin_client_monitoring.js +568 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Server clients inspector</title>
|
|
7
|
+
<style>body {
|
|
8
|
+
color: #111827;
|
|
9
|
+
background: #f3f4f6;
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 24px;
|
|
12
|
+
font: 14px / 1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
h1 {
|
|
16
|
+
margin: 0 0 4px;
|
|
17
|
+
font-size: 1.4rem;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
h2 {
|
|
21
|
+
color: #6b7280;
|
|
22
|
+
text-transform: uppercase;
|
|
23
|
+
letter-spacing: .04em;
|
|
24
|
+
margin: 26px 0 8px;
|
|
25
|
+
font-size: .95rem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
p.intro {
|
|
29
|
+
color: #6b7280;
|
|
30
|
+
margin: 0 0 8px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
table {
|
|
34
|
+
border-collapse: collapse;
|
|
35
|
+
background: #fff;
|
|
36
|
+
border-radius: 8px;
|
|
37
|
+
width: 100%;
|
|
38
|
+
overflow: hidden;
|
|
39
|
+
box-shadow: 0 1px 3px #0000001a;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
th, td {
|
|
43
|
+
text-align: left;
|
|
44
|
+
vertical-align: middle;
|
|
45
|
+
border-bottom: 1px solid #eef2f7;
|
|
46
|
+
padding: 10px 14px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
th {
|
|
50
|
+
color: #6b7280;
|
|
51
|
+
text-transform: uppercase;
|
|
52
|
+
letter-spacing: .04em;
|
|
53
|
+
background: #f9fafb;
|
|
54
|
+
font-size: .78rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
tr:last-child td {
|
|
58
|
+
border-bottom: none;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.empty {
|
|
62
|
+
color: #9ca3af;
|
|
63
|
+
text-align: center;
|
|
64
|
+
padding: 22px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.muted {
|
|
68
|
+
color: #9ca3af;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.when {
|
|
72
|
+
color: #374151;
|
|
73
|
+
white-space: nowrap;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.url {
|
|
77
|
+
color: #374151;
|
|
78
|
+
text-overflow: ellipsis;
|
|
79
|
+
white-space: nowrap;
|
|
80
|
+
vertical-align: middle;
|
|
81
|
+
max-width: 240px;
|
|
82
|
+
display: inline-block;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.tag {
|
|
87
|
+
color: #4338ca;
|
|
88
|
+
background: #eef2ff;
|
|
89
|
+
border-radius: 999px;
|
|
90
|
+
padding: 1px 7px;
|
|
91
|
+
font-size: .72rem;
|
|
92
|
+
font-weight: 600;
|
|
93
|
+
display: inline-block;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.tag.on {
|
|
97
|
+
color: #047857;
|
|
98
|
+
background: #d1fae5;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.tag.ellip {
|
|
102
|
+
text-overflow: ellipsis;
|
|
103
|
+
white-space: nowrap;
|
|
104
|
+
vertical-align: middle;
|
|
105
|
+
max-width: 320px;
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.actions {
|
|
110
|
+
flex-wrap: wrap;
|
|
111
|
+
align-items: center;
|
|
112
|
+
gap: 6px;
|
|
113
|
+
display: flex;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.btn {
|
|
117
|
+
color: #374151;
|
|
118
|
+
font: inherit;
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
background: #f3f4f6;
|
|
121
|
+
border: 1px solid #d1d5db;
|
|
122
|
+
border-radius: 6px;
|
|
123
|
+
padding: 2px 9px;
|
|
124
|
+
font-size: .75rem;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.btn:hover {
|
|
128
|
+
background: #e5e7eb;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.btn.primary {
|
|
132
|
+
color: #fff;
|
|
133
|
+
background: #2563eb;
|
|
134
|
+
border-color: #2563eb;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.btn.primary:hover {
|
|
138
|
+
background: #1d4ed8;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.picker-filter {
|
|
142
|
+
box-sizing: border-box;
|
|
143
|
+
width: 100%;
|
|
144
|
+
font: inherit;
|
|
145
|
+
border: 1px solid #d1d5db;
|
|
146
|
+
border-radius: 6px;
|
|
147
|
+
margin-bottom: 10px;
|
|
148
|
+
padding: 8px 10px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.pagepick {
|
|
152
|
+
text-align: left;
|
|
153
|
+
word-break: break-all;
|
|
154
|
+
width: 100%;
|
|
155
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.monitor {
|
|
159
|
+
color: #2563eb;
|
|
160
|
+
white-space: nowrap;
|
|
161
|
+
margin-left: 4px;
|
|
162
|
+
padding: 2px 4px;
|
|
163
|
+
font-size: .8rem;
|
|
164
|
+
font-weight: 600;
|
|
165
|
+
text-decoration: none;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.monitor:hover {
|
|
169
|
+
text-decoration: underline;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
dialog {
|
|
173
|
+
border: none;
|
|
174
|
+
border-radius: 10px;
|
|
175
|
+
width: min(90vw, 640px);
|
|
176
|
+
padding: 0;
|
|
177
|
+
box-shadow: 0 20px 60px #0000004d;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
dialog::backdrop {
|
|
181
|
+
background: #0006;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.dlg-head {
|
|
185
|
+
border-bottom: 1px solid #eef2f7;
|
|
186
|
+
align-items: center;
|
|
187
|
+
gap: 8px;
|
|
188
|
+
padding: 12px 16px;
|
|
189
|
+
display: flex;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.dlg-head .spacer {
|
|
193
|
+
flex: 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.dlg-body {
|
|
197
|
+
max-height: 60vh;
|
|
198
|
+
padding: 14px 16px;
|
|
199
|
+
overflow: auto;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.dlg-body pre {
|
|
203
|
+
white-space: pre-wrap;
|
|
204
|
+
word-break: break-all;
|
|
205
|
+
margin: 0;
|
|
206
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
207
|
+
font-size: 12px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
ul.list {
|
|
211
|
+
margin: 0;
|
|
212
|
+
padding: 0;
|
|
213
|
+
list-style: none;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
ul.list li {
|
|
217
|
+
border-bottom: 1px solid #f1f5f9;
|
|
218
|
+
padding: 8px 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
ul.list li:last-child {
|
|
222
|
+
border-bottom: none;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.url2 {
|
|
226
|
+
color: #374151;
|
|
227
|
+
word-break: break-all;
|
|
228
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
229
|
+
font-size: 12px;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.ttl {
|
|
233
|
+
font-weight: 600;
|
|
234
|
+
}
|
|
235
|
+
</style>
|
|
236
|
+
</head>
|
|
237
|
+
<body>
|
|
238
|
+
<h1>Server clients inspector</h1>
|
|
239
|
+
<p class="intro">
|
|
240
|
+
Every browser that has connected to this dev server since it started. The
|
|
241
|
+
columns are a résumé; use the actions to dig in or monitor a client's
|
|
242
|
+
logs.
|
|
243
|
+
</p>
|
|
244
|
+
|
|
245
|
+
<h2>This browser</h2>
|
|
246
|
+
<table>
|
|
247
|
+
<thead id="head"></thead>
|
|
248
|
+
<tbody id="self-rows"></tbody>
|
|
249
|
+
</table>
|
|
250
|
+
|
|
251
|
+
<h2>Other clients</h2>
|
|
252
|
+
<table>
|
|
253
|
+
<thead id="head2"></thead>
|
|
254
|
+
<tbody id="other-rows"></tbody>
|
|
255
|
+
</table>
|
|
256
|
+
|
|
257
|
+
<dialog id="dlg">
|
|
258
|
+
<div class="dlg-head">
|
|
259
|
+
<strong id="dlg-title"></strong>
|
|
260
|
+
<span class="spacer"></span>
|
|
261
|
+
<button class="btn" id="dlg-copy">Copy</button>
|
|
262
|
+
<button class="btn" id="dlg-close">Close</button>
|
|
263
|
+
</div>
|
|
264
|
+
<div class="dlg-body" id="dlg-body"></div>
|
|
265
|
+
</dialog>
|
|
266
|
+
|
|
267
|
+
<script type="module">const selfRows = document.getElementById("self-rows");
|
|
268
|
+
const otherRows = document.getElementById("other-rows");
|
|
269
|
+
// The monitoring script (injected into this page too) stores our own id.
|
|
270
|
+
let selfId = null;
|
|
271
|
+
try {
|
|
272
|
+
selfId = localStorage.getItem("jsenv_client_id");
|
|
273
|
+
} catch {
|
|
274
|
+
// storage blocked — we just won't single ourselves out
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const headHtml = `<tr>
|
|
278
|
+
<th>First seen</th><th>OS</th><th>Browser</th><th>Active tab</th>
|
|
279
|
+
<th>Last activity</th><th>Logs</th><th></th>
|
|
280
|
+
</tr>`;
|
|
281
|
+
document.getElementById("head").innerHTML = headHtml;
|
|
282
|
+
document.getElementById("head2").innerHTML = headHtml;
|
|
283
|
+
const COLSPAN = 7;
|
|
284
|
+
|
|
285
|
+
const ago = (ts) => {
|
|
286
|
+
const s = Math.round((Date.now() - ts) / 1000);
|
|
287
|
+
if (s < 5) return "just now";
|
|
288
|
+
if (s < 60) return `${s}s ago`;
|
|
289
|
+
const m = Math.round(s / 60);
|
|
290
|
+
if (m < 60) return `${m}m ago`;
|
|
291
|
+
const h = Math.round(m / 60);
|
|
292
|
+
return `${h}h ago`;
|
|
293
|
+
};
|
|
294
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
295
|
+
// Absolute date+time, kept for the "first seen" tooltip.
|
|
296
|
+
const fmtDate = (ts) => {
|
|
297
|
+
const d = new Date(ts);
|
|
298
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
299
|
+
};
|
|
300
|
+
// Human relative time for "first seen" ("5 minutes ago", "yesterday"),
|
|
301
|
+
// rendered with the largest unit that fits. Re-rendered every few seconds
|
|
302
|
+
// (see the interval below) so it stays fresh.
|
|
303
|
+
const relativeTimeFormat = new Intl.RelativeTimeFormat(undefined, {
|
|
304
|
+
numeric: "auto",
|
|
305
|
+
});
|
|
306
|
+
const RELATIVE_UNITS = [
|
|
307
|
+
["year", 31536000],
|
|
308
|
+
["month", 2592000],
|
|
309
|
+
["week", 604800],
|
|
310
|
+
["day", 86400],
|
|
311
|
+
["hour", 3600],
|
|
312
|
+
["minute", 60],
|
|
313
|
+
];
|
|
314
|
+
const relativeTime = (ts) => {
|
|
315
|
+
const deltaSeconds = Math.round((ts - Date.now()) / 1000); // <0 = past
|
|
316
|
+
const abs = deltaSeconds < 0 ? -deltaSeconds : deltaSeconds;
|
|
317
|
+
for (const [unit, secondsInUnit] of RELATIVE_UNITS) {
|
|
318
|
+
if (abs >= secondsInUnit) {
|
|
319
|
+
return relativeTimeFormat.format(
|
|
320
|
+
Math.round(deltaSeconds / secondsInUnit),
|
|
321
|
+
unit,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return relativeTimeFormat.format(deltaSeconds, "second");
|
|
326
|
+
};
|
|
327
|
+
const escapeHtml = (str) =>
|
|
328
|
+
String(str).replace(
|
|
329
|
+
/[&<>"]/g,
|
|
330
|
+
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
|
331
|
+
);
|
|
332
|
+
// Trim noisy sub-pixel precision for display (e.g. scroll "0/432.333" →
|
|
333
|
+
// "0/432"); the full value stays in the stored/reported data.
|
|
334
|
+
const roundNumbers = (text) =>
|
|
335
|
+
String(text).replace(/-?\d+\.\d+/g, (n) => String(Math.round(n)));
|
|
336
|
+
const shortUrl = (url) => {
|
|
337
|
+
try {
|
|
338
|
+
const u = new URL(url);
|
|
339
|
+
return `${u.pathname}${u.search}${u.hash}` || "/";
|
|
340
|
+
} catch {
|
|
341
|
+
return url;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
// Last path segment of a URL ("…/8_picker_wheel_demo.html" → the filename),
|
|
345
|
+
// for compact activity labels. Non-URLs pass through unchanged.
|
|
346
|
+
const fileName = (url) => {
|
|
347
|
+
try {
|
|
348
|
+
const segments = new URL(url).pathname.split("/").filter(Boolean);
|
|
349
|
+
return segments.length ? segments[segments.length - 1] : "/";
|
|
350
|
+
} catch {
|
|
351
|
+
return url;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
// The compact form of an activity detail for the "Last activity" column:
|
|
355
|
+
// URL-valued details collapse to their filename, a request keeps its
|
|
356
|
+
// method, coordinates lose sub-pixel noise. The full detail stays in the
|
|
357
|
+
// tooltip and the activity dialog / monitor.
|
|
358
|
+
const compactDetail = (type, detail) => {
|
|
359
|
+
if (!detail) {
|
|
360
|
+
return "";
|
|
361
|
+
}
|
|
362
|
+
if (type === "load" || type === "navigation") {
|
|
363
|
+
return fileName(detail);
|
|
364
|
+
}
|
|
365
|
+
if (type === "request") {
|
|
366
|
+
const spaceIndex = detail.indexOf(" ");
|
|
367
|
+
return spaceIndex === -1
|
|
368
|
+
? fileName(detail)
|
|
369
|
+
: `${detail.slice(0, spaceIndex)} ${fileName(detail.slice(spaceIndex + 1))}`;
|
|
370
|
+
}
|
|
371
|
+
return roundNumbers(detail);
|
|
372
|
+
};
|
|
373
|
+
const RUNTIME_LABELS = {
|
|
374
|
+
ios_safari: "iOS Safari",
|
|
375
|
+
chrome: "Chrome",
|
|
376
|
+
edge: "Edge",
|
|
377
|
+
opera: "Opera",
|
|
378
|
+
samsung: "Samsung Internet",
|
|
379
|
+
firefox: "Firefox",
|
|
380
|
+
safari: "Safari",
|
|
381
|
+
node: "Node",
|
|
382
|
+
};
|
|
383
|
+
const named = ({ name, version } = {}) => {
|
|
384
|
+
if (!name || name === "unknown") {
|
|
385
|
+
return '<span class="muted">unknown</span>';
|
|
386
|
+
}
|
|
387
|
+
const label = RUNTIME_LABELS[name] || name;
|
|
388
|
+
const v = version && version !== "unknown" ? ` ${version}` : "";
|
|
389
|
+
return escapeHtml(`${label}${v}`);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const firstSeenCell = (d) =>
|
|
393
|
+
`<span class="when" title="${escapeHtml(fmtDate(d.firstSeen))}">${escapeHtml(relativeTime(d.firstSeen))}</span>`;
|
|
394
|
+
const tabCell = (d) => {
|
|
395
|
+
if (!d.activeTab) {
|
|
396
|
+
return '<span class="muted">—</span>';
|
|
397
|
+
}
|
|
398
|
+
const url = d.activeTab.url || "";
|
|
399
|
+
return `<span class="url" title="${escapeHtml(url)}">${escapeHtml(shortUrl(url))}</span>`;
|
|
400
|
+
};
|
|
401
|
+
const activityCell = (d) => {
|
|
402
|
+
const a = d.lastActivity;
|
|
403
|
+
if (!a) {
|
|
404
|
+
return '<span class="muted">—</span>';
|
|
405
|
+
}
|
|
406
|
+
const compact = a.detail
|
|
407
|
+
? `${a.type}: ${compactDetail(a.type, a.detail)}`
|
|
408
|
+
: a.type;
|
|
409
|
+
const full = a.detail ? `${a.type}: ${a.detail}` : a.type;
|
|
410
|
+
return `<span class="tag ellip" title="${escapeHtml(full)}">${escapeHtml(compact)}</span> <span class="muted">${ago(a.ts)}</span>`;
|
|
411
|
+
};
|
|
412
|
+
const actionsCell = (d) => {
|
|
413
|
+
const id = escapeHtml(d.id);
|
|
414
|
+
return `<div class="actions">
|
|
415
|
+
<button class="btn primary" data-action="navigate" data-id="${id}">Navigate…</button>
|
|
416
|
+
<button class="btn" data-action="reload" data-id="${id}">Reload</button>
|
|
417
|
+
<button class="btn" data-action="tabs" data-id="${id}">Tabs</button>
|
|
418
|
+
<button class="btn" data-action="activity" data-id="${id}">Activity</button>
|
|
419
|
+
<button class="btn" data-action="ua" data-id="${id}">User agent</button>
|
|
420
|
+
<a class="monitor" href="/.internal/client?id=${encodeURIComponent(d.id)}">Monitor →</a>
|
|
421
|
+
</div>`;
|
|
422
|
+
};
|
|
423
|
+
const rowHtml = (d) => {
|
|
424
|
+
return `<tr>
|
|
425
|
+
<td>${firstSeenCell(d)}</td>
|
|
426
|
+
<td>${named(d.os)}</td>
|
|
427
|
+
<td>${named(d.runtime)}</td>
|
|
428
|
+
<td>${tabCell(d)}</td>
|
|
429
|
+
<td>${activityCell(d)}</td>
|
|
430
|
+
<td>${d.logCount}</td>
|
|
431
|
+
<td>${actionsCell(d)}</td>
|
|
432
|
+
</tr>`;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
let lastClients = [];
|
|
436
|
+
const render = (clients) => {
|
|
437
|
+
lastClients = clients;
|
|
438
|
+
const self = selfId ? clients.find((d) => d.id === selfId) : null;
|
|
439
|
+
// Others ordered by when they first connected, so rows don't reshuffle.
|
|
440
|
+
const others = clients
|
|
441
|
+
.filter((d) => d !== self)
|
|
442
|
+
.sort((a, b) => a.firstSeen - b.firstSeen);
|
|
443
|
+
selfRows.innerHTML = self
|
|
444
|
+
? rowHtml(self)
|
|
445
|
+
: `<tr><td class="empty" colspan="${COLSPAN}">This browser isn't registered yet — it will appear shortly.</td></tr>`;
|
|
446
|
+
otherRows.innerHTML = others.length
|
|
447
|
+
? others.map(rowHtml).join("")
|
|
448
|
+
: `<tr><td class="empty" colspan="${COLSPAN}">No other client has connected.</td></tr>`;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// Dialogs (all tabs / recent activity / user agent).
|
|
452
|
+
const dlg = document.getElementById("dlg");
|
|
453
|
+
const dlgTitle = document.getElementById("dlg-title");
|
|
454
|
+
const dlgBody = document.getElementById("dlg-body");
|
|
455
|
+
const dlgCopy = document.getElementById("dlg-copy");
|
|
456
|
+
document.getElementById("dlg-close").onclick = () => dlg.close();
|
|
457
|
+
const openDialog = (title, html, copyText) => {
|
|
458
|
+
dlgTitle.textContent = title;
|
|
459
|
+
dlgBody.innerHTML = html;
|
|
460
|
+
if (copyText === undefined) {
|
|
461
|
+
dlgCopy.style.display = "none";
|
|
462
|
+
} else {
|
|
463
|
+
dlgCopy.style.display = "";
|
|
464
|
+
dlgCopy.textContent = "Copy";
|
|
465
|
+
dlgCopy.onclick = async () => {
|
|
466
|
+
try {
|
|
467
|
+
await window.navigator.clipboard.writeText(copyText);
|
|
468
|
+
dlgCopy.textContent = "Copied!";
|
|
469
|
+
setTimeout(() => {
|
|
470
|
+
dlgCopy.textContent = "Copy";
|
|
471
|
+
}, 1200);
|
|
472
|
+
} catch {
|
|
473
|
+
// clipboard blocked — ignore
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
dlg.showModal();
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const fetchClient = async (id) => {
|
|
481
|
+
try {
|
|
482
|
+
return await (
|
|
483
|
+
await fetch(`/.internal/client.json?id=${encodeURIComponent(id)}`)
|
|
484
|
+
).json();
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
const tabsHtml = (tabs) => {
|
|
490
|
+
if (!tabs.length) {
|
|
491
|
+
return '<p class="muted">No tab.</p>';
|
|
492
|
+
}
|
|
493
|
+
return `<ul class="list">${tabs
|
|
494
|
+
.slice()
|
|
495
|
+
.sort((a, b) => b.visible - a.visible || b.lastSeen - a.lastSeen)
|
|
496
|
+
.map(
|
|
497
|
+
(t) => `<li>
|
|
498
|
+
<div>${t.visible ? '<span class="tag on">active</span> ' : ""}<span class="ttl">${escapeHtml(t.title) || '<span class="muted">(no title)</span>'}</span> <span class="muted">${ago(t.lastSeen)}</span></div>
|
|
499
|
+
<div class="url2">${escapeHtml(t.url)}</div>
|
|
500
|
+
</li>`,
|
|
501
|
+
)
|
|
502
|
+
.join("")}</ul>`;
|
|
503
|
+
};
|
|
504
|
+
const activitiesHtml = (activities) => {
|
|
505
|
+
if (!activities.length) {
|
|
506
|
+
return '<p class="muted">No activity yet.</p>';
|
|
507
|
+
}
|
|
508
|
+
return `<ul class="list">${activities
|
|
509
|
+
.slice()
|
|
510
|
+
.reverse()
|
|
511
|
+
.map(
|
|
512
|
+
(a) => `<li>
|
|
513
|
+
<div><span class="tag">${escapeHtml(a.type)}</span> <span class="muted">${ago(a.ts)}</span></div>
|
|
514
|
+
${a.detail ? `<div class="url2">${escapeHtml(roundNumbers(a.detail))}</div>` : ""}
|
|
515
|
+
</li>`,
|
|
516
|
+
)
|
|
517
|
+
.join("")}</ul>`;
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// Pilot a client: POST a command; the server broadcasts it and the
|
|
521
|
+
// matching reporter runs it (navigate/reload) on the targeted tab.
|
|
522
|
+
const sendCommand = async (payload) => {
|
|
523
|
+
try {
|
|
524
|
+
await fetch("/.internal/clients/command", {
|
|
525
|
+
method: "POST",
|
|
526
|
+
headers: { "content-type": "application/json" },
|
|
527
|
+
body: JSON.stringify(payload),
|
|
528
|
+
});
|
|
529
|
+
} catch {
|
|
530
|
+
// dev server gone — ignore
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
// The .html pages available to navigate to (fetched once, lazily).
|
|
534
|
+
let navigablePages = null;
|
|
535
|
+
const loadPages = async () => {
|
|
536
|
+
if (navigablePages) {
|
|
537
|
+
return navigablePages;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
navigablePages = await (
|
|
541
|
+
await fetch("/.internal/clients/pages.json")
|
|
542
|
+
).json();
|
|
543
|
+
} catch {
|
|
544
|
+
navigablePages = [];
|
|
545
|
+
}
|
|
546
|
+
return navigablePages;
|
|
547
|
+
};
|
|
548
|
+
const shortLabel = (d) => {
|
|
549
|
+
const parts = [d.os && d.os.name, d.runtime && d.runtime.name].filter(
|
|
550
|
+
Boolean,
|
|
551
|
+
);
|
|
552
|
+
return parts.length ? parts.join(" ") : d.id.slice(0, 8);
|
|
553
|
+
};
|
|
554
|
+
// A searchable list of pages; picking one navigates the client's active
|
|
555
|
+
// tab there (tabId from activeTab so only that tab moves).
|
|
556
|
+
const openNavigateDialog = async (client) => {
|
|
557
|
+
dlgTitle.textContent = `Navigate ${shortLabel(client)} to…`;
|
|
558
|
+
dlgCopy.style.display = "none";
|
|
559
|
+
dlgBody.innerHTML = `
|
|
560
|
+
<input class="picker-filter" id="page-filter" type="search" placeholder="Filter pages…" />
|
|
561
|
+
<ul class="list" id="page-list"></ul>`;
|
|
562
|
+
dlg.showModal();
|
|
563
|
+
const pages = await loadPages();
|
|
564
|
+
const listEl = document.getElementById("page-list");
|
|
565
|
+
const filterEl = document.getElementById("page-filter");
|
|
566
|
+
const renderPages = (query) => {
|
|
567
|
+
const needle = query.trim().toLowerCase();
|
|
568
|
+
const matches = needle
|
|
569
|
+
? pages.filter((p) => p.toLowerCase().includes(needle))
|
|
570
|
+
: pages;
|
|
571
|
+
listEl.innerHTML = matches.length
|
|
572
|
+
? matches
|
|
573
|
+
.slice(0, 300)
|
|
574
|
+
.map(
|
|
575
|
+
(p) =>
|
|
576
|
+
`<li><button class="btn pagepick" data-url="${escapeHtml(p)}">${escapeHtml(p)}</button></li>`,
|
|
577
|
+
)
|
|
578
|
+
.join("")
|
|
579
|
+
: '<li class="muted">No page matches.</li>';
|
|
580
|
+
};
|
|
581
|
+
renderPages("");
|
|
582
|
+
filterEl.oninput = () => renderPages(filterEl.value);
|
|
583
|
+
filterEl.focus();
|
|
584
|
+
listEl.onclick = (event) => {
|
|
585
|
+
const button = event.target.closest("button.pagepick");
|
|
586
|
+
if (!button) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
sendCommand({
|
|
590
|
+
clientId: client.id,
|
|
591
|
+
tabId: client.activeTab ? client.activeTab.id : null,
|
|
592
|
+
type: "navigate",
|
|
593
|
+
url: button.getAttribute("data-url"),
|
|
594
|
+
});
|
|
595
|
+
dlg.close();
|
|
596
|
+
};
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
document.addEventListener("click", async (event) => {
|
|
600
|
+
const button = event.target.closest("button[data-action]");
|
|
601
|
+
if (!button) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const id = button.getAttribute("data-id");
|
|
605
|
+
const action = button.getAttribute("data-action");
|
|
606
|
+
if (action === "ua") {
|
|
607
|
+
const client = lastClients.find((d) => d.id === id);
|
|
608
|
+
const ua = (client && client.userAgent) || "";
|
|
609
|
+
openDialog("User agent", `<pre>${escapeHtml(ua)}</pre>`, ua);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (action === "navigate") {
|
|
613
|
+
const client = lastClients.find((d) => d.id === id);
|
|
614
|
+
if (client) {
|
|
615
|
+
openNavigateDialog(client);
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (action === "reload") {
|
|
620
|
+
const client = lastClients.find((d) => d.id === id);
|
|
621
|
+
sendCommand({
|
|
622
|
+
clientId: id,
|
|
623
|
+
tabId: client && client.activeTab ? client.activeTab.id : null,
|
|
624
|
+
type: "reload",
|
|
625
|
+
});
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const detail = await fetchClient(id);
|
|
629
|
+
if (!detail) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (action === "tabs") {
|
|
633
|
+
openDialog(`Tabs (${detail.tabs.length})`, tabsHtml(detail.tabs));
|
|
634
|
+
} else if (action === "activity") {
|
|
635
|
+
openDialog("Recent activity", activitiesHtml(detail.activities));
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// This page is cooked through the dev server, so it gets both the jsenv
|
|
640
|
+
// server-events client (window.__server_events__, injected first — the
|
|
641
|
+
// same channel autoreload rides) and the monitoring script (so opening
|
|
642
|
+
// this page counts as a connected client). The server pushes the full
|
|
643
|
+
// list on every change; no polling. Initial snapshot via the JSON route.
|
|
644
|
+
(async () => {
|
|
645
|
+
try {
|
|
646
|
+
render(await (await fetch("/.internal/clients.json")).json());
|
|
647
|
+
} catch {
|
|
648
|
+
// dev server unreachable — the live channel will fill the list in.
|
|
649
|
+
}
|
|
650
|
+
})();
|
|
651
|
+
|
|
652
|
+
window.__server_events__.listenEvents({
|
|
653
|
+
clients_list: (event) => render(event.data),
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// Refresh relative "last activity" times between server pushes.
|
|
657
|
+
setInterval(() => render(lastClients), 5000);
|
|
658
|
+
</script>
|
|
659
|
+
</body>
|
|
660
|
+
</html>
|