@jsenv/core 41.2.14 → 41.3.1
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/build/build.js +10 -0
- 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 +741 -143
- package/package.json +2 -2
- 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
- package/src/plugins/workspace_bundle/jsenv_plugin_workspace_bundle.js +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/core",
|
|
3
|
-
"version": "41.
|
|
3
|
+
"version": "41.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Tool to develop, test and build js projects",
|
|
6
6
|
"repository": {
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"@jsenv/plugin-minification": "1.7.5",
|
|
79
79
|
"@jsenv/plugin-supervisor": "1.8.7",
|
|
80
80
|
"@jsenv/plugin-transpilation": "1.5.78",
|
|
81
|
-
"@jsenv/server": "17.
|
|
81
|
+
"@jsenv/server": "17.4.1",
|
|
82
82
|
"@jsenv/sourcemap": "1.4.2",
|
|
83
83
|
"react-table": "7.8.0"
|
|
84
84
|
},
|
|
@@ -16,6 +16,7 @@ import { createEventEmitter } from "../helpers/event_emitter.js";
|
|
|
16
16
|
import { jsenvCoreDirectoryUrl } from "../jsenv_core_directory_url.js";
|
|
17
17
|
import { createPackageDirectory } from "../kitchen/package_directory.js";
|
|
18
18
|
import { createJsenvPluginStore } from "../plugins/jsenv_plugins_controller.js";
|
|
19
|
+
import { jsenvPluginClientMonitoring } from "../plugins/client_monitoring/jsenv_plugin_client_monitoring.js";
|
|
19
20
|
import { getCorePlugins } from "../plugins/plugins.js";
|
|
20
21
|
import { jsenvPluginServerEvents } from "../plugins/server_events/jsenv_plugin_server_events.js";
|
|
21
22
|
import { devServerPluginChromeDevToolsJson } from "./dev_server_plugins/dev_server_plugin_chrome_devtools_json.js";
|
|
@@ -26,26 +27,41 @@ import { devServerPluginServeSourceFiles } from "./dev_server_plugins/dev_server
|
|
|
26
27
|
const EXECUTED_BY_TEST_PLAN = process.argv.includes("--jsenv-test");
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
|
-
* Starts the development server
|
|
30
|
+
* Starts the jsenv development server: serves files from a source directory,
|
|
31
|
+
* transforming (cooking) them on the fly through a plugin pipeline, with live
|
|
32
|
+
* reload. Built on top of `@jsenv/server`.
|
|
30
33
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* @param {string} [params.hostname="localhost"] - Hostname to bind the server to.
|
|
34
|
-
* @param {boolean} [params.https=false] - Whether to use HTTPS.
|
|
34
|
+
* See the "dev-server" skill (.agents/skills/dev-server) for the plugin system,
|
|
35
|
+
* server events, and how internal pages get script injection.
|
|
35
36
|
*
|
|
36
|
-
* @
|
|
37
|
-
* @
|
|
37
|
+
* @param {Object} [params={}]
|
|
38
|
+
* @param {string|URL} params.sourceDirectoryUrl - Root directory to serve (required; must exist).
|
|
39
|
+
* @param {string} [params.sourceMainFilePath="./index.html"] - File served for "/".
|
|
40
|
+
* @param {number} [params.port=3456] - Port to listen on (0 = a free port).
|
|
41
|
+
* @param {string} [params.hostname] - Hostname to bind to.
|
|
42
|
+
* @param {boolean} [params.acceptAnyIp=true] - Also accept connections on the machine's IPs.
|
|
43
|
+
* @param {boolean|object} [params.https=false] - HTTPS as `{ certificate, privateKey }`.
|
|
44
|
+
* @param {boolean} [params.http2=false] - HTTP/2 (requires https).
|
|
45
|
+
* @param {Array} [params.plugins=[]] - jsenv plugins (transformUrlContent, serverRoutes, serverEvents, effect, …).
|
|
46
|
+
* @param {Array} [params.serverPlugins=[]] - `@jsenv/server`-level plugins.
|
|
47
|
+
* @param {boolean|object} [params.clientAutoreload=true] - Live reload; also gates the server-events channel.
|
|
48
|
+
* @param {boolean} [params.ribbon=true] - The dev "ribbon" overlay.
|
|
49
|
+
* @param {boolean} [params.supervisor=true] - Script supervisor (better error reporting).
|
|
50
|
+
* @param {boolean|object} [params.directoryListing=true] - Directory listing pages.
|
|
51
|
+
* @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build.
|
|
52
|
+
* @param {string} [params.sourcemaps="inline"] - Sourcemap mode.
|
|
53
|
+
* @param {AbortSignal} [params.signal] - Abort to stop the server.
|
|
54
|
+
* @param {boolean} [params.handleSIGINT=true] - Stop on SIGINT.
|
|
55
|
+
* @param {boolean} [params.keepProcessAlive=true] - Keep the process alive while running.
|
|
38
56
|
*
|
|
39
|
-
* @
|
|
40
|
-
*
|
|
41
|
-
* const server = await startDevServer();
|
|
42
|
-
* console.log(`Server started at ${server.origin}`);
|
|
57
|
+
* @returns {Promise<{origin: string, sourceDirectoryUrl: URL, stop: () => Promise<void>, kitchenCache: object}>}
|
|
58
|
+
* @throws {TypeError} On unknown params.
|
|
43
59
|
*
|
|
44
60
|
* @example
|
|
45
|
-
* // Start a server with custom params
|
|
46
61
|
* const server = await startDevServer({
|
|
47
|
-
*
|
|
62
|
+
* sourceDirectoryUrl: new URL("./src/", import.meta.url),
|
|
48
63
|
* });
|
|
64
|
+
* console.log(`Server started at ${server.origin}`);
|
|
49
65
|
*/
|
|
50
66
|
export const startDevServer = async ({
|
|
51
67
|
sourceDirectoryUrl,
|
|
@@ -181,6 +197,14 @@ export const startDevServer = async ({
|
|
|
181
197
|
|
|
182
198
|
const devServerJsenvPluginStore = await createJsenvPluginStore([
|
|
183
199
|
jsenvPluginServerEvents({ clientAutoreload }),
|
|
200
|
+
// The client-monitoring dashboard is a dev-time convenience; a test-plan run
|
|
201
|
+
// doesn't use it and shouldn't pay for the reporter being injected into
|
|
202
|
+
// every page.
|
|
203
|
+
...(EXECUTED_BY_TEST_PLAN
|
|
204
|
+
? []
|
|
205
|
+
: [
|
|
206
|
+
jsenvPluginClientMonitoring({ rootDirectoryUrl: sourceDirectoryUrl }),
|
|
207
|
+
]),
|
|
184
208
|
...plugins,
|
|
185
209
|
...getCorePlugins({
|
|
186
210
|
packageDirectory,
|
|
@@ -0,0 +1,487 @@
|
|
|
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>
|
|
8
|
+
html,
|
|
9
|
+
body {
|
|
10
|
+
height: 100%;
|
|
11
|
+
margin: 0;
|
|
12
|
+
}
|
|
13
|
+
body {
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
color: #e5e7eb;
|
|
17
|
+
font:
|
|
18
|
+
13px/1.5 -apple-system,
|
|
19
|
+
BlinkMacSystemFont,
|
|
20
|
+
"Segoe UI",
|
|
21
|
+
Roboto,
|
|
22
|
+
sans-serif;
|
|
23
|
+
background: #0b1020;
|
|
24
|
+
}
|
|
25
|
+
header {
|
|
26
|
+
display: flex;
|
|
27
|
+
padding: 12px 16px;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 12px;
|
|
30
|
+
background: #111827;
|
|
31
|
+
border-bottom: 1px solid #1f2937;
|
|
32
|
+
}
|
|
33
|
+
header h1 {
|
|
34
|
+
margin: 0;
|
|
35
|
+
font-size: 1rem;
|
|
36
|
+
}
|
|
37
|
+
.back {
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
width: 26px;
|
|
40
|
+
height: 26px;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
color: #9ca3af;
|
|
44
|
+
font-size: 1.1rem;
|
|
45
|
+
text-decoration: none;
|
|
46
|
+
border-radius: 6px;
|
|
47
|
+
}
|
|
48
|
+
.back:hover {
|
|
49
|
+
color: #e5e7eb;
|
|
50
|
+
background: #1f2937;
|
|
51
|
+
}
|
|
52
|
+
.status {
|
|
53
|
+
color: #9ca3af;
|
|
54
|
+
font-size: 0.8rem;
|
|
55
|
+
}
|
|
56
|
+
.status .dot {
|
|
57
|
+
display: inline-block;
|
|
58
|
+
width: 8px;
|
|
59
|
+
height: 8px;
|
|
60
|
+
margin-right: 5px;
|
|
61
|
+
vertical-align: middle;
|
|
62
|
+
background: #9ca3af;
|
|
63
|
+
border-radius: 50%;
|
|
64
|
+
}
|
|
65
|
+
.status.on .dot {
|
|
66
|
+
background: #22c55e;
|
|
67
|
+
}
|
|
68
|
+
.spacer {
|
|
69
|
+
flex: 1;
|
|
70
|
+
}
|
|
71
|
+
button {
|
|
72
|
+
padding: 4px 10px;
|
|
73
|
+
color: #e5e7eb;
|
|
74
|
+
font-size: 0.78rem;
|
|
75
|
+
background: #374151;
|
|
76
|
+
border: 1px solid #4b5563;
|
|
77
|
+
border-radius: 6px;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
}
|
|
80
|
+
button:hover {
|
|
81
|
+
background: #4b5563;
|
|
82
|
+
}
|
|
83
|
+
/* Each stream is a card floating on the page background so logs and
|
|
84
|
+
activity read as two distinct things. They share the remaining height;
|
|
85
|
+
each grows when open, shrinks to just its header when collapsed — so you
|
|
86
|
+
can monitor one, both, or none. */
|
|
87
|
+
.panels {
|
|
88
|
+
display: flex;
|
|
89
|
+
min-height: 0;
|
|
90
|
+
padding: 12px;
|
|
91
|
+
flex: 1;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
gap: 12px;
|
|
94
|
+
}
|
|
95
|
+
.panel {
|
|
96
|
+
display: flex;
|
|
97
|
+
min-height: 0;
|
|
98
|
+
flex: 0 0 auto;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
background: #111a2e;
|
|
101
|
+
border: 1px solid #1f2937;
|
|
102
|
+
border-radius: 8px;
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
}
|
|
105
|
+
.panel[data-open] {
|
|
106
|
+
flex: 1;
|
|
107
|
+
}
|
|
108
|
+
.panel-head {
|
|
109
|
+
display: flex;
|
|
110
|
+
padding: 8px 12px;
|
|
111
|
+
align-items: center;
|
|
112
|
+
gap: 8px;
|
|
113
|
+
border-left: 3px solid transparent;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
user-select: none;
|
|
116
|
+
}
|
|
117
|
+
.panel-head:hover {
|
|
118
|
+
background: rgba(255, 255, 255, 0.03);
|
|
119
|
+
}
|
|
120
|
+
/* Distinct accent per stream so they don't blur together. */
|
|
121
|
+
#logs-panel .panel-head {
|
|
122
|
+
background: rgba(167, 139, 250, 0.08);
|
|
123
|
+
border-left-color: #a78bfa;
|
|
124
|
+
}
|
|
125
|
+
#logs-panel .panel-title {
|
|
126
|
+
color: #c4b5fd;
|
|
127
|
+
}
|
|
128
|
+
#activity-panel .panel-head {
|
|
129
|
+
background: rgba(52, 211, 153, 0.08);
|
|
130
|
+
border-left-color: #34d399;
|
|
131
|
+
}
|
|
132
|
+
#activity-panel .panel-title {
|
|
133
|
+
color: #6ee7b7;
|
|
134
|
+
}
|
|
135
|
+
.chevron::before {
|
|
136
|
+
display: inline-block;
|
|
137
|
+
color: #6b7280;
|
|
138
|
+
transition: transform 0.1s;
|
|
139
|
+
content: "▸";
|
|
140
|
+
}
|
|
141
|
+
.panel[data-open] .chevron::before {
|
|
142
|
+
transform: rotate(90deg);
|
|
143
|
+
}
|
|
144
|
+
.panel-title {
|
|
145
|
+
font-weight: 600;
|
|
146
|
+
}
|
|
147
|
+
.count {
|
|
148
|
+
color: #6b7280;
|
|
149
|
+
font-size: 0.78rem;
|
|
150
|
+
}
|
|
151
|
+
.panel-body {
|
|
152
|
+
margin: 0;
|
|
153
|
+
padding: 8px 12px;
|
|
154
|
+
flex: 1;
|
|
155
|
+
font-size: 12px;
|
|
156
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
157
|
+
white-space: pre-wrap;
|
|
158
|
+
word-break: break-word;
|
|
159
|
+
overflow: auto;
|
|
160
|
+
}
|
|
161
|
+
.panel:not([data-open]) .panel-body {
|
|
162
|
+
display: none;
|
|
163
|
+
}
|
|
164
|
+
.entry {
|
|
165
|
+
padding: 2px 4px;
|
|
166
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
|
167
|
+
}
|
|
168
|
+
.entry .time {
|
|
169
|
+
margin-right: 8px;
|
|
170
|
+
color: #6b7280;
|
|
171
|
+
}
|
|
172
|
+
.entry .lvl {
|
|
173
|
+
display: inline-block;
|
|
174
|
+
min-width: 42px;
|
|
175
|
+
margin-right: 8px;
|
|
176
|
+
font-weight: 600;
|
|
177
|
+
}
|
|
178
|
+
.lvl-error {
|
|
179
|
+
color: #f87171;
|
|
180
|
+
}
|
|
181
|
+
.lvl-warn {
|
|
182
|
+
color: #fbbf24;
|
|
183
|
+
}
|
|
184
|
+
.lvl-info {
|
|
185
|
+
color: #60a5fa;
|
|
186
|
+
}
|
|
187
|
+
.lvl-debug {
|
|
188
|
+
color: #a78bfa;
|
|
189
|
+
}
|
|
190
|
+
.lvl-log {
|
|
191
|
+
color: #9ca3af;
|
|
192
|
+
}
|
|
193
|
+
.act-type {
|
|
194
|
+
display: inline-block;
|
|
195
|
+
min-width: 84px;
|
|
196
|
+
margin-right: 8px;
|
|
197
|
+
color: #93c5fd;
|
|
198
|
+
font-weight: 600;
|
|
199
|
+
}
|
|
200
|
+
.empty {
|
|
201
|
+
padding: 20px;
|
|
202
|
+
color: #6b7280;
|
|
203
|
+
}
|
|
204
|
+
</style>
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
<header>
|
|
208
|
+
<a class="back" id="back" title="Back to clients">←</a>
|
|
209
|
+
<h1>Monitor <span id="clientinfo"></span></h1>
|
|
210
|
+
<span class="status" id="status"
|
|
211
|
+
><span class="dot"></span>connecting…</span
|
|
212
|
+
>
|
|
213
|
+
</header>
|
|
214
|
+
|
|
215
|
+
<div class="panels">
|
|
216
|
+
<section class="panel" id="logs-panel" data-open>
|
|
217
|
+
<div class="panel-head">
|
|
218
|
+
<span class="chevron"></span>
|
|
219
|
+
<span class="panel-title">Logs</span>
|
|
220
|
+
<span class="count" id="logs-count"></span>
|
|
221
|
+
<span class="spacer"></span>
|
|
222
|
+
<button data-copy="logs">Copy</button>
|
|
223
|
+
<button data-clear="logs">Clear</button>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="panel-body" id="logs">
|
|
226
|
+
<div class="empty">Waiting for logs…</div>
|
|
227
|
+
</div>
|
|
228
|
+
</section>
|
|
229
|
+
|
|
230
|
+
<section class="panel" id="activity-panel">
|
|
231
|
+
<div class="panel-head">
|
|
232
|
+
<span class="chevron"></span>
|
|
233
|
+
<span class="panel-title">Activity</span>
|
|
234
|
+
<span class="count" id="activity-count"></span>
|
|
235
|
+
<span class="spacer"></span>
|
|
236
|
+
<button data-copy="activity">Copy</button>
|
|
237
|
+
<button data-clear="activity">Clear</button>
|
|
238
|
+
</div>
|
|
239
|
+
<div class="panel-body" id="activities">
|
|
240
|
+
<div class="empty">Waiting for activity…</div>
|
|
241
|
+
</div>
|
|
242
|
+
</section>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<script type="module">
|
|
246
|
+
const params = new URLSearchParams(window.location.search);
|
|
247
|
+
const clientId = params.get("id") || "";
|
|
248
|
+
const statusEl = document.getElementById("status");
|
|
249
|
+
const clientinfoEl = document.getElementById("clientinfo");
|
|
250
|
+
clientinfoEl.textContent = clientId.slice(0, 8);
|
|
251
|
+
// Set at runtime so the dev server doesn't rewrite the static href to the
|
|
252
|
+
// internal graph URL of the dashboard page.
|
|
253
|
+
document.getElementById("back").href = "/.internal/clients";
|
|
254
|
+
|
|
255
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
256
|
+
const fmtTime = (ts) => {
|
|
257
|
+
const d = new Date(ts);
|
|
258
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
259
|
+
};
|
|
260
|
+
// Trim noisy sub-pixel precision for display (e.g. scroll "0/432.333" →
|
|
261
|
+
// "0/432"); the full value stays in the stored/reported data.
|
|
262
|
+
const roundNumbers = (text) =>
|
|
263
|
+
String(text).replace(/-?\d+\.\d+/g, (n) => String(Math.round(n)));
|
|
264
|
+
|
|
265
|
+
const RUNTIME_LABELS = {
|
|
266
|
+
ios_safari: "iOS Safari",
|
|
267
|
+
chrome: "Chrome",
|
|
268
|
+
edge: "Edge",
|
|
269
|
+
opera: "Opera",
|
|
270
|
+
samsung: "Samsung Internet",
|
|
271
|
+
firefox: "Firefox",
|
|
272
|
+
safari: "Safari",
|
|
273
|
+
node: "Node",
|
|
274
|
+
};
|
|
275
|
+
// "Chrome 149 · iOS 17.2" from the client's parsed runtime/os.
|
|
276
|
+
const describeClient = (client) => {
|
|
277
|
+
const part = ({ name, version } = {}, labels) => {
|
|
278
|
+
if (!name || name === "unknown") {
|
|
279
|
+
return "";
|
|
280
|
+
}
|
|
281
|
+
const label = labels ? labels[name] || name : name;
|
|
282
|
+
const v = version && version !== "unknown" ? ` ${version}` : "";
|
|
283
|
+
return `${label}${v}`;
|
|
284
|
+
};
|
|
285
|
+
return (
|
|
286
|
+
[part(client.runtime, RUNTIME_LABELS), part(client.os)]
|
|
287
|
+
.filter(Boolean)
|
|
288
|
+
.join(" · ") || clientId.slice(0, 8)
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// Filled from client.json; drives the copied header so a pasted log always
|
|
293
|
+
// says WHERE it came from (browser, OS, full user-agent) — e.g. so it's
|
|
294
|
+
// obvious it's Android Chrome and not iOS.
|
|
295
|
+
let clientInfo = null;
|
|
296
|
+
const copyHeader = (label) => {
|
|
297
|
+
const info = clientInfo || {};
|
|
298
|
+
const ua = info.userAgent || window.navigator.userAgent || "unknown";
|
|
299
|
+
return `${[
|
|
300
|
+
`# jsenv client monitor — ${label}`,
|
|
301
|
+
`# client: ${describeClient(info)}`,
|
|
302
|
+
`# user-agent: ${ua}`,
|
|
303
|
+
`# client id: ${clientId}`,
|
|
304
|
+
`# copied: ${new Date().toISOString()}`,
|
|
305
|
+
].join("\n")}\n\n`;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// A stream = one collapsible panel with its own entries, rendering + a
|
|
309
|
+
// clipboard/clear pair. Logs render colored console segments; activity
|
|
310
|
+
// renders "type: detail". Both keep a plain-text form for Copy.
|
|
311
|
+
const createStream = (bodyId, countId, { render, toText, label }) => {
|
|
312
|
+
const bodyEl = document.getElementById(bodyId);
|
|
313
|
+
const countEl = document.getElementById(countId);
|
|
314
|
+
const entries = [];
|
|
315
|
+
let empty = true;
|
|
316
|
+
const add = (entry) => {
|
|
317
|
+
entries.push(entry);
|
|
318
|
+
countEl.textContent = `(${entries.length})`;
|
|
319
|
+
if (empty) {
|
|
320
|
+
bodyEl.innerHTML = "";
|
|
321
|
+
empty = false;
|
|
322
|
+
}
|
|
323
|
+
const atBottom =
|
|
324
|
+
bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 40;
|
|
325
|
+
bodyEl.appendChild(render(entry));
|
|
326
|
+
if (atBottom) {
|
|
327
|
+
bodyEl.scrollTop = bodyEl.scrollHeight;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
const clear = () => {
|
|
331
|
+
entries.length = 0;
|
|
332
|
+
countEl.textContent = "";
|
|
333
|
+
bodyEl.innerHTML = `<div class="empty">Cleared.</div>`;
|
|
334
|
+
empty = true;
|
|
335
|
+
};
|
|
336
|
+
const copy = async (button) => {
|
|
337
|
+
try {
|
|
338
|
+
const text = copyHeader(label) + entries.map(toText).join("\n");
|
|
339
|
+
await window.navigator.clipboard.writeText(text);
|
|
340
|
+
const prev = button.textContent;
|
|
341
|
+
button.textContent = "Copied!";
|
|
342
|
+
setTimeout(() => {
|
|
343
|
+
button.textContent = prev;
|
|
344
|
+
}, 1200);
|
|
345
|
+
} catch {
|
|
346
|
+
// clipboard blocked — ignore
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
return { add, clear, copy };
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// The %c styling comes from the reported page; applying it via cssText
|
|
353
|
+
// (not innerHTML) can't inject markup, but drop the obvious escape hatches.
|
|
354
|
+
const sanitizeCss = (css) =>
|
|
355
|
+
String(css)
|
|
356
|
+
.replace(/url\(/gi, "")
|
|
357
|
+
.replace(/expression/gi, "")
|
|
358
|
+
.replace(/[<>]/g, "");
|
|
359
|
+
|
|
360
|
+
const renderLog = (entry) => {
|
|
361
|
+
const row = document.createElement("div");
|
|
362
|
+
row.className = "entry";
|
|
363
|
+
const time = document.createElement("span");
|
|
364
|
+
time.className = "time";
|
|
365
|
+
time.textContent = fmtTime(entry.ts);
|
|
366
|
+
const lvl = document.createElement("span");
|
|
367
|
+
lvl.className = `lvl lvl-${entry.level}`;
|
|
368
|
+
lvl.textContent = entry.level;
|
|
369
|
+
row.append(time, lvl);
|
|
370
|
+
if (entry.segments && entry.segments.length) {
|
|
371
|
+
for (const segment of entry.segments) {
|
|
372
|
+
const span = document.createElement("span");
|
|
373
|
+
span.textContent = segment.text;
|
|
374
|
+
if (segment.css) {
|
|
375
|
+
span.style.cssText = sanitizeCss(segment.css);
|
|
376
|
+
}
|
|
377
|
+
row.appendChild(span);
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
row.appendChild(document.createTextNode(entry.text));
|
|
381
|
+
}
|
|
382
|
+
return row;
|
|
383
|
+
};
|
|
384
|
+
const renderActivity = (a) => {
|
|
385
|
+
const row = document.createElement("div");
|
|
386
|
+
row.className = "entry";
|
|
387
|
+
const time = document.createElement("span");
|
|
388
|
+
time.className = "time";
|
|
389
|
+
time.textContent = fmtTime(a.ts);
|
|
390
|
+
const type = document.createElement("span");
|
|
391
|
+
type.className = "act-type";
|
|
392
|
+
type.textContent = a.type;
|
|
393
|
+
row.append(
|
|
394
|
+
time,
|
|
395
|
+
type,
|
|
396
|
+
document.createTextNode(roundNumbers(a.detail || "")),
|
|
397
|
+
);
|
|
398
|
+
return row;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const logs = createStream("logs", "logs-count", {
|
|
402
|
+
label: "Logs",
|
|
403
|
+
render: renderLog,
|
|
404
|
+
toText: (e) => `${fmtTime(e.ts)} [${e.level}] ${e.text}`,
|
|
405
|
+
});
|
|
406
|
+
const activity = createStream("activities", "activity-count", {
|
|
407
|
+
label: "Activity",
|
|
408
|
+
render: renderActivity,
|
|
409
|
+
toText: (a) =>
|
|
410
|
+
`${fmtTime(a.ts)} ${a.type}${a.detail ? `: ${roundNumbers(a.detail)}` : ""}`,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Collapse/expand: click a panel header (but not its buttons).
|
|
414
|
+
for (const head of document.querySelectorAll(".panel-head")) {
|
|
415
|
+
head.addEventListener("click", (event) => {
|
|
416
|
+
if (event.target.closest("button")) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const panel = head.closest(".panel");
|
|
420
|
+
if (panel.hasAttribute("data-open")) {
|
|
421
|
+
panel.removeAttribute("data-open");
|
|
422
|
+
} else {
|
|
423
|
+
panel.setAttribute("data-open", "");
|
|
424
|
+
const body = panel.querySelector(".panel-body");
|
|
425
|
+
body.scrollTop = body.scrollHeight;
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const streamByName = { logs, activity };
|
|
430
|
+
for (const button of document.querySelectorAll("button[data-copy]")) {
|
|
431
|
+
button.addEventListener("click", () =>
|
|
432
|
+
streamByName[button.getAttribute("data-copy")].copy(button),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
for (const button of document.querySelectorAll("button[data-clear]")) {
|
|
436
|
+
button.addEventListener("click", () =>
|
|
437
|
+
streamByName[button.getAttribute("data-clear")].clear(),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const setStatus = (on, label) => {
|
|
442
|
+
statusEl.className = on ? "status on" : "status";
|
|
443
|
+
statusEl.innerHTML = `<span class="dot"></span>${label}`;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Backlog first (a monitor opened late still sees recent history), and
|
|
447
|
+
// fill the header with the client's parsed browser/OS.
|
|
448
|
+
(async () => {
|
|
449
|
+
try {
|
|
450
|
+
const client = await (
|
|
451
|
+
await fetch(
|
|
452
|
+
`/.internal/client.json?id=${encodeURIComponent(clientId)}`,
|
|
453
|
+
)
|
|
454
|
+
).json();
|
|
455
|
+
clientInfo = client;
|
|
456
|
+
clientinfoEl.textContent = describeClient(client);
|
|
457
|
+
for (const entry of client.logs || []) {
|
|
458
|
+
logs.add(entry);
|
|
459
|
+
}
|
|
460
|
+
for (const entry of client.activities || []) {
|
|
461
|
+
activity.add(entry);
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
// dev server unreachable — the live channel still delivers updates.
|
|
465
|
+
}
|
|
466
|
+
})();
|
|
467
|
+
|
|
468
|
+
// …then live. This page is cooked through the dev server, so it gets the
|
|
469
|
+
// jsenv server-events client injected (window.__server_events__, ready
|
|
470
|
+
// before this script — the same channel autoreload rides). Everything is
|
|
471
|
+
// broadcast there, so keep only what belongs to the client we monitor.
|
|
472
|
+
setStatus(true, "monitoring");
|
|
473
|
+
window.__server_events__.listenEvents({
|
|
474
|
+
client_log: (event) => {
|
|
475
|
+
if (event.data.clientId === clientId) {
|
|
476
|
+
logs.add(event.data);
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
client_activity: (event) => {
|
|
480
|
+
if (event.data.clientId === clientId) {
|
|
481
|
+
activity.add(event.data);
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
</script>
|
|
486
|
+
</body>
|
|
487
|
+
</html>
|