hrpc-inspector 0.0.0 → 0.1.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/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +65 -0
- package/USAGE.md +180 -0
- package/bin/hrpc-inspector.mjs +79 -0
- package/dist/detect.js +21 -0
- package/dist/index.js +441 -0
- package/dist/observe.js +417 -0
- package/gui/index.html +996 -0
- package/gui/server.mjs +240 -0
- package/package.json +65 -1
- package/src/detect.ts +49 -0
- package/src/index.ts +37 -0
- package/src/init.mjs +89 -0
- package/src/observe.ts +430 -0
- package/src/reporters.ts +84 -0
- package/src/wrap-client.ts +140 -0
package/gui/index.html
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
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>hrpc inspector</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* ── Design tokens ───────────────────────────────────────────────────────────
|
|
9
|
+
Functionalist system (Dieter Rams): one type scale, a 4px spatial grid, colour as
|
|
10
|
+
information not decoration, hairlines over shadows. Dependency-free — system fonts
|
|
11
|
+
only (the GUI is served offline). Status/accent hexes are the validated palette
|
|
12
|
+
(≥3:1 both themes); pill/badge fills are decorative tints via color-mix (meaning is
|
|
13
|
+
always carried by glyph + word + outline, never colour alone). Dark is default;
|
|
14
|
+
light via prefers-color-scheme; the ◐ toggle forces either. */
|
|
15
|
+
:root, :root[data-theme="dark"] {
|
|
16
|
+
--bg:#0d1013; --panel:#161a20; --panel-2:#1b2027; --line:#232a33; --line-2:#2d353f;
|
|
17
|
+
--fg:#e8eaed; --dim:#8b949e; --faint:#5b636e;
|
|
18
|
+
--ok:#3fb950; --err:#f85149; --pend:#d29922; --accent:#589bff; --oncolor:#0b0f14; --embed:#a78bfa;
|
|
19
|
+
--rowhover:#161c25; --rowsel:#1c2836; --shadow:0 8px 24px rgba(0,0,0,.4);
|
|
20
|
+
}
|
|
21
|
+
@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) {
|
|
22
|
+
--bg:#ffffff; --panel:#f6f8fa; --panel-2:#eef1f4; --line:#d8dee4; --line-2:#c9d1d9;
|
|
23
|
+
--fg:#1a1f26; --dim:#5c636d; --faint:#8b939d;
|
|
24
|
+
--ok:#1a7f37; --err:#cf222e; --pend:#9a6700; --accent:#0969da; --oncolor:#ffffff; --embed:#6d28d9;
|
|
25
|
+
--rowhover:#f0f3f6; --rowsel:#dde7f5; --shadow:0 8px 24px rgba(31,35,40,.12);
|
|
26
|
+
} }
|
|
27
|
+
:root[data-theme="light"] {
|
|
28
|
+
--bg:#ffffff; --panel:#f6f8fa; --panel-2:#eef1f4; --line:#d8dee4; --line-2:#c9d1d9;
|
|
29
|
+
--fg:#1a1f26; --dim:#5c636d; --faint:#8b939d;
|
|
30
|
+
--ok:#1a7f37; --err:#cf222e; --pend:#9a6700; --accent:#0969da; --oncolor:#ffffff; --embed:#6d28d9;
|
|
31
|
+
--rowhover:#f0f3f6; --rowsel:#dde7f5; --shadow:0 8px 24px rgba(31,35,40,.12);
|
|
32
|
+
}
|
|
33
|
+
:root {
|
|
34
|
+
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
|
|
35
|
+
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
36
|
+
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --r1:5px; --r2:7px; --r3:10px;
|
|
37
|
+
}
|
|
38
|
+
* { box-sizing:border-box; }
|
|
39
|
+
::selection { background:color-mix(in srgb, var(--accent) 30%, transparent); }
|
|
40
|
+
body { margin:0; height:100vh; display:flex; flex-direction:column; font:13px/1.5 var(--sans);
|
|
41
|
+
background:var(--bg); color:var(--fg); -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; }
|
|
42
|
+
|
|
43
|
+
/* ── Header ── */
|
|
44
|
+
header { display:flex; align-items:center; gap:var(--s2); padding:0 var(--s3); height:44px; flex:0 0 auto;
|
|
45
|
+
border-bottom:1px solid var(--line); background:var(--panel); }
|
|
46
|
+
header h1 { font-size:12px; margin:0; font-weight:600; letter-spacing:.02em; display:flex; align-items:center; gap:8px; white-space:nowrap; }
|
|
47
|
+
header h1::before { content:""; width:9px; height:9px; border-radius:2px; background:var(--accent);
|
|
48
|
+
box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
|
|
49
|
+
#status { font-size:11px; color:var(--dim); display:inline-flex; align-items:center; gap:5px; }
|
|
50
|
+
#status::before { content:""; width:6px; height:6px; border-radius:50%; background:var(--faint); transition:background .2s; }
|
|
51
|
+
#status.on { color:var(--ok); }
|
|
52
|
+
#status.on::before { background:var(--ok); box-shadow:0 0 6px var(--ok); }
|
|
53
|
+
input#filter { margin-left:auto; width:200px; cursor:text; }
|
|
54
|
+
input#filter, button, select { height:28px; background:var(--bg); border:1px solid var(--line); color:var(--fg);
|
|
55
|
+
border-radius:var(--r1); padding:0 var(--s2); cursor:pointer; font:inherit; font-size:12px;
|
|
56
|
+
transition:border-color .12s, background .12s, color .12s; }
|
|
57
|
+
input#filter:focus, select:focus { outline:none; border-color:var(--accent); }
|
|
58
|
+
input#filter::placeholder { color:var(--faint); }
|
|
59
|
+
button:hover, select:hover { border-color:var(--line-2); }
|
|
60
|
+
select { color:var(--dim); }
|
|
61
|
+
#theme { width:28px; padding:0; }
|
|
62
|
+
button#pause.on { color:var(--pend); border-color:var(--pend); background:color-mix(in srgb, var(--pend) 12%, transparent); }
|
|
63
|
+
|
|
64
|
+
/* ── KPI stat tiles ── */
|
|
65
|
+
.stats { display:flex; gap:var(--s2); padding:var(--s2) var(--s3); border-bottom:1px solid var(--line); background:var(--bg); }
|
|
66
|
+
.stats:empty { display:none; }
|
|
67
|
+
.tile { flex:0 0 auto; background:var(--panel); border:1px solid var(--line); border-radius:var(--r2); padding:var(--s2) var(--s3); min-width:80px; }
|
|
68
|
+
.tval { font:650 19px/1.1 var(--mono); font-variant-numeric:tabular-nums; letter-spacing:-.01em; }
|
|
69
|
+
.tval.st-error { color:var(--err); } .tval.st-streaming { color:var(--accent); }
|
|
70
|
+
.tlabel { font-size:10px; color:var(--dim); text-transform:uppercase; letter-spacing:.06em; margin-top:3px; }
|
|
71
|
+
|
|
72
|
+
/* ── Device chips (horizontally scrollable source rail) ── */
|
|
73
|
+
.devices { display:flex; align-items:center; gap:var(--s3); padding:var(--s2) var(--s3); border-bottom:1px solid var(--line); background:var(--panel); }
|
|
74
|
+
.devices-chips { display:flex; gap:var(--s2); flex-wrap:nowrap; overflow-x:auto; flex:1 1 auto; min-width:0; scrollbar-width:thin; }
|
|
75
|
+
.clear-device { flex:0 0 auto; font-size:11px; padding:3px 10px; border:1px solid var(--line); border-radius:4px; background:var(--panel-2); color:var(--dim); cursor:pointer; white-space:nowrap; }
|
|
76
|
+
.clear-device:hover { color:var(--fg); border-color:var(--dim); }
|
|
77
|
+
.clear-device[hidden] { display:none; }
|
|
78
|
+
.chip { flex:0 0 auto; font-size:11px; padding:3px 10px; border:1px solid var(--line); border-radius:999px; color:var(--dim); cursor:pointer; white-space:nowrap; transition:color .12s, border-color .12s, background .12s; }
|
|
79
|
+
.chip:hover { color:var(--fg); border-color:var(--line-2); }
|
|
80
|
+
.chip.sel { background:var(--accent); border-color:var(--accent); color:var(--oncolor); }
|
|
81
|
+
/* Leading liveness dot — a shape+colour cue independent of text contrast (● live / ○ idle). */
|
|
82
|
+
.chip .dot { font-size:9px; vertical-align:middle; color:var(--ok); }
|
|
83
|
+
.chip.stale .dot { color:var(--dim); }
|
|
84
|
+
.chip.sel .dot { color:var(--oncolor); }
|
|
85
|
+
/* Idle/gone source: idle > STALE_MS. Fade is only a SECONDARY cue — opacity .75 keeps text at
|
|
86
|
+
≥3:1 in both themes (measured: dark 3.76, light 3.03; .45 failed at 2.16/1.85). Primary cues
|
|
87
|
+
are the hollow ○ dot + dashed border + italic. Never auto-removed on idle alone (a quiet-but-
|
|
88
|
+
live app must not vanish); server removes it on disconnect. .sel wins over the fade. */
|
|
89
|
+
.chip.stale { opacity:.75; border-style:dashed; font-style:italic; }
|
|
90
|
+
.chip.stale.sel { opacity:1; font-style:normal; }
|
|
91
|
+
/* Inline order toggle inside the detail pane's Stream heading. */
|
|
92
|
+
.ord-toggle { font-size:11px; font-weight:400; margin-left:8px; padding:1px 8px; border:1px solid var(--line); border-radius:999px; color:var(--dim); cursor:pointer; white-space:nowrap; }
|
|
93
|
+
.ord-toggle:hover { color:var(--fg); border-color:var(--line-2); }
|
|
94
|
+
|
|
95
|
+
/* ── Endpoint ignore-list dropdown — replaces the old horizontally-scrolling chip row (dozens
|
|
96
|
+
of endpoints are expected, but they now live in a scrollable popover, not the page). Native
|
|
97
|
+
<details>/<summary> popover: dependency-free, no JS toggle logic needed for open/close.
|
|
98
|
+
This is an IGNORE list: a CHECKED box means that endpoint's rows are HIDDEN — the opposite
|
|
99
|
+
sense of an include filter — so the menu states this explicitly, and .ep-row.is-ignored below
|
|
100
|
+
reads visually as "struck out / muted", matching the old .chip.ignored semantics. ── */
|
|
101
|
+
.ep-dropdown { position:relative; align-self:center; }
|
|
102
|
+
.ep-dropdown > summary { list-style:none; height:28px; display:inline-flex; align-items:center; gap:6px;
|
|
103
|
+
background:var(--bg); border:1px solid var(--line); color:var(--dim); border-radius:var(--r1);
|
|
104
|
+
padding:0 var(--s2); cursor:pointer; font:inherit; font-size:12px; white-space:nowrap;
|
|
105
|
+
transition:border-color .12s, background .12s, color .12s; }
|
|
106
|
+
.ep-dropdown > summary::-webkit-details-marker { display:none; }
|
|
107
|
+
.ep-dropdown > summary::marker { content:''; }
|
|
108
|
+
.ep-dropdown > summary:hover { color:var(--fg); border-color:var(--line-2); }
|
|
109
|
+
.ep-dropdown[open] > summary { border-color:var(--accent); color:var(--fg); }
|
|
110
|
+
/* Badge — visible even while the menu is closed, so the active ignore-state is never hidden. */
|
|
111
|
+
.ep-badge { font:600 10px/1 var(--mono); color:var(--oncolor); background:var(--accent);
|
|
112
|
+
border-radius:999px; padding:2px 6px; }
|
|
113
|
+
/* Floats over content; scrolls internally instead of the old horizontal page scroll. */
|
|
114
|
+
.ep-dropdown .menu { position:absolute; top:calc(100% + 6px); left:0; z-index:5; width:280px;
|
|
115
|
+
max-height:320px; overflow-y:auto; background:var(--panel-2); border:1px solid var(--line-2);
|
|
116
|
+
border-radius:var(--r2); box-shadow:var(--shadow); padding:var(--s2); scrollbar-width:thin; }
|
|
117
|
+
.ep-menu-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2);
|
|
118
|
+
font-size:10px; color:var(--dim); text-transform:uppercase; letter-spacing:.05em; margin-bottom:var(--s2);
|
|
119
|
+
padding-bottom:var(--s2); border-bottom:1px solid var(--line); }
|
|
120
|
+
.ep-menu-head button { height:22px; font-size:11px; padding:0 var(--s2); text-transform:none; letter-spacing:0; }
|
|
121
|
+
.ep-menu-empty { color:var(--dim); font-size:12px; padding:var(--s2) 2px; text-align:center; }
|
|
122
|
+
.ep-row { display:flex; align-items:center; gap:var(--s2); padding:4px 2px; font-size:12px; cursor:pointer; border-radius:3px; }
|
|
123
|
+
.ep-row:hover { background:var(--rowhover); }
|
|
124
|
+
.ep-row input[type="checkbox"] { margin:0; cursor:pointer; accent-color:var(--accent); flex:0 0 auto; }
|
|
125
|
+
.ep-row .ep-name { flex:1; min-width:0; font-family:var(--mono); color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
126
|
+
.ep-row .ep-count { color:var(--dim); font-family:var(--mono); font-size:11px; flex:0 0 auto; }
|
|
127
|
+
.ep-row.is-ignored .ep-name { color:var(--faint); text-decoration:line-through; }
|
|
128
|
+
|
|
129
|
+
/* ── Collapsible JSON tree (dependency-free, native <details>). Objects/arrays fold; primitives
|
|
130
|
+
are plain rows. Fold state is preserved across live re-renders via data-path (see script). ── */
|
|
131
|
+
.json { font:12px/1.6 var(--mono); }
|
|
132
|
+
.json summary { list-style:none; cursor:pointer; white-space:pre-wrap; word-break:break-word; border-radius:3px; }
|
|
133
|
+
.json summary:hover { background:color-mix(in srgb, var(--accent) 9%, transparent); }
|
|
134
|
+
.json summary::-webkit-details-marker { display:none; }
|
|
135
|
+
.json summary::before { content:'▸'; display:inline-block; width:1em; color:var(--dim); }
|
|
136
|
+
.json details[open] > summary::before { content:'▾'; }
|
|
137
|
+
.json .j-kids { padding-left:.6em; margin-left:.55em; border-left:1px solid var(--line); }
|
|
138
|
+
.json .j-row { padding-left:1em; white-space:pre-wrap; word-break:break-word; }
|
|
139
|
+
/* Show '{' when open, the collapsed '{ …N… }' summary when closed; hide children/close-brace when closed. */
|
|
140
|
+
.json details[open] > summary > .b-collapsed { display:none; }
|
|
141
|
+
.json details:not([open]) > summary > .b-open { display:none; }
|
|
142
|
+
.json details:not([open]) > .j-kids,
|
|
143
|
+
.json details:not([open]) > .j-close { display:none; }
|
|
144
|
+
.json .j-key { color:var(--accent); }
|
|
145
|
+
.json .j-string { color:var(--ok); }
|
|
146
|
+
.json .j-number { color:var(--fg); }
|
|
147
|
+
.json .j-boolean { color:var(--pend); }
|
|
148
|
+
.json .j-null { color:var(--dim); }
|
|
149
|
+
.json .j-count { color:var(--dim); font-style:italic; }
|
|
150
|
+
/* Badge marking a node that was parsed from a JSON string (double-encoded value). */
|
|
151
|
+
.json .j-embed { font-size:9px; letter-spacing:.04em; text-transform:uppercase; color:var(--embed);
|
|
152
|
+
border:1px solid color-mix(in srgb, var(--embed) 55%, var(--line)); border-radius:999px; padding:0 5px; margin-right:4px; }
|
|
153
|
+
/* Each stream-response entry: a little breathing room + a hairline so consecutive items read apart. */
|
|
154
|
+
.j-item { padding:var(--s2) 0; border-top:1px solid var(--line); }
|
|
155
|
+
.j-item:first-of-type { border-top:none; padding-top:var(--s1); }
|
|
156
|
+
/* Detail-pane action bar (replay / edit / expand-collapse). */
|
|
157
|
+
.j-tools { display:flex; flex-wrap:wrap; gap:var(--s2); margin:var(--s2) 0 var(--s3); }
|
|
158
|
+
.j-tools button, .edit-actions button { height:26px; font-size:11px; padding:0 var(--s2); }
|
|
159
|
+
#replayBtn, #sendEdit, #editReplayBtn { border-color:var(--accent); color:var(--accent); }
|
|
160
|
+
#replayBtn:hover, #sendEdit:hover, #editReplayBtn:hover { background:var(--accent); color:var(--oncolor); }
|
|
161
|
+
#editPanel { margin:var(--s2) 0; }
|
|
162
|
+
#editArgs { width:100%; min-height:88px; resize:vertical; font:12px/1.55 var(--mono);
|
|
163
|
+
background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:var(--r2); padding:var(--s2); }
|
|
164
|
+
#editArgs:focus { outline:none; border-color:var(--accent); }
|
|
165
|
+
.edit-actions { display:flex; gap:var(--s2); margin-top:var(--s2); }
|
|
166
|
+
/* GUI-replay provenance badge — injected traffic must never look like organic peer traffic. */
|
|
167
|
+
.gui-badge { font-size:10px; padding:1px 7px; border:1px solid var(--accent); border-radius:999px; color:var(--accent);
|
|
168
|
+
background:color-mix(in srgb, var(--accent) 12%, transparent); vertical-align:middle; white-space:nowrap; }
|
|
169
|
+
/* Transient toast (replay sent / replay failed). */
|
|
170
|
+
#toast { position:fixed; bottom:var(--s4); left:50%; transform:translateX(-50%) translateY(20px); opacity:0; pointer-events:none; z-index:10;
|
|
171
|
+
background:var(--panel-2); border:1px solid var(--line-2); color:var(--fg); border-radius:var(--r3); padding:var(--s2) var(--s4); font-size:12px;
|
|
172
|
+
box-shadow:var(--shadow); transition:opacity .15s, transform .15s; max-width:80vw; }
|
|
173
|
+
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
|
174
|
+
#toast.err { border-color:var(--err); color:var(--err); }
|
|
175
|
+
|
|
176
|
+
/* ── Waterfall list + detail pane ── */
|
|
177
|
+
.wrap { flex:1; min-height:0; display:flex; position:relative; }
|
|
178
|
+
.list { flex:1; overflow:auto; scrollbar-width:thin; }
|
|
179
|
+
table { width:100%; border-collapse:collapse; }
|
|
180
|
+
th,td { text-align:left; padding:6px var(--s3); border-bottom:1px solid var(--line); white-space:nowrap; }
|
|
181
|
+
th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--dim); font-weight:600; font-size:10px; text-transform:uppercase; letter-spacing:.05em; }
|
|
182
|
+
tr.row { cursor:pointer; transition:background .1s; }
|
|
183
|
+
tr.row td:first-child { box-shadow:inset 2px 0 0 transparent; color:var(--dim); font-family:var(--mono); font-size:11px; }
|
|
184
|
+
tr.row:hover { background:var(--rowhover); }
|
|
185
|
+
tr.row.sel { background:var(--rowsel); }
|
|
186
|
+
tr.row.sel td:first-child { box-shadow:inset 2px 0 0 var(--accent); }
|
|
187
|
+
.ep { color:var(--accent); font-family:var(--mono); }
|
|
188
|
+
|
|
189
|
+
/* Status pill: tint + outline + glyph + word (identity is never colour-alone — dataviz) */
|
|
190
|
+
.pill { display:inline-flex; align-items:center; gap:4px; font-size:11px; line-height:1.6; padding:0 8px; border-radius:999px; border:1px solid currentColor; }
|
|
191
|
+
.st-ok { color:var(--ok); background:color-mix(in srgb, var(--ok) 13%, transparent); }
|
|
192
|
+
.st-error { color:var(--err); background:color-mix(in srgb, var(--err) 13%, transparent); }
|
|
193
|
+
.st-pending { color:var(--pend); background:color-mix(in srgb, var(--pend) 13%, transparent); }
|
|
194
|
+
.st-streaming { color:var(--accent); background:color-mix(in srgb, var(--accent) 13%, transparent); }
|
|
195
|
+
.st-trace { color:var(--embed); background:color-mix(in srgb, var(--embed) 13%, transparent); }
|
|
196
|
+
.st-stall { color:var(--pend); background:color-mix(in srgb, var(--pend) 13%, transparent); }
|
|
197
|
+
.st-closed { color:var(--dim); background:color-mix(in srgb, var(--dim) 13%, transparent); }
|
|
198
|
+
/* stream.data child row — calm/muted tint (never alarming like error/stall): identity carried
|
|
199
|
+
by the ↳ glyph + word, not colour alone. */
|
|
200
|
+
.st-stream { color:var(--dim); background:color-mix(in srgb, var(--dim) 10%, transparent); }
|
|
201
|
+
.num { text-align:right; font-variant-numeric:tabular-nums; color:var(--dim); font-family:var(--mono); font-size:12px; }
|
|
202
|
+
/* Stream-item (child) row — compact + indented, visually lighter than a real call row. */
|
|
203
|
+
tr.row.child-row td:first-child { color:var(--faint); }
|
|
204
|
+
tr.row.child-row .ep { color:var(--dim); font-weight:400; }
|
|
205
|
+
.ep-child-mark { padding-left:18px; }
|
|
206
|
+
.ep-child-seq { color:var(--faint); font-family:var(--mono); font-size:10.5px; margin-left:4px; }
|
|
207
|
+
.ep-child-snippet { color:var(--dim); font-family:var(--mono); font-size:11px; text-align:left !important; }
|
|
208
|
+
|
|
209
|
+
/* ── Thread-occupancy strip (thread.sample roll-ups) — a compact always-visible header widget,
|
|
210
|
+
one small bar per thread label seen ('js'/'core'/'ui'), filled to the latest busyPct. Fill
|
|
211
|
+
colour is a continuous color-mix from --ok (calm) toward --err (saturated) driven by the
|
|
212
|
+
percentage itself, so there is no hard threshold flicker — never a raw hex. Theme-aware via
|
|
213
|
+
the same tokens as everything else. thread.sample NEVER creates a row; it only repaints this
|
|
214
|
+
strip (see upsertThreadSample). ── */
|
|
215
|
+
.tstrip { display:flex; gap:var(--s3); flex-wrap:wrap; align-items:center; padding:var(--s2) var(--s3);
|
|
216
|
+
border-bottom:1px solid var(--line); background:var(--bg); }
|
|
217
|
+
.tstrip:empty { display:none; }
|
|
218
|
+
.tbar { display:inline-flex; align-items:center; gap:6px; font:11px var(--mono); }
|
|
219
|
+
.tbar .tlab { text-transform:uppercase; letter-spacing:.04em; color:var(--dim); font-weight:600; }
|
|
220
|
+
.tbar .ttrack { position:relative; width:64px; height:7px; border-radius:999px; background:var(--panel-2);
|
|
221
|
+
border:1px solid var(--line); overflow:hidden; }
|
|
222
|
+
.tbar .tfill { position:absolute; inset:0 auto 0 0; height:100%; border-radius:999px 0 0 999px; transition:width .2s; }
|
|
223
|
+
.tbar .tpct { color:var(--fg); font-variant-numeric:tabular-nums; min-width:2.6em; }
|
|
224
|
+
|
|
225
|
+
.detail { width:42%; min-width:340px; border-left:1px solid var(--line); overflow:auto; padding:var(--s4); display:none; background:var(--panel); scrollbar-width:thin; }
|
|
226
|
+
.detail.show { display:block; }
|
|
227
|
+
.detail h1 { font:600 15px/1.3 var(--mono); margin:0 0 var(--s2); word-break:break-all; padding-right:26px; }
|
|
228
|
+
/* Close button for the detail pane — a sibling AFTER .detail, shown only while the pane is open
|
|
229
|
+
(pure CSS via the general-sibling combinator), floating over the pane's top-right corner. */
|
|
230
|
+
.detail-close { display:none; position:absolute; top:var(--s2); right:var(--s2); z-index:6;
|
|
231
|
+
width:22px; height:22px; padding:0; border:1px solid var(--line); border-radius:4px;
|
|
232
|
+
background:color-mix(in srgb, var(--panel) 80%, transparent); color:var(--dim);
|
|
233
|
+
font:600 13px/1 var(--sans); cursor:pointer; align-items:center; justify-content:center; }
|
|
234
|
+
.detail-close:hover { color:var(--fg); border-color:var(--dim); background:var(--panel-2); }
|
|
235
|
+
.detail.show ~ .detail-close { display:flex; }
|
|
236
|
+
.detail h2 { font-size:10px; color:var(--dim); margin:var(--s4) 0 var(--s1); text-transform:uppercase; letter-spacing:.06em; font-weight:600; }
|
|
237
|
+
pre { background:var(--bg); border:1px solid var(--line); border-radius:var(--r2); padding:10px; overflow:auto; white-space:pre-wrap; word-break:break-word; margin:0 0 6px; font:12px/1.55 var(--mono); }
|
|
238
|
+
.empty { color:var(--dim); padding:var(--s6) var(--s4); text-align:center; }
|
|
239
|
+
|
|
240
|
+
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
|
|
241
|
+
</style>
|
|
242
|
+
</head>
|
|
243
|
+
<body>
|
|
244
|
+
<header>
|
|
245
|
+
<h1>hrpc inspector</h1>
|
|
246
|
+
<span id="status">connecting…</span>
|
|
247
|
+
<select id="statusFilter" title="filter by status">
|
|
248
|
+
<option value="">all status</option>
|
|
249
|
+
<option value="ok">ok</option>
|
|
250
|
+
<option value="error">error</option>
|
|
251
|
+
<option value="streaming">streaming</option>
|
|
252
|
+
<option value="stream">stream (item)</option>
|
|
253
|
+
<option value="trace">trace</option>
|
|
254
|
+
<option value="stall">stall</option>
|
|
255
|
+
<option value="pending">pending</option>
|
|
256
|
+
<option value="closed">closed</option>
|
|
257
|
+
</select>
|
|
258
|
+
<select id="appSel" title="filter by app id"><option value="">all apps</option></select>
|
|
259
|
+
<details class="ep-dropdown" id="epDropdown">
|
|
260
|
+
<summary>Ignore endpoints<span class="ep-badge" id="epBadge" hidden>0</span></summary>
|
|
261
|
+
<div class="menu">
|
|
262
|
+
<div class="ep-menu-head">
|
|
263
|
+
<span>Checked endpoints are hidden</span>
|
|
264
|
+
<button type="button" id="epClearAll">Show all</button>
|
|
265
|
+
</div>
|
|
266
|
+
<div class="ep-menu-list" id="epMenuList"></div>
|
|
267
|
+
</div>
|
|
268
|
+
</details>
|
|
269
|
+
<input id="filter" placeholder="filter endpoint…" />
|
|
270
|
+
<button id="order" title="toggle newest-first / newest-last">Newest ↓</button>
|
|
271
|
+
<button id="pause">Pause</button>
|
|
272
|
+
<button id="clear" title="Clear all logs everywhere (purges server history too)">Clear all</button>
|
|
273
|
+
<button id="theme" title="toggle light/dark">◐</button>
|
|
274
|
+
</header>
|
|
275
|
+
<div id="stats" class="stats"></div>
|
|
276
|
+
<div id="threads" class="tstrip"></div>
|
|
277
|
+
<div id="devices" class="devices" style="display:none">
|
|
278
|
+
<div id="deviceChips" class="devices-chips"></div>
|
|
279
|
+
<button id="clearDevice" class="clear-device" type="button" hidden title="Clear only the selected device's logs">Clear device</button>
|
|
280
|
+
</div>
|
|
281
|
+
<div class="wrap">
|
|
282
|
+
<div class="list">
|
|
283
|
+
<table>
|
|
284
|
+
<thead><tr><th>Time</th><th>Endpoint</th><th>Status</th><th class="num">ms</th><th class="num">resp</th></tr></thead>
|
|
285
|
+
<tbody id="rows"></tbody>
|
|
286
|
+
</table>
|
|
287
|
+
<div class="empty" id="empty">Waiting for RPC events… (start the app, or run the server with <code>--demo</code>)</div>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="detail" id="detail"></div>
|
|
290
|
+
<button class="detail-close" id="detailClose" type="button" title="Close (Esc)" aria-label="Close detail">✕</button>
|
|
291
|
+
</div>
|
|
292
|
+
<div id="toast"></div>
|
|
293
|
+
<script>
|
|
294
|
+
const rowsEl = document.getElementById('rows');
|
|
295
|
+
const emptyEl = document.getElementById('empty');
|
|
296
|
+
const statusEl = document.getElementById('status');
|
|
297
|
+
const detailEl = document.getElementById('detail');
|
|
298
|
+
const filterEl = document.getElementById('filter');
|
|
299
|
+
const statsEl = document.getElementById('stats');
|
|
300
|
+
const threadsEl = document.getElementById('threads');
|
|
301
|
+
const listEl = document.querySelector('.list'); // the scrollable row container
|
|
302
|
+
const byId = new Map(); // corrId -> {row data}
|
|
303
|
+
const order = []; // corrId in arrival order
|
|
304
|
+
const childrenByParent = new Map(); // parent corrId -> [childId,...] arrival order, capped at CHILD_CAP
|
|
305
|
+
const CHILD_CAP = 50; // per-stream cap on stream.data child rows (last 50 kept)
|
|
306
|
+
let selected = null;
|
|
307
|
+
let newestFirst = false; // false = append at bottom (autoscroll); true = newest on top
|
|
308
|
+
let streamNewestFirst = true; // detail-pane stream order (true = newest on top, the old default)
|
|
309
|
+
const foldState = new Map(); // json tree: data-path -> open? — persists folds across live rebuilds;
|
|
310
|
+
// cleared per selection so it can't collide across requests or grow unbounded
|
|
311
|
+
let editDraft = null; // {open,value} of the edit-args panel — survives throttled live rebuilds
|
|
312
|
+
// (a stream editing session isn't clobbered); cleared on a new selection
|
|
313
|
+
const STALE_MS = 15000; // a source with no event for this long is greyed as idle/gone
|
|
314
|
+
const MAX_DEVICES = 200; // cap the source rail; evict least-recently-seen beyond this
|
|
315
|
+
let sock = null; // the live WS to the server (for sending replay invokes)
|
|
316
|
+
let invSeq = 0; // invokeId counter
|
|
317
|
+
|
|
318
|
+
// Coalesce table re-renders (≤4/s) so a high-frequency stream can't rebuild the DOM out
|
|
319
|
+
// from under a click. Detail-pane updates stay immediate. Paused → don't rebuild.
|
|
320
|
+
let renderPending = false;
|
|
321
|
+
function scheduleRender() { if (renderPending || paused) return; renderPending = true; setTimeout(() => { renderPending = false; render(); }, 250); }
|
|
322
|
+
|
|
323
|
+
// --- Devices (sources) + filters ---
|
|
324
|
+
const devices = new Map(); // srcId -> { id, label, runtime, deviceId?, appId?, … }
|
|
325
|
+
const threadSamples = new Map(); // 'src|thread' -> latest thread.sample — occupancy strip only,
|
|
326
|
+
// NEVER touches byId/order (would flood the list at ~1/s/thread).
|
|
327
|
+
let deviceFilter = null; // a specific source (chip row); null = all
|
|
328
|
+
const ignoredEndpoints = new Set(); // RPC method names to HIDE (exclude-style ignore list, not
|
|
329
|
+
// an include filter) — empty = show all endpoints; a method
|
|
330
|
+
// in this set has its rows hidden regardless of other filters.
|
|
331
|
+
let appIdFilter = ''; // by app id (multiple apps, same package)
|
|
332
|
+
let statusFilterVal = '';
|
|
333
|
+
let paused = false;
|
|
334
|
+
const devicesEl = document.getElementById('devices');
|
|
335
|
+
const deviceChipsEl = document.getElementById('deviceChips');
|
|
336
|
+
const clearDeviceBtn = document.getElementById('clearDevice');
|
|
337
|
+
const epBadgeEl = document.getElementById('epBadge');
|
|
338
|
+
const epMenuListEl = document.getElementById('epMenuList');
|
|
339
|
+
const appSelEl = document.getElementById('appSel');
|
|
340
|
+
|
|
341
|
+
function registerDevice(info) {
|
|
342
|
+
if (!info || !info.id) return;
|
|
343
|
+
const prev = devices.get(info.id) || {};
|
|
344
|
+
// Fallback label (before the __source announce lands) is the raw src id, which is
|
|
345
|
+
// URL-encoded — decode it so a slashy id reads "…/Aug-14", never "%2F". The announce's
|
|
346
|
+
// human label overrides this via ...info when it arrives.
|
|
347
|
+
let decoded; try { decoded = decodeURIComponent(info.id); } catch { decoded = info.id; }
|
|
348
|
+
devices.set(info.id, { label: decoded, runtime: '', ...prev, ...info, lastSeen: Date.now() });
|
|
349
|
+
// Bound the rail (Map has no cap otherwise): if a churning id-space overflows it, evict the
|
|
350
|
+
// least-recently-seen source that isn't the current filter. Selected source is always kept.
|
|
351
|
+
while (devices.size > MAX_DEVICES) {
|
|
352
|
+
let oldestId = null, oldestT = Infinity;
|
|
353
|
+
for (const [k, v] of devices) { if (k !== deviceFilter && (v.lastSeen || 0) < oldestT) { oldestT = v.lastSeen || 0; oldestId = k; } }
|
|
354
|
+
if (oldestId == null) break;
|
|
355
|
+
devices.delete(oldestId);
|
|
356
|
+
}
|
|
357
|
+
renderDevices();
|
|
358
|
+
populateSelectors();
|
|
359
|
+
}
|
|
360
|
+
// Bump a source's liveness on any event it emits (announce OR traffic). Cheap: no re-render
|
|
361
|
+
// here — the periodic sweep repaints staleness, and upsert already schedules a table render.
|
|
362
|
+
function touchDevice(id) { const d = devices.get(id); if (d) d.lastSeen = Date.now(); }
|
|
363
|
+
// Server signals a source is gone (its app disconnected) — remove the chip so it stops
|
|
364
|
+
// lingering. Reset the filter if it pointed at the removed source.
|
|
365
|
+
function removeDevice(id) {
|
|
366
|
+
if (!devices.delete(id)) return;
|
|
367
|
+
if (deviceFilter === id) deviceFilter = null;
|
|
368
|
+
renderDevices(); populateSelectors(); render(); renderThreads();
|
|
369
|
+
}
|
|
370
|
+
function populateSelectors() {
|
|
371
|
+
// App dropdown only; device selection is the horizontally-scrollable chip row above.
|
|
372
|
+
const apps = [...new Set([...devices.values()].map((d) => d.appId).filter(Boolean))].sort();
|
|
373
|
+
appSelEl.innerHTML = `<option value="">all apps</option>` +
|
|
374
|
+
apps.map((v) => `<option value="${esc(v)}"${v === appIdFilter ? ' selected' : ''}>${esc(v)}</option>`).join('');
|
|
375
|
+
}
|
|
376
|
+
function renderDevices() {
|
|
377
|
+
if (devices.size === 0) { deviceChipsEl.innerHTML = ''; devicesEl.style.display = 'none'; clearDeviceBtn.hidden = true; return; }
|
|
378
|
+
devicesEl.style.display = 'flex';
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
const chip = (id, label, sel, stale, title, dot) =>
|
|
381
|
+
`<span class="chip${sel ? ' sel' : ''}${stale ? ' stale' : ''}" data-dev="${esc(id)}"${title ? ` title="${esc(title)}"` : ''}>`
|
|
382
|
+
+ `${dot ? `<span class="dot">${stale ? '○' : '●'}</span> ` : ''}${esc(label)}</span>`;
|
|
383
|
+
let html = chip('', `All (${devices.size})`, deviceFilter === null, false, '', false);
|
|
384
|
+
for (const [id, info] of devices) {
|
|
385
|
+
const idle = now - (info.lastSeen || 0);
|
|
386
|
+
const stale = idle > STALE_MS;
|
|
387
|
+
const title = stale ? `idle ${Math.round(idle / 1000)}s — no recent events` : 'live';
|
|
388
|
+
html += chip(id, info.label + (info.runtime ? ' · ' + info.runtime : ''), deviceFilter === id, stale, title, true);
|
|
389
|
+
}
|
|
390
|
+
deviceChipsEl.innerHTML = html;
|
|
391
|
+
// The per-device clear button shows only when a specific device is selected; label names it.
|
|
392
|
+
if (deviceFilter !== null && devices.has(deviceFilter)) {
|
|
393
|
+
const info = devices.get(deviceFilter);
|
|
394
|
+
clearDeviceBtn.hidden = false;
|
|
395
|
+
clearDeviceBtn.textContent = 'Clear ' + (info.label || deviceFilter);
|
|
396
|
+
} else {
|
|
397
|
+
clearDeviceBtn.hidden = true;
|
|
398
|
+
}
|
|
399
|
+
for (const el of deviceChipsEl.querySelectorAll('.chip')) {
|
|
400
|
+
el.onclick = () => { const d = el.getAttribute('data-dev'); deviceFilter = d === '' ? null : d; renderDevices(); render(); renderThreads(); };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// --- Endpoint ignore list (RPC method names) ---
|
|
405
|
+
// Distinct methods + per-method counts, built from real request/response rows ONLY — never
|
|
406
|
+
// plane:'trace' (obs.bridgeTraces) or plane:'thread' (thread.stall) rows, and thread.sample
|
|
407
|
+
// never reaches byId/order at all (see upsertThreadSample) so it can't appear here either.
|
|
408
|
+
// Respects the active deviceFilter, same as the table: selecting a source narrows the counts
|
|
409
|
+
// (and the endpoint list) to that source's traffic.
|
|
410
|
+
//
|
|
411
|
+
// Presented as a <details>/<summary> dropdown of checkboxes (not the old horizontally-
|
|
412
|
+
// scrolling chip row): CHECKED == ignored == hidden from the table. The trigger badge shows
|
|
413
|
+
// the ignored count so the active state reads even while the menu is closed.
|
|
414
|
+
function renderEndpoints() {
|
|
415
|
+
const counts = new Map(); // method -> count of currently-matching rows
|
|
416
|
+
for (const id of order) {
|
|
417
|
+
const r = byId.get(id);
|
|
418
|
+
if (!r || r.plane) continue; // trace/thread-plane rows are not "endpoints"
|
|
419
|
+
if (deviceFilter !== null && r.src !== deviceFilter) continue;
|
|
420
|
+
const m = r.method ?? '?';
|
|
421
|
+
counts.set(m, (counts.get(m) || 0) + 1);
|
|
422
|
+
}
|
|
423
|
+
// Union with the ignore set: a method the user already ignored must keep its row (and stay
|
|
424
|
+
// checked) even if its count is momentarily 0 — e.g. narrowed out by deviceFilter, or its
|
|
425
|
+
// rows aged out past the 5000-row cap. The user's choice is never silently dropped.
|
|
426
|
+
const methods = new Set([...counts.keys(), ...ignoredEndpoints]);
|
|
427
|
+
|
|
428
|
+
epBadgeEl.hidden = ignoredEndpoints.size === 0;
|
|
429
|
+
epBadgeEl.textContent = String(ignoredEndpoints.size);
|
|
430
|
+
|
|
431
|
+
if (methods.size === 0) { epMenuListEl.innerHTML = '<div class="ep-menu-empty">No endpoints yet</div>'; return; }
|
|
432
|
+
// Alphabetical — a stable order so rows don't reshuffle position under the user's cursor as
|
|
433
|
+
// live traffic counts change (a count-desc sort would).
|
|
434
|
+
const sorted = [...methods].sort((a, b) => String(a).localeCompare(String(b)));
|
|
435
|
+
let html = '';
|
|
436
|
+
for (const m of sorted) {
|
|
437
|
+
const n = counts.get(m) || 0;
|
|
438
|
+
const ignored = ignoredEndpoints.has(m);
|
|
439
|
+
const key = esc(encodeURIComponent(m));
|
|
440
|
+
html += `<label class="ep-row${ignored ? ' is-ignored' : ''}">`
|
|
441
|
+
+ `<input type="checkbox" data-ep-cb="${key}"${ignored ? ' checked' : ''} />`
|
|
442
|
+
+ `<span class="ep-name">${esc(m)}</span><span class="ep-count">${n}</span>`
|
|
443
|
+
+ `</label>`;
|
|
444
|
+
}
|
|
445
|
+
epMenuListEl.innerHTML = html;
|
|
446
|
+
for (const cb of epMenuListEl.querySelectorAll('input[data-ep-cb]')) {
|
|
447
|
+
cb.onchange = () => {
|
|
448
|
+
const key = decodeURIComponent(cb.getAttribute('data-ep-cb'));
|
|
449
|
+
if (cb.checked) ignoredEndpoints.add(key); else ignoredEndpoints.delete(key);
|
|
450
|
+
renderEndpoints(); render();
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// --- Thread-occupancy strip (thread.sample) --- keep only the latest sample per (src, thread);
|
|
456
|
+
// never creates a waterfall row. Respects deviceFilter: when a device is selected, only its
|
|
457
|
+
// threads are shown; when "All" is selected, threads are grouped by label (latest wins across
|
|
458
|
+
// sources) so the strip stays compact rather than one row per device.
|
|
459
|
+
function upsertThreadSample(ev) {
|
|
460
|
+
if (!ev || !ev.thread) return;
|
|
461
|
+
if (ev.src) touchDevice(ev.src);
|
|
462
|
+
threadSamples.set((ev.src || '') + '|' + ev.thread, ev);
|
|
463
|
+
renderThreads();
|
|
464
|
+
}
|
|
465
|
+
const THREAD_ORDER = ['js', 'core', 'ui']; // stable, familiar ordering; unknown labels sort after, alphabetically
|
|
466
|
+
function renderThreads() {
|
|
467
|
+
const byThread = new Map(); // thread label -> latest matching sample
|
|
468
|
+
for (const s of threadSamples.values()) {
|
|
469
|
+
if (deviceFilter !== null && s.src !== deviceFilter) continue;
|
|
470
|
+
const cur = byThread.get(s.thread);
|
|
471
|
+
if (!cur || (s.t || 0) >= (cur.t || 0)) byThread.set(s.thread, s);
|
|
472
|
+
}
|
|
473
|
+
if (byThread.size === 0) { threadsEl.innerHTML = ''; return; }
|
|
474
|
+
const labels = [...byThread.keys()].sort((a, b) => {
|
|
475
|
+
const ia = THREAD_ORDER.indexOf(a), ib = THREAD_ORDER.indexOf(b);
|
|
476
|
+
if (ia === -1 && ib === -1) return a.localeCompare(b);
|
|
477
|
+
if (ia === -1) return 1; if (ib === -1) return -1;
|
|
478
|
+
return ia - ib;
|
|
479
|
+
});
|
|
480
|
+
threadsEl.innerHTML = labels.map((th) => {
|
|
481
|
+
const s = byThread.get(th);
|
|
482
|
+
const pct = Math.max(0, Math.min(100, Math.round(Number(s.busyPct) || 0)));
|
|
483
|
+
const title = `${th} · ${pct}% busy over ${s.windowMs ?? '?'}ms · worst hitch ${s.maxLag ?? '?'}ms`;
|
|
484
|
+
// Continuous severity: mix toward --err as busy% rises, calm (--ok) under ~50%, warm above.
|
|
485
|
+
return `<span class="tbar" title="${esc(title)}">`
|
|
486
|
+
+ `<span class="tlab">${esc(th)}</span>`
|
|
487
|
+
+ `<span class="ttrack"><span class="tfill" style="width:${pct}%;background:color-mix(in srgb, var(--err) ${pct}%, var(--ok))"></span></span>`
|
|
488
|
+
+ `<span class="tpct">${pct}%</span></span>`;
|
|
489
|
+
}).join('');
|
|
490
|
+
}
|
|
491
|
+
// Repaint staleness on a timer so a chip fades even while its app sits silent (no event to
|
|
492
|
+
// trigger a render). Interval < STALE_MS so the transition lands promptly.
|
|
493
|
+
setInterval(renderDevices, 5000);
|
|
494
|
+
|
|
495
|
+
const size = (v) => { try { return new Blob([JSON.stringify(v)]).size; } catch { return 0; } };
|
|
496
|
+
// Escape ALL interpolated text — method/label/args/response/stream items can carry remote
|
|
497
|
+
// peer / user-entered content (panel-engineer confirmed a live XSS otherwise).
|
|
498
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
499
|
+
const fmt = (v) => { try { return esc(JSON.stringify(v, null, 2)); } catch { return esc(String(v)); } };
|
|
500
|
+
const time = (t) => new Date(t).toLocaleTimeString('en-GB', { hour12:false }) + '.' + String(t % 1000).padStart(3,'0');
|
|
501
|
+
// Numeric-by-convention wire fields (dur, count, lag, …). Nothing validates their type, and
|
|
502
|
+
// interpolating them raw let dur:'<img src=x onerror=…>' execute script here (CodeQL js/xss).
|
|
503
|
+
// Escapes UNCONDITIONALLY: esc() cannot alter any numeric form (42, -1.5, 1e+21, NaN all come
|
|
504
|
+
// out identical), and a conditional sanitiser leaves a String(v) path static analysis must
|
|
505
|
+
// treat as tainted — which is why the first version of this fix did not close the alert.
|
|
506
|
+
const num = (v) => (v == null ? '' : esc(String(v)));
|
|
507
|
+
|
|
508
|
+
// --- Collapsible JSON tree. Every key/value is esc()'d (same XSS rule as fmt). Objects/arrays
|
|
509
|
+
// become foldable <details>; primitives are plain rows. `path` keys the fold state so a live
|
|
510
|
+
// stream rebuild keeps the user's open/closed choices.
|
|
511
|
+
const jsonScalar = (v) => {
|
|
512
|
+
if (v === null) return '<span class="j-null">null</span>';
|
|
513
|
+
const t = typeof v;
|
|
514
|
+
if (t === 'number' || t === 'boolean') return `<span class="j-${t}">${esc(String(v))}</span>`;
|
|
515
|
+
if (t === 'string') return `<span class="j-string">"${esc(v)}"</span>`;
|
|
516
|
+
return `<span class="j-string">${esc(String(v))}</span>`; // undefined / bigint / symbol fallback
|
|
517
|
+
};
|
|
518
|
+
// A value that is itself a complete JSON string (double-encoded) → its parsed object/array, else
|
|
519
|
+
// undefined. Only strings that trim to a '{'/'[' and fully parse to an object are treated this way
|
|
520
|
+
// (a truncated or plain string won't parse and stays a string).
|
|
521
|
+
const embeddedJson = (s) => {
|
|
522
|
+
if (typeof s !== 'string' || s.length < 2) return undefined;
|
|
523
|
+
const t = s.trim();
|
|
524
|
+
if (t[0] !== '{' && t[0] !== '[') return undefined;
|
|
525
|
+
try { const p = JSON.parse(t); return p && typeof p === 'object' ? p : undefined; } catch { return undefined; }
|
|
526
|
+
};
|
|
527
|
+
const JSON_MAX_DEPTH = 200; // guard the recursive renderer: peer-controlled data can nest deeply
|
|
528
|
+
// enough (≈30k levels fit in the 64KB body cap) to overflow the stack.
|
|
529
|
+
// Real RPC payloads are shallow; bail with a marker well before the limit.
|
|
530
|
+
function jsonNode(v, keyHtml, path, isLast, depth) {
|
|
531
|
+
depth = depth || 0;
|
|
532
|
+
const comma = isLast ? '' : ',';
|
|
533
|
+
if (depth > JSON_MAX_DEPTH) return `<div class="j-row">${keyHtml}<span class="j-count">…max depth (${JSON_MAX_DEPTH})…</span>${comma}</div>`;
|
|
534
|
+
// Double-encoded JSON: render the parsed structure (foldable), flagged so it's clear it came
|
|
535
|
+
// from a string. The `json` badge distinguishes it from an organically-structured value.
|
|
536
|
+
if (typeof v === 'string') {
|
|
537
|
+
const parsed = embeddedJson(v);
|
|
538
|
+
if (parsed !== undefined) return jsonNode(parsed, keyHtml + '<span class="j-embed" title="parsed from a JSON string">json</span> ', path, isLast, depth + 1);
|
|
539
|
+
}
|
|
540
|
+
if (!v || typeof v !== 'object') return `<div class="j-row">${keyHtml}${jsonScalar(v)}${comma}</div>`;
|
|
541
|
+
const isArr = Array.isArray(v);
|
|
542
|
+
const entries = isArr ? v.map((x, i) => [i, x]) : Object.entries(v);
|
|
543
|
+
const [open, close] = isArr ? ['[', ']'] : ['{', '}'];
|
|
544
|
+
if (entries.length === 0) return `<div class="j-row">${keyHtml}${open}${close}${comma}</div>`;
|
|
545
|
+
const n = entries.length;
|
|
546
|
+
const kids = entries.map(([k, val], i) => {
|
|
547
|
+
const childKey = isArr ? '' : `<span class="j-key">"${esc(String(k))}"</span>: `;
|
|
548
|
+
// Encode each segment so a key containing '/' (e.g. a multiaddr) can't forge another node's
|
|
549
|
+
// path and couple their fold state — '/' never appears literally inside an encoded segment.
|
|
550
|
+
return jsonNode(val, childKey, path + '/' + encodeURIComponent(String(k)), i === n - 1, depth + 1);
|
|
551
|
+
}).join('');
|
|
552
|
+
const count = isArr ? `${n} item${n > 1 ? 's' : ''}` : `${n} key${n > 1 ? 's' : ''}`;
|
|
553
|
+
const isOpen = foldState.get(path) !== false; // default open
|
|
554
|
+
return `<details class="j-node" data-path="${esc(path)}"${isOpen ? ' open' : ''}>`
|
|
555
|
+
+ `<summary>${keyHtml}<span class="b-open">${open}</span>`
|
|
556
|
+
+ `<span class="b-collapsed">${open} <span class="j-count">…${count}…</span> ${close}${comma}</span></summary>`
|
|
557
|
+
+ `<div class="j-kids">${kids}</div>`
|
|
558
|
+
+ `<div class="j-row j-close">${close}${comma}</div></details>`;
|
|
559
|
+
}
|
|
560
|
+
// Render a value as a tree. On any failure, fall back WITHOUT re-serialising the (possibly
|
|
561
|
+
// pathological, e.g. ultra-deeply-nested) value — fmt(v) could itself overflow the stack and
|
|
562
|
+
// escape as an uncaught error at the render tick. jsonNode is depth-bounded, so this is belt-and-braces.
|
|
563
|
+
const jsonTree = (v, path) => {
|
|
564
|
+
try { return `<div class="json">${jsonNode(v, '', path, true)}</div>`; }
|
|
565
|
+
catch { return `<pre>(value could not be rendered)</pre>`; }
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// Status shown as a pill: glyph + word (secondary encoding, never color-alone).
|
|
569
|
+
const GLYPH = { ok: '●', error: '✖', streaming: '▶', pending: '·', closed: '○', trace: '⋯', stall: '▚', stream: '↳' };
|
|
570
|
+
// Brief single-line preview of a stream.data item's value for the child row / snippet column.
|
|
571
|
+
// Truncated so a large item can't blow up a row; never left un-esc'd downstream.
|
|
572
|
+
function briefSnippet(v, max) {
|
|
573
|
+
max = max || 80;
|
|
574
|
+
let s;
|
|
575
|
+
try { s = JSON.stringify(v); } catch { s = String(v); }
|
|
576
|
+
if (s === undefined) s = String(v);
|
|
577
|
+
s = s.replace(/\s+/g, ' ');
|
|
578
|
+
if (s.length > max) s = s.slice(0, max - 1) + '…';
|
|
579
|
+
return s;
|
|
580
|
+
}
|
|
581
|
+
const statusPill = (s) => `<span class="pill st-${esc(s)}">${GLYPH[s] || '•'} ${esc(s)}</span>`;
|
|
582
|
+
|
|
583
|
+
function computeStats() {
|
|
584
|
+
if (order.length === 0) { statsEl.innerHTML = ''; return; }
|
|
585
|
+
let total = 0, errors = 0, streams = 0, durSum = 0, durN = 0;
|
|
586
|
+
for (const r of byId.values()) {
|
|
587
|
+
// Count only real RPC calls in the KPIs — the "plane" rows (stream-item children, trace,
|
|
588
|
+
// thread/stall) are indicators, not calls, and would inflate Requests (and skew the others).
|
|
589
|
+
if (r.plane) continue;
|
|
590
|
+
total++;
|
|
591
|
+
if (r.status === 'error') errors++;
|
|
592
|
+
if (r.stream) streams++;
|
|
593
|
+
else if (typeof r.dur === 'number') { durSum += r.dur; durN++; }
|
|
594
|
+
}
|
|
595
|
+
const tile = (label, val, cls = '') => `<div class="tile"><div class="tval ${cls}">${val}</div><div class="tlabel">${label}</div></div>`;
|
|
596
|
+
statsEl.innerHTML =
|
|
597
|
+
tile('Requests', total) +
|
|
598
|
+
tile('Errors', errors, errors ? 'st-error' : '') +
|
|
599
|
+
tile('Avg ms', durN ? Math.round(durSum / durN) : '—') +
|
|
600
|
+
tile('Streams', streams, streams ? 'st-streaming' : '') +
|
|
601
|
+
tile('Devices', devices.size);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Remove a single row (by id) from order + byId, keeping the per-parent child-tracking map
|
|
605
|
+
// consistent no matter which path evicted it (global 5000-cap, per-stream 50-cap, or Clear).
|
|
606
|
+
// This is the ONE place that mutates childrenByParent on removal — every eviction path below
|
|
607
|
+
// routes through it so counts can't drift.
|
|
608
|
+
function dropRow(id) {
|
|
609
|
+
const idx = order.indexOf(id);
|
|
610
|
+
if (idx !== -1) order.splice(idx, 1);
|
|
611
|
+
const r = byId.get(id);
|
|
612
|
+
byId.delete(id);
|
|
613
|
+
if (r && r.plane === 'stream-item' && r.parent) {
|
|
614
|
+
const kids = childrenByParent.get(r.parent);
|
|
615
|
+
if (kids) {
|
|
616
|
+
const ci = kids.indexOf(id);
|
|
617
|
+
if (ci !== -1) kids.splice(ci, 1);
|
|
618
|
+
if (kids.length === 0) childrenByParent.delete(r.parent);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
childrenByParent.delete(id); // in case `id` was itself a parent with tracked children
|
|
622
|
+
if (selected === id) { selected = null; detailEl.classList.remove('show'); }
|
|
623
|
+
}
|
|
624
|
+
// Global cap (mirrors the server's bound): evict oldest rows once order exceeds 5000. Shared by
|
|
625
|
+
// every upsert* path so trace/stall/request/stream-child rows are all subject to the same bound.
|
|
626
|
+
function capOrder() { while (order.length > 5000) dropRow(order[0]); }
|
|
627
|
+
|
|
628
|
+
let traceSeq = 0;
|
|
629
|
+
// A hypertrace event (from obs.bridgeTraces) is a POINT event, not a request/response — each is
|
|
630
|
+
// its own row on the "trace" plane, labelled className.event, with the trace data in the detail.
|
|
631
|
+
function upsertTrace(ev) {
|
|
632
|
+
const id = 'trace-' + (++traceSeq);
|
|
633
|
+
const r = { corrId: id, method: ev.method || 'trace', t: ev.t || Date.now(), status: 'trace', plane: 'trace', trace: ev, items: [] };
|
|
634
|
+
if (ev.src) { r.src = ev.src; touchDevice(ev.src); }
|
|
635
|
+
byId.set(id, r); order.push(id);
|
|
636
|
+
capOrder();
|
|
637
|
+
scheduleRender();
|
|
638
|
+
}
|
|
639
|
+
let stallSeq = 0;
|
|
640
|
+
// A thread.stall event ('hard hitch just happened') is a POINT event on its own 'thread' plane —
|
|
641
|
+
// same treatment as upsertTrace: one row per stall, never coalesced with the request/response
|
|
642
|
+
// rows it may reference via blame[].corrId. thread.sample (the periodic roll-up) is intercepted
|
|
643
|
+
// earlier in ws.onmessage and never reaches here — it must not create a row.
|
|
644
|
+
function upsertStall(ev) {
|
|
645
|
+
const id = 'stall-' + (++stallSeq);
|
|
646
|
+
const r = { corrId: id, method: 'stall · ' + (ev.thread || '?'), t: ev.t || Date.now(), status: 'stall', plane: 'thread', stall: ev, items: [] };
|
|
647
|
+
if (ev.src) { r.src = ev.src; touchDevice(ev.src); }
|
|
648
|
+
byId.set(id, r); order.push(id);
|
|
649
|
+
capOrder();
|
|
650
|
+
scheduleRender();
|
|
651
|
+
}
|
|
652
|
+
function upsert(ev) {
|
|
653
|
+
if (ev && ev.type === 'trace') return upsertTrace(ev);
|
|
654
|
+
if (ev && ev.type === 'thread.stall') return upsertStall(ev);
|
|
655
|
+
const id = ev.corrId;
|
|
656
|
+
let r = byId.get(id);
|
|
657
|
+
if (!r) { r = { corrId:id, method:ev.method, t:ev.t, status:'pending', items:[] }; byId.set(id, r); order.push(id); }
|
|
658
|
+
if (ev.method && !r.method) r.method = ev.method;
|
|
659
|
+
if (ev.origin === 'gui') { r.origin = 'gui'; if (ev.replayOf) r.replayOf = ev.replayOf; } // a GUI-initiated replay
|
|
660
|
+
// Only the __source announce (sticky + replayed first) creates a chip; we just bump liveness
|
|
661
|
+
// for a known one. A src with NO announce means the source is gone (its announce was evicted on
|
|
662
|
+
// disconnect) — don't resurrect a chip for it when its old events replay from history.
|
|
663
|
+
if (ev.src) { r.src = ev.src; touchDevice(ev.src); }
|
|
664
|
+
switch (ev.type) {
|
|
665
|
+
case 'request.start': r.args = ev.args; r.status = 'pending'; break;
|
|
666
|
+
case 'stream.open': r.stream = true; r.status = 'streaming'; break;
|
|
667
|
+
case 'stream.data': {
|
|
668
|
+
// Parent row keeps accumulating exactly as before (count + its own STREAM RESPONSES detail).
|
|
669
|
+
r.stream = true; r.status = 'streaming';
|
|
670
|
+
r.count = (r.count || 0) + 1;
|
|
671
|
+
r.items.push({ seq: ev.seq, item: ev.item, t: ev.t });
|
|
672
|
+
if (r.items.length > 100) r.items.shift();
|
|
673
|
+
// IN ADDITION: a distinct, compact child row so the response flow is visible in the
|
|
674
|
+
// waterfall itself, in arrival order. Stable id per item — guard a duplicate delivery.
|
|
675
|
+
const childId = `${id}#${ev.seq}`;
|
|
676
|
+
if (!byId.has(childId)) {
|
|
677
|
+
const cr = { corrId: childId, method: ev.method || r.method, t: ev.t, status: 'stream',
|
|
678
|
+
plane: 'stream-item', parent: id, seq: ev.seq, item: ev.item, src: ev.src || r.src };
|
|
679
|
+
byId.set(childId, cr); order.push(childId);
|
|
680
|
+
let kids = childrenByParent.get(id);
|
|
681
|
+
if (!kids) { kids = []; childrenByParent.set(id, kids); }
|
|
682
|
+
kids.push(childId);
|
|
683
|
+
// Per-stream cap: evict this stream's oldest child once it exceeds CHILD_CAP. Routed
|
|
684
|
+
// through dropRow so it stays consistent with the global cap's bookkeeping.
|
|
685
|
+
if (kids.length > CHILD_CAP) dropRow(kids[0]);
|
|
686
|
+
}
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
case 'request.end':
|
|
690
|
+
r.status = ev.status || 'ok'; r.response = ev.response; r.error = ev.error; r.dur = ev.dur;
|
|
691
|
+
if (ev.count != null) r.count = ev.count;
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
// Cap client-side history (memory + render cost) — mirror the server's bound.
|
|
695
|
+
capOrder();
|
|
696
|
+
scheduleRender();
|
|
697
|
+
if (selected === id && !paused) scheduleDetail(r); // throttled, like the table — a fast stream can't thrash it
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Coalesce detail-pane rebuilds to ≤4/s (same reason as scheduleRender): a high-frequency
|
|
701
|
+
// stream would otherwise re-escape up to 40 <pre> blocks on every message. Selection is
|
|
702
|
+
// immediate (showDetail(r, true) on click); this path is only live updates of the open record.
|
|
703
|
+
let detailPending = false, detailRec = null;
|
|
704
|
+
function scheduleDetail(r) {
|
|
705
|
+
detailRec = r;
|
|
706
|
+
if (detailPending || paused) return;
|
|
707
|
+
detailPending = true;
|
|
708
|
+
// Guard at FIRE time against the selection changing during the 250ms window: only repaint if
|
|
709
|
+
// the scheduled record is still the selected one. Without this, clicking another row (or Clear)
|
|
710
|
+
// mid-window would let the stale timer overwrite the pane with the old record (or resurrect a
|
|
711
|
+
// cleared one). corrId match covers both: a new selection or selected===null both mismatch.
|
|
712
|
+
setTimeout(() => {
|
|
713
|
+
detailPending = false;
|
|
714
|
+
if (detailRec && !paused && detailRec.corrId === selected) showDetail(detailRec, false);
|
|
715
|
+
}, 250);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function render() {
|
|
719
|
+
computeStats();
|
|
720
|
+
renderEndpoints(); // cheap; piggybacks on render()'s existing ≤4/s throttle (scheduleRender)
|
|
721
|
+
const f = filterEl.value.trim().toLowerCase();
|
|
722
|
+
// Detect whether the user is parked at the "new" end BEFORE we rebuild — so we only
|
|
723
|
+
// autoscroll when they're already there, never yanking them while reading older rows.
|
|
724
|
+
const nearBottom = listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight < 40;
|
|
725
|
+
const atTop = listEl.scrollTop < 40;
|
|
726
|
+
rowsEl.innerHTML = '';
|
|
727
|
+
let shown = 0;
|
|
728
|
+
const ids = newestFirst ? order.slice().reverse() : order;
|
|
729
|
+
for (const id of ids) {
|
|
730
|
+
const r = byId.get(id);
|
|
731
|
+
if (deviceFilter !== null && r.src !== deviceFilter) continue;
|
|
732
|
+
if (appIdFilter) { const srcInfo = devices.get(r.src); if (!srcInfo || srcInfo.appId !== appIdFilter) continue; }
|
|
733
|
+
if (statusFilterVal && r.status !== statusFilterVal) continue;
|
|
734
|
+
if (ignoredEndpoints.has(r.method)) continue; // exclude-style: hide rows for ignored endpoints
|
|
735
|
+
if (f && !String(r.method).toLowerCase().includes(f)) continue;
|
|
736
|
+
const tr = document.createElement('tr');
|
|
737
|
+
// Stream-data child row: compact, indented, visually lighter than a real call row — a flow
|
|
738
|
+
// marker, not its own request.
|
|
739
|
+
if (r.plane === 'stream-item') {
|
|
740
|
+
tr.className = 'row child-row' + (selected === id ? ' sel' : '');
|
|
741
|
+
tr.innerHTML = `<td>${time(r.t)}</td>`
|
|
742
|
+
+ `<td class="ep ep-child-mark">↳ ${esc(r.method ?? '?')}<span class="ep-child-seq">#${esc(String(r.seq))}</span></td>`
|
|
743
|
+
+ `<td>${statusPill(r.status)}</td>`
|
|
744
|
+
+ `<td class="num"></td>`
|
|
745
|
+
+ `<td class="num ep-child-snippet" title="${esc(briefSnippet(r.item, 300))}">${esc(briefSnippet(r.item))}</td>`;
|
|
746
|
+
tr.onclick = () => { selected = id; foldState.clear(); editDraft = null; render(); showDetail(r, true); };
|
|
747
|
+
rowsEl.appendChild(tr); shown++;
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
tr.className = 'row' + (selected === id ? ' sel' : '');
|
|
751
|
+
const respSize = r.trace ? (r.trace.event || '·')
|
|
752
|
+
: r.stall ? `${r.stall.lag ?? '?'}ms`
|
|
753
|
+
: (r.stream ? `▶ ${r.count || 0}` : (r.response !== undefined ? size(r.response) + 'B' : (r.error ? 'err' : '')));
|
|
754
|
+
const replayBadge = r.origin === 'gui' ? `<span class="gui-badge" title="replayed from the GUI">↩ gui</span> ` : '';
|
|
755
|
+
tr.innerHTML = `<td>${time(r.t)}</td><td class="ep">${replayBadge}${esc(r.method ?? '?')}</td>`
|
|
756
|
+
+ `<td>${statusPill(r.status)}</td>`
|
|
757
|
+
+ `<td class="num">${num(r.dur)}</td><td class="num">${esc(respSize)}</td>`;
|
|
758
|
+
tr.onclick = () => { selected = id; foldState.clear(); editDraft = null; render(); showDetail(r, true); }; // new request → fresh folds/edit
|
|
759
|
+
rowsEl.appendChild(tr); shown++;
|
|
760
|
+
}
|
|
761
|
+
// Distinguish "no events yet" from "events exist but filtered out".
|
|
762
|
+
if (order.length === 0) { emptyEl.textContent = 'Waiting for RPC events… (start the app, or run the server with --demo)'; emptyEl.style.display = 'block'; }
|
|
763
|
+
else if (shown === 0) { emptyEl.textContent = `0 of ${order.length} events match the current filters`; emptyEl.style.display = 'block'; }
|
|
764
|
+
else emptyEl.style.display = 'none';
|
|
765
|
+
// Stick to the "new" end only if the user was already parked there.
|
|
766
|
+
if (newestFirst) { if (atTop) listEl.scrollTop = 0; }
|
|
767
|
+
else if (nearBottom) listEl.scrollTop = listEl.scrollHeight;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function showDetail(r, isSelection = false) {
|
|
771
|
+
detailEl.classList.add('show');
|
|
772
|
+
// NOTE: fold state is cleared by the row-selection handler (a genuine new request), NOT here —
|
|
773
|
+
// showDetail also runs for same-record reformats (stream-order toggle) and live updates, which
|
|
774
|
+
// must PRESERVE folds. isSelection here governs only scroll-to-top.
|
|
775
|
+
// Capture scroll state BEFORE rebuild so a live stream never yanks a reader. Both stream
|
|
776
|
+
// orders are handled (the row list only covers append): newest-first PREPENDS, so a mid-scroll
|
|
777
|
+
// reader must be re-anchored by the inserted height; newest-last APPENDS below, so position is
|
|
778
|
+
// already stable unless parked at the bottom.
|
|
779
|
+
const oldH = detailEl.scrollHeight, oldTop = detailEl.scrollTop;
|
|
780
|
+
const wasNearBottom = oldH - oldTop - detailEl.clientHeight < 40;
|
|
781
|
+
const wasNearTop = oldTop < 40;
|
|
782
|
+
// Stream-item (child) row: a single stream.data item, not its own request — no replay/edit.
|
|
783
|
+
if (r.plane === 'stream-item') {
|
|
784
|
+
detailEl.innerHTML = `<h1 class="ep">↳ ${esc(r.method ?? '?')} <span style="color:var(--dim);font:400 12px var(--mono)">#${esc(String(r.seq))}</span></h1>`
|
|
785
|
+
+ `<div><span class="pill st-stream">↳ stream</span><span style="color:var(--dim)"> · ${esc(time(r.t))}</span></div>`
|
|
786
|
+
+ `<h2>Item</h2>${jsonTree(r.item, 'sitem-' + r.corrId)}`;
|
|
787
|
+
for (const d of detailEl.querySelectorAll('details[data-path]')) d.addEventListener('toggle', () => foldState.set(d.dataset.path, d.open));
|
|
788
|
+
if (isSelection) detailEl.scrollTop = 0;
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
// Trace-plane row (from obs.bridgeTraces): a point event, rendered as event-data + traced-object.
|
|
792
|
+
if (r.trace) {
|
|
793
|
+
const t = r.trace;
|
|
794
|
+
detailEl.innerHTML = `<h1 class="ep">${esc(t.method || 'trace')}</h1>`
|
|
795
|
+
+ `<div><span class="pill st-trace">⋯ trace</span><span style="color:var(--dim)">${t.caller ? ' · ' + esc(t.caller) : ''}</span></div>`
|
|
796
|
+
+ `<h2>Event data</h2>${jsonTree(t.data ?? { event: t.event }, 'tdata')}`
|
|
797
|
+
+ (t.objProps ? `<h2>Traced object · ${esc(t.className || '')}</h2>${jsonTree(t.objProps, 'tobj')}` : '');
|
|
798
|
+
if (isSelection) detailEl.scrollTop = 0;
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// Thread-stall row: the whole point is "what was running when it hitched" — put the blame
|
|
802
|
+
// list (in-flight RPCs at the instant of the stall) front and centre, not buried under a
|
|
803
|
+
// generic request/response layout. Guard against missing/empty blame (a stall can legitimately
|
|
804
|
+
// have no in-flight calls).
|
|
805
|
+
if (r.stall) {
|
|
806
|
+
const s = r.stall;
|
|
807
|
+
const blame = Array.isArray(s.blame) ? s.blame : [];
|
|
808
|
+
detailEl.innerHTML = `<h1 class="ep">${esc('stall · ' + (s.thread || '?'))}</h1>`
|
|
809
|
+
+ `<div><span class="pill st-stall">▚ stall</span><span style="color:var(--dim)"> · ${esc(String(s.lag ?? '?'))} ms blocked</span></div>`
|
|
810
|
+
+ `<h2>Thread</h2><pre>${esc(s.thread ?? '?')}</pre>`
|
|
811
|
+
+ `<h2>Blame — in-flight at the moment of the stall (${blame.length})</h2>`
|
|
812
|
+
+ (blame.length
|
|
813
|
+
? blame.map((b, i) => `<div class="j-item"><div class="j-count">#${i + 1}${b && b.corrId != null ? ' · ' + esc(String(b.corrId)) : ''}${b && b.t != null ? ' · ' + esc(time(b.t)) : ''}</div>`
|
|
814
|
+
+ `<div class="ep">${esc(b && b.method != null ? String(b.method) : '(unknown method)')}</div></div>`).join('')
|
|
815
|
+
: `<pre>(no in-flight calls — the stall wasn't attributable to a pending RPC)</pre>`);
|
|
816
|
+
if (isSelection) detailEl.scrollTop = 0;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const extra = `${r.dur != null ? ' · ' + num(r.dur) + ' ms' : ''}${r.count != null ? ' · ' + num(r.count) + ' msgs' : ''}`;
|
|
820
|
+
const guiBadge = r.origin === 'gui' ? `<span class="gui-badge" title="GUI replay of ${esc(r.replayOf || '?')}">↩ gui${r.replayOf ? ' · replay of ' + esc(r.replayOf) : ''}</span>` : '';
|
|
821
|
+
// Replay is offered only for a source that advertised caps.invoke (a dev build with the
|
|
822
|
+
// capability on). Sends nothing but the corrId — the app re-runs its own logged call.
|
|
823
|
+
const canInvoke = !!(r.src && devices.get(r.src)?.caps?.invoke) && !!r.corrId;
|
|
824
|
+
const replayBtn = canInvoke
|
|
825
|
+
? `<button id="replayBtn" title="re-run this exact call on the source app">↩ Replay</button>`
|
|
826
|
+
+ `<button id="editReplayBtn" title="edit the args, then re-run (same method)">✎ Edit & replay</button>`
|
|
827
|
+
: '';
|
|
828
|
+
// Editable-args panel: the METHOD stays locked to this call; only args are editable, sent as JSON.
|
|
829
|
+
// Open-state + in-progress text come from editDraft so a throttled live rebuild (streaming record)
|
|
830
|
+
// never wipes what the user is typing.
|
|
831
|
+
const draftOpen = canInvoke && editDraft && editDraft.open;
|
|
832
|
+
const draftVal = canInvoke && editDraft ? editDraft.value : JSON.stringify(r.args ?? [], null, 2);
|
|
833
|
+
const editPanel = canInvoke
|
|
834
|
+
? `<div id="editPanel" style="display:${draftOpen ? 'block' : 'none'}"><textarea id="editArgs" spellcheck="false">${esc(draftVal)}</textarea>`
|
|
835
|
+
+ `<div class="edit-actions"><button id="sendEdit" title="send with the edited args">↩ Send edited</button><button id="cancelEdit">Cancel</button></div></div>`
|
|
836
|
+
: '';
|
|
837
|
+
let html = `<h1 class="ep">${esc(r.method ?? '?')} ${guiBadge}</h1>`
|
|
838
|
+
+ `<div>${statusPill(r.status)}<span style="color:var(--dim)">${extra}</span></div>`
|
|
839
|
+
+ `<div class="j-tools">${replayBtn}<button id="jExpand">Expand all</button><button id="jCollapse">Collapse all</button></div>`
|
|
840
|
+
+ editPanel
|
|
841
|
+
+ `<h2>Request</h2>${jsonTree(r.args, 'req')}`;
|
|
842
|
+
if (r.stream) {
|
|
843
|
+
const items = r.items.slice(-40);
|
|
844
|
+
const ordered = streamNewestFirst ? items.slice().reverse() : items;
|
|
845
|
+
const toggle = `<span id="streamOrder" class="ord-toggle" title="toggle newest-first / newest-last">${streamNewestFirst ? 'Newest ↑' : 'Newest ↓'}</span>`;
|
|
846
|
+
html += `<h2>Stream responses (${num(r.count ?? 0)})${toggle}</h2>`;
|
|
847
|
+
html += ordered.length
|
|
848
|
+
? ordered.map((it) => `<div class="j-item"><div class="j-count">#${esc(String(it.seq))} · ${time(it.t)}</div>${jsonTree(it.item, 's' + it.seq)}</div>`).join('')
|
|
849
|
+
: `<pre>(subscribed — waiting for data…)</pre>`;
|
|
850
|
+
if (r.error) html += `<h2>Error</h2><div class="st-error">${jsonTree(r.error, 'err')}</div>`;
|
|
851
|
+
} else if (r.error) {
|
|
852
|
+
html += `<h2>Error</h2><div class="st-error">${jsonTree(r.error, 'err')}</div>`;
|
|
853
|
+
} else {
|
|
854
|
+
html += `<h2>Core response</h2>${r.response === undefined ? '<pre>(pending…)</pre>' : jsonTree(r.response, 'resp')}`;
|
|
855
|
+
}
|
|
856
|
+
detailEl.innerHTML = html;
|
|
857
|
+
// Persist folds across live rebuilds, and wire expand/collapse-all over every node in the pane.
|
|
858
|
+
for (const d of detailEl.querySelectorAll('details[data-path]')) d.addEventListener('toggle', () => foldState.set(d.dataset.path, d.open));
|
|
859
|
+
const je = document.getElementById('jExpand'), jc = document.getElementById('jCollapse');
|
|
860
|
+
if (je) je.onclick = () => { for (const d of detailEl.querySelectorAll('details[data-path]')) { d.open = true; foldState.set(d.dataset.path, true); } };
|
|
861
|
+
if (jc) jc.onclick = () => { for (const d of detailEl.querySelectorAll('details[data-path]')) { d.open = false; foldState.set(d.dataset.path, false); } };
|
|
862
|
+
const rb = document.getElementById('replayBtn');
|
|
863
|
+
if (rb) rb.onclick = () => replay(r);
|
|
864
|
+
const ta = document.getElementById('editArgs');
|
|
865
|
+
if (ta) ta.oninput = () => { editDraft = { open: true, value: ta.value }; }; // persist keystrokes across live rebuilds
|
|
866
|
+
const erb = document.getElementById('editReplayBtn');
|
|
867
|
+
if (erb) erb.onclick = () => {
|
|
868
|
+
const p = document.getElementById('editPanel'); if (!p) return;
|
|
869
|
+
const open = p.style.display === 'none';
|
|
870
|
+
p.style.display = open ? 'block' : 'none';
|
|
871
|
+
editDraft = { open, value: (document.getElementById('editArgs') || {}).value ?? draftVal };
|
|
872
|
+
if (open) { const t = document.getElementById('editArgs'); if (t) t.focus(); } // autofocus on open
|
|
873
|
+
};
|
|
874
|
+
const se = document.getElementById('sendEdit');
|
|
875
|
+
if (se) se.onclick = () => {
|
|
876
|
+
let parsed;
|
|
877
|
+
try { parsed = JSON.parse(document.getElementById('editArgs').value); }
|
|
878
|
+
catch { return toast('args must be valid JSON', true); }
|
|
879
|
+
if (!Array.isArray(parsed)) return toast('args must be a JSON array, e.g. ["room-1"]', true);
|
|
880
|
+
replay(r, parsed);
|
|
881
|
+
};
|
|
882
|
+
const ce = document.getElementById('cancelEdit');
|
|
883
|
+
if (ce) ce.onclick = () => { const p = document.getElementById('editPanel'); if (p) p.style.display = 'none'; editDraft = null; };
|
|
884
|
+
const so = document.getElementById('streamOrder');
|
|
885
|
+
if (so) so.onclick = (e) => { e.stopPropagation(); streamNewestFirst = !streamNewestFirst; showDetail(r, false); }; // reformat, not a new selection — keep folds & scroll
|
|
886
|
+
// Restore scroll. New selection → start at top. Live update of the open stream → hold the
|
|
887
|
+
// reader's place: newest-first prepends (re-anchor by height delta unless at top); newest-last
|
|
888
|
+
// appends (stick to bottom only if already there).
|
|
889
|
+
if (isSelection) detailEl.scrollTop = 0;
|
|
890
|
+
else if (r.stream && streamNewestFirst) detailEl.scrollTop = wasNearTop ? 0 : oldTop + (detailEl.scrollHeight - oldH);
|
|
891
|
+
else if (r.stream && !streamNewestFirst) detailEl.scrollTop = wasNearBottom ? detailEl.scrollHeight : oldTop;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
filterEl.oninput = render;
|
|
895
|
+
document.getElementById('statusFilter').onchange = (e) => { statusFilterVal = e.target.value; render(); };
|
|
896
|
+
appSelEl.onchange = (e) => { appIdFilter = e.target.value; render(); };
|
|
897
|
+
document.getElementById('epClearAll').onclick = () => { ignoredEndpoints.clear(); renderEndpoints(); render(); };
|
|
898
|
+
const orderBtn = document.getElementById('order');
|
|
899
|
+
orderBtn.onclick = () => {
|
|
900
|
+
newestFirst = !newestFirst;
|
|
901
|
+
orderBtn.textContent = newestFirst ? 'Newest ↑' : 'Newest ↓';
|
|
902
|
+
render();
|
|
903
|
+
listEl.scrollTop = newestFirst ? 0 : listEl.scrollHeight; // jump to the new end
|
|
904
|
+
};
|
|
905
|
+
const pauseBtn = document.getElementById('pause');
|
|
906
|
+
pauseBtn.onclick = () => {
|
|
907
|
+
paused = !paused; pauseBtn.classList.toggle('on', paused); pauseBtn.textContent = paused ? 'Paused' : 'Pause';
|
|
908
|
+
// On unpause, refresh the table AND the open detail pane — events for the selected record may
|
|
909
|
+
// have arrived while paused (and a completed request would otherwise never re-trigger a repaint).
|
|
910
|
+
if (!paused) { render(); const r = selected && byId.get(selected); if (r) showDetail(r, false); }
|
|
911
|
+
};
|
|
912
|
+
// Clear logs. Local wipe + a server purge so it actually sticks: the server replays HISTORY to
|
|
913
|
+
// every browser on connect, so without purging it the rows come back on refresh/reconnect. The
|
|
914
|
+
// server echoes a {__cleared} broadcast to ALL viewers (see ws.onmessage), which calls clearLogs
|
|
915
|
+
// — so the normal path clears via that broadcast; the offline fallback clears locally.
|
|
916
|
+
function clearLogs(scope, src) {
|
|
917
|
+
if (scope === 'device' && src) {
|
|
918
|
+
// Routed through dropRow so childrenByParent stays consistent (a parent's src and its
|
|
919
|
+
// stream-item children's src are the same, since children inherit ev.src || r.src).
|
|
920
|
+
const hadSelected = selected;
|
|
921
|
+
for (let i = order.length - 1; i >= 0; i--) {
|
|
922
|
+
const id = order[i], r = byId.get(id);
|
|
923
|
+
if (r && r.src === src) dropRow(id);
|
|
924
|
+
}
|
|
925
|
+
if (hadSelected && selected === null) editDraft = null; // the selected row was among those removed
|
|
926
|
+
for (const k of [...threadSamples.keys()]) if (k.startsWith(src + '|')) threadSamples.delete(k);
|
|
927
|
+
} else {
|
|
928
|
+
byId.clear(); order.length = 0; threadSamples.clear(); childrenByParent.clear();
|
|
929
|
+
selected = null; editDraft = null; detailEl.classList.remove('show');
|
|
930
|
+
}
|
|
931
|
+
render(); renderThreads();
|
|
932
|
+
}
|
|
933
|
+
document.getElementById('clear').onclick = () => {
|
|
934
|
+
if (sock && sock.readyState === 1) sock.send(JSON.stringify({ __clear: { scope: 'all' } }));
|
|
935
|
+
else clearLogs('all'); // offline fallback (no server to purge/echo)
|
|
936
|
+
};
|
|
937
|
+
clearDeviceBtn.onclick = () => {
|
|
938
|
+
if (deviceFilter === null || !devices.has(deviceFilter)) return;
|
|
939
|
+
const src = deviceFilter;
|
|
940
|
+
if (sock && sock.readyState === 1) sock.send(JSON.stringify({ __clear: { scope: 'device', src } }));
|
|
941
|
+
else clearLogs('device', src);
|
|
942
|
+
};
|
|
943
|
+
// Close the detail pane (deselects the row). Wired to the ✕ button and the Esc key.
|
|
944
|
+
function closeDetail() {
|
|
945
|
+
if (!selected && !detailEl.classList.contains('show')) return;
|
|
946
|
+
selected = null; editDraft = null; detailEl.classList.remove('show'); render();
|
|
947
|
+
}
|
|
948
|
+
document.getElementById('detailClose').onclick = closeDetail;
|
|
949
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
|
|
950
|
+
document.getElementById('theme').onclick = () => {
|
|
951
|
+
const cur = document.documentElement.dataset.theme || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
|
952
|
+
document.documentElement.dataset.theme = cur === 'dark' ? 'light' : 'dark';
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
// Replay a logged call: ask its source to re-run it, referenced BY corrId. Plain replay sends no
|
|
956
|
+
// args (app uses its own logged args). "Edit & replay" passes editedArgs (a JSON array) — the app
|
|
957
|
+
// keeps the METHOD locked to the logged call and only substitutes the args. caps.invoke-gated.
|
|
958
|
+
function replay(r, editedArgs) {
|
|
959
|
+
if (!r || !r.src || !r.corrId) return;
|
|
960
|
+
if (!sock || sock.readyState !== 1) { toast('not connected — cannot replay', true); return; } // no silent no-op
|
|
961
|
+
const inv = { corrId: r.corrId, target: r.src, invokeId: 'inv-' + (++invSeq) };
|
|
962
|
+
if (editedArgs !== undefined) inv.args = editedArgs;
|
|
963
|
+
sock.send(JSON.stringify({ __invoke: inv }));
|
|
964
|
+
toast(`↩ replaying ${r.method || r.corrId}${editedArgs !== undefined ? ' (edited)' : ''}…`);
|
|
965
|
+
}
|
|
966
|
+
let toastTimer = null;
|
|
967
|
+
function toast(msg, isErr) {
|
|
968
|
+
const el = document.getElementById('toast');
|
|
969
|
+
el.textContent = msg; el.className = 'show' + (isErr ? ' err' : '');
|
|
970
|
+
clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.className = ''; }, 3500);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function connect() {
|
|
974
|
+
const ws = new WebSocket(`ws://${location.host}/ws`);
|
|
975
|
+
ws.onopen = () => { sock = ws; statusEl.textContent = 'connected'; statusEl.className = 'on'; ws.send(JSON.stringify({ __hello:'browser' })); };
|
|
976
|
+
ws.onclose = () => { if (sock === ws) sock = null; statusEl.textContent = 'disconnected — retrying'; statusEl.className = ''; setTimeout(connect, 1500); };
|
|
977
|
+
ws.onmessage = (m) => { try {
|
|
978
|
+
const ev = JSON.parse(m.data);
|
|
979
|
+
if (ev && ev.__source) registerDevice(ev.__source);
|
|
980
|
+
else if (ev && ev.__cleared) clearLogs(ev.__cleared.scope, ev.__cleared.src); // server purged history → clear this viewer too
|
|
981
|
+
else if (ev && ev.__source_gone) removeDevice(ev.__source_gone);
|
|
982
|
+
else if (ev && ev.type === 'invoke.error') toast(ev.error === 'unknown-corrId'
|
|
983
|
+
? 'can’t replay: this call is too old (outside the app’s replay window)'
|
|
984
|
+
: 'replay failed: ' + (ev.error || 'error'), true);
|
|
985
|
+
// thread.sample is a periodic (~1/s) per-thread roll-up: it only repaints the occupancy
|
|
986
|
+
// strip, and is intercepted here so it NEVER reaches upsert()/order/byId (would flood the
|
|
987
|
+
// waterfall at 1 row/s/thread). thread.stall, by contrast, falls through to upsert(ev) below,
|
|
988
|
+
// which dispatches it to upsertStall — a genuine row on the 'thread' plane.
|
|
989
|
+
else if (ev && ev.type === 'thread.sample') upsertThreadSample(ev);
|
|
990
|
+
else upsert(ev);
|
|
991
|
+
} catch {} };
|
|
992
|
+
}
|
|
993
|
+
connect();
|
|
994
|
+
</script>
|
|
995
|
+
</body>
|
|
996
|
+
</html>
|