@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.
@@ -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">&#9679; 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">&#9679; 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:'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'}}},
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
- // a non-blank spec that matches no preset = a Claude-pushed custom dashboard
243
- CUSTOM_ACTIVE = !!spec && !spec.blank && specMatchesMode(spec)===null;
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
- 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…'); } }
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
- 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; }
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='source='+SPEC.source+' match='+JSON.stringify(SPEC.match||{})+(SPEC.extract?' extract='+SPEC.extract.field:'');
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
- if(FEED.filter && !r._search.includes(FEED.filter)) r.classList.add('hidden');
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(); applyFeedControls(); fi.focus(); };
778
+ const clearFilterBox=()=>{ fi.value=''; FEED.filter=''; syncClear(); rebuildFeedFromCache(); fi.focus(); };
649
779
  syncClear();
650
- fi.oninput=()=>{ FEED.filter=fi.value.trim().toLowerCase(); syncClear(); applyFeedControls(); };
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
@@ -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
- interface ParsedLog {
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