@slack/radar-mcp 1.2.0 → 1.4.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.
@@ -1,729 +1,774 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <title>Slack Radar</title>
6
- <style>
7
- * { box-sizing: border-box; }
8
- html, body {
9
- margin: 0;
10
- padding: 0;
11
- height: 100%;
12
- overflow: hidden;
13
- }
14
- body {
15
- font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
16
- font-size: 13px;
17
- background: #ffffff;
18
- color: #1d1c1d;
19
- display: flex;
20
- flex-direction: column;
21
- }
22
- header {
23
- display: flex;
24
- align-items: center;
25
- gap: 12px;
26
- padding: 10px 16px;
27
- background: #f8f8f8;
28
- border-bottom: 1px solid #e0e0e0;
29
- }
30
- header h1 {
31
- margin: 0;
32
- font-size: 15px;
33
- font-weight: 600;
34
- color: #1d1c1d;
35
- }
36
- .badge {
37
- display: inline-flex;
38
- align-items: center;
39
- gap: 6px;
40
- font-size: 11px;
41
- color: #616061;
42
- padding: 3px 10px;
43
- background: #eeeeee;
44
- border-radius: 10px;
45
- }
46
- .status-dot {
47
- width: 8px;
48
- height: 8px;
49
- border-radius: 50%;
50
- background: #e01e5a;
51
- }
52
- .status-dot.connected { background: #2bac76; }
53
- .status-dot.paused { background: #de9f19; }
54
- .tabs {
55
- display: flex;
56
- background: #f8f8f8;
57
- border-bottom: 1px solid #e0e0e0;
58
- }
59
- .tab {
60
- padding: 10px 16px;
61
- cursor: pointer;
62
- color: #616061;
63
- border-bottom: 2px solid transparent;
64
- user-select: none;
65
- font-weight: 500;
66
- }
67
- .tab:hover { color: #1d1c1d; }
68
- .tab.active {
69
- color: #1d1c1d;
70
- border-bottom-color: #1264a3;
71
- }
72
- .tab .count {
73
- margin-left: 6px;
74
- font-size: 11px;
75
- color: #868686;
76
- }
77
- .toolbar {
78
- display: flex;
79
- gap: 8px;
80
- padding: 8px 16px;
81
- background: #ffffff;
82
- border-bottom: 1px solid #e0e0e0;
83
- align-items: center;
84
- }
85
- .toolbar input, .toolbar button, .toolbar select {
86
- padding: 6px 10px;
87
- border: 1px solid #d0d0d0;
88
- border-radius: 4px;
89
- background: #ffffff;
90
- color: #1d1c1d;
91
- font-size: 12px;
92
- font-family: inherit;
93
- }
94
- .toolbar input { flex: 1; }
95
- .toolbar input:focus { outline: none; border-color: #1264a3; }
96
- .toolbar button {
97
- cursor: pointer;
98
- user-select: none;
99
- }
100
- .toolbar button:hover { background: #f0f0f0; }
101
- .toolbar button.active { background: #1264a3; border-color: #1264a3; color: #fff; }
102
- .main {
103
- flex: 1;
104
- min-height: 0;
105
- display: flex;
106
- overflow: hidden;
107
- }
108
- .list {
109
- flex: 1;
110
- min-height: 0;
111
- overflow-y: auto;
112
- background: #ffffff;
113
- }
114
- .row {
115
- display: grid;
116
- grid-template-columns: 90px 60px 1fr 120px;
117
- gap: 12px;
118
- padding: 6px 16px;
119
- border-bottom: 1px solid #f0f0f0;
120
- cursor: pointer;
121
- font-family: "SF Mono", Menlo, monospace;
122
- font-size: 12px;
123
- align-items: center;
124
- }
125
- .row:hover { background: #f7f7f7; }
126
- .row.selected { background: #e3f1fa; }
127
- .row .time { color: #868686; }
128
- .row .status { text-align: right; color: #868686; }
129
- .row .status.ok { color: #2bac76; }
130
- .row .status.err { color: #e01e5a; }
131
- .row .status.warn { color: #de9f19; }
132
- .row .main-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
133
- .row .method, .row .direction {
134
- font-weight: 600;
135
- color: #616061;
136
- }
137
- .row .method.POST { color: #1264a3; }
138
- .row .method.GET { color: #2bac76; }
139
- .row .method.PUT { color: #de9f19; }
140
- .row .method.DELETE { color: #e01e5a; }
141
- .row .direction.IN { color: #2bac76; }
142
- .row .direction.OUT { color: #1264a3; }
143
- .detail {
144
- flex: 1;
145
- min-height: 0;
146
- overflow-y: auto;
147
- background: #fafafa;
148
- border-left: 1px solid #e0e0e0;
149
- padding: 16px;
150
- font-family: "SF Mono", Menlo, monospace;
151
- font-size: 12px;
152
- white-space: pre-wrap;
153
- word-break: break-word;
154
- color: #1d1c1d;
155
- }
156
- .detail .placeholder {
157
- color: #868686;
158
- font-family: inherit;
159
- font-style: italic;
160
- }
161
- .detail h3 {
162
- margin: 12px 0 6px 0;
163
- font-family: inherit;
164
- font-size: 12px;
165
- font-weight: 600;
166
- color: #616061;
167
- text-transform: uppercase;
168
- letter-spacing: 0.5px;
169
- }
170
- .detail h3:first-child { margin-top: 0; }
171
- .detail-actions {
172
- position: sticky;
173
- top: 0;
174
- background: #fafafa;
175
- padding: 4px 0 8px;
176
- z-index: 1;
177
- display: flex;
178
- gap: 6px;
179
- }
180
- .detail-actions button {
181
- font-size: 11px;
182
- padding: 3px 10px;
183
- cursor: pointer;
184
- background: #e8e8e8;
185
- border: 1px solid #ccc;
186
- border-radius: 4px;
187
- color: #333;
188
- }
189
- .detail-actions button:hover { background: #ddd; }
190
- .detail-actions button.copied { background: #2bac76; color: #fff; border-color: #2bac76; }
191
- .empty {
192
- padding: 40px 16px;
193
- text-align: center;
194
- color: #868686;
195
- }
196
- .marker {
197
- padding: 6px 16px;
198
- color: #868686;
199
- font-style: italic;
200
- font-size: 11px;
201
- text-align: center;
202
- border-top: 1px dashed #d0d0d0;
203
- border-bottom: 1px dashed #d0d0d0;
204
- background: #fafafa;
205
- }
206
- .tab-panel { display: none; flex: 1; flex-direction: column; overflow: hidden; }
207
- .tab-panel.active { display: flex; }
208
- </style>
209
- </head>
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"/>
3
+ <title>Slack Radar Dashboard</title>
4
+ <style>
5
+ :root { color-scheme: dark; } * { box-sizing: border-box; }
6
+ body { margin:0; font:15px/1.4 ui-monospace,Menlo,Consolas,monospace; background:#0b0f14; color:#dfe7ef; padding:28px; }
7
+ h1 { font-size:15px; font-weight:600; color:#8aa0b4; margin:0 0 14px; letter-spacing:.04em; }
8
+ .live { color:#39d98a; }
9
+ .conn { font-size:13px; font-weight:600; }
10
+ .conn.connected { color:#39d98a; }
11
+ .conn.waiting { color:#ffb454; }
12
+ .reconnect { background:#3a2a10; border:1px solid #ffb454; color:#ffb454; border-radius:8px; padding:4px 12px; font:inherit; font-size:12px; font-weight:600; cursor:pointer; margin-left:8px; }
13
+ .reconnect:hover { background:#4a360f; } .reconnect:disabled { opacity:.6; cursor:default; }
14
+ .modebar { display:flex; gap:6px; margin:10px 0 6px; flex-wrap:wrap; }
15
+ .modebtn { background:#121925; border:1px solid #243244; color:#8aa0b4; border-radius:8px; padding:6px 14px; font:inherit; font-size:12.5px; cursor:pointer; }
16
+ .modebtn:hover { border-color:#39d98a; color:#dfe7ef; }
17
+ .modebtn.active { background:#1c3a2a; border-color:#39d98a; color:#39d98a; font-weight:600; }
18
+ .modebtn.custom { margin-left:10px; border-style:dashed; color:#b39ddb; } .modebtn.custom:hover { border-color:#b39ddb; color:#d6c8f0; }
19
+ .modebtn.custom.active { background:#241c3a; border-color:#b39ddb; color:#d6c8f0; }
20
+ .status { font-size:12px; color:#5f7186; margin-bottom:18px; min-height:16px; }
21
+ .status.err { color:#ff7b72; } .status.think { color:#ffd479; } .status.ready { color:#39d98a; }
22
+ .sub { margin-bottom:22px; }
23
+ .sub .summary { display:block; color:#dfe7ef; font-size:15px; font-weight:600; margin-bottom:4px; }
24
+ .sub .tech { display:block; color:#5f7186; font-size:11.5px; }
25
+ .filters { max-width:1100px; margin-bottom:18px; background:#0e151e; border:1px solid #1e2a3a; border-radius:10px; padding:6px 14px; }
26
+ .filters summary { cursor:pointer; color:#8aa0b4; font-size:12px; font-weight:600; padding:6px 0; }
27
+ .filters .fsec { color:#5f7186; font-size:10.5px; text-transform:uppercase; letter-spacing:.06em; margin:12px 0 6px; }
28
+ .filters .frow { display:flex; gap:8px; align-items:center; margin:6px 0; }
29
+ .filters .frow label { color:#5f7186; font-size:12px; min-width:96px; }
30
+ .filters input, .filters select { background:#121925; border:1px solid #243244; border-radius:7px; color:#e8f0f8; padding:6px 9px; font:inherit; font-size:12px; }
31
+ .filters input { flex:1; min-width:0; } .filters input:focus, .filters select:focus { outline:none; border-color:#39d98a; }
32
+ .filters .matchrow .f_mk { flex:0 0 130px; } .filters .matchrow .f_mvwrap { flex:1; display:flex; } .filters .matchrow .f_mv { flex:1; width:100%; }
33
+ .combo { position:relative; flex:1; display:flex; }
34
+ .combo .f_mv { flex:1; padding-right:24px; }
35
+ .combocaret { position:absolute; right:8px; top:50%; transform:translateY(-50%); color:#5f7186; font-size:10px; cursor:pointer; }
36
+ .combolist { display:none; position:absolute; top:100%; left:0; right:0; z-index:30; margin-top:2px; max-height:200px; overflow:auto; background:#121925; border:1px solid #2c3e52; border-radius:7px; box-shadow:0 6px 18px rgba(0,0,0,.5); }
37
+ .combolist.open { display:block; }
38
+ .comboitem { padding:6px 10px; font-size:12px; color:#dfe7ef; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
39
+ .comboitem:hover { background:#1d2c3e; color:#39d98a; }
40
+ .meta { color:#5f7186; font-size:11.5px; margin-bottom:8px; }
41
+ .filters .fdel { background:none; border:none; color:#ff6b60; cursor:pointer; font-size:13px; padding:0 4px; }
42
+ .filters .fadd { background:none; border:1px dashed #2c3e52; color:#8aa0b4; border-radius:7px; padding:5px 10px; font:inherit; font-size:11.5px; cursor:pointer; margin-top:4px; }
43
+ .filters .fapply { background:#1c3a2a; border:1px solid #39d98a; color:#39d98a; border-radius:8px; padding:7px 16px; font:inherit; font-size:12.5px; font-weight:600; cursor:pointer; }
44
+ .filters .fmsg { color:#5f7186; font-size:12px; } .filters .fhint { color:#5f7186; font-size:11.5px; font-style:italic; }
45
+ .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,190px)); gap:14px; max-width:none; align-items:start; }
46
+ .card { background:#121925; border:1px solid #1e2a3a; border-radius:12px; padding:12px 16px; }
47
+ .label { font-size:11px; text-transform:uppercase; letter-spacing:.08em; color:#5f7186; margin-bottom:8px; }
48
+ .big { font-size:36px; font-weight:700; color:#e8f0f8; font-variant-numeric:tabular-nums; line-height:1; }
49
+ .unit { font-size:12px; color:#5f7186; margin-left:5px; }
50
+ .graphcard { grid-column:1/-1; background:#121925; border:1px solid #1e2a3a; border-radius:12px; padding:14px 18px; }
51
+ .graphcard svg { width:100%; height:180px; display:block; background:#0e151e; border-radius:8px; margin-top:8px; }
52
+ .graphcard .legend { margin-top:8px; font-size:12px; }
53
+ .graphcard .legend span { margin-right:14px; }
54
+ .spark { display:flex; align-items:flex-end; gap:3px; height:60px; }
55
+ .bar { width:8px; background:#39d98a; border-radius:2px 2px 0 0; min-height:2px; transition:height .2s; }
56
+ .stage { display:flex; gap:18px; max-width:none; margin-top:18px; align-items:flex-start; }
57
+ .stage.split { flex-wrap:wrap; }
58
+ .stage.haswidth #feedwrap { flex:2 1 380px; }
59
+ .stage.haswidth .inspector { flex:1 1 300px; max-width:520px; }
60
+ .feed { background:#121925; border:1px solid #1e2a3a; border-radius:12px; padding:14px 18px; max-height:calc(100vh - 230px); overflow-y:auto; }
61
+ .feedbar { display:flex; gap:8px; align-items:center; margin-bottom:8px; }
62
+ .feedbar input { flex:1; background:#0e151e; border:1px solid #243244; border-radius:8px; color:#e8f0f8; padding:7px 10px; font:inherit; font-size:12.5px; }
63
+ .feedbar input:focus { outline:none; border-color:#39d98a; }
64
+ .ffilterwrap { position:relative; flex:1; display:flex; }
65
+ .ffilterwrap input { flex:1; padding-right:26px; }
66
+ .ffilterclear { position:absolute; right:8px; top:50%; transform:translateY(-50%); cursor:pointer; color:#7e8ea0; background:#1c2737; border-radius:50%; width:16px; height:16px; line-height:14px; text-align:center; font-size:10px; }
67
+ .ffilterclear:hover { color:#fff; background:#ff5b50; }
68
+ .feedbar button { background:#1c2a3a; border:1px solid #2c3e52; border-radius:8px; color:#dfe7ef; padding:6px 10px; font:inherit; font-size:12px; cursor:pointer; white-space:nowrap; }
69
+ .feedbar button:hover { border-color:#39d98a; }
70
+ .feedbar button.paused { color:#ffb454; border-color:#ffb454; }
71
+ .fcount { color:#5f7186; font-size:11.5px; white-space:nowrap; }
72
+ .fsortlbl { color:#5f7186; font-size:12px; display:flex; align-items:center; gap:5px; white-space:nowrap; }
73
+ .feedbar select { background:#1c2a3a; border:1px solid #2c3e52; border-radius:8px; color:#dfe7ef; padding:6px 8px; font:inherit; font-size:12px; cursor:pointer; max-width:200px; }
74
+ .feedbar select:focus { outline:none; border-color:#39d98a; }
75
+ #fpkg { color:#7d9bd6; }
76
+ .chips { display:flex; flex-wrap:wrap; gap:7px; margin-bottom:10px; }
77
+ .chip { display:inline-flex; align-items:center; gap:7px; background:#141d2b; border:1px solid #2c3e52; color:#cfe0f0; border-radius:999px; padding:4px 6px 4px 11px; font-size:12px; }
78
+ .chip b { color:#7d9bd6; font-weight:600; } .chip .v { color:#e8f0f8; }
79
+ .chip .x { cursor:pointer; color:#7e8ea0; background:#1c2737; border-radius:50%; width:16px; height:16px; line-height:14px; text-align:center; font-size:11px; }
80
+ .chip .x:hover { color:#fff; background:#ff5b50; }
81
+ .row.hidden { display:none; }
82
+ .row { display:flex; gap:14px; padding:5px 0; border-bottom:1px solid #16202c; font-size:12.5px; }
83
+ .row:last-child{border-bottom:none}
84
+ .row.clickable{ cursor:pointer; border-radius:6px; padding-left:8px; margin-left:-8px; }
85
+ .row.clickable:hover{ background:#172230; }
86
+ .row.sel{ background:#1d2c3e; }
87
+ .row.breach{ background:rgba(255,59,48,.14); box-shadow:inset 3px 0 #ff3b30; }
88
+ .row.breach span:nth-child(3){ color:#ff6b60; font-weight:600; }
89
+ .breachbadge{ margin-left:auto; background:#3a1513; color:#ff8077; font-weight:700; font-size:11.5px; padding:2px 9px; border-radius:999px; white-space:nowrap; }
90
+ .tchip{ font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; padding:1px 6px; border-radius:4px; }
91
+ .t-network{ background:#13314a; color:#6cb6ff; } .t-rtm{ background:#2c1f47; color:#c39bff; } .t-clog{ background:#10332a; color:#39d98a; } .t-log{ background:#2a2a2a; color:#b0b0b0; }
92
+ .lvl{ font-size:10px; font-weight:700; padding:1px 5px; border-radius:4px; }
93
+ .lvl-ERROR,.lvl-FATAL{ background:#3a1513; color:#ff6b60; } .lvl-WARN{ background:#3a2f10; color:#ffd479; } .lvl-INFO{ background:#10283a; color:#6cb6ff; } .lvl-DEBUG,.lvl-VERBOSE{ background:#222; color:#8aa0b4; }
94
+ .logpkg{ color:#7d9bd6; background:#141d2b; font-size:10.5px; padding:1px 6px; border-radius:4px; white-space:nowrap; max-width:130px; overflow:hidden; text-overflow:ellipsis; }
95
+ .logtag{ color:#8aa0b4; font-weight:600; } .logmsg{ color:#cfd8e3; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
96
+ .t { color:#5f7186; } .dir-OUTGOING{color:#6cb6ff} .dir-INCOMING{color:#c39bff}
97
+ .chan { color:#8aa0b4; } .pulse{animation:p .4s} @keyframes p{from{color:#39d98a}to{}}
98
+ .extract { color:#ffd479; font-weight:600; margin-left:auto; max-width:46%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
99
+ #flash { position:fixed; inset:0; background:#ff3b30; opacity:0; pointer-events:none; z-index:50; transition:opacity .08s; }
100
+ #flash.on { opacity:.38; }
101
+ #alertbanner { position:fixed; top:0; left:0; right:0; z-index:60; display:none; text-align:center; background:#ff3b30; color:#fff; font-weight:700; padding:10px; letter-spacing:.04em; box-shadow:0 2px 12px rgba(0,0,0,.4); }
102
+ #alertbanner.on { display:block; animation:slidein .15s; }
103
+ @keyframes slidein { from{transform:translateY(-100%)} to{transform:translateY(0)} }
104
+ .inspector { flex:1 1 340px; min-width:280px; background:#0e151e; border:1px solid #1e2a3a; border-radius:12px; padding:14px 18px; max-height:calc(100vh - 32px); overflow:auto; position:sticky; top:16px; align-self:flex-start; }
105
+ .inspector .label{ margin-bottom:10px; }
106
+ .inspector pre { margin:0; white-space:pre-wrap; word-break:break-word; font-size:12px; color:#cfe3d6; }
107
+ .inspector .empty{ color:#5f7186; font-size:12.5px; }
108
+ .copybar { display:flex; gap:6px; margin-bottom:8px; flex-wrap:wrap; }
109
+ .copybtn { background:#172a1f; border:1px solid #2c5a3e; color:#7fd6a3; border-radius:6px; padding:3px 9px; font:inherit; font-size:11.5px; cursor:pointer; }
110
+ .copybtn:hover { border-color:#39d98a; color:#39d98a; }
111
+ </style></head>
210
112
  <body>
211
- <header>
212
- <h1>Slack Radar</h1>
213
- <span class="badge"><span id="status-dot" class="status-dot"></span> <span id="status-text">connecting</span></span>
214
- <span class="badge" id="uptime-badge"></span>
215
- <button id="extend-btn" style="display: none; font-size: 11px; padding: 2px 8px; margin-left: 4px; cursor: pointer; background: #2bac76; color: #fff; border: none; border-radius: 10px;">Extend 5m</button>
216
- </header>
217
-
218
- <div class="tabs">
219
- <div class="tab active" data-tab="network">Network <span class="count" id="network-count">0</span></div>
220
- <div class="tab" data-tab="rtm">RTM <span class="count" id="rtm-count">0</span></div>
221
- <div class="tab" data-tab="clog">Clogs <span class="count" id="clog-count">0</span></div>
222
- </div>
223
-
224
- <div class="toolbar">
225
- <input type="text" id="filter" placeholder="Filter by URL, channel, event name, payload..." />
226
- <span id="profile-label" style="display: none; font-size: 12px; color: #616061; margin-right: 4px;"></span>
227
- <button id="enable-btn" style="display: none;">Enable Radar</button>
228
- <button id="disconnect-btn" style="display: none;">Disconnect</button>
229
- <button id="pause-btn">Pause</button>
230
- <button id="clear-btn">Clear</button>
231
- </div>
232
-
233
- <div class="main">
234
- <div class="list" id="list"></div>
235
- <div class="detail" id="detail">
236
- <div class="placeholder">Select a row to see the full payload.</div>
113
+ <div id="flash"></div>
114
+ <div id="alertbanner"></div>
115
+ <h1>SLACK RADAR DASHBOARD <span class="conn waiting" id="conn">&#9679; connecting…</span> <button id="reconnect" class="reconnect" style="display:none">⟳ Reconnect Radar</button></h1>
116
+ <div class="modebar" id="modebar"></div>
117
+ <div class="status" id="status"></div>
118
+ <div class="sub" id="sub"></div>
119
+ <details class="filters" id="filterpanel" style="display:none">
120
+ <summary>Filters</summary>
121
+ <div id="filterbody"></div>
122
+ </details>
123
+ <div class="grid" id="cards"></div>
124
+ <div class="stage" id="stage">
125
+ <div class="feedwrap" id="feedwrap" style="flex:1; min-width:0;">
126
+ <div class="feedbar" id="feedbar" style="display:none">
127
+ <span class="ffilterwrap"><input id="ffilter" placeholder="filter rows…" autocomplete="off"/><span class="ffilterclear" id="ffilterclear" title="clear filter" style="display:none">✕</span></span>
128
+ <label class="fsortlbl" id="fpkgwrap" style="display:none">app <select id="fpkg" title="show only one app's log lines"></select></label>
129
+ <label class="fsortlbl">sort <select id="fsort"></select></label>
130
+ <button id="fpause" title="pause stream">⏸ live</button>
131
+ <span id="fcount" class="fcount"></span>
132
+ </div>
133
+ <div class="chips" id="chips" style="display:none"></div>
134
+ <div class="feed" id="feed"></div>
237
135
  </div>
136
+ <div class="inspector" id="inspector" style="display:none"></div>
238
137
  </div>
239
-
240
- <script>
241
- const state = {
242
- tab: "network",
243
- filter: "",
244
- paused: false,
245
- connected: false,
246
- hadConnection: false, // true once we've ever connected successfully
247
- selected: null, // { type, id }
248
- buffers: {
249
- network: [],
250
- rtm: [],
251
- clog: [],
252
- },
253
- MAX_BUFFER: 1000,
254
- };
255
-
256
- const listEl = document.getElementById("list");
257
- const detailEl = document.getElementById("detail");
258
- const filterEl = document.getElementById("filter");
259
- const enableBtn = document.getElementById("enable-btn");
260
- const profileLabel = document.getElementById("profile-label");
261
- const disconnectBtn = document.getElementById("disconnect-btn");
262
- const pauseBtn = document.getElementById("pause-btn");
263
- const clearBtn = document.getElementById("clear-btn");
264
- const statusDot = document.getElementById("status-dot");
265
- const statusText = document.getElementById("status-text");
266
- const uptimeBadge = document.getElementById("uptime-badge");
267
-
268
- // --- Tabs ---
269
- document.querySelectorAll(".tab").forEach((el) => {
270
- el.addEventListener("click", () => {
271
- state.tab = el.dataset.tab;
272
- document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === el));
273
- state.selected = null;
274
- renderList();
275
- renderDetail();
276
- });
277
- });
278
-
279
- // --- Toolbar ---
280
- filterEl.addEventListener("input", () => {
281
- state.filter = filterEl.value.toLowerCase();
282
- renderList();
283
- });
284
-
285
- pauseBtn.addEventListener("click", () => {
286
- state.paused = !state.paused;
287
- pauseBtn.textContent = state.paused ? "Resume" : "Pause";
288
- pauseBtn.classList.toggle("active", state.paused);
289
- statusDot.classList.toggle("paused", state.paused);
290
- statusText.textContent = state.paused ? "paused" : (state.connected ? "connected" : "disconnected");
291
- });
292
-
293
- clearBtn.addEventListener("click", async () => {
294
- state.buffers.network = [];
295
- state.buffers.rtm = [];
296
- state.buffers.clog = [];
297
- state.selected = null;
298
- try {
299
- await fetch("/api/clear");
300
- } catch (e) {
301
- // Ignore — device may have shut down
302
- }
303
- updateCounts();
304
- renderList();
305
- renderDetail();
306
- });
307
-
308
- enableBtn.addEventListener("click", async () => {
309
- enableBtn.disabled = true;
310
- enableBtn.textContent = "Enabling…";
311
- try {
312
- const res = await fetch("/_control/activate", { method: "POST" });
313
- const data = await res.json();
314
- if (!data.ok) {
315
- statusText.textContent = "enable failed";
316
- alert(data.error || "Failed to enable radar");
317
- } else {
318
- statusText.textContent = "connecting";
319
- // Device-side server needs a moment to start after the broadcast.
320
- // Without this delay, the SSE connect races the server startup and
321
- // gets connection-refused, snapping back to disconnected instantly.
322
- await new Promise((r) => setTimeout(r, 1500));
323
- connectStream();
324
- return; // skip the finally reset — stay in "connecting" state
325
- }
326
- } catch (e) {
327
- alert("Failed to reach server: " + e.message);
328
- }
329
- enableBtn.disabled = false;
330
- enableBtn.textContent = "Enable Radar";
331
- });
332
-
333
- async function fetchProfiles() {
334
- try {
335
- const res = await fetch("/_control/profiles");
336
- const data = await res.json();
337
- const profiles = data.profiles || [];
338
- const activeId = data.active_user_id;
339
-
340
- if (activeId && profiles.length >= 1) {
341
- const active = profiles.find((p) => p.userId === activeId) ?? profiles[0];
342
- profileLabel.textContent = active.label;
343
- profileLabel.style.display = "";
344
- } else if (profiles.length >= 1) {
345
- profileLabel.textContent = profiles[0].label;
346
- profileLabel.style.display = "";
347
- } else {
348
- profileLabel.style.display = "none";
349
- }
350
- } catch {
351
- profileLabel.style.display = "none";
352
- }
353
- }
354
-
355
- function setDisconnectedUi(disconnected) {
356
- enableBtn.style.display = disconnected ? "" : "none";
357
- disconnectBtn.style.display = disconnected ? "none" : "";
358
- if (disconnected) {
359
- uptimeBadge.textContent = "";
360
- extendBtn.style.display = "none";
361
- }
362
- fetchProfiles();
363
- }
364
-
365
- disconnectBtn.addEventListener("click", () => {
366
- if (activeStream) {
367
- activeStream.close();
368
- activeStream = null;
369
- }
370
- state.connected = false;
371
- statusDot.classList.remove("connected");
372
- statusText.textContent = "disconnected";
373
- enableBtn.disabled = false;
374
- enableBtn.textContent = "Enable Radar";
375
- setDisconnectedUi(true);
376
- renderList();
377
- });
378
-
379
- // --- SSE ---
380
- function insertReconnectMarker() {
381
- const marker = {
382
- __marker: true,
383
- timestamp: Date.now(),
384
- label: `reconnected @ ${new Date().toLocaleTimeString("en-US", { hour12: false })}`,
385
- };
386
- const buffers = state.buffers;
387
- for (const type of ["network", "rtm", "clog"]) {
388
- const buf = buffers[type];
389
- if (buf.length > 0) buf.push({ ...marker }); // avoid empty-list markers
390
- }
391
- }
392
-
393
- let activeStream = null;
394
-
395
- function connectStream() {
396
- const es = new EventSource("/api/stream");
397
- activeStream = es;
398
- es.onopen = () => {
399
- const wasDisconnected = !state.connected && state.hadConnection;
400
- state.connected = true;
401
- state.hadConnection = true;
402
- if (wasDisconnected) insertReconnectMarker();
403
- statusDot.classList.add("connected");
404
- statusText.textContent = state.paused ? "paused" : "connected";
405
- setDisconnectedUi(false);
406
- renderList();
407
- };
408
- es.onmessage = (ev) => {
409
- if (state.paused) return;
410
- try {
411
- const parsed = JSON.parse(ev.data);
412
- ingest(parsed.type, parsed.event);
413
- } catch (e) {
414
- // Skip malformed
415
- }
416
- };
417
- es.onerror = () => {
418
- state.connected = false;
419
- statusDot.classList.remove("connected");
420
- statusText.textContent = "disconnected";
421
- enableBtn.disabled = false;
422
- enableBtn.textContent = "Enable Radar";
423
- setDisconnectedUi(true);
424
- renderList();
425
- es.close();
426
- };
427
- }
428
-
429
- function ingest(type, event) {
430
- // Attribute incoming events to whichever profile is currently active.
431
- // There is a small misattribution window right at a switch boundary
432
- // (a few events in flight may land in the wrong bucket), which is an
433
- // acceptable cost relative to server-side event tagging.
434
- const buffers = state.buffers;
435
- const buf = buffers[type];
436
- if (!buf) return;
437
- buf.push(event);
438
- if (buf.length > state.MAX_BUFFER) buf.shift();
439
- updateCounts();
440
- if (state.tab === type) renderList();
441
- }
442
-
443
- function updateCounts() {
444
- const buffers = state.buffers;
445
- document.getElementById("network-count").textContent = buffers.network.length;
446
- document.getElementById("rtm-count").textContent = buffers.rtm.length;
447
- document.getElementById("clog-count").textContent = buffers.clog.length;
448
- }
449
-
450
- // --- Rendering ---
451
- function filterRows(rows) {
452
- if (!state.filter) return rows;
453
- return rows.filter((r) => {
454
- if (r.__marker) return true; // always keep markers so history stays anchored
455
- return JSON.stringify(r).toLowerCase().includes(state.filter);
456
- });
457
- }
458
-
459
- function renderList() {
460
- const rows = filterRows(state.buffers[state.tab]);
461
- // Count real (non-marker) rows when deciding empty-state
462
- const nonMarkerCount = rows.filter((r) => !r.__marker).length;
463
- if (nonMarkerCount === 0) {
464
- let msg;
465
- if (state.filter) {
466
- msg = "No matches. Clear the filter or try a different query.";
467
- } else if (!state.connected) {
468
- msg = "Radar is not connected. Click Enable Radar in the toolbar to activate.";
469
- } else {
470
- msg = `No ${state.tab} events yet. Interact with the app to capture events.`;
471
- }
472
- listEl.innerHTML = `<div class="empty">${msg}</div>`;
473
- return;
474
- }
475
- // Newest first
476
- const html = rows.slice().reverse().map((row) => {
477
- if (row.__marker) return `<div class="marker">─── ${row.label} ───</div>`;
478
- return renderRow(state.tab, row);
479
- }).join("");
480
- listEl.innerHTML = html;
481
- listEl.querySelectorAll(".row").forEach((el) => {
482
- el.addEventListener("click", () => {
483
- const id = Number(el.dataset.id);
484
- state.selected = { type: state.tab, id };
485
- listEl.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r === el));
486
- renderDetail();
487
- });
488
- });
489
- }
490
-
491
- function renderRow(type, row) {
492
- const time = new Date(row.timestamp).toLocaleTimeString("en-US", { hour12: false });
493
- const selected = state.selected && state.selected.type === type && state.selected.id === row.id ? "selected" : "";
494
- if (type === "network") {
495
- const statusClass = row.status_code >= 500 ? "err" : row.status_code >= 400 ? "warn" : "ok";
496
- return `
497
- <div class="row ${selected}" data-id="${row.id}">
498
- <span class="time">${time}</span>
499
- <span class="method ${row.method}">${row.method}</span>
500
- <span class="main-col">${escapeHtml(row.url)}</span>
501
- <span class="status ${statusClass}">${row.status_code} · ${row.duration_ms}ms</span>
502
- </div>`;
503
- }
504
- if (type === "rtm") {
505
- return `
506
- <div class="row ${selected}" data-id="${row.id}">
507
- <span class="time">${time}</span>
508
- <span class="direction ${row.direction === "INCOMING" ? "IN" : "OUT"}">${row.direction === "INCOMING" ? "←" : "→"}</span>
509
- <span class="main-col">${escapeHtml(row.event_type ?? "(no type)")}${row.channel ? " · " + escapeHtml(row.channel) : ""}</span>
510
- <span class="status"></span>
511
- </div>`;
512
- }
513
- // clog
514
- return `
515
- <div class="row ${selected}" data-id="${row.id}">
516
- <span class="time">${time}</span>
517
- <span></span>
518
- <span class="main-col">${escapeHtml(row.event_name ?? "(unknown)")}</span>
519
- <span class="status"></span>
520
- </div>`;
521
- }
522
-
523
- let lastDetailData = null;
524
-
525
- async function renderDetail() {
526
- if (!state.selected) {
527
- lastDetailData = null;
528
- detailEl.innerHTML = '<div class="placeholder">Select a row to see the full payload.</div>';
529
- return;
530
- }
531
- const { type, id } = state.selected;
532
- detailEl.innerHTML = '<div class="placeholder">Loading…</div>';
533
- try {
534
- const endpoint = type === "network" ? "/api/network/" : type === "rtm" ? "/api/rtm/" : "/api/clogs/";
535
- const res = await fetch(endpoint + id);
536
- const data = await res.json();
537
- lastDetailData = data;
538
- detailEl.innerHTML = detailActions() + formatDetail(type, data);
539
- wireDetailActions();
540
- } catch (e) {
541
- lastDetailData = null;
542
- detailEl.innerHTML = '<div class="placeholder">Failed to load detail: ' + escapeHtml(e.message) + '</div>';
543
- }
544
- }
545
-
546
- function detailActions() {
547
- const isNetwork = state.selected && state.selected.type === "network";
548
- return `<div class="detail-actions">
549
- <button id="copy-json-btn">Copy JSON</button>
550
- <button id="copy-body-btn">Copy Body</button>
551
- ${isNetwork ? '<button id="copy-curl-btn">Copy cURL</button>' : ''}
552
- </div>`;
553
- }
554
-
555
- function wireDetailActions() {
556
- const copyJsonBtn = document.getElementById("copy-json-btn");
557
- const copyBodyBtn = document.getElementById("copy-body-btn");
558
- if (copyJsonBtn) {
559
- copyJsonBtn.addEventListener("click", () => {
560
- if (!lastDetailData) return;
561
- navigator.clipboard.writeText(JSON.stringify(lastDetailData, null, 2));
562
- copyJsonBtn.textContent = "Copied!";
563
- copyJsonBtn.classList.add("copied");
564
- setTimeout(() => { copyJsonBtn.textContent = "Copy JSON"; copyJsonBtn.classList.remove("copied"); }, 1500);
565
- });
566
- }
567
- if (copyBodyBtn) {
568
- copyBodyBtn.addEventListener("click", () => {
569
- if (!lastDetailData) return;
570
- const body = lastDetailData.response_body ?? lastDetailData.data ?? lastDetailData.request_body ?? "";
571
- const text = tryPretty(body);
572
- navigator.clipboard.writeText(text);
573
- copyBodyBtn.textContent = "Copied!";
574
- copyBodyBtn.classList.add("copied");
575
- setTimeout(() => { copyBodyBtn.textContent = "Copy Body"; copyBodyBtn.classList.remove("copied"); }, 1500);
576
- });
577
- }
578
- const copyCurlBtn = document.getElementById("copy-curl-btn");
579
- if (copyCurlBtn) {
580
- copyCurlBtn.addEventListener("click", () => {
581
- if (!lastDetailData) return;
582
- navigator.clipboard.writeText(buildCurl(lastDetailData));
583
- copyCurlBtn.textContent = "Copied!";
584
- copyCurlBtn.classList.add("copied");
585
- setTimeout(() => { copyCurlBtn.textContent = "Copy cURL"; copyCurlBtn.classList.remove("copied"); }, 1500);
586
- });
587
- }
588
- }
589
-
590
- function shellEscape(s) {
591
- if (!s) return "''";
592
- return "'" + s.replace(/'/g, "'\\''") + "'";
593
- }
594
-
595
- function buildCurl(d) {
596
- const parts = [`curl -X ${d.method} ${shellEscape(d.url)}`];
597
- const headers = d.request_headers ?? {};
598
- let hasAuth = false;
599
- for (const [k, v] of Object.entries(headers)) {
600
- const lower = k.toLowerCase();
601
- if (lower === "accept-encoding" || lower === "host" || lower === "content-length") continue;
602
- if (lower === "authorization" || lower === "cookie") hasAuth = true;
603
- parts.push(` -H ${shellEscape(k + ": " + v)}`);
604
- }
605
- if (d.request_body) {
606
- parts.push(` --data-raw ${shellEscape(d.request_body)}`);
607
- }
608
- let cmd = parts.join(" \\\n");
609
- if (!hasAuth) {
610
- cmd += "\n# Note: auth cookies are injected by OkHttp after the app interceptor and are not captured. Add -b 'cookie=...' manually to replay.";
611
- }
612
- return cmd;
613
- }
614
-
615
- function formatDetail(type, data) {
616
- if (type === "network") {
617
- return `
618
- <h3>Request</h3>
619
- ${escapeHtml(data.method)} ${escapeHtml(data.url)}
620
-
621
- <h3>Request Headers</h3>
622
- ${escapeHtml(JSON.stringify(data.request_headers ?? {}, null, 2))}
623
-
624
- <h3>Request Body</h3>
625
- ${escapeHtml(data.request_body ?? "(none)")}
626
-
627
- <h3>Response · ${data.status_code} · ${data.duration_ms}ms</h3>
628
-
629
- <h3>Response Headers</h3>
630
- ${escapeHtml(JSON.stringify(data.response_headers ?? {}, null, 2))}
631
-
632
- <h3>Response Body</h3>
633
- ${escapeHtml(tryPretty(data.response_body))}
634
- `;
635
- }
636
- if (type === "rtm") {
637
- return `
638
- <h3>${escapeHtml(data.direction)} · ${escapeHtml(data.event_type ?? "(no type)")}</h3>
639
- Channel: ${escapeHtml(data.channel ?? "(none)")}
640
- Timestamp: ${new Date(data.timestamp).toISOString()}
641
- Base64: ${data.is_base64}
642
-
643
- <h3>Payload</h3>
644
- ${escapeHtml(tryPretty(data.data))}
645
- `;
646
- }
647
- // clog
648
- return `
649
- <h3>${escapeHtml(data.event_name)}</h3>
650
- Timestamp: ${new Date(data.timestamp).toISOString()}
651
-
652
- <h3>Payload</h3>
653
- ${escapeHtml(tryPretty(data.data))}
654
- `;
655
- }
656
-
657
- function tryPretty(s) {
658
- if (!s) return "(empty)";
659
- try {
660
- return JSON.stringify(JSON.parse(s), null, 2);
661
- } catch {
662
- return s;
663
- }
664
- }
665
-
666
- function escapeHtml(s) {
667
- return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
668
- }
669
-
670
- // --- Uptime ping ---
671
- const extendBtn = document.getElementById("extend-btn");
672
-
673
- function formatUptime(ms) {
674
- const totalSec = Math.floor(ms / 1000);
675
- const h = Math.floor(totalSec / 3600);
676
- const m = Math.floor((totalSec % 3600) / 60);
677
- const s = totalSec % 60;
678
- if (h > 0) return `up ${h}h ${m}m`;
679
- if (m > 0) return `up ${m}m ${s}s`;
680
- return `up ${s}s`;
681
- }
682
-
683
- async function extendSession() {
684
- try {
685
- await fetch("/api/ping?timeout_minutes=5");
686
- } catch {}
687
- }
688
-
689
- extendBtn.addEventListener("click", async () => {
690
- extendBtn.disabled = true;
691
- extendBtn.textContent = "Extending…";
692
- await extendSession();
693
- await pollPing();
694
- extendBtn.disabled = false;
695
- extendBtn.textContent = "Extend 5m";
696
- });
697
-
698
- async function pollPing() {
699
- if (!state.connected) return;
700
- try {
701
- const res = await fetch("/api/ping");
702
- const data = await res.json();
703
- if (data.uptime_ms != null) {
704
- const remaining = data.remaining_minutes ?? 0;
705
- const timerLabel = remaining >= 4 ? "active" : `${remaining}m left`;
706
- uptimeBadge.textContent = `${formatUptime(data.uptime_ms)} · ${timerLabel}`;
707
- extendBtn.style.display = state.connected ? "" : "none";
708
- extendBtn.style.background = remaining < 4 ? "#de9f19" : "#2bac76";
709
- if (remaining <= 1 && state.connected) {
710
- await extendSession();
711
- }
712
- }
713
- } catch {
714
- uptimeBadge.textContent = "";
715
- extendBtn.style.display = "none";
716
- }
717
- }
718
- // pollPing runs on interval; skips when not connected.
719
- setInterval(pollPing, 5000);
720
-
721
- // --- Go ---
722
- // Start in disconnected state. User picks a profile and clicks Enable.
723
- // connectStream() is called after Enable succeeds.
724
- setDisconnectedUi(true);
725
- updateCounts();
726
- renderList();
727
- </script>
728
- </body>
729
- </html>
138
+ <script>
139
+ // The web page is just pre-built dashboard TABS over the device Radar stream. No in-page
140
+ // Claude. Customization happens in a terminal Claude Code session (via the
141
+ // open_radar_dashboard MCP tool -> /setspec); when a pushed spec does not match any
142
+ // pre-built tab, a "Custom" tab appears and selects. The page polls /spec and renders
143
+ // whichever preset (or custom spec) is active.
144
+ const statusEl=document.getElementById('status');
145
+ function setStatus(state,msg){ statusEl.className='status '+(state==='error'?'err':state==='ready'?'ready':''); statusEl.textContent = msg||''; }
146
+ document.getElementById('reconnect').onclick=doReconnect;
147
+
148
+ // The live dashboard spec. Hoisted above the first renderModes() call so markActiveMode()
149
+ // can read it before build() first assigns it, without a TDZ ReferenceError. Reassigned by
150
+ // build(spec); the engine section below treats this as its single source of truth.
151
+ let SPEC=null;
152
+
153
+ // ---- pre-built dashboards: the product. one tab each. ----
154
+ // Presets use only generic placeholders. The dashboard learns the real endpoints,
155
+ // channels, and event names LIVE from the device stream; nothing internal is baked in.
156
+ const MODES=[
157
+ {key:'all', label:'Live', spec:{title:'Mission Control',summary:'Live: network, RTM, analytics clogs, and Slack-app logcat in one timeline. (Logs filtered to the Slack app; clear the package chip for all apps.)',source:'all',viz:['counter','rate','sparkline','feed','inspector'],match:{package:'com.Slack.internal.debug'}}},
158
+ {key:'network',label:'Network',spec:{title:'Network',summary:'Network calls per second vs average response time, every call listed, slow ones flagged.',source:'network',viz:['counter','rate','graph','feed','inspector'],series:[{metric:'ratePerSec',label:'calls/s',unit:'/s'},{metric:'avg',field:'duration_ms',label:'avg ms',unit:'ms'}],highlight:{field:'duration_ms',op:'>',value:800,label:'slow (>800ms)'},rateWindowSec:10}},
159
+ {key:'rtm', label:'RTM', spec:{title:'RTM',summary:'Real-time websocket events as they happen.',source:'rtm',viz:['counter','rate','feed','inspector']}},
160
+ {key:'clog', label:'Clogs', spec:{title:'Clogs',summary:'Analytics clog events, click any to see its full payload.',source:'clog',viz:['counter','rate','feed','inspector']}},
161
+ {key:'log', label:'Logs', spec:{title:'Device Logs',summary:'Live Slack app logcat, color-coded by level. Clear the package chip for all apps, or switch app with the dropdown.',source:'log',viz:['counter','feed','inspector'],match:{package:'com.Slack.internal.debug'}}},
162
+ ];
163
+ // A spec is "custom" when it did NOT come from a MODES preset — i.e. a terminal Claude
164
+ // session pushed it via the open_radar_dashboard MCP tool. We detect that by comparing
165
+ // the live spec to the presets; a non-match shows the Custom tab.
166
+ let CUSTOM_ACTIVE=false; // is the live spec the custom (Claude-pushed) one RIGHT NOW?
167
+ let lastCustomSpec=null; // the most-recent custom spec; keeps the Custom tab present
168
+ // until a page refresh, so you can flip back to it.
169
+ function specMatchesMode(spec){ if(!spec||spec.blank) return null;
170
+ return MODES.find(m=>JSON.stringify(m.spec)===JSON.stringify(spec))?.key || null; }
171
+ function renderModes(){ const bar=document.getElementById('modebar'); if(!bar) return;
172
+ let html=MODES.map(m=>'<button class="modebtn" data-k="'+m.key+'">'+m.label+'</button>').join('');
173
+ // Custom tab persists once a custom spec has been pushed this page-load (lastCustomSpec),
174
+ // not just while it is the live spec — so switching to a pre-built tab does not lose it.
175
+ if(lastCustomSpec) html+='<button class="modebtn custom" data-k="__custom__" title="'+esc(lastCustomSpec.title||'custom')+' — pushed from a terminal Claude Code session; persists until refresh">✎ '+esc(lastCustomSpec.title||'Custom')+'</button>';
176
+ bar.innerHTML=html;
177
+ bar.querySelectorAll('.modebtn').forEach(b=>b.onclick=async()=>{
178
+ const k=b.dataset.k;
179
+ const spec = k==='__custom__' ? lastCustomSpec : MODES.find(x=>x.key===k)?.spec;
180
+ if(!spec) return;
181
+ setStatus('', k==='__custom__' ? 'showing: '+(lastCustomSpec.title||'Custom') : 'showing: '+MODES.find(x=>x.key===k).label);
182
+ try{ const r=await (await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)})).json();
183
+ if(!r.ok) setStatus('error','⚠ '+(r.error||'failed')); }catch{ setStatus('error','switch failed'); } });
184
+ markActiveMode();
185
+ }
186
+ function markActiveMode(){ const bar=document.getElementById('modebar'); if(!bar) return;
187
+ const activeKey = CUSTOM_ACTIVE ? '__custom__' : specMatchesMode(SPEC);
188
+ bar.querySelectorAll('.modebtn').forEach(b=>b.classList.toggle('active', b.dataset.k===activeKey)); }
189
+ renderModes();
190
+
191
+ // ---- keyboard navigation ----
192
+ // Up/Down (or vim j/k) move the selected feed row; Home/End jump to first/last.
193
+ // Left/Right (or vim h/l) switch tabs. All clamp at the ends — no wrap.
194
+ // Reuses inspect() for selection and the existing .modebtn click for tab switches,
195
+ // and reads the live DOM each press so it survives stream-driven re-renders.
196
+ function visibleRows(){
197
+ // feed is null on tabs without a feed viz; guard.
198
+ return feed ? [...feed.children].filter(r=>r.classList.contains('row') && !r.classList.contains('hidden')) : [];
199
+ }
200
+ function pickRow(r){
201
+ if(!r) return;
202
+ // inspect() toggles selection OFF when called on the already-selected row, so skip it.
203
+ if(!r.classList.contains('sel')) inspect(r,r._ev);
204
+ r.scrollIntoView({block:'nearest'});
205
+ }
206
+ function moveRow(dir){ // dir: -1 up, +1 down, 'first', 'last'
207
+ const rows=visibleRows();
208
+ if(rows.length===0) return;
209
+ if(dir==='first') return pickRow(rows[0]);
210
+ if(dir==='last') return pickRow(rows[rows.length-1]);
211
+ const cur=rows.findIndex(r=>r.classList.contains('sel'));
212
+ if(cur===-1) return pickRow(dir>0?rows[0]:rows[rows.length-1]);
213
+ const next=Math.max(0,Math.min(rows.length-1,cur+dir)); // clamp
214
+ pickRow(rows[next]);
215
+ }
216
+ function moveTab(dir){ // dir: -1 left, +1 right; clamp
217
+ const btns=[...document.querySelectorAll('#modebar .modebtn')];
218
+ if(btns.length===0) return;
219
+ let cur=btns.findIndex(b=>b.classList.contains('active'));
220
+ if(cur===-1) cur=0;
221
+ const next=Math.max(0,Math.min(btns.length-1,cur+dir));
222
+ if(next!==cur) btns[next].click(); // fires existing onclick → /setspec → rebuild
223
+ }
224
+ document.addEventListener('keydown',(e)=>{
225
+ const t=e.target;
226
+ if(t && (t.tagName==='INPUT' || t.tagName==='TEXTAREA' || t.isContentEditable)) return;
227
+ switch(e.key){
228
+ case 'ArrowDown': case 'j': moveRow(+1); break;
229
+ case 'ArrowUp': case 'k': moveRow(-1); break;
230
+ case 'ArrowRight': case 'l': moveTab(+1); break;
231
+ case 'ArrowLeft': case 'h': moveTab(-1); break;
232
+ case 'Home': moveRow('first'); break;
233
+ case 'End': moveRow('last'); break;
234
+ default: return;
235
+ }
236
+ e.preventDefault(); // stop the list/page from also scrolling
237
+ });
238
+
239
+ // ---- poll /spec; rebuild when it changes, and show/hide the Custom tab accordingly ----
240
+ let specKey='';
241
+ async function pollSpec(){ try{ const spec=await (await fetch('/spec')).json(); const key=JSON.stringify(spec); if(key===specKey) return; specKey=key;
242
+ // a non-blank spec that matches no preset = a Claude-pushed custom dashboard
243
+ CUSTOM_ACTIVE = !!spec && !spec.blank && specMatchesMode(spec)===null;
244
+ let needRerender=false;
245
+ if(CUSTOM_ACTIVE){ // remember it so the tab persists; re-render if it's new/changed
246
+ if(!lastCustomSpec || JSON.stringify(lastCustomSpec)!==JSON.stringify(spec)){ lastCustomSpec=spec; needRerender=true; }
247
+ }
248
+ if(needRerender) renderModes(); // add or update the Custom tab (label = its title)
249
+ build(spec);
250
+ if(CUSTOM_ACTIVE) setStatus('ready','Custom dashboard (pushed from a Claude Code session): '+(spec.title||''));
251
+ else { const k=specMatchesMode(spec); const m=MODES.find(x=>x.key===k); if(m) setStatus('','showing: '+m.label); }
252
+ markActiveMode();
253
+ }catch{} }
254
+ setInterval(pollSpec,700);
255
+ // Default to the Live dashboard on load (instead of a blank canvas).
256
+ async function bootDefault(){ try{ const cur=await (await fetch('/spec')).json();
257
+ if(!cur||cur.blank){ const live=MODES[0]; await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(live.spec)}).catch(()=>{}); }
258
+ }catch{} }
259
+ bootDefault();
260
+ // Connection dot driven by an always-on device health check, independent of whether
261
+ // a dashboard is built (a blank dashboard has no stream, so the dot was otherwise dead).
262
+ let lastEventTs=0;
263
+ async function pollHealth(){
264
+ if(Date.now()-lastEventTs<4000){ setConn('connected',''); return; }
265
+ try{ const h=await (await fetch('/health')).json(); setConn(h.device?'connected':'waiting', h.device?'':'device offline, retrying…'); }catch{ setConn('waiting','reconnecting…'); } }
266
+ pollHealth(); setInterval(pollHealth,2000);
267
+
268
+ // ---- the dashboard engine: build(spec) tears down and rebuilds widgets live ----
269
+ let es=null, tickTimer=null;
270
+ let total=0, times=[], perSec=[], counterEl=null, rateEl=null, sparkEl=null, feed=null, cards=null, GRAPH=null;
271
+ // liveMatches counts ONLY genuine live arrivals (not backfilled/replayed history), so the
272
+ // alert {at:N} "fire once at the Nth match" threshold is measured from when the dashboard
273
+ // opened independent of how many matching events the device buffer already held. `total`
274
+ // stays history-inclusive because the counter widget should show every event seen.
275
+ let liveMatches=0;
276
+ function has(v){ return SPEC.viz.includes(v); }
277
+ function showInspector(){ return has('feed') || has('inspector'); }
278
+ function build(spec){
279
+ SPEC=spec; total=0; liveMatches=0; times=[]; perSec=new Array(30).fill(0);
280
+ markActiveMode();
281
+ cards=document.getElementById('cards'); cards.innerHTML=''; feed=document.getElementById('feed'); feed.innerHTML='';
282
+ counterEl=rateEl=sparkEl=null;
283
+ const insp=document.getElementById('inspector'), stage=document.getElementById('stage');
284
+ if(es){ es.close(); es=null; } if(tickTimer){ clearInterval(tickTimer); tickTimer=null; }
285
+ if(SPEC.blank){ document.getElementById('sub').textContent='Pick a dashboard above (Live, Network, RTM, Clogs, Logs). For a custom view, ask a Claude Code session in your terminal to build one — it appears here as a Custom tab.'; insp.style.display='none'; stage.classList.remove('split'); document.getElementById('filterpanel').style.display='none'; stage.style.display='flex'; return; }
286
+ document.getElementById('stage').style.display='flex'; cards.style.display='grid';
287
+ if(showInspector()){ insp.style.display='block'; insp.innerHTML='<div class="label">inspector</div><div class="empty">click a row to see its full detail</div>'; }
288
+ else { insp.style.display='none'; }
289
+ const split = showInspector();
290
+ stage.classList.toggle('split', split); stage.classList.toggle('haswidth', split);
291
+ setupFeedControls();
292
+ const subEl=document.getElementById('sub');
293
+ const tech='source='+SPEC.source+' match='+JSON.stringify(SPEC.match||{})+(SPEC.extract?' extract='+SPEC.extract.field:'');
294
+ subEl.innerHTML = SPEC.summary
295
+ ? '<span class="summary">'+esc(SPEC.summary)+'</span><span class="tech">'+esc(tech)+'</span>'
296
+ : esc(SPEC.title+' — '+tech);
297
+ renderFilters();
298
+ if(has('counter')){ const c=card('total matched', big('0','counterEl')); c.querySelector('.label').id='counterLabel'; cards.appendChild(c); }
299
+ if(has('rate')){ cards.appendChild(card('rate ('+(SPEC.rateWindowSec||10)+'s)', big('0','rateEl','/'+(SPEC.rateWindowSec||10)+'s'))); }
300
+ if(has('sparkline')){ const c=card('per second',''); const s=document.createElement('div'); s.className='spark'; s.id='spark'; c.appendChild(s); cards.appendChild(c); }
301
+ counterEl=document.getElementById('counterEl'); rateEl=document.getElementById('rateEl'); sparkEl=document.getElementById('spark');
302
+ GRAPH=null;
303
+ if(has('graph') && Array.isArray(SPEC.series)){
304
+ const N=60;
305
+ GRAPH={ series: SPEC.series.map(s=>({def:s, buckets:new Array(N).fill(0), counts:new Array(N).fill(0)})), N };
306
+ const wrap=document.createElement('div'); wrap.className='graphcard';
307
+ wrap.innerHTML='<div class="label">'+esc(SPEC.series.map(s=>s.label||s.metric).join(' vs '))+'</div><svg id="graph" viewBox="0 0 640 200"></svg><div class="legend" id="legend"></div>';
308
+ cards.appendChild(wrap);
309
+ }
310
+ if(es) es.close();
311
+ es=new EventSource('/stream'); es.onmessage=onEvent;
312
+ es.onerror=()=>{ setConn('waiting','reconnecting…'); };
313
+ if(tickTimer) clearInterval(tickTimer); tickTimer=setInterval(tick,1000);
314
+ replayCache(); // instant: everything seen this session for this source (incl logs)
315
+ backfill(); // device ring-buffer history from before this session; dedup avoids double-count
316
+ }
317
+ // Pre-populate a freshly-built tab from the device ring-buffer (the source of truth), so
318
+ // switching tabs shows recent data immediately instead of resetting to nothing. Logcat is
319
+ // host-side (no device buffer) so "log"-only tabs have nothing to backfill — they still
320
+ // stream live. Each replayed frame goes through onEvent with live=false, so it populates
321
+ // widgets but does not fire alerts (those are reserved for genuine /stream arrivals).
322
+ let backfillToken=0;
323
+ async function backfill(){
324
+ const myToken=++backfillToken; const src=SPEC.source;
325
+ if(!src) return;
326
+ if(src==='log'){ setStatus('','live device logs — history is not buffered on-device, so this tab fills as new lines arrive'); return; }
327
+ try{ const r=await (await fetch('/backfill?source='+encodeURIComponent(src)+'&limit=200')).json();
328
+ if(myToken!==backfillToken) return;
329
+ const frames=(r&&r.frames)||[];
330
+ // live=false: backfilled frames are device HISTORY (events from before this tab opened),
331
+ // so they count toward widgets but must NOT fire alerts only genuine /stream arrivals do.
332
+ for(const f of frames) onEvent({data:JSON.stringify(f)}, false);
333
+ if(frames.length===0){
334
+ try{ const h=await (await fetch('/health')).json();
335
+ if(!h.device) setStatus('error','no history loaded — device unreachable. Wake/replug the phone, then ⟳ Reconnect Radar');
336
+ else setStatus('','no recent '+src+' activity buffered on the device yet — interact with Slack and it will stream in');
337
+ }catch{ setStatus('error','no history loaded — device unreachable. ⟳ Reconnect Radar'); }
338
+ }
339
+ }catch{ setStatus('error','backfill failed device unreachable?'); }
340
+ }
341
+ function matches(ev){ if(!SPEC.match) return true;
342
+ return Object.entries(SPEC.match).every(([k,v])=>{
343
+ // the package field only exists on LOG events. In the unified "all" timeline, applying
344
+ // it to network/rtm/clog (which have no package) would wrongly drop them — they already
345
+ // come from the Slack app. So a package filter constrains log rows only; others pass.
346
+ if(k==='package' && ev._type && ev._type!=='log') return true;
347
+ return String(ev[k]??'').toLowerCase().includes(String(v).toLowerCase());
348
+ }); }
349
+
350
+ // ---- hand-editable Filters panel: renders the current spec's filters as form controls,
351
+ // edits POST back to /setspec, engine rebuilds live ----
352
+ const MATCH_FIELDS={ network:['url','method','status_code'], rtm:['event_type','direction','channel'], clog:['event_name'],
353
+ log:['tag','level','message','pid','package'], all:['url','method','status_code','event_type','direction','channel','event_name','tag','level','message','pid','package'] };
354
+ const FIELD_VALUES={ direction:['OUTGOING','INCOMING'], level:['VERBOSE','DEBUG','INFO','WARN','ERROR','FATAL'], method:['GET','POST','PUT','DELETE','PATCH'] };
355
+ function candidatesFor(field){
356
+ if(FIELD_VALUES[field]) return FIELD_VALUES[field].slice();
357
+ const seen=SEEN[field]; return seen ? [...seen].sort() : [];
358
+ }
359
+ function valueControlHTML(field,val){
360
+ const n=candidatesFor(field).length;
361
+ const ph = FIELD_VALUES[field] ? 'pick or type…' : (n? 'type or pick ('+n+' seen)' : 'contains…');
362
+ return '<span class="combo"><input class="f_mv" autocomplete="off" data-field="'+esc(field)+'" value="'+esc(String(val).trim())+'" placeholder="'+ph+'"/><span class="combocaret">▾</span><div class="combolist"></div></span>';
363
+ }
364
+ function renderFilters(){
365
+ const panel=document.getElementById('filterpanel'), bodyEl=document.getElementById('filterbody');
366
+ if(!panel||SPEC.blank){ if(panel) panel.style.display='none'; return; }
367
+ if(!panel._wired){ panel._wired=true; panel.addEventListener('toggle',()=>{ if(panel.open) renderFilters(); }); }
368
+ panel.style.display='block';
369
+ const fields=MATCH_FIELDS[SPEC.source]||[];
370
+ const m=SPEC.match&&typeof SPEC.match==='object'?SPEC.match:{};
371
+ let h='';
372
+ h+='<div class="frow"><label>source</label><select id="f_source">'+
373
+ ['rtm','network','clog','log','all'].map(s=>'<option'+(s===SPEC.source?' selected':'')+'>'+s+'</option>').join('')+'</select></div>';
374
+ h+='<div class="fsec">match (all must hold)</div><div id="f_matchrows">';
375
+ const entries=Object.entries(m); if(!entries.length) h+='<div class="fhint">no match filter — showing everything</div>';
376
+ for(const [k,v] of entries){ h+=matchRow(k,v,fields); }
377
+ h+='</div><button class="fadd" id="f_addmatch">+ add match field</button>';
378
+ if(SPEC.source==='network'||SPEC.source==='all')
379
+ h+='<div class="frow"><label>body contains</label><input id="f_body" value="'+esc(SPEC.bodyContains||'')+'" placeholder="substring in request/response body"/></div>';
380
+ const hl=SPEC.highlight||{};
381
+ h+='<div class="fsec">highlight (flag rows)</div><div class="frow">'+
382
+ '<input id="f_hlfield" value="'+esc(hl.field||'')+'" placeholder="field e.g. duration_ms" style="flex:2"/>'+
383
+ '<select id="f_hlop">'+['>','>=','<','<=','=='].map(o=>'<option'+(o===(hl.op||'>')?' selected':'')+'>'+o+'</option>').join('')+'</select>'+
384
+ '<input id="f_hlval" value="'+(hl.value!=null?hl.value:'')+'" placeholder="value" style="flex:1"/></div>';
385
+ h+='<div class="frow"><label>rate window (s)</label><input id="f_rate" type="number" value="'+(SPEC.rateWindowSec||10)+'" style="flex:1"/></div>';
386
+ h+='<div class="frow"><button class="fapply" id="f_apply">Apply changes</button><span id="f_msg" class="fmsg"></span></div>';
387
+ bodyEl.innerHTML=h;
388
+ bodyEl.querySelectorAll('.matchrow').forEach(wireMatchRow);
389
+ document.getElementById('f_addmatch').onclick=()=>{ const d=document.createElement('div'); d.innerHTML=matchRow(fields[0]||'','',fields); const row=d.firstChild; document.getElementById('f_matchrows').appendChild(row); wireMatchRow(row); };
390
+ document.getElementById('f_apply').onclick=applyFilters;
391
+ }
392
+ function matchRow(k,v,fields){
393
+ const opts=fields.map(f=>'<option'+(f===k?' selected':'')+'>'+f+'</option>').join('');
394
+ return '<div class="frow matchrow"><select class="f_mk">'+opts+(fields.includes(k)?'':'<option selected>'+esc(k)+'</option>')+
395
+ '</select><span class="f_mvwrap">'+valueControlHTML(k,v)+'</span><button class="fdel">✕</button></div>';
396
+ }
397
+ function wireMatchRow(row){
398
+ const mk=row.querySelector('.f_mk'), wrap=row.querySelector('.f_mvwrap');
399
+ mk.onchange=()=>{ wrap.innerHTML=valueControlHTML(mk.value,''); wireCombo(wrap.querySelector('.combo')); };
400
+ row.querySelector('.fdel').onclick=()=>row.remove();
401
+ wireCombo(wrap.querySelector('.combo'));
402
+ }
403
+ function wireCombo(combo){
404
+ if(!combo) return;
405
+ const input=combo.querySelector('.f_mv'), caret=combo.querySelector('.combocaret'), list=combo.querySelector('.combolist');
406
+ const field=input.getAttribute('data-field');
407
+ const openList=()=>{ const q=input.value.trim().toLowerCase();
408
+ const cands=candidatesFor(field).filter(v=>!q||String(v).toLowerCase().includes(q)).slice(0,50);
409
+ if(!cands.length){ list.classList.remove('open'); return; }
410
+ list.innerHTML=cands.map(v=>'<div class="comboitem">'+esc(v)+'</div>').join('');
411
+ list.classList.add('open');
412
+ list.querySelectorAll('.comboitem').forEach(it=>it.onmousedown=(e)=>{ e.preventDefault(); input.value=it.textContent; list.classList.remove('open'); });
413
+ };
414
+ input.onfocus=openList; input.oninput=openList;
415
+ caret.onmousedown=(e)=>{ e.preventDefault(); if(list.classList.contains('open')) list.classList.remove('open'); else { input.focus(); openList(); } };
416
+ input.onblur=()=>setTimeout(()=>list.classList.remove('open'),120);
417
+ }
418
+ async function applyFilters(){
419
+ const g=id=>document.getElementById(id);
420
+ const spec=JSON.parse(JSON.stringify(SPEC));
421
+ spec.source=g('f_source').value;
422
+ const match={}; document.querySelectorAll('#f_matchrows .matchrow').forEach(r=>{ const k=r.querySelector('.f_mk').value.trim(); const v=r.querySelector('.f_mv').value.trim(); if(k&&v) match[k]=v; });
423
+ spec.match=match;
424
+ const body=g('f_body'); if(body){ const bv=body.value.trim(); if(bv) spec.bodyContains=bv; else delete spec.bodyContains; }
425
+ const hf=g('f_hlfield').value.trim(), hv=g('f_hlval').value.trim();
426
+ if(hf&&hv!==''&&!isNaN(Number(hv))) spec.highlight={field:hf,op:g('f_hlop').value,value:Number(hv),label:(SPEC.highlight&&SPEC.highlight.label)||hf}; else delete spec.highlight;
427
+ const rw=Number(g('f_rate').value); if(rw>0) spec.rateWindowSec=rw;
428
+ const msg=g('f_msg'); msg.textContent='applying…';
429
+ try{ const r=await (await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)})).json();
430
+ msg.textContent = r.ok?'applied ✓':('⚠ '+(r.error||'rejected')); }
431
+ catch{ msg.textContent=' failed'; }
432
+ }
433
+ function pluck(o,p){ return p.split('.').reduce((x,k)=>(x==null?undefined:x[k]),o); }
434
+ async function enrich(ev){ if(!SPEC.extract||ev.id==null) return null; try{ const kind=SPEC.extract.kind||SPEC.source; const d=await (await fetch('/detail?kind='+kind+'&id='+ev.id)).json(); let src=d[SPEC.extract.parse||'response_body']; if(typeof src==='string'){ try{src=JSON.parse(src);}catch{} } return pluck(src,SPEC.extract.field); }catch{ return null; } }
435
+ function setConn(state,msg){ const d=document.getElementById('conn'); if(!d) return; d.className='conn '+state; d.textContent=(state==='connected'?'● live':'● '+(msg||'reconnecting…'));
436
+ const rc=document.getElementById('reconnect'); if(rc && !rc._busy) rc.style.display = state==='connected' ? 'none' : 'inline-block'; }
437
+ async function doReconnect(){ const rc=document.getElementById('reconnect'); if(!rc) return; rc._busy=true; rc.disabled=true; rc.textContent='⟳ enabling…';
438
+ try{ await fetch('/enable',{method:'POST'}); }catch{}
439
+ rc._busy=false; rc.disabled=false; rc.textContent='⟳ Reconnect Radar'; pollHealth(); }
440
+ // live defaults true so the SSE handler (es.onmessage=onEvent) keeps firing alerts on
441
+ // genuine arrivals; backfill() passes live=false so replayed device HISTORY counts toward
442
+ // widgets without triggering alerts for events that already happened.
443
+ function onEvent(m, live){ if(live===undefined) live=true; let f; try{f=JSON.parse(m.data);}catch{return;}
444
+ if(f.type==='_status'){ setConn(f.event.state, f.event.msg); return; }
445
+ if(f.type==='_error'){ setConn('waiting', f.event.msg); return; }
446
+ if(!['network','rtm','clog','log'].includes(f.type)) return;
447
+ lastEventTs=Date.now(); setConn('connected','');
448
+ const ev=f.event||{}; ev._type=f.type;
449
+ // Cache EVERY frame (all types, before the current-tab filter) so a tab switch can
450
+ // replay history including logs, which the device cannot backfill. Dedup: a frame
451
+ // already cached (re-delivered by a stream reconnect or present in backfill) is not
452
+ // re-counted. cacheFrame() also learns dropdown values via observe().
453
+ const isNew=cacheFrame(f.type,ev);
454
+ if(SPEC.source!=='all' && f.type!==SPEC.source) return; // not for the current tab's widgets
455
+ if(!isNew) return; // already rendered/counted this frame
456
+ if(!matches(ev)) return;
457
+ if(SPEC.bodyContains){ bodyMatches(ev).then(ok=>{ if(ok) acceptEvent(ev,live); }); return; }
458
+ acceptEvent(ev,live);
459
+ }
460
+ async function bodyMatches(ev){ if(ev.id==null || ev._type==='clog') return false; const needle=String(SPEC.bodyContains).toLowerCase();
461
+ try{ const d=await (await fetch('/detail?kind='+(ev._type||SPEC.source)+'&id='+ev.id)).json();
462
+ return ((d.request_body||'')+(d.response_body||'')).toLowerCase().includes(needle); }catch{ return false; } }
463
+ const SEEN={};
464
+ const SUGGEST_FIELDS=['event_type','event_name','tag','status_code','channel','url','package'];
465
+ function observe(ev){ for(const f of SUGGEST_FIELDS){ let v=ev[f]; if(v==null||v==='') continue;
466
+ if(f==='url'){ try{ v=new URL(v).pathname.replace('/api/',''); }catch{} }
467
+ v=String(v); if(v.length>80) continue;
468
+ (SEEN[f]||(SEEN[f]=new Set())).add(v);
469
+ const cap = f==='package' ? Infinity : 500;
470
+ while(SEEN[f].size>cap){ const it=SEEN[f].values().next().value; SEEN[f].delete(it); } } }
471
+ // Session cache: every frame seen this browser session, across ALL tabs, keyed for
472
+ // dedup. Switching tabs replays from here so events (and logs, which the device cannot
473
+ // backfill) persist instead of the stream restarting from zero.
474
+ // EVICTION (two bounds, whichever hits first, so memory is bounded on a long-lived tab):
475
+ // 1. COUNT — at most CACHE_CAP frames (FIFO drop oldest).
476
+ // 2. AGE — frames older than CACHE_TTL_MS are dropped on each insert + on a timer.
477
+ // The dedup Set is kept in lockstep with the array (evicted keys are deleted) so it can
478
+ // never grow unbounded. evictCache() also runs on a 30s timer to age out an idle tab.
479
+ const CACHE=[]; const CACHE_KEYS=new Set(); const CACHE_CAP=8000; const CACHE_TTL_MS=15*60*1000;
480
+ function frameKey(type,ev){ return type+':'+(ev.id!=null?ev.id:(ev.log_ts||'')+'|'+(ev.pid||'')+'|'+(ev.message||ev.event_type||ev.event_name||'')); }
481
+ function dropFront(){ const old=CACHE.shift(); if(old) CACHE_KEYS.delete(frameKey(old.type,old.ev)); }
482
+ function evictCache(){ const cutoff=Date.now()-CACHE_TTL_MS;
483
+ while(CACHE.length && CACHE[0].ts<cutoff) dropFront(); // age bound
484
+ while(CACHE.length>CACHE_CAP) dropFront(); // count bound
485
+ }
486
+ setInterval(evictCache,30000); // age out an idle tab even with no new frames arriving
487
+ // Add a frame to the cache. Returns true if NEW (not a dup re-delivery). Learns
488
+ // dropdown values here so suggestions reflect everything seen, not just rendered.
489
+ function cacheFrame(type,ev){ const k=frameKey(type,ev); if(CACHE_KEYS.has(k)) return false;
490
+ CACHE_KEYS.add(k); CACHE.push({type,ev,ts:ev.timestamp||Date.now()}); observe(ev);
491
+ evictCache();
492
+ return true; }
493
+ // Replay the cache into the freshly-built tab, filtered to the current source, in
494
+ // chronological order. Counters/rate/sparkline/graph all reflect it because acceptEvent
495
+ // buckets by each event's REAL timestamp. Like backfill, this is HISTORY: it passes
496
+ // live=false so alerts do not fire — only genuine /stream arrivals (onEvent default) do.
497
+ // Replay applies matches() but NOT bodyContains: the body filter is live-only by design.
498
+ // Honoring it here would mean one /detail fetch per cached row on every tab switch (a
499
+ // fetch storm up to the full cache). Replay shows what you already saw; the body filter
500
+ // stays a live-only refinement.
501
+ function replayCache(){
502
+ const rows=CACHE.filter(c=> (SPEC.source==='all'||c.type===SPEC.source));
503
+ for(const c of rows){ const ev=c.ev; ev._type=c.type; if(matches(ev)) acceptEvent(ev,false); }
504
+ }
505
+ // accept an event into the widgets. live=true for stream arrivals (fires alerts, enrich,
506
+ // pulse); live=false for cache replay (silent, time-accurate). Buckets by the event's own
507
+ // timestamp so replayed history lands in the right second of the rate/sparkline/graph.
508
+ function acceptEvent(ev, live){ if(live===undefined) live=true; const now=Date.now(); const ets=ev.timestamp||now;
509
+ const ageSec=Math.max(0,Math.floor((now-ets)/1000));
510
+ total++; times.push(ets);
511
+ const si=perSec.length-1-ageSec; if(si>=0&&si<perSec.length) perSec[si]++;
512
+ if(GRAPH){ const gi=GRAPH.N-1-ageSec; if(gi>=0&&gi<GRAPH.N){ for(const s of GRAPH.series){ const d=s.def;
513
+ if(d.metric==='ratePerSec') s.buckets[gi]++;
514
+ else if(d.metric==='count') s.buckets[gi]++;
515
+ else if(d.metric==='avg'){ const v=Number(ev[d.field]); if(!isNaN(v)){ s.buckets[gi]+=v; s.counts[gi]++; } }
516
+ else if(d.metric==='errorRate'){ s.counts[gi]++; if(Number(ev.status_code)>=400) s.buckets[gi]++; }
517
+ } } }
518
+ if(counterEl){ syncCounter(); if(live){ counterEl.classList.remove('pulse'); void counterEl.offsetWidth; counterEl.classList.add('pulse'); } }
519
+ const row=FEED.paused?null:addRow(ev,ets); if(row && live && SPEC.extract) enrich(ev).then(val=>{ if(val!=null&&row) appendExtract(row,val); });
520
+ if(live){ liveMatches++;
521
+ if(SPEC.alert){ const at=Number(SPEC.alert.at)||0;
522
+ // at:N fires once on the Nth LIVE match (liveMatches), not the Nth event overall —
523
+ // device history replayed at build time must not consume the threshold.
524
+ if(at>0){ if(liveMatches===at) fireAlert(ev, 'reached '+at); }
525
+ else fireAlert(ev);
526
+ }
527
+ }
528
+ }
529
+ let audioCtx=null;
530
+ function beep(){ try{ audioCtx=audioCtx||new (window.AudioContext||window.webkitAudioContext)(); const o=audioCtx.createOscillator(), g=audioCtx.createGain(); o.type='sine'; o.frequency.value=880; o.connect(g); g.connect(audioCtx.destination); g.gain.setValueAtTime(0.0001,audioCtx.currentTime); g.gain.exponentialRampToValueAtTime(0.25,audioCtx.currentTime+0.01); g.gain.exponentialRampToValueAtTime(0.0001,audioCtx.currentTime+0.25); o.start(); o.stop(audioCtx.currentTime+0.26); }catch{} }
531
+ let alertTimer=null;
532
+ function dismissAlert(){ const b=document.getElementById('alertbanner'); b.classList.remove('on'); if(alertTimer){ clearTimeout(alertTimer); alertTimer=null; } }
533
+ function fireAlert(ev, note){ const a=SPEC.alert||{};
534
+ if(a.flash!==false){ const f=document.getElementById('flash'); f.classList.add('on'); setTimeout(()=>f.classList.remove('on'),140); }
535
+ if(a.sound!==false) beep();
536
+ const tail = note ? note : esc(ev.event_type||ev.event_name||shortUrl(ev.url)||'');
537
+ const b=document.getElementById('alertbanner'); b.innerHTML='<span>'+esc(a.say||'ALERT')+' — '+tail+'</span><span class="x">dismiss ✕</span>'; b.classList.add('on');
538
+ if(!alertTimer) alertTimer=setTimeout(dismissAlert,2500);
539
+ }
540
+ function tick(){ const now=Date.now(), w=(SPEC.rateWindowSec||10)*1000; while(times.length&&times[0]<now-w) times.shift(); if(rateEl) rateEl.textContent=times.length; perSec.push(0); if(perSec.length>30) perSec.shift(); if(sparkEl){ const mx=Math.max(1,...perSec); sparkEl.innerHTML=perSec.map(n=>'<div class="bar" style="height:'+(2+n/mx*56)+'px"></div>').join(''); }
541
+ if(GRAPH){ drawGraph(); for(const s of GRAPH.series){ s.buckets.push(0); s.buckets.shift(); s.counts.push(0); s.counts.shift(); } }
542
+ if((SPEC.source==='log'||SPEC.source==='all') && document.activeElement!==document.getElementById('fpkg')) refreshPkgOptions();
543
+ }
544
+ const GCOLORS=['#39d98a','#ffd479'];
545
+ function unitFor(def){
546
+ if(def.unit) return def.unit;
547
+ if(def.metric==='ratePerSec') return '/s';
548
+ if(def.metric==='avg') return (def.field&&/ms|duration/i.test(def.field))?'ms':'';
549
+ if(def.metric==='errorRate') return '%';
550
+ if(def.metric==='count') return '';
551
+ return '';
552
+ }
553
+ function fmtNum(v){ if(v==null) return '—'; const a=Math.abs(v); return a>=100?Math.round(v):a>=10?Math.round(v*10)/10:Math.round(v*100)/100; }
554
+ function seriesValues(s){
555
+ if(s.def.metric==='count'){ let run=0; return s.buckets.map(b=>run+=b); }
556
+ if(s.def.metric==='avg') return s.buckets.map((b,i)=> s.counts[i]? b/s.counts[i] : null);
557
+ if(s.def.metric==='errorRate') return s.buckets.map((b,i)=> s.counts[i]? b/s.counts[i] : null);
558
+ return s.buckets.slice();
559
+ }
560
+ function drawGraph(){ const svg=document.getElementById('graph'); if(!svg) return;
561
+ const VB_W=640, VB_H=200, N=GRAPH.N;
562
+ const L=46, R=GRAPH.series.length>1?52:14, T=10, B=26;
563
+ const x0=L, x1=VB_W-R, y0=T, y1=VB_H-B, pw=x1-x0, ph=y1-y0;
564
+ let out='', legend='';
565
+ out+='<rect x="'+x0+'" y="'+y0+'" width="'+pw+'" height="'+ph+'" fill="none" stroke="#1e2a3a"/>';
566
+ for(let k=0;k<=4;k++){ const fx=x0+(k/4)*pw; const secsAgo=Math.round((1-k/4)*N); const lbl=secsAgo===0?'now':'-'+secsAgo+'s';
567
+ out+='<line x1="'+fx.toFixed(1)+'" y1="'+y1+'" x2="'+fx.toFixed(1)+'" y2="'+(y1+4)+'" stroke="#33455c"/>';
568
+ out+='<text x="'+fx.toFixed(1)+'" y="'+(y1+16)+'" fill="#5f7186" font-size="10" text-anchor="middle">'+lbl+'</text>'; }
569
+ GRAPH.series.forEach((s,si)=>{ const raw=seriesValues(s);
570
+ const scale = s.def.metric==='errorRate'?100:1;
571
+ let last=null; const vals=raw.map(v=>{ v=(v==null?null:v*scale); if(v!=null){ last=v; return v; } return last; });
572
+ const real=vals.filter(v=>v!=null); const mx=Math.max(1,...real);
573
+ const col=GCOLORS[si], unit=unitFor(s.def);
574
+ const axX = si===0 ? x0 : x1; const anchor = si===0?'end':'start'; const tx = si===0 ? x0-6 : x1+6;
575
+ for(let k=0;k<=2;k++){ const val=mx*k/2; const fy=y1-(k/2)*ph;
576
+ out+='<text x="'+tx+'" y="'+(fy+3).toFixed(1)+'" fill="'+col+'" font-size="10" text-anchor="'+anchor+'">'+fmtNum(val)+'</text>'; }
577
+ out+='<text x="'+axX+'" y="'+(y0-1)+'" fill="'+col+'" font-size="9" text-anchor="'+(si===0?'start':'end')+'">'+esc(unit||s.def.metric)+'</text>';
578
+ const pts=vals.map((v,i)=>{ if(v==null) return null; const px=x0+(i/(N-1))*pw; const py=y1-(v/mx)*ph; return px.toFixed(1)+','+py.toFixed(1); }).filter(Boolean).join(' ');
579
+ if(pts) out+='<polyline fill="none" stroke="'+col+'" stroke-width="2" points="'+pts+'"/>';
580
+ const h=SPEC.highlight;
581
+ if(h && s.def.field && h.field===s.def.field && h.value<=mx){ const ty=y1-(h.value/mx)*ph;
582
+ out+='<line x1="'+x0+'" y1="'+ty.toFixed(1)+'" x2="'+x1+'" y2="'+ty.toFixed(1)+'" stroke="#ff3b30" stroke-width="1" stroke-dasharray="4 3"/>';
583
+ out+='<text x="'+(x0+4)+'" y="'+(ty-3).toFixed(1)+'" fill="#ff6b60" font-size="9">'+esc(h.label||('>'+h.value))+' '+esc(unit||'')+'</text>'; }
584
+ const shown=fmtNum(last)+(unit?(' '+unit):'');
585
+ legend+='<span style="color:'+col+'">■ '+esc(s.def.label||s.def.metric)+': '+shown+'</span> ';
586
+ });
587
+ svg.innerHTML=out; const lg=document.getElementById('legend'); if(lg) lg.innerHTML=legend+' <span style="color:#5f7186">x: time (last '+N+'s, right=now)</span>';
588
+ }
589
+ function shortUrl(u){ try{ return new URL(u).pathname.replace('/api/',''); }catch{ return u||''; } }
590
+ function shortPkg(p){ if(!p) return ''; const parts=String(p).split('.'); return parts.length<=2?p:parts.slice(-2).join('.'); }
591
+ function addRow(ev,now){ if(!has('feed')) return null; const r=document.createElement('div'); r.className='row'; const ts=new Date(ev.timestamp||now).toLocaleTimeString();
592
+ const typeChip = (SPEC.source==='all' && ev._type) ? '<span class="tchip t-'+ev._type+'">'+ev._type+'</span>' : '';
593
+ if((ev._type||SPEC.source)==='log'){
594
+ const lv=(ev.level||'').toUpperCase();
595
+ const pkg = ev.package ? '<span class="logpkg" title="'+esc(ev.package)+'">'+esc(shortPkg(ev.package))+'</span>' : '';
596
+ r.innerHTML='<span class="t">'+ts+'</span>'+typeChip+'<span class="lvl lvl-'+lv+'">'+esc(lv||'LOG')+'</span>'+pkg+'<span class="logtag">'+esc(ev.tag||'')+'</span><span class="logmsg">'+esc(ev.message||'')+'</span>';
597
+ } else {
598
+ r.innerHTML='<span class="t">'+ts+'</span>'+typeChip+'<span class="dir-'+(ev.direction||'')+'">'+(ev.direction||ev.method||'')+'</span><span>'+esc(ev.event_type||ev.event_name||shortUrl(ev.url))+'</span>'+(ev.channel?'<span class="chan">'+esc(ev.channel)+'</span>':'');
599
+ }
600
+ if(showInspector()){ r.classList.add('clickable'); r._ev=ev; r.onclick=()=>inspect(r,ev); }
601
+ if(breaches(ev)){ r.classList.add('breach'); const h=SPEC.highlight; const v=Number(ev[h.field]);
602
+ const unit = /ms|duration/i.test(h.field)?'ms' : (h.field==='status_code'?'':'');
603
+ const b=document.createElement('span'); b.className='breachbadge'; b.textContent=fmtNum(v)+unit+' '+(h.op||'>')+' '+h.value+unit;
604
+ r.appendChild(b); }
605
+ r._search=(r.textContent||'').toLowerCase(); r._ev=ev; r._ts=ev.timestamp||now;
606
+ if(FEED.filter && !r._search.includes(FEED.filter)) r.classList.add('hidden');
607
+ const atTop = feed.scrollTop <= 4;
608
+ feed.insertBefore(r,feed.firstChild); while(feed.children.length>200) feed.removeChild(feed.lastChild);
609
+ if(!atTop){ feed.scrollTop += r.offsetHeight; }
610
+ if(FEED.sort!=='newest') applyFeedControls(); else updateFeedCount();
611
+ return r; }
612
+ const LOG_LEVEL_RANK={ FATAL:5, ERROR:4, WARN:3, INFO:2, DEBUG:1, VERBOSE:0 };
613
+ const TYPE_RANK={ network:0, rtm:1, clog:2, log:3 };
614
+ function sortOptionsFor(spec){
615
+ const src=spec.source, opts=[{key:'newest',label:'newest first',get:r=>r._ts,dir:'desc'},{key:'oldest',label:'oldest first',get:r=>r._ts,dir:'asc'}];
616
+ if(src==='all'){
617
+ opts.push({key:'type',label:'group by type',get:r=>TYPE_RANK[r._ev._type]??9,dir:'asc'});
618
+ return opts;
619
+ }
620
+ if(src==='network'){
621
+ opts.push({key:'slow',label:'slowest (response time)',get:r=>Number(r._ev.duration_ms),dir:'desc'});
622
+ opts.push({key:'status',label:'status code (high→low)',get:r=>Number(r._ev.status_code),dir:'desc'});
623
+ }
624
+ if(src==='log') opts.push({key:'severity',label:'severity (errors first)',get:r=>LOG_LEVEL_RANK[(r._ev.level||'').toUpperCase()]??-1,dir:'desc'});
625
+ if(src==='rtm') opts.push({key:'evtype',label:'event type (A→Z)',get:r=>r._ev.event_type||'',dir:'asc'});
626
+ if(src==='clog') opts.push({key:'name',label:'clog name (A→Z)',get:r=>r._ev.event_name||'',dir:'asc'});
627
+ if(spec.highlight && spec.highlight.field && !opts.some(o=>o.key===spec.highlight.field))
628
+ opts.push({key:spec.highlight.field,label:spec.highlight.field+' (high→low)',get:r=>Number(r._ev[spec.highlight.field]),dir:'desc'});
629
+ return opts;
630
+ }
631
+ function breaches(ev){ const h=SPEC.highlight; if(!h) return false; const v=Number(ev[h.field]); if(isNaN(v)) return false;
632
+ const op=h.op||'>'; return op==='>'?v>h.value:op==='>='?v>=h.value:op==='<'?v<h.value:op==='<='?v<=h.value:v===h.value; }
633
+
634
+ // ---- feed controls: instant filter, pause, dynamic sort (operate on rendered rows) ----
635
+ let FEED={ filter:'', sort:'newest', paused:false, opts:[] };
636
+ function setupFeedControls(){
637
+ const opts=sortOptionsFor(SPEC);
638
+ const stickySort = opts.some(o=>o.key===FEED.sort) ? FEED.sort : 'newest';
639
+ FEED={ filter:FEED.filter||'', sort:stickySort, paused:false, opts };
640
+ const bar=document.getElementById('feedbar'); if(!bar) return;
641
+ bar.style.display = has('feed') ? 'flex' : 'none';
642
+ const fi=document.getElementById('ffilter'), fs=document.getElementById('fsort'), fp=document.getElementById('fpause');
643
+ fi.value=FEED.filter; fp.textContent='⏸ live'; fp.classList.remove('paused');
644
+ fs.innerHTML=FEED.opts.map(o=>'<option value="'+o.key+'">'+o.label+'</option>').join('');
645
+ fs.value=FEED.sort;
646
+ const fx=document.getElementById('ffilterclear');
647
+ const syncClear=()=>{ if(fx) fx.style.display = fi.value ? 'block' : 'none'; };
648
+ const clearFilterBox=()=>{ fi.value=''; FEED.filter=''; syncClear(); applyFeedControls(); fi.focus(); };
649
+ syncClear();
650
+ fi.oninput=()=>{ FEED.filter=fi.value.trim().toLowerCase(); syncClear(); applyFeedControls(); };
651
+ fi.onkeydown=(e)=>{ if(e.key==='Escape') clearFilterBox(); };
652
+ if(fx) fx.onclick=clearFilterBox;
653
+ fs.onchange=()=>{ FEED.sort=fs.value; applyFeedControls(); };
654
+ fp.onclick=()=>{ FEED.paused=!FEED.paused; fp.textContent=FEED.paused?'▶ paused':'⏸ live'; fp.classList.toggle('paused',FEED.paused); };
655
+ setupPkgControl();
656
+ renderChips();
657
+ }
658
+ function renderChips(){
659
+ const wrap=document.getElementById('chips'); if(!wrap) return;
660
+ if(!SPEC||SPEC.blank||!has('feed')){ wrap.style.display='none'; return; }
661
+ const m=(SPEC.match&&typeof SPEC.match==='object')?SPEC.match:{};
662
+ const items=Object.entries(m).map(([k,v])=>({k,v,kind:'match'}));
663
+ if(SPEC.bodyContains) items.push({k:'body contains',v:SPEC.bodyContains,kind:'body'});
664
+ if(!items.length){ wrap.style.display='none'; return; }
665
+ wrap.style.display='flex';
666
+ wrap.innerHTML=items.map((it,i)=>'<span class="chip"><b>'+esc(it.k)+':</b><span class="v">'+esc(String(it.v))+'</span><span class="x" data-i="'+i+'">✕</span></span>').join('');
667
+ wrap.querySelectorAll('.x').forEach(x=>x.onclick=()=>{ const it=items[Number(x.dataset.i)]; clearFilter(it); });
668
+ }
669
+ async function clearFilter(it){
670
+ const spec=JSON.parse(JSON.stringify(SPEC));
671
+ if(it.kind==='body') delete spec.bodyContains;
672
+ else { if(spec.match) delete spec.match[it.k]; if(spec.match&&!Object.keys(spec.match).length) delete spec.match; }
673
+ await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)}).catch(()=>{});
674
+ }
675
+ function setupPkgControl(){
676
+ const wrap=document.getElementById('fpkgwrap'), sel=document.getElementById('fpkg');
677
+ if(!wrap||!sel) return;
678
+ const isLogish = SPEC.source==='log'||SPEC.source==='all';
679
+ wrap.style.display = isLogish ? 'flex' : 'none';
680
+ if(!isLogish) return;
681
+ refreshPkgOptions();
682
+ sel.onchange=async()=>{ const v=sel.value;
683
+ const spec=JSON.parse(JSON.stringify(SPEC)); spec.match=spec.match||{};
684
+ if(v==='__all__') delete spec.match.package; else spec.match.package=v;
685
+ if(!Object.keys(spec.match).length) delete spec.match;
686
+ await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)}).catch(()=>{});
687
+ };
688
+ }
689
+ // Package options are learned live from the stream (observed in SEEN.package). No internal
690
+ // package list is baked in; the running debug build surfaces as soon as it emits a line.
691
+ function refreshPkgOptions(){
692
+ const sel=document.getElementById('fpkg'); if(!sel) return;
693
+ const cur=(SPEC.match&&SPEC.match.package)||'__all__';
694
+ const observed=[...(SEEN.package||new Set())].sort();
695
+ let pkgs=observed.slice();
696
+ if(cur!=='__all__'&&!pkgs.includes(cur)) pkgs.unshift(cur);
697
+ const opts=['<option value="__all__">all apps</option>'].concat(pkgs.map(p=>'<option value="'+esc(p)+'"'+(p===cur?' selected':'')+'>'+esc(shortPkg(p))+'</option>'));
698
+ sel.innerHTML=opts.join(''); sel.value=cur;
699
+ }
700
+ function applyFeedControls(){ if(!feed) return; const rows=[...feed.children];
701
+ let shown=0; for(const r of rows){ const hide = FEED.filter && !(r._search||'').includes(FEED.filter); r.classList.toggle('hidden',hide); if(!hide) shown++; }
702
+ const opt=FEED.opts.find(o=>o.key===FEED.sort)||FEED.opts[0];
703
+ const val=(r)=>{ const v=opt.get(r); return (v==null||(typeof v==='number'&&isNaN(v)))?null:v; };
704
+ rows.sort((a,b)=>{ const va=val(a), vb=val(b); if(va==null&&vb==null) return 0; if(va==null) return 1; if(vb==null) return -1;
705
+ if(va<vb) return opt.dir==='asc'?-1:1; if(va>vb) return opt.dir==='asc'?1:-1; return 0; });
706
+ for(const r of rows) feed.appendChild(r);
707
+ updateFeedCount(shown);
708
+ }
709
+ function updateFeedCount(shown){ const c=document.getElementById('fcount'); if(!c||!feed) return;
710
+ const total=feed.children.length; if(shown==null) shown=[...feed.children].filter(r=>!r.classList.contains('hidden')).length;
711
+ c.textContent = FEED.filter ? (shown+' / '+total+' shown') : (total+' rows');
712
+ syncCounter(shown); }
713
+ function syncCounter(shown){
714
+ if(!counterEl) return;
715
+ const label=document.getElementById('counterLabel');
716
+ if(FEED && FEED.filter){
717
+ if(shown==null) shown=[...feed.children].filter(r=>!r.classList.contains('hidden')).length;
718
+ counterEl.textContent=shown;
719
+ if(label) label.textContent = total>feed.children.length ? 'matching "'+FEED.filter+'" (recent)' : 'matching "'+FEED.filter+'"';
720
+ } else {
721
+ counterEl.textContent=total;
722
+ if(label) label.textContent='total matched';
723
+ }
724
+ }
725
+ let inspectToken=0;
726
+ async function inspect(row,ev){
727
+ const insp=document.getElementById('inspector');
728
+ if(row.classList.contains('sel')){ row.classList.remove('sel'); insp.innerHTML='<div class="label">inspector</div><div class="empty">click a row to see its full detail</div>'; inspectToken++; return; }
729
+ document.querySelectorAll('.row.sel').forEach(x=>x.classList.remove('sel')); row.classList.add('sel');
730
+ const myToken=++inspectToken;
731
+ insp.innerHTML='<div class="label">inspector</div><div class="empty">loading…</div>';
732
+ let detail=ev;
733
+ const kind=ev._type||SPEC.source;
734
+ if(ev.id!=null && kind!=='clog' && kind!=='log'){ try{ detail=await (await fetch('/detail?kind='+kind+'&id='+ev.id)).json(); }catch{} }
735
+ if(myToken!==inspectToken) return;
736
+ const shown=JSON.parse(JSON.stringify(detail));
737
+ for(const k of ['response_body','request_body','data']){ if(typeof shown[k]==='string'){ try{ shown[k]=JSON.parse(shown[k]); }catch{} } }
738
+ INSPECTED=detail; // raw detail for the copy actions
739
+ // Copy actions: JSON always; Body + cURL when this is a network call (has url/method).
740
+ const isNet=(kind==='network')||detail.url!=null||detail.method!=null;
741
+ let btns='<button class="copybtn" data-c="json">Copy JSON</button>';
742
+ if(isNet) btns+='<button class="copybtn" data-c="body">Copy body</button><button class="copybtn" data-c="curl">Copy cURL</button>';
743
+ insp.innerHTML='<div class="label">inspector — '+esc(ev.event_name||ev.event_type||shortUrl(ev.url)||'detail')+'</div><div class="copybar">'+btns+'</div><pre>'+esc(JSON.stringify(shown,null,2))+'</pre>';
744
+ insp.querySelectorAll('.copybtn').forEach(b=>b.onclick=()=>copyInspected(b.dataset.c,b));
745
+ }
746
+ let INSPECTED=null;
747
+ function copyInspected(kind,btn){ const d=INSPECTED||{}; let text='';
748
+ if(kind==='json') text=JSON.stringify(d,null,2);
749
+ else if(kind==='body') text=String(d.response_body??d.request_body??'');
750
+ else if(kind==='curl') text=toCurl(d);
751
+ navigator.clipboard?.writeText(text).then(()=>{ const o=btn.textContent; btn.textContent='copied ✓'; setTimeout(()=>btn.textContent=o,1200); }).catch(()=>{});
752
+ }
753
+ // Build a runnable curl from a captured network call. Headers + method + body if present.
754
+ function toCurl(d){ if(!d.url) return ''; const parts=["curl -i"];
755
+ if(d.method && d.method!=='GET') parts.push("-X "+d.method);
756
+ const h=d.request_headers||d.requestHeaders||{};
757
+ for(const k of Object.keys(h)) parts.push("-H "+shq(k+": "+h[k]));
758
+ const body=d.request_body||d.requestBody; if(body) parts.push("--data "+shq(typeof body==='string'?body:JSON.stringify(body)));
759
+ parts.push(shq(d.url));
760
+ return parts.join(" \\\n ");
761
+ }
762
+ function shq(s){ return "'"+String(s).replace(/'/g,"'\\''")+"'"; }
763
+ function appendExtract(row,val){ const s=document.createElement('span'); s.className='extract'; s.textContent=(SPEC.extract.label?SPEC.extract.label+': ':'')+val; row.appendChild(s); }
764
+ // Escapes the five HTML-significant chars. Quotes are included because esc() output is
765
+ // interpolated into double-quoted ATTRIBUTES (title="...", value="..."), not just text
766
+ // content — a spec-authored title/value containing a quote would otherwise break out of the
767
+ // attribute and inject an event handler (DOM-XSS with no < or > needed). All esc() call
768
+ // sites in this file feed HTML, so escaping quotes is safe everywhere.
769
+ function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
770
+ function card(label,inner){ const d=document.createElement('div'); d.className='card'; d.innerHTML='<div class="label">'+label+'</div>'+inner; return d; }
771
+ function big(v,id,unit){ return '<div class="big"><span id="'+id+'">'+v+'</span>'+(unit?'<span class="unit">'+unit+'</span>':'')+'</div>'; }
772
+
773
+ pollSpec(); // initial build
774
+ </script></body></html>