@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,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>
|