@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.
@@ -0,0 +1,508 @@
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 client monitor</title>
7
+ <style>html, body {
8
+ height: 100%;
9
+ margin: 0;
10
+ }
11
+
12
+ body {
13
+ color: #e5e7eb;
14
+ background: #0b1020;
15
+ flex-direction: column;
16
+ font: 13px / 1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;
17
+ display: flex;
18
+ }
19
+
20
+ header {
21
+ background: #111827;
22
+ border-bottom: 1px solid #1f2937;
23
+ align-items: center;
24
+ gap: 12px;
25
+ padding: 12px 16px;
26
+ display: flex;
27
+ }
28
+
29
+ header h1 {
30
+ margin: 0;
31
+ font-size: 1rem;
32
+ }
33
+
34
+ .back {
35
+ color: #9ca3af;
36
+ border-radius: 6px;
37
+ justify-content: center;
38
+ align-items: center;
39
+ width: 26px;
40
+ height: 26px;
41
+ font-size: 1.1rem;
42
+ text-decoration: none;
43
+ display: inline-flex;
44
+ }
45
+
46
+ .back:hover {
47
+ color: #e5e7eb;
48
+ background: #1f2937;
49
+ }
50
+
51
+ .status {
52
+ color: #9ca3af;
53
+ font-size: .8rem;
54
+ }
55
+
56
+ .status .dot {
57
+ vertical-align: middle;
58
+ background: #9ca3af;
59
+ border-radius: 50%;
60
+ width: 8px;
61
+ height: 8px;
62
+ margin-right: 5px;
63
+ display: inline-block;
64
+ }
65
+
66
+ .status.on .dot {
67
+ background: #22c55e;
68
+ }
69
+
70
+ .spacer {
71
+ flex: 1;
72
+ }
73
+
74
+ button {
75
+ color: #e5e7eb;
76
+ cursor: pointer;
77
+ background: #374151;
78
+ border: 1px solid #4b5563;
79
+ border-radius: 6px;
80
+ padding: 4px 10px;
81
+ font-size: .78rem;
82
+ }
83
+
84
+ button:hover {
85
+ background: #4b5563;
86
+ }
87
+
88
+ .panels {
89
+ flex-direction: column;
90
+ flex: 1;
91
+ gap: 12px;
92
+ min-height: 0;
93
+ padding: 12px;
94
+ display: flex;
95
+ }
96
+
97
+ .panel {
98
+ background: #111a2e;
99
+ border: 1px solid #1f2937;
100
+ border-radius: 8px;
101
+ flex-direction: column;
102
+ flex: none;
103
+ min-height: 0;
104
+ display: flex;
105
+ overflow: hidden;
106
+ }
107
+
108
+ .panel[data-open] {
109
+ flex: 1;
110
+ }
111
+
112
+ .panel-head {
113
+ cursor: pointer;
114
+ user-select: none;
115
+ border-left: 3px solid #0000;
116
+ align-items: center;
117
+ gap: 8px;
118
+ padding: 8px 12px;
119
+ display: flex;
120
+ }
121
+
122
+ .panel-head:hover {
123
+ background: #ffffff08;
124
+ }
125
+
126
+ #logs-panel .panel-head {
127
+ background: #a78bfa14;
128
+ border-left-color: #a78bfa;
129
+ }
130
+
131
+ #logs-panel .panel-title {
132
+ color: #c4b5fd;
133
+ }
134
+
135
+ #activity-panel .panel-head {
136
+ background: #34d39914;
137
+ border-left-color: #34d399;
138
+ }
139
+
140
+ #activity-panel .panel-title {
141
+ color: #6ee7b7;
142
+ }
143
+
144
+ .chevron:before {
145
+ color: #6b7280;
146
+ content: "▸";
147
+ transition: transform .1s;
148
+ display: inline-block;
149
+ }
150
+
151
+ .panel[data-open] .chevron:before {
152
+ transform: rotate(90deg);
153
+ }
154
+
155
+ .panel-title {
156
+ font-weight: 600;
157
+ }
158
+
159
+ .count {
160
+ color: #6b7280;
161
+ font-size: .78rem;
162
+ }
163
+
164
+ .panel-body {
165
+ white-space: pre-wrap;
166
+ word-break: break-word;
167
+ flex: 1;
168
+ margin: 0;
169
+ padding: 8px 12px;
170
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
171
+ font-size: 12px;
172
+ overflow: auto;
173
+ }
174
+
175
+ .panel:not([data-open]) .panel-body {
176
+ display: none;
177
+ }
178
+
179
+ .entry {
180
+ border-bottom: 1px solid #ffffff0a;
181
+ padding: 2px 4px;
182
+ }
183
+
184
+ .entry .time {
185
+ color: #6b7280;
186
+ margin-right: 8px;
187
+ }
188
+
189
+ .entry .lvl {
190
+ min-width: 42px;
191
+ margin-right: 8px;
192
+ font-weight: 600;
193
+ display: inline-block;
194
+ }
195
+
196
+ .lvl-error {
197
+ color: #f87171;
198
+ }
199
+
200
+ .lvl-warn {
201
+ color: #fbbf24;
202
+ }
203
+
204
+ .lvl-info {
205
+ color: #60a5fa;
206
+ }
207
+
208
+ .lvl-debug {
209
+ color: #a78bfa;
210
+ }
211
+
212
+ .lvl-log {
213
+ color: #9ca3af;
214
+ }
215
+
216
+ .act-type {
217
+ color: #93c5fd;
218
+ min-width: 84px;
219
+ margin-right: 8px;
220
+ font-weight: 600;
221
+ display: inline-block;
222
+ }
223
+
224
+ .empty {
225
+ color: #6b7280;
226
+ padding: 20px;
227
+ }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <header>
232
+ <a class="back" id="back" title="Back to clients">←</a>
233
+ <h1>Monitor <span id="clientinfo"></span></h1>
234
+ <span class="status" id="status"><span class="dot"></span>connecting…</span>
235
+ </header>
236
+
237
+ <div class="panels">
238
+ <section class="panel" id="logs-panel" data-open="">
239
+ <div class="panel-head">
240
+ <span class="chevron"></span>
241
+ <span class="panel-title">Logs</span>
242
+ <span class="count" id="logs-count"></span>
243
+ <span class="spacer"></span>
244
+ <button data-copy="logs">Copy</button>
245
+ <button data-clear="logs">Clear</button>
246
+ </div>
247
+ <div class="panel-body" id="logs">
248
+ <div class="empty">Waiting for logs…</div>
249
+ </div>
250
+ </section>
251
+
252
+ <section class="panel" id="activity-panel">
253
+ <div class="panel-head">
254
+ <span class="chevron"></span>
255
+ <span class="panel-title">Activity</span>
256
+ <span class="count" id="activity-count"></span>
257
+ <span class="spacer"></span>
258
+ <button data-copy="activity">Copy</button>
259
+ <button data-clear="activity">Clear</button>
260
+ </div>
261
+ <div class="panel-body" id="activities">
262
+ <div class="empty">Waiting for activity…</div>
263
+ </div>
264
+ </section>
265
+ </div>
266
+
267
+ <script type="module">const params = new URLSearchParams(window.location.search);
268
+ const clientId = params.get("id") || "";
269
+ const statusEl = document.getElementById("status");
270
+ const clientinfoEl = document.getElementById("clientinfo");
271
+ clientinfoEl.textContent = clientId.slice(0, 8);
272
+ // Set at runtime so the dev server doesn't rewrite the static href to the
273
+ // internal graph URL of the dashboard page.
274
+ document.getElementById("back").href = "/.internal/clients";
275
+
276
+ const pad = (n) => String(n).padStart(2, "0");
277
+ const fmtTime = (ts) => {
278
+ const d = new Date(ts);
279
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
280
+ };
281
+ // Trim noisy sub-pixel precision for display (e.g. scroll "0/432.333" →
282
+ // "0/432"); the full value stays in the stored/reported data.
283
+ const roundNumbers = (text) =>
284
+ String(text).replace(/-?\d+\.\d+/g, (n) => String(Math.round(n)));
285
+
286
+ const RUNTIME_LABELS = {
287
+ ios_safari: "iOS Safari",
288
+ chrome: "Chrome",
289
+ edge: "Edge",
290
+ opera: "Opera",
291
+ samsung: "Samsung Internet",
292
+ firefox: "Firefox",
293
+ safari: "Safari",
294
+ node: "Node",
295
+ };
296
+ // "Chrome 149 · iOS 17.2" from the client's parsed runtime/os.
297
+ const describeClient = (client) => {
298
+ const part = ({ name, version } = {}, labels) => {
299
+ if (!name || name === "unknown") {
300
+ return "";
301
+ }
302
+ const label = labels ? labels[name] || name : name;
303
+ const v = version && version !== "unknown" ? ` ${version}` : "";
304
+ return `${label}${v}`;
305
+ };
306
+ return (
307
+ [part(client.runtime, RUNTIME_LABELS), part(client.os)]
308
+ .filter(Boolean)
309
+ .join(" · ") || clientId.slice(0, 8)
310
+ );
311
+ };
312
+
313
+ // Filled from client.json; drives the copied header so a pasted log always
314
+ // says WHERE it came from (browser, OS, full user-agent) — e.g. so it's
315
+ // obvious it's Android Chrome and not iOS.
316
+ let clientInfo = null;
317
+ const copyHeader = (label) => {
318
+ const info = clientInfo || {};
319
+ const ua = info.userAgent || window.navigator.userAgent || "unknown";
320
+ return `${[
321
+ `# jsenv client monitor — ${label}`,
322
+ `# client: ${describeClient(info)}`,
323
+ `# user-agent: ${ua}`,
324
+ `# client id: ${clientId}`,
325
+ `# copied: ${new Date().toISOString()}`,
326
+ ].join("\n")}\n\n`;
327
+ };
328
+
329
+ // A stream = one collapsible panel with its own entries, rendering + a
330
+ // clipboard/clear pair. Logs render colored console segments; activity
331
+ // renders "type: detail". Both keep a plain-text form for Copy.
332
+ const createStream = (bodyId, countId, { render, toText, label }) => {
333
+ const bodyEl = document.getElementById(bodyId);
334
+ const countEl = document.getElementById(countId);
335
+ const entries = [];
336
+ let empty = true;
337
+ const add = (entry) => {
338
+ entries.push(entry);
339
+ countEl.textContent = `(${entries.length})`;
340
+ if (empty) {
341
+ bodyEl.innerHTML = "";
342
+ empty = false;
343
+ }
344
+ const atBottom =
345
+ bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 40;
346
+ bodyEl.appendChild(render(entry));
347
+ if (atBottom) {
348
+ bodyEl.scrollTop = bodyEl.scrollHeight;
349
+ }
350
+ };
351
+ const clear = () => {
352
+ entries.length = 0;
353
+ countEl.textContent = "";
354
+ bodyEl.innerHTML = `<div class="empty">Cleared.</div>`;
355
+ empty = true;
356
+ };
357
+ const copy = async (button) => {
358
+ try {
359
+ const text = copyHeader(label) + entries.map(toText).join("\n");
360
+ await window.navigator.clipboard.writeText(text);
361
+ const prev = button.textContent;
362
+ button.textContent = "Copied!";
363
+ setTimeout(() => {
364
+ button.textContent = prev;
365
+ }, 1200);
366
+ } catch {
367
+ // clipboard blocked — ignore
368
+ }
369
+ };
370
+ return { add, clear, copy };
371
+ };
372
+
373
+ // The %c styling comes from the reported page; applying it via cssText
374
+ // (not innerHTML) can't inject markup, but drop the obvious escape hatches.
375
+ const sanitizeCss = (css) =>
376
+ String(css)
377
+ .replace(/url\(/gi, "")
378
+ .replace(/expression/gi, "")
379
+ .replace(/[<>]/g, "");
380
+
381
+ const renderLog = (entry) => {
382
+ const row = document.createElement("div");
383
+ row.className = "entry";
384
+ const time = document.createElement("span");
385
+ time.className = "time";
386
+ time.textContent = fmtTime(entry.ts);
387
+ const lvl = document.createElement("span");
388
+ lvl.className = `lvl lvl-${entry.level}`;
389
+ lvl.textContent = entry.level;
390
+ row.append(time, lvl);
391
+ if (entry.segments && entry.segments.length) {
392
+ for (const segment of entry.segments) {
393
+ const span = document.createElement("span");
394
+ span.textContent = segment.text;
395
+ if (segment.css) {
396
+ span.style.cssText = sanitizeCss(segment.css);
397
+ }
398
+ row.appendChild(span);
399
+ }
400
+ } else {
401
+ row.appendChild(document.createTextNode(entry.text));
402
+ }
403
+ return row;
404
+ };
405
+ const renderActivity = (a) => {
406
+ const row = document.createElement("div");
407
+ row.className = "entry";
408
+ const time = document.createElement("span");
409
+ time.className = "time";
410
+ time.textContent = fmtTime(a.ts);
411
+ const type = document.createElement("span");
412
+ type.className = "act-type";
413
+ type.textContent = a.type;
414
+ row.append(
415
+ time,
416
+ type,
417
+ document.createTextNode(roundNumbers(a.detail || "")),
418
+ );
419
+ return row;
420
+ };
421
+
422
+ const logs = createStream("logs", "logs-count", {
423
+ label: "Logs",
424
+ render: renderLog,
425
+ toText: (e) => `${fmtTime(e.ts)} [${e.level}] ${e.text}`,
426
+ });
427
+ const activity = createStream("activities", "activity-count", {
428
+ label: "Activity",
429
+ render: renderActivity,
430
+ toText: (a) =>
431
+ `${fmtTime(a.ts)} ${a.type}${a.detail ? `: ${roundNumbers(a.detail)}` : ""}`,
432
+ });
433
+
434
+ // Collapse/expand: click a panel header (but not its buttons).
435
+ for (const head of document.querySelectorAll(".panel-head")) {
436
+ head.addEventListener("click", (event) => {
437
+ if (event.target.closest("button")) {
438
+ return;
439
+ }
440
+ const panel = head.closest(".panel");
441
+ if (panel.hasAttribute("data-open")) {
442
+ panel.removeAttribute("data-open");
443
+ } else {
444
+ panel.setAttribute("data-open", "");
445
+ const body = panel.querySelector(".panel-body");
446
+ body.scrollTop = body.scrollHeight;
447
+ }
448
+ });
449
+ }
450
+ const streamByName = { logs, activity };
451
+ for (const button of document.querySelectorAll("button[data-copy]")) {
452
+ button.addEventListener("click", () =>
453
+ streamByName[button.getAttribute("data-copy")].copy(button),
454
+ );
455
+ }
456
+ for (const button of document.querySelectorAll("button[data-clear]")) {
457
+ button.addEventListener("click", () =>
458
+ streamByName[button.getAttribute("data-clear")].clear(),
459
+ );
460
+ }
461
+
462
+ const setStatus = (on, label) => {
463
+ statusEl.className = "status on" ;
464
+ statusEl.innerHTML = `<span class="dot"></span>${label}`;
465
+ };
466
+
467
+ // Backlog first (a monitor opened late still sees recent history), and
468
+ // fill the header with the client's parsed browser/OS.
469
+ (async () => {
470
+ try {
471
+ const client = await (
472
+ await fetch(
473
+ `/.internal/client.json?id=${encodeURIComponent(clientId)}`,
474
+ )
475
+ ).json();
476
+ clientInfo = client;
477
+ clientinfoEl.textContent = describeClient(client);
478
+ for (const entry of client.logs || []) {
479
+ logs.add(entry);
480
+ }
481
+ for (const entry of client.activities || []) {
482
+ activity.add(entry);
483
+ }
484
+ } catch {
485
+ // dev server unreachable — the live channel still delivers updates.
486
+ }
487
+ })();
488
+
489
+ // …then live. This page is cooked through the dev server, so it gets the
490
+ // jsenv server-events client injected (window.__server_events__, ready
491
+ // before this script — the same channel autoreload rides). Everything is
492
+ // broadcast there, so keep only what belongs to the client we monitor.
493
+ setStatus(true, "monitoring");
494
+ window.__server_events__.listenEvents({
495
+ client_log: (event) => {
496
+ if (event.data.clientId === clientId) {
497
+ logs.add(event.data);
498
+ }
499
+ },
500
+ client_activity: (event) => {
501
+ if (event.data.clientId === clientId) {
502
+ activity.add(event.data);
503
+ }
504
+ },
505
+ });
506
+ </script>
507
+ </body>
508
+ </html>