@slack/radar-mcp 1.4.0 → 1.5.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/README.md +9 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/tools.js +11 -0
- package/dist/shared/android.d.ts +8 -0
- package/dist/shared/android.js +10 -0
- package/dist/shared/constants.d.ts +14 -0
- package/dist/shared/constants.js +14 -0
- package/dist/shared/db.d.ts +75 -0
- package/dist/shared/db.js +403 -0
- package/dist/shared/screen.d.ts +132 -0
- package/dist/shared/screen.js +576 -0
- package/dist/web/bin.js +20 -1
- package/dist/web/log-session.d.ts +89 -0
- package/dist/web/log-session.js +144 -0
- package/dist/web/public/index.html +321 -13
- package/dist/web/server.d.ts +13 -11
- package/dist/web/server.js +399 -48
- package/dist/web/spec.d.ts +13 -5
- package/dist/web/spec.js +23 -5
- package/package.json +1 -1
|
@@ -108,11 +108,71 @@
|
|
|
108
108
|
.copybar { display:flex; gap:6px; margin-bottom:8px; flex-wrap:wrap; }
|
|
109
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
110
|
.copybtn:hover { border-color:#39d98a; color:#39d98a; }
|
|
111
|
+
/* DB browser (source:"db") */
|
|
112
|
+
.dbview { display:flex; gap:16px; max-width:1180px; margin-top:8px; height:72vh; }
|
|
113
|
+
.dbside { flex:0 0 260px; overflow:auto; border:1px solid #1e2a3a; border-radius:10px; padding:12px; }
|
|
114
|
+
.dbsec { color:#5f7186; font-size:10.5px; text-transform:uppercase; letter-spacing:.06em; margin:6px 0 6px; }
|
|
115
|
+
.dbsec:not(:first-child){ margin-top:14px; }
|
|
116
|
+
.dbitem { padding:6px 9px; border-radius:7px; cursor:pointer; font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
117
|
+
.dbitem:hover { background:#172230; } .dbitem.sel { background:#1d2c3e; color:#39d98a; }
|
|
118
|
+
.dbitem .rows { float:right; color:#5f7186; font-size:11px; }
|
|
119
|
+
.dbmain { flex:1; min-width:0; display:flex; flex-direction:column; }
|
|
120
|
+
.dbqbar { display:flex; gap:8px; margin-bottom:8px; }
|
|
121
|
+
.dbqbar input { flex:1; background:#121925; border:1px solid #243244; border-radius:8px; color:#e8f0f8; padding:8px 11px; font:inherit; font-size:12.5px; }
|
|
122
|
+
.dbqbar input:focus { outline:none; border-color:#39d98a; }
|
|
123
|
+
.dbqbar button { background:#1c3a2a; border:1px solid #39d98a; color:#39d98a; border-radius:8px; padding:0 16px; font:inherit; font-size:12.5px; font-weight:600; cursor:pointer; }
|
|
124
|
+
.dbmeta { color:#5f7186; font-size:11.5px; margin-bottom:8px; }
|
|
125
|
+
.dbprivacy { background:#2a2112; border:1px solid #6b5320; color:#e8c879; border-radius:8px; padding:8px 11px; font-size:11.5px; line-height:1.5; margin-bottom:10px; }
|
|
126
|
+
.dbprofile { color:#7d9bd6; font-size:11.5px; margin-bottom:8px; }
|
|
127
|
+
.dbprofile b { color:#cfe0f0; }
|
|
128
|
+
.dbprofcap { color:#5f7186; text-transform:uppercase; letter-spacing:.06em; font-size:10px; }
|
|
129
|
+
.dbprofile select { background:#121925; border:1px solid #243244; border-radius:6px; color:#e8f0f8; padding:3px 6px; font:inherit; font-size:11px; margin-left:4px; }
|
|
130
|
+
.dbrefresh { background:#1c2a3a; border:1px solid #2c3e52; border-radius:6px; color:#8aa0b4; padding:2px 8px; font:inherit; font-size:11px; cursor:pointer; margin-left:8px; }
|
|
131
|
+
.dbrefresh:hover { border-color:#39d98a; color:#39d98a; }
|
|
132
|
+
.dbout { flex:1; overflow:auto; border:1px solid #1e2a3a; border-radius:10px; }
|
|
133
|
+
.dbout table { border-collapse:collapse; width:100%; font-size:12px; }
|
|
134
|
+
.dbout th { position:sticky; top:0; background:#121925; color:#8aa0b4; text-align:left; padding:7px 10px; border-bottom:1px solid #1e2a3a; white-space:nowrap; }
|
|
135
|
+
.dbout td { padding:6px 10px; border-bottom:1px solid #16202c; max-width:380px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
136
|
+
.dbout tr:hover td { background:#10161f; }
|
|
137
|
+
.msg { color:#5f7186; font-size:12px; padding:10px; }
|
|
138
|
+
.msg.err { color:#ff6b60; }
|
|
139
|
+
/* Screen overlay: a global panel docked to the right, available over any tab. */
|
|
140
|
+
.screenbtn { background:#121925; border:1px solid #243244; color:#8aa0b4; border-radius:8px; padding:5px 12px; font:inherit; font-size:12.5px; cursor:pointer; margin-left:8px; }
|
|
141
|
+
.screenbtn:hover { border-color:#39d98a; color:#dfe7ef; }
|
|
142
|
+
.screenbtn.on { background:#1c3a2a; border-color:#39d98a; color:#39d98a; }
|
|
143
|
+
#screenpanel { position:fixed; top:0; right:0; bottom:0; width:340px; background:#0b1016; border-left:1px solid #1e2a3a; z-index:40; display:none; flex-direction:column; padding:12px; box-shadow:-4px 0 16px rgba(0,0,0,.4); overflow:auto; }
|
|
144
|
+
#screenpanel.on { display:flex; }
|
|
145
|
+
body.screenopen { padding-right:356px; }
|
|
146
|
+
#screenpanel .schead { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
|
|
147
|
+
#screenpanel .schead .sctitle { font-size:12.5px; color:#dfe7ef; font-weight:600; flex:1; }
|
|
148
|
+
#screenpanel .scbtn { background:#121925; border:1px solid #243244; color:#8aa0b4; border-radius:6px; padding:3px 9px; font:inherit; font-size:11.5px; cursor:pointer; }
|
|
149
|
+
#screenpanel .scbtn:hover { border-color:#39d98a; color:#dfe7ef; }
|
|
150
|
+
#screenpanel .scbtn.rec { color:#ff6b60; border-color:#5a2c2c; }
|
|
151
|
+
#screenimg { width:100%; border:1px solid #1e2a3a; border-radius:8px; background:#000; min-height:120px; }
|
|
152
|
+
#schint { font-size:11.5px; color:#5f7186; margin-top:8px; line-height:1.5; }
|
|
153
|
+
.scconsent { background:#161b22; border:1px solid #2c3a4a; border-radius:8px; padding:12px; font-size:12px; color:#cfd8e3; line-height:1.55; }
|
|
154
|
+
.scconsent b { color:#ffd479; }
|
|
155
|
+
.scconsent .scaccept { background:#1c3a2a; border:1px solid #39d98a; color:#39d98a; border-radius:6px; padding:6px 14px; font:inherit; font-size:12px; cursor:pointer; margin-top:10px; }
|
|
156
|
+
.scconsent .scaccept:hover { background:#24492f; }
|
|
157
|
+
.scinstall { background:#0e151e; border:1px solid #1e2a3a; border-radius:6px; padding:8px 10px; margin-top:8px; font-size:11.5px; color:#cfe3d6; }
|
|
158
|
+
.scinstall code { color:#7fd6a3; }
|
|
159
|
+
.scinstall .sccopy { background:#172a1f; border:1px solid #2c5a3e; color:#7fd6a3; border-radius:5px; padding:2px 8px; font:inherit; font-size:11px; cursor:pointer; margin-left:6px; }
|
|
111
160
|
</style></head>
|
|
112
161
|
<body>
|
|
113
162
|
<div id="flash"></div>
|
|
114
163
|
<div id="alertbanner"></div>
|
|
115
|
-
<h1>SLACK RADAR DASHBOARD <span class="conn waiting" id="conn">● connecting…</span> <button id="reconnect" class="reconnect" style="display:none">⟳ Reconnect Radar</button></h1>
|
|
164
|
+
<h1>SLACK RADAR DASHBOARD <span class="conn waiting" id="conn">● connecting…</span> <button id="reconnect" class="reconnect" style="display:none">⟳ Reconnect Radar</button> <button id="screenbtn" class="screenbtn" title="Mirror the live device screen">▣ Screen</button></h1>
|
|
165
|
+
<div id="screenpanel">
|
|
166
|
+
<div class="schead">
|
|
167
|
+
<span class="sctitle">Device screen</span>
|
|
168
|
+
<button class="scbtn" id="screenshot" title="Full-resolution screenshot">📷</button>
|
|
169
|
+
<button class="scbtn" id="screenrec" title="Record to mp4">● rec</button>
|
|
170
|
+
<button class="scbtn" id="screenredetect" title="Re-detect the active display">⟳</button>
|
|
171
|
+
<button class="scbtn" id="screenclose" title="Close">✕</button>
|
|
172
|
+
</div>
|
|
173
|
+
<div id="screenbody"></div>
|
|
174
|
+
<div id="schint"></div>
|
|
175
|
+
</div>
|
|
116
176
|
<div class="modebar" id="modebar"></div>
|
|
117
177
|
<div class="status" id="status"></div>
|
|
118
178
|
<div class="sub" id="sub"></div>
|
|
@@ -135,6 +195,15 @@
|
|
|
135
195
|
</div>
|
|
136
196
|
<div class="inspector" id="inspector" style="display:none"></div>
|
|
137
197
|
</div>
|
|
198
|
+
<div class="dbview" id="dbview" style="display:none">
|
|
199
|
+
<div class="dbside"><div class="dbsec">databases</div><div id="db_dbs" class="msg">loading…</div><div class="dbsec">tables</div><div id="db_tbls" class="msg">pick a database</div></div>
|
|
200
|
+
<div class="dbmain">
|
|
201
|
+
<div class="dbprivacy">Privacy: opening a database copies the on-device Slack store (message text, request/response bodies, user, channel, and team IDs) in plaintext to a temp file on this machine. The copy is read-only and auto-deleted when this dashboard stops. Review before sharing anything from here.</div>
|
|
202
|
+
<div class="dbqbar"><input id="db_sql" placeholder="SELECT * FROM table LIMIT 100 (read-only)" autocomplete="off"/><button id="db_run">Run</button></div>
|
|
203
|
+
<div class="dbmeta" id="db_meta"></div>
|
|
204
|
+
<div class="dbout" id="db_out"><div class="msg">pick a database, then a table</div></div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
138
207
|
<script>
|
|
139
208
|
// The web page is just pre-built dashboard TABS over the device Radar stream. No in-page
|
|
140
209
|
// Claude. Customization happens in a terminal Claude Code session (via the
|
|
@@ -154,11 +223,12 @@ let SPEC=null;
|
|
|
154
223
|
// Presets use only generic placeholders. The dashboard learns the real endpoints,
|
|
155
224
|
// channels, and event names LIVE from the device stream; nothing internal is baked in.
|
|
156
225
|
const MODES=[
|
|
157
|
-
{key:'all', label:'Live', spec:{title:'
|
|
226
|
+
{key:'all', label:'Live', spec:{title:'Live',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
227
|
{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
228
|
{key:'rtm', label:'RTM', spec:{title:'RTM',summary:'Real-time websocket events as they happen.',source:'rtm',viz:['counter','rate','feed','inspector']}},
|
|
160
229
|
{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
230
|
{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'}}},
|
|
231
|
+
{key:'db', label:'Database', spec:{title:'Device DB',summary:'Browse the on-device SQLite databases (read-only snapshot).',source:'db'}},
|
|
162
232
|
];
|
|
163
233
|
// A spec is "custom" when it did NOT come from a MODES preset — i.e. a terminal Claude
|
|
164
234
|
// session pushed it via the open_radar_dashboard MCP tool. We detect that by comparing
|
|
@@ -166,6 +236,13 @@ const MODES=[
|
|
|
166
236
|
let CUSTOM_ACTIVE=false; // is the live spec the custom (Claude-pushed) one RIGHT NOW?
|
|
167
237
|
let lastCustomSpec=null; // the most-recent custom spec; keeps the Custom tab present
|
|
168
238
|
// until a page refresh, so you can flip back to it.
|
|
239
|
+
// A spec pushed by THIS browser (a Filters-panel Apply, or a tab click) is a local edit, not
|
|
240
|
+
// a Claude-pushed custom dashboard — so it must NOT spawn a "Custom" tab. localPushKey holds
|
|
241
|
+
// the JSON of the next spec we expect back from /spec because we just pushed it; pollSpec sees
|
|
242
|
+
// it match and treats it as a local edit (stays on the current tab). Only a non-preset spec we
|
|
243
|
+
// did NOT push (an MCP open_radar_dashboard) becomes the Custom tab.
|
|
244
|
+
let localPushKey=null;
|
|
245
|
+
function markLocalPush(spec){ localPushKey=JSON.stringify(spec); }
|
|
169
246
|
function specMatchesMode(spec){ if(!spec||spec.blank) return null;
|
|
170
247
|
return MODES.find(m=>JSON.stringify(m.spec)===JSON.stringify(spec))?.key || null; }
|
|
171
248
|
function renderModes(){ const bar=document.getElementById('modebar'); if(!bar) return;
|
|
@@ -239,8 +316,11 @@ document.addEventListener('keydown',(e)=>{
|
|
|
239
316
|
// ---- poll /spec; rebuild when it changes, and show/hide the Custom tab accordingly ----
|
|
240
317
|
let specKey='';
|
|
241
318
|
async function pollSpec(){ try{ const spec=await (await fetch('/spec')).json(); const key=JSON.stringify(spec); if(key===specKey) return; specKey=key;
|
|
242
|
-
//
|
|
243
|
-
|
|
319
|
+
// A non-blank spec that matches no preset is a Claude-pushed custom dashboard — UNLESS this
|
|
320
|
+
// browser just pushed it (a Filters-panel Apply or a tab click), which is a local edit and
|
|
321
|
+
// must stay on the current tab, not spawn a Custom tab.
|
|
322
|
+
const isLocalEdit = key===localPushKey; if(isLocalEdit) localPushKey=null;
|
|
323
|
+
CUSTOM_ACTIVE = !isLocalEdit && !!spec && !spec.blank && specMatchesMode(spec)===null;
|
|
244
324
|
let needRerender=false;
|
|
245
325
|
if(CUSTOM_ACTIVE){ // remember it so the tab persists; re-render if it's new/changed
|
|
246
326
|
if(!lastCustomSpec || JSON.stringify(lastCustomSpec)!==JSON.stringify(spec)){ lastCustomSpec=spec; needRerender=true; }
|
|
@@ -259,10 +339,20 @@ async function bootDefault(){ try{ const cur=await (await fetch('/spec')).json()
|
|
|
259
339
|
bootDefault();
|
|
260
340
|
// Connection dot driven by an always-on device health check, independent of whether
|
|
261
341
|
// a dashboard is built (a blank dashboard has no stream, so the dot was otherwise dead).
|
|
262
|
-
let lastEventTs=0;
|
|
342
|
+
let lastEventTs=0, healthMisses=0;
|
|
343
|
+
// Clear a stale red "device unreachable / backfill failed" status once health is back. These
|
|
344
|
+
// errors are set once (e.g. at build/backfill time) and were otherwise sticky until a tab
|
|
345
|
+
// switch — so a transient blip left the red banner up long after the device recovered.
|
|
346
|
+
function clearStaleError(){ if(statusEl.classList.contains('err')) setStatus('',''); }
|
|
263
347
|
async function pollHealth(){
|
|
264
|
-
|
|
265
|
-
|
|
348
|
+
// Recent stream activity proves liveness without a /health round-trip.
|
|
349
|
+
if(Date.now()-lastEventTs<6000){ healthMisses=0; setConn('connected',''); clearStaleError(); return; }
|
|
350
|
+
try{ const h=await (await fetch('/health')).json();
|
|
351
|
+
if(h.device){ healthMisses=0; setConn('connected',''); clearStaleError(); }
|
|
352
|
+
// Require TWO consecutive misses before flipping the dot — a single slow/missed poll
|
|
353
|
+
// (while the stream is otherwise fine) should not flash "connecting".
|
|
354
|
+
else if(++healthMisses>=2){ setConn('waiting','device offline, retrying…'); }
|
|
355
|
+
}catch{ if(++healthMisses>=2) setConn('waiting','reconnecting…'); } }
|
|
266
356
|
pollHealth(); setInterval(pollHealth,2000);
|
|
267
357
|
|
|
268
358
|
// ---- the dashboard engine: build(spec) tears down and rebuilds widgets live ----
|
|
@@ -282,7 +372,21 @@ function build(spec){
|
|
|
282
372
|
counterEl=rateEl=sparkEl=null;
|
|
283
373
|
const insp=document.getElementById('inspector'), stage=document.getElementById('stage');
|
|
284
374
|
if(es){ es.close(); es=null; } if(tickTimer){ clearInterval(tickTimer); tickTimer=null; }
|
|
285
|
-
|
|
375
|
+
const dbv=document.getElementById('dbview');
|
|
376
|
+
const fp=document.getElementById('filterpanel');
|
|
377
|
+
if(SPEC.blank){ document.getElementById('sub').textContent='Pick a dashboard above (Live, Network, RTM, Clogs, Logs, Database). 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'); if(fp) fp.style.display='none'; stage.style.display='flex'; if(dbv) dbv.style.display='none'; return; }
|
|
378
|
+
if(SPEC.source==='db'){
|
|
379
|
+
// The DB browser is a distinct, point-in-time view (no stream/widgets/filters). Hide the
|
|
380
|
+
// streaming UI + the Filters panel, show the db list -> tables -> query grid.
|
|
381
|
+
stage.style.display='none'; cards.style.display='none'; insp.style.display='none';
|
|
382
|
+
if(fp) fp.style.display='none';
|
|
383
|
+
const subEl=document.getElementById('sub');
|
|
384
|
+
subEl.innerHTML='<span class="summary">'+esc(SPEC.summary||'Device database browser')+'</span><span class="tech">source=db (on-device SQLite snapshot, read-only)</span>';
|
|
385
|
+
if(dbv) dbv.style.display='flex';
|
|
386
|
+
buildDbView(SPEC);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if(dbv) dbv.style.display='none';
|
|
286
390
|
document.getElementById('stage').style.display='flex'; cards.style.display='grid';
|
|
287
391
|
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
392
|
else { insp.style.display='none'; }
|
|
@@ -290,7 +394,7 @@ function build(spec){
|
|
|
290
394
|
stage.classList.toggle('split', split); stage.classList.toggle('haswidth', split);
|
|
291
395
|
setupFeedControls();
|
|
292
396
|
const subEl=document.getElementById('sub');
|
|
293
|
-
const tech=
|
|
397
|
+
const tech=describeConstraints(SPEC);
|
|
294
398
|
subEl.innerHTML = SPEC.summary
|
|
295
399
|
? '<span class="summary">'+esc(SPEC.summary)+'</span><span class="tech">'+esc(tech)+'</span>'
|
|
296
400
|
: esc(SPEC.title+' — '+tech);
|
|
@@ -426,6 +530,7 @@ async function applyFilters(){
|
|
|
426
530
|
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
531
|
const rw=Number(g('f_rate').value); if(rw>0) spec.rateWindowSec=rw;
|
|
428
532
|
const msg=g('f_msg'); msg.textContent='applying…';
|
|
533
|
+
markLocalPush(spec); // a hand-edit of the current view, not a Claude-pushed Custom dashboard
|
|
429
534
|
try{ const r=await (await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)})).json();
|
|
430
535
|
msg.textContent = r.ok?'applied ✓':('⚠ '+(r.error||'rejected')); }
|
|
431
536
|
catch{ msg.textContent='⚠ failed'; }
|
|
@@ -436,13 +541,18 @@ function setConn(state,msg){ const d=document.getElementById('conn'); if(!d) ret
|
|
|
436
541
|
const rc=document.getElementById('reconnect'); if(rc && !rc._busy) rc.style.display = state==='connected' ? 'none' : 'inline-block'; }
|
|
437
542
|
async function doReconnect(){ const rc=document.getElementById('reconnect'); if(!rc) return; rc._busy=true; rc.disabled=true; rc.textContent='⟳ enabling…';
|
|
438
543
|
try{ await fetch('/enable',{method:'POST'}); }catch{}
|
|
439
|
-
rc._busy=false; rc.disabled=false; rc.textContent='⟳ Reconnect Radar'; pollHealth(); }
|
|
544
|
+
rc._busy=false; rc.disabled=false; rc.textContent='⟳ Reconnect Radar'; pollHealth(); pollSpec(); }
|
|
440
545
|
// live defaults true so the SSE handler (es.onmessage=onEvent) keeps firing alerts on
|
|
441
546
|
// genuine arrivals; backfill() passes live=false so replayed device HISTORY counts toward
|
|
442
547
|
// widgets without triggering alerts for events that already happened.
|
|
443
548
|
function onEvent(m, live){ if(live===undefined) live=true; let f; try{f=JSON.parse(m.data);}catch{return;}
|
|
444
549
|
if(f.type==='_status'){ setConn(f.event.state, f.event.msg); return; }
|
|
445
550
|
if(f.type==='_error'){ setConn('waiting', f.event.msg); return; }
|
|
551
|
+
// Server-replayed log history is tagged replay:true. Treat it as history (live=false)
|
|
552
|
+
// so it never fires alerts or pulses, even on a fresh browser whose own cache is empty
|
|
553
|
+
// (the server log ring outlives/outsizes the client cache, so reconnect replay can carry
|
|
554
|
+
// frames the client never saw). Live frames are unaffected.
|
|
555
|
+
if(f.replay) live=false;
|
|
446
556
|
if(!['network','rtm','clog','log'].includes(f.type)) return;
|
|
447
557
|
lastEventTs=Date.now(); setConn('connected','');
|
|
448
558
|
const ev=f.event||{}; ev._type=f.type;
|
|
@@ -502,6 +612,22 @@ function replayCache(){
|
|
|
502
612
|
const rows=CACHE.filter(c=> (SPEC.source==='all'||c.type===SPEC.source));
|
|
503
613
|
for(const c of rows){ const ev=c.ev; ev._type=c.type; if(matches(ev)) acceptEvent(ev,false); }
|
|
504
614
|
}
|
|
615
|
+
// Rebuild the FEED rows from the session cache for the current FEED.filter, WITHOUT
|
|
616
|
+
// touching counters/graph (those already counted every event). Needed because addRow now
|
|
617
|
+
// drops non-matching rows from the DOM entirely (not insert-then-hide), so changing or
|
|
618
|
+
// clearing the feed filter cannot just un-hide rows that are no longer there — it must
|
|
619
|
+
// re-add the matching ones from the cache.
|
|
620
|
+
function rebuildFeedFromCache(){
|
|
621
|
+
if(!feed) return;
|
|
622
|
+
feed.innerHTML='';
|
|
623
|
+
const rows=CACHE.filter(c=> (SPEC.source==='all'||c.type===SPEC.source));
|
|
624
|
+
for(const c of rows){ const ev=c.ev; ev._type=c.type;
|
|
625
|
+
if(!matches(ev)) continue;
|
|
626
|
+
const r=addRow(ev, ev.timestamp||Date.now());
|
|
627
|
+
if(r && SPEC.extract) enrich(ev).then(val=>{ if(val!=null&&r) appendExtract(r,val); });
|
|
628
|
+
}
|
|
629
|
+
if(FEED.sort!=='newest') applyFeedControls(); else updateFeedCount();
|
|
630
|
+
}
|
|
505
631
|
// accept an event into the widgets. live=true for stream arrivals (fires alerts, enrich,
|
|
506
632
|
// pulse); live=false for cache replay (silent, time-accurate). Buckets by the event's own
|
|
507
633
|
// timestamp so replayed history lands in the right second of the rate/sparkline/graph.
|
|
@@ -603,7 +729,11 @@ function addRow(ev,now){ if(!has('feed')) return null; const r=document.createEl
|
|
|
603
729
|
const b=document.createElement('span'); b.className='breachbadge'; b.textContent=fmtNum(v)+unit+' '+(h.op||'>')+' '+h.value+unit;
|
|
604
730
|
r.appendChild(b); }
|
|
605
731
|
r._search=(r.textContent||'').toLowerCase(); r._ev=ev; r._ts=ev.timestamp||now;
|
|
606
|
-
|
|
732
|
+
// When the feed text filter is active, DROP non-matching rows entirely instead of
|
|
733
|
+
// inserting-then-hiding them. The 200-row DOM cap below counts every child, so hidden
|
|
734
|
+
// non-matching rows would otherwise flood the cap and evict the matching rows the user
|
|
735
|
+
// is watching for (filtered rows flash in, then vanish as unmatched rows push them out).
|
|
736
|
+
if(FEED.filter && !r._search.includes(FEED.filter)) return null;
|
|
607
737
|
const atTop = feed.scrollTop <= 4;
|
|
608
738
|
feed.insertBefore(r,feed.firstChild); while(feed.children.length>200) feed.removeChild(feed.lastChild);
|
|
609
739
|
if(!atTop){ feed.scrollTop += r.offsetHeight; }
|
|
@@ -645,9 +775,16 @@ function setupFeedControls(){
|
|
|
645
775
|
fs.value=FEED.sort;
|
|
646
776
|
const fx=document.getElementById('ffilterclear');
|
|
647
777
|
const syncClear=()=>{ if(fx) fx.style.display = fi.value ? 'block' : 'none'; };
|
|
648
|
-
const clearFilterBox=()=>{ fi.value=''; FEED.filter=''; syncClear();
|
|
778
|
+
const clearFilterBox=()=>{ fi.value=''; FEED.filter=''; syncClear(); rebuildFeedFromCache(); fi.focus(); };
|
|
649
779
|
syncClear();
|
|
650
|
-
|
|
780
|
+
// Rebuild from cache (not just hide/show) because addRow drops non-matching rows from
|
|
781
|
+
// the DOM, so a filter change must re-add matching rows that are no longer present.
|
|
782
|
+
// Debounced: oninput fires per keystroke, and rebuild re-runs enrich() per matching row
|
|
783
|
+
// on extract dashboards, so an undebounced rebuild-per-keystroke can fan out /detail
|
|
784
|
+
// fetches. Settle ~180ms after the last keystroke before rebuilding.
|
|
785
|
+
let filterTimer=null;
|
|
786
|
+
fi.oninput=()=>{ FEED.filter=fi.value.trim().toLowerCase(); syncClear();
|
|
787
|
+
if(filterTimer) clearTimeout(filterTimer); filterTimer=setTimeout(rebuildFeedFromCache,180); };
|
|
651
788
|
fi.onkeydown=(e)=>{ if(e.key==='Escape') clearFilterBox(); };
|
|
652
789
|
if(fx) fx.onclick=clearFilterBox;
|
|
653
790
|
fs.onchange=()=>{ FEED.sort=fs.value; applyFeedControls(); };
|
|
@@ -767,8 +904,179 @@ function appendExtract(row,val){ const s=document.createElement('span'); s.class
|
|
|
767
904
|
// attribute and inject an event handler (DOM-XSS with no < or > needed). All esc() call
|
|
768
905
|
// sites in this file feed HTML, so escaping quotes is safe everywhere.
|
|
769
906
|
function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
907
|
+
// Human-readable one-line summary of a spec's actual filters + constraints, so every tab
|
|
908
|
+
// (the Custom one especially) explains what it is showing rather than just "source=X".
|
|
909
|
+
// Covers the fields the old "source=X match={}" line dropped: highlight, bodyContains,
|
|
910
|
+
// alert, graph series, rate window. Returns a "·"-joined clause string.
|
|
911
|
+
function describeConstraints(spec){
|
|
912
|
+
const parts=['source '+spec.source];
|
|
913
|
+
if(spec.match && Object.keys(spec.match).length)
|
|
914
|
+
parts.push('where '+Object.entries(spec.match).map(([k,v])=>k+'='+v).join(', '));
|
|
915
|
+
if(spec.bodyContains) parts.push('body contains "'+spec.bodyContains+'"');
|
|
916
|
+
if(spec.highlight && spec.highlight.field)
|
|
917
|
+
parts.push('flag '+spec.highlight.field+' '+(spec.highlight.op||'>')+' '+spec.highlight.value+(spec.highlight.label?' ('+spec.highlight.label+')':''));
|
|
918
|
+
if(Array.isArray(spec.series) && spec.series.length)
|
|
919
|
+
parts.push('graph '+spec.series.map(s=>s.label||s.metric+(s.field?' of '+s.field:'')).join(' vs '));
|
|
920
|
+
if(spec.extract && spec.extract.field) parts.push('extract '+spec.extract.field);
|
|
921
|
+
if(spec.alert){ const a=spec.alert; parts.push('alert'+(a.at?' at '+a.at:'')+(a.say?' "'+a.say+'"':'')); }
|
|
922
|
+
if(spec.rateWindowSec) parts.push('rate window '+spec.rateWindowSec+'s');
|
|
923
|
+
return parts.join(' · ');
|
|
924
|
+
}
|
|
770
925
|
function card(label,inner){ const d=document.createElement('div'); d.className='card'; d.innerHTML='<div class="label">'+label+'</div>'+inner; return d; }
|
|
771
926
|
function big(v,id,unit){ return '<div class="big"><span id="'+id+'">'+v+'</span>'+(unit?'<span class="unit">'+unit+'</span>':'')+'</div>'; }
|
|
772
927
|
|
|
928
|
+
// ---- DB browser view (source:"db"): db list -> tables -> read-only query grid ----
|
|
929
|
+
let DBV={ db:null, user:null };
|
|
930
|
+
// Build the ?user= suffix so every db call targets the profile the operator picked
|
|
931
|
+
// (or the dashboard's bound profile). Keeps list / pull / query on the same store.
|
|
932
|
+
function dbUserQS(){ return DBV.user!=null ? '&user='+encodeURIComponent(DBV.user) : ''; }
|
|
933
|
+
async function buildDbView(spec){
|
|
934
|
+
const dbsEl=document.getElementById('db_dbs'), tblsEl=document.getElementById('db_tbls');
|
|
935
|
+
document.getElementById('db_run').onclick=dbRun;
|
|
936
|
+
document.getElementById('db_sql').onkeydown=e=>{ if(e.key==='Enter') dbRun(); };
|
|
937
|
+
tblsEl.innerHTML='<div class="msg">pick a database</div>';
|
|
938
|
+
document.getElementById('db_out').innerHTML='<div class="msg">pick a database, then a table</div>';
|
|
939
|
+
document.getElementById('db_meta').textContent='';
|
|
940
|
+
dbsEl.innerHTML='<div class="msg">loading…</div>';
|
|
941
|
+
await loadDbList(spec);
|
|
942
|
+
}
|
|
943
|
+
function profileLabel(u){ return u===''||u==null ? 'device' : (u==='0' ? 'personal (user 0)' : 'secondary (user '+esc(u)+')'); }
|
|
944
|
+
async function loadDbList(spec){
|
|
945
|
+
const dbsEl=document.getElementById('db_dbs');
|
|
946
|
+
try{ const r=await (await fetch('/dbs?'+dbUserQS().slice(1))).json();
|
|
947
|
+
if(r.error){ dbsEl.innerHTML='<div class="msg err">'+esc(r.error)+'</div>'; return; }
|
|
948
|
+
if(!r.names||!r.names.length){ dbsEl.innerHTML='<div class="msg">no databases (device unreachable, or debug build not installed)</div>'; return; }
|
|
949
|
+
DBV.user = r.user;
|
|
950
|
+
// Which profile's store this is. With more than one profile, show ONLY a switcher
|
|
951
|
+
// (the dropdown's selected option already names the profile — no redundant text line).
|
|
952
|
+
// With a single profile, show it as plain text.
|
|
953
|
+
const avail=r.availableUsers||[];
|
|
954
|
+
let head;
|
|
955
|
+
if(avail.length>1){
|
|
956
|
+
head='<div class="dbprofile"><span class="dbprofcap">profile</span> <select id="db_userpick" title="which profile\'s on-device store to browse">'+
|
|
957
|
+
avail.map(u=>'<option value="'+esc(u)+'"'+(u===r.user?' selected':'')+'>'+profileLabel(u)+'</option>').join('')+'</select></div>';
|
|
958
|
+
} else {
|
|
959
|
+
head='<div class="dbprofile">profile: <b>'+profileLabel(r.user)+'</b></div>';
|
|
960
|
+
}
|
|
961
|
+
dbsEl.innerHTML=head+r.names.map(n=>'<div class="dbitem" data-n="'+esc(n)+'">'+esc(n)+'</div>').join('');
|
|
962
|
+
dbsEl.querySelectorAll('.dbitem').forEach(d=>d.onclick=()=>dbSelectDb(d.dataset.n,d));
|
|
963
|
+
const pick=document.getElementById('db_userpick');
|
|
964
|
+
if(pick) pick.onchange=()=>{ DBV.user=pick.value; DBV.db=null;
|
|
965
|
+
document.getElementById('db_tbls').innerHTML='<div class="msg">pick a database</div>';
|
|
966
|
+
document.getElementById('db_out').innerHTML='<div class="msg">pick a database, then a table</div>';
|
|
967
|
+
document.getElementById('db_meta').textContent=''; loadDbList(spec); };
|
|
968
|
+
if(spec&&spec.db){ const el=[...dbsEl.querySelectorAll('.dbitem')].find(x=>x.dataset.n===spec.db); if(el) dbSelectDb(spec.db,el,spec.table,spec.sql); }
|
|
969
|
+
}catch(e){ dbsEl.innerHTML='<div class="msg err">'+esc(String(e))+'</div>'; }
|
|
970
|
+
}
|
|
971
|
+
async function dbSelectDb(name,el,wantTable,wantSql,refresh){ DBV.db=name;
|
|
972
|
+
document.querySelectorAll('#db_dbs .dbitem').forEach(x=>x.classList.remove('sel')); if(el) el.classList.add('sel');
|
|
973
|
+
// clear stale SQL/results so a leftover table query can't run against the newly-picked db
|
|
974
|
+
document.getElementById('db_sql').value=''; document.getElementById('db_out').innerHTML='<div class="msg">pick a table or write a query</div>';
|
|
975
|
+
const tblsEl=document.getElementById('db_tbls'); tblsEl.innerHTML='<div class="msg">'+(refresh?'re-pulling':'pulling')+' + reading…</div>';
|
|
976
|
+
try{ const r=await (await fetch('/dbtables?db='+encodeURIComponent(name)+dbUserQS()+(refresh?'&refresh=1':''))).json();
|
|
977
|
+
if(r.error){ tblsEl.innerHTML='<div class="msg err">'+esc(r.error)+'</div>'; return; }
|
|
978
|
+
tblsEl.innerHTML=r.tables.map(t=>'<div class="dbitem" data-t="'+esc(t.name)+'">'+esc(t.name)+'<span class="rows">'+esc(String(t.rows))+'</span></div>').join('');
|
|
979
|
+
tblsEl.querySelectorAll('.dbitem').forEach(d=>d.onclick=()=>dbSelectTbl(d.dataset.t,d));
|
|
980
|
+
document.getElementById('db_meta').innerHTML=esc(name)+' — '+((r.sizeBytes/1024)|0)+' KB snapshot, '+r.tables.length+' tables · '+esc(profileLabel(DBV.user))+' <button class="dbrefresh" id="db_refresh" title="re-pull the latest from the device">↻ refresh</button>';
|
|
981
|
+
const rb=document.getElementById('db_refresh'); if(rb) rb.onclick=()=>dbSelectDb(name,el,null,null,true);
|
|
982
|
+
if(wantSql){ document.getElementById('db_sql').value=wantSql; dbRun(); }
|
|
983
|
+
else if(wantTable){ const te=[...tblsEl.querySelectorAll('.dbitem')].find(x=>x.dataset.t===wantTable); if(te) dbSelectTbl(wantTable,te); }
|
|
984
|
+
}catch(e){ tblsEl.innerHTML='<div class="msg err">'+esc(String(e))+'</div>'; }
|
|
985
|
+
}
|
|
986
|
+
function dbSelectTbl(t,el){ document.querySelectorAll('#db_tbls .dbitem').forEach(x=>x.classList.remove('sel')); if(el) el.classList.add('sel');
|
|
987
|
+
document.getElementById('db_sql').value='SELECT * FROM "'+t+'" LIMIT 100'; dbRun(); }
|
|
988
|
+
async function dbRun(){ if(!DBV.db) return; const sql=document.getElementById('db_sql').value;
|
|
989
|
+
const out=document.getElementById('db_out'); out.innerHTML='<div class="msg">running…</div>';
|
|
990
|
+
try{ const r=await (await fetch('/dbquery?db='+encodeURIComponent(DBV.db)+'&sql='+encodeURIComponent(sql)+dbUserQS())).json();
|
|
991
|
+
if(r.error){ out.innerHTML='<div class="msg err">'+esc(r.error)+'</div>'; return; }
|
|
992
|
+
if(!r.rows||!r.rows.length){ out.innerHTML='<div class="msg">0 rows</div>'; document.getElementById('db_meta').textContent=DBV.db+' — 0 rows'; return; }
|
|
993
|
+
const cols=Object.keys(r.rows[0]);
|
|
994
|
+
let h='<table><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
|
|
995
|
+
for(const row of r.rows){ h+='<tr>'+cols.map(c=>'<td title="'+esc(String(row[c]??''))+'">'+esc(String(row[c]??''))+'</td>').join('')+'</tr>'; }
|
|
996
|
+
h+='</tbody></table>'; out.innerHTML=h;
|
|
997
|
+
document.getElementById('db_meta').textContent=DBV.db+' — '+r.rows.length+' rows';
|
|
998
|
+
}catch(e){ out.innerHTML='<div class="msg err">'+esc(String(e))+'</div>'; }
|
|
999
|
+
}
|
|
1000
|
+
// ---- Screen overlay: a global panel that mirrors the live device screen. -----
|
|
1001
|
+
// OFF by default. The live screen shows real DMs and message content, so the panel
|
|
1002
|
+
// opens to a consent gate first; only after the user accepts does it request frames.
|
|
1003
|
+
// Consent is granted server-side (per process) and the stream/record routes enforce it.
|
|
1004
|
+
const SCREEN={ on:false, consent:false, recording:false, caps:null };
|
|
1005
|
+
function scCopy(text,btn){ navigator.clipboard?.writeText(text).then(()=>{ if(btn){ const t=btn.textContent; btn.textContent='copied'; setTimeout(()=>btn.textContent=t,1200); } }).catch(()=>{}); }
|
|
1006
|
+
async function scLoadCaps(){ try{ SCREEN.caps=await (await fetch('/screen/caps')).json(); SCREEN.consent=!!SCREEN.caps.consent; }catch{ SCREEN.caps=null; } }
|
|
1007
|
+
function scRenderConsent(){
|
|
1008
|
+
const body=document.getElementById('screenbody'); const hint=document.getElementById('schint');
|
|
1009
|
+
hint.textContent='';
|
|
1010
|
+
body.innerHTML='<div class="scconsent">This mirrors the <b>live device screen</b>. It shows whatever is on the phone right now, including open DMs, message content, and notifications. Review what is on screen before sharing it. Frames are not requested until you accept, and the mirror stays on until you restart the dashboard. If you <b>record</b>, the clip is saved to a file on this computer (owner-only, removed when the dashboard restarts).<button class="scaccept" id="scaccept">Show the screen</button></div>';
|
|
1011
|
+
document.getElementById('scaccept').onclick=async()=>{ try{ await fetch('/screen/consent',{method:'POST'}); SCREEN.consent=true; scRenderLive(); }catch{ hint.textContent='consent failed'; } };
|
|
1012
|
+
}
|
|
1013
|
+
function scRenderLive(){
|
|
1014
|
+
const body=document.getElementById('screenbody'); const hint=document.getElementById('schint');
|
|
1015
|
+
const caps=SCREEN.caps||{};
|
|
1016
|
+
if(caps.liveCast){
|
|
1017
|
+
body.innerHTML='<img id="screenimg" alt="device screen"/>';
|
|
1018
|
+
document.getElementById('screenimg').src='/screen/stream?fps=20&t='+Date.now();
|
|
1019
|
+
hint.textContent='capturing first frame (~3s on a foldable)…';
|
|
1020
|
+
setTimeout(()=>{ if(SCREEN.on) hint.textContent='live. ▣ Screen again to close.'; },3500);
|
|
1021
|
+
} else {
|
|
1022
|
+
// No ffmpeg: live cast is unavailable. Screenshot still works (pure screencap).
|
|
1023
|
+
// Show the platform install command (copy only — never auto-installed) + scrcpy hint.
|
|
1024
|
+
const cmd=esc(caps.installHint||'install ffmpeg');
|
|
1025
|
+
let html='<div class="scinstall">Live mirroring needs <b>ffmpeg</b>, which is not installed. Install it, then reopen this panel:<br><br><code>'+cmd+'</code><button class="sccopy" id="scffcopy">copy</button></div>';
|
|
1026
|
+
html+='<div class="scinstall">📷 Screenshot works without ffmpeg — use the button above.</div>';
|
|
1027
|
+
if(caps.scrcpy) html+='<div class="scinstall">scrcpy is installed; for a native mirror window run <code>scrcpy</code> in a terminal.</div>';
|
|
1028
|
+
body.innerHTML=html;
|
|
1029
|
+
const c=document.getElementById('scffcopy'); if(c) c.onclick=()=>scCopy(caps.installHint||'',c);
|
|
1030
|
+
hint.textContent='live mirroring unavailable (no ffmpeg); screenshot still works.';
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
async function scOpen(){
|
|
1034
|
+
SCREEN.on=true; document.getElementById('screenpanel').classList.add('on');
|
|
1035
|
+
document.body.classList.add('screenopen'); document.getElementById('screenbtn').classList.add('on');
|
|
1036
|
+
await scLoadCaps();
|
|
1037
|
+
if(SCREEN.consent) scRenderLive(); else scRenderConsent();
|
|
1038
|
+
}
|
|
1039
|
+
function scClose(){
|
|
1040
|
+
SCREEN.on=false; document.getElementById('screenpanel').classList.remove('on');
|
|
1041
|
+
document.body.classList.remove('screenopen'); document.getElementById('screenbtn').classList.remove('on');
|
|
1042
|
+
const img=document.getElementById('screenimg'); if(img) img.src=''; // drop the stream viewer
|
|
1043
|
+
if(SCREEN.recording){ fetch('/screen/record/stop',{method:'POST'}).catch(()=>{}); SCREEN.recording=false; document.getElementById('screenrec').textContent='● rec'; }
|
|
1044
|
+
}
|
|
1045
|
+
document.getElementById('screenbtn').onclick=()=>{ SCREEN.on?scClose():scOpen(); };
|
|
1046
|
+
document.getElementById('screenclose').onclick=scClose;
|
|
1047
|
+
document.getElementById('screenshot').onclick=async()=>{
|
|
1048
|
+
if(!SCREEN.consent){ document.getElementById('schint').textContent='accept the notice first'; return; }
|
|
1049
|
+
// Blob download (not a bare <a href> navigation), so taking a screenshot cannot cancel
|
|
1050
|
+
// the in-flight /screen/stream connection and drop the live <img>.
|
|
1051
|
+
try{ const blob=await (await fetch('/screen/shot?t='+Date.now())).blob(); const u=URL.createObjectURL(blob);
|
|
1052
|
+
const a=document.createElement('a'); a.href=u; a.download='radar-screen-'+Date.now()+'.png'; document.body.appendChild(a); a.click(); a.remove();
|
|
1053
|
+
setTimeout(()=>URL.revokeObjectURL(u),10000);
|
|
1054
|
+
}catch{ document.getElementById('schint').textContent='screenshot failed'; }
|
|
1055
|
+
};
|
|
1056
|
+
document.getElementById('screenrec').onclick=async()=>{
|
|
1057
|
+
if(!SCREEN.consent || !(SCREEN.caps&&SCREEN.caps.liveCast)){ document.getElementById('schint').textContent='recording needs ffmpeg + consent'; return; }
|
|
1058
|
+
const rec=document.getElementById('screenrec');
|
|
1059
|
+
if(!SCREEN.recording){ SCREEN.recording=true; rec.textContent='■ stop'; rec.classList.add('rec'); await fetch('/screen/record/start',{method:'POST'}).catch(()=>{}); }
|
|
1060
|
+
else { rec.textContent='saving…'; const r=await (await fetch('/screen/record/stop',{method:'POST'})).json().catch(()=>({})); SCREEN.recording=false; rec.classList.remove('rec'); rec.textContent='● rec';
|
|
1061
|
+
if(r.download){
|
|
1062
|
+
// Fetch the mp4 as a blob and download via an object URL. A bare <a href=/screen/download>
|
|
1063
|
+
// click is a same-origin navigation that the browser can use to cancel the in-flight
|
|
1064
|
+
// /screen/stream connection, which silently kills the live <img> (server keeps casting).
|
|
1065
|
+
// A blob download touches no navigation and leaves the stream connection alone.
|
|
1066
|
+
try{ const blob=await (await fetch(r.download)).blob(); const u=URL.createObjectURL(blob);
|
|
1067
|
+
const a=document.createElement('a'); a.href=u; a.download=(r.file||'radar-screen.mp4'); document.body.appendChild(a); a.click(); a.remove();
|
|
1068
|
+
setTimeout(()=>URL.revokeObjectURL(u),10000);
|
|
1069
|
+
}catch{ document.getElementById('schint').textContent='recording saved on host: '+(r.file||''); }
|
|
1070
|
+
// Belt-and-suspenders: re-arm the live <img> in case the browser still dropped it.
|
|
1071
|
+
const img=document.getElementById('screenimg'); if(img) img.src='/screen/stream?fps=20&t='+Date.now();
|
|
1072
|
+
} }
|
|
1073
|
+
};
|
|
1074
|
+
document.getElementById('screenredetect').onclick=async()=>{
|
|
1075
|
+
document.getElementById('schint').textContent='re-detecting active panel…';
|
|
1076
|
+
const r=await (await fetch('/screen/redetect')).json().catch(()=>({}));
|
|
1077
|
+
if(SCREEN.on && SCREEN.consent && SCREEN.caps&&SCREEN.caps.liveCast){ const img=document.getElementById('screenimg'); if(img) img.src='/screen/stream?fps=20&t='+Date.now(); }
|
|
1078
|
+
document.getElementById('schint').textContent='display: '+(r.display||'default');
|
|
1079
|
+
};
|
|
1080
|
+
|
|
773
1081
|
pollSpec(); // initial build
|
|
774
1082
|
</script></body></html>
|
package/dist/web/server.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import http from "http";
|
|
2
|
+
import { type LogFrame } from "./log-session.js";
|
|
2
3
|
declare const WEB_PORT: number;
|
|
4
|
+
/**
|
|
5
|
+
* Decide whether to drop a log frame under backpressure. LIVE frames are dropped
|
|
6
|
+
* when the connection's unwritten buffer is already past the cap (an unbounded
|
|
7
|
+
* queue to a slow/backgrounded tab). REPLAY frames are NEVER dropped: replay is a
|
|
8
|
+
* bounded one-time send of the whole ring and IS the connection's purpose; it runs
|
|
9
|
+
* synchronously so writableLength climbs monotonically, and dropping past the cap
|
|
10
|
+
* would silently cut the newest history and reintroduce the reconnect log-loss
|
|
11
|
+
* this feature fixes. Exported pure so the replay-exemption is unit-testable
|
|
12
|
+
* without a socket. `cap` defaults to the live ceiling.
|
|
13
|
+
*/
|
|
14
|
+
export declare function shouldDropLogFrame(replay: boolean, writableLength: number, cap?: number): boolean;
|
|
3
15
|
export declare function createServer(): http.Server;
|
|
4
16
|
/**
|
|
5
17
|
* Finish a /detail proxy response after an upstream error or timeout. The success
|
|
@@ -10,17 +22,7 @@ export declare function createServer(): http.Server;
|
|
|
10
22
|
* have not gone out yet; otherwise just end the already-started response. Exported for tests.
|
|
11
23
|
*/
|
|
12
24
|
export declare function failDetailResponse(res: Pick<http.ServerResponse, "headersSent" | "writeHead" | "end">, status: number): void;
|
|
13
|
-
|
|
14
|
-
id: number;
|
|
15
|
-
timestamp: number;
|
|
16
|
-
log_ts: string;
|
|
17
|
-
pid: number;
|
|
18
|
-
tid: number;
|
|
19
|
-
level: string;
|
|
20
|
-
tag: string;
|
|
21
|
-
message: string;
|
|
22
|
-
package?: string;
|
|
23
|
-
}
|
|
25
|
+
type ParsedLog = LogFrame;
|
|
24
26
|
/**
|
|
25
27
|
* Parse one `adb logcat -v threadtime` line into a structured event, or null if it does
|
|
26
28
|
* not match the format. `resolvePkg` maps a pid to its package name (defaults to the
|