@slack/radar-mcp 1.4.0 → 1.6.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.
@@ -11,6 +11,8 @@
11
11
  .conn.waiting { color:#ffb454; }
12
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
13
  .reconnect:hover { background:#4a360f; } .reconnect:disabled { opacity:.6; cursor:default; }
14
+ .profilewrap { font-size:12px; color:#7d9bd6; margin-left:8px; }
15
+ .profilewrap b { color:#cfe0f0; }
14
16
  .modebar { display:flex; gap:6px; margin:10px 0 6px; flex-wrap:wrap; }
15
17
  .modebtn { background:#121925; border:1px solid #243244; color:#8aa0b4; border-radius:8px; padding:6px 14px; font:inherit; font-size:12.5px; cursor:pointer; }
16
18
  .modebtn:hover { border-color:#39d98a; color:#dfe7ef; }
@@ -108,11 +110,77 @@
108
110
  .copybar { display:flex; gap:6px; margin-bottom:8px; flex-wrap:wrap; }
109
111
  .copybtn { background:#172a1f; border:1px solid #2c5a3e; color:#7fd6a3; border-radius:6px; padding:3px 9px; font:inherit; font-size:11.5px; cursor:pointer; }
110
112
  .copybtn:hover { border-color:#39d98a; color:#39d98a; }
111
- </style></head>
113
+ /* DB browser (source:"db") */
114
+ .dbview { display:flex; gap:16px; max-width:1180px; margin-top:8px; height:72vh; }
115
+ .dbside { flex:0 0 260px; overflow:auto; border:1px solid #1e2a3a; border-radius:10px; padding:12px; }
116
+ .dbsec { color:#5f7186; font-size:10.5px; text-transform:uppercase; letter-spacing:.06em; margin:6px 0 6px; }
117
+ .dbsec:not(:first-child){ margin-top:14px; }
118
+ .dbitem { padding:6px 9px; border-radius:7px; cursor:pointer; font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
119
+ .dbitem:hover { background:#172230; } .dbitem.sel { background:#1d2c3e; color:#39d98a; }
120
+ .dbitem .rows { float:right; color:#5f7186; font-size:11px; }
121
+ .dbmain { flex:1; min-width:0; display:flex; flex-direction:column; }
122
+ .dbqbar { display:flex; gap:8px; margin-bottom:8px; }
123
+ .dbqbar input { flex:1; background:#121925; border:1px solid #243244; border-radius:8px; color:#e8f0f8; padding:8px 11px; font:inherit; font-size:12.5px; }
124
+ .dbqbar input:focus { outline:none; border-color:#39d98a; }
125
+ .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; }
126
+ .dbmeta { color:#5f7186; font-size:11.5px; margin-bottom:8px; }
127
+ .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; }
128
+ .dbprofile { color:#7d9bd6; font-size:11.5px; margin-bottom:8px; }
129
+ .dbprofile b { color:#cfe0f0; }
130
+ .dbprofcap { color:#5f7186; text-transform:uppercase; letter-spacing:.06em; font-size:10px; }
131
+ .dbprofile select { background:#121925; border:1px solid #243244; border-radius:6px; color:#e8f0f8; padding:3px 6px; font:inherit; font-size:11px; margin-left:4px; }
132
+ .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; }
133
+ .dbrefresh:hover { border-color:#39d98a; color:#39d98a; }
134
+ .dbout { flex:1; overflow:auto; border:1px solid #1e2a3a; border-radius:10px; }
135
+ .dbout table { border-collapse:collapse; width:100%; font-size:12px; }
136
+ .dbout th { position:sticky; top:0; background:#121925; color:#8aa0b4; text-align:left; padding:7px 10px; border-bottom:1px solid #1e2a3a; white-space:nowrap; }
137
+ .dbout td { padding:6px 10px; border-bottom:1px solid #16202c; max-width:380px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
138
+ .dbout tr:hover td { background:#10161f; }
139
+ .msg { color:#5f7186; font-size:12px; padding:10px; }
140
+ .msg.err { color:#ff6b60; }
141
+ /* Screen overlay: a global panel docked to the right, available over any tab. */
142
+ .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; }
143
+ .screenbtn:hover { border-color:#39d98a; color:#dfe7ef; }
144
+ .screenbtn.on { background:#1c3a2a; border-color:#39d98a; color:#39d98a; }
145
+ #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; }
146
+ #screenpanel.on { display:flex; }
147
+ body.screenopen { padding-right:356px; }
148
+ #screenpanel .schead { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
149
+ #screenpanel .schead .sctitle { font-size:12.5px; color:#dfe7ef; font-weight:600; flex:1; }
150
+ #screenpanel .scbtn { background:#121925; border:1px solid #243244; color:#8aa0b4; border-radius:6px; padding:3px 9px; font:inherit; font-size:11.5px; cursor:pointer; }
151
+ #screenpanel .scbtn:hover { border-color:#39d98a; color:#dfe7ef; }
152
+ #screenpanel .scbtn.rec { color:#ff6b60; border-color:#5a2c2c; }
153
+ #screenimg { width:100%; border:1px solid #1e2a3a; border-radius:8px; background:#000; min-height:120px; }
154
+ #schint { font-size:11.5px; color:#5f7186; margin-top:8px; line-height:1.5; }
155
+ .scconsent { background:#161b22; border:1px solid #2c3a4a; border-radius:8px; padding:12px; font-size:12px; color:#cfd8e3; line-height:1.55; }
156
+ .scconsent b { color:#ffd479; }
157
+ .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; }
158
+ .scconsent .scaccept:hover { background:#24492f; }
159
+ .scinstall { background:#0e151e; border:1px solid #1e2a3a; border-radius:6px; padding:8px 10px; margin-top:8px; font-size:11.5px; color:#cfe3d6; }
160
+ .scinstall code { color:#7fd6a3; }
161
+ .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; }
162
+ </style>
163
+ <!-- Import map so the vendored Preact runtime resolves the bare `preact` specifier (Preact
164
+ Hooks imports `from "preact"`). Inert until a UI module imports it; ships ahead of the
165
+ component migration (container by container) so later PRs are pure client modules. -->
166
+ <script type="importmap">
167
+ { "imports": { "preact": "./vendor/preact.module.js" } }
168
+ </script></head>
112
169
  <body>
113
170
  <div id="flash"></div>
114
171
  <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>
172
+ <h1>SLACK RADAR DASHBOARD <span class="conn waiting" id="conn">&#9679; connecting…</span> <span id="profilewrap" class="profilewrap" title="the device profile Radar is connected to (the first app to launch wins the socket; to watch another profile, run only that profile's Slack)">profile: <b id="profilename">detecting…</b></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>
173
+ <div id="screenpanel">
174
+ <div class="schead">
175
+ <span class="sctitle">Device screen</span>
176
+ <button class="scbtn" id="screenshot" title="Full-resolution screenshot">📷</button>
177
+ <button class="scbtn" id="screenrec" title="Record to mp4">● rec</button>
178
+ <button class="scbtn" id="screenredetect" title="Re-detect the active display">⟳</button>
179
+ <button class="scbtn" id="screenclose" title="Close">✕</button>
180
+ </div>
181
+ <div id="screenbody"></div>
182
+ <div id="schint"></div>
183
+ </div>
116
184
  <div class="modebar" id="modebar"></div>
117
185
  <div class="status" id="status"></div>
118
186
  <div class="sub" id="sub"></div>
@@ -135,6 +203,15 @@
135
203
  </div>
136
204
  <div class="inspector" id="inspector" style="display:none"></div>
137
205
  </div>
206
+ <div class="dbview" id="dbview" style="display:none">
207
+ <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>
208
+ <div class="dbmain">
209
+ <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>
210
+ <div class="dbqbar"><input id="db_sql" placeholder="SELECT * FROM table LIMIT 100 (read-only)" autocomplete="off"/><button id="db_run">Run</button></div>
211
+ <div class="dbmeta" id="db_meta"></div>
212
+ <div class="dbout" id="db_out"><div class="msg">pick a database, then a table</div></div>
213
+ </div>
214
+ </div>
138
215
  <script>
139
216
  // The web page is just pre-built dashboard TABS over the device Radar stream. No in-page
140
217
  // Claude. Customization happens in a terminal Claude Code session (via the
@@ -154,11 +231,12 @@ let SPEC=null;
154
231
  // Presets use only generic placeholders. The dashboard learns the real endpoints,
155
232
  // channels, and event names LIVE from the device stream; nothing internal is baked in.
156
233
  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'}}},
234
+ {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
235
  {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
236
  {key:'rtm', label:'RTM', spec:{title:'RTM',summary:'Real-time websocket events as they happen.',source:'rtm',viz:['counter','rate','feed','inspector']}},
160
237
  {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
238
  {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'}}},
239
+ {key:'db', label:'Database', spec:{title:'Device DB',summary:'Browse the on-device SQLite databases (read-only snapshot).',source:'db'}},
162
240
  ];
163
241
  // A spec is "custom" when it did NOT come from a MODES preset — i.e. a terminal Claude
164
242
  // session pushed it via the open_radar_dashboard MCP tool. We detect that by comparing
@@ -166,6 +244,13 @@ const MODES=[
166
244
  let CUSTOM_ACTIVE=false; // is the live spec the custom (Claude-pushed) one RIGHT NOW?
167
245
  let lastCustomSpec=null; // the most-recent custom spec; keeps the Custom tab present
168
246
  // until a page refresh, so you can flip back to it.
247
+ // A spec pushed by THIS browser (a Filters-panel Apply, or a tab click) is a local edit, not
248
+ // a Claude-pushed custom dashboard — so it must NOT spawn a "Custom" tab. localPushKey holds
249
+ // the JSON of the next spec we expect back from /spec because we just pushed it; pollSpec sees
250
+ // it match and treats it as a local edit (stays on the current tab). Only a non-preset spec we
251
+ // did NOT push (an MCP open_radar_dashboard) becomes the Custom tab.
252
+ let localPushKey=null;
253
+ function markLocalPush(spec){ localPushKey=JSON.stringify(spec); }
169
254
  function specMatchesMode(spec){ if(!spec||spec.blank) return null;
170
255
  return MODES.find(m=>JSON.stringify(m.spec)===JSON.stringify(spec))?.key || null; }
171
256
  function renderModes(){ const bar=document.getElementById('modebar'); if(!bar) return;
@@ -239,8 +324,11 @@ document.addEventListener('keydown',(e)=>{
239
324
  // ---- poll /spec; rebuild when it changes, and show/hide the Custom tab accordingly ----
240
325
  let specKey='';
241
326
  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;
327
+ // A non-blank spec that matches no preset is a Claude-pushed custom dashboard — UNLESS this
328
+ // browser just pushed it (a Filters-panel Apply or a tab click), which is a local edit and
329
+ // must stay on the current tab, not spawn a Custom tab.
330
+ const isLocalEdit = key===localPushKey; if(isLocalEdit) localPushKey=null;
331
+ CUSTOM_ACTIVE = !isLocalEdit && !!spec && !spec.blank && specMatchesMode(spec)===null;
244
332
  let needRerender=false;
245
333
  if(CUSTOM_ACTIVE){ // remember it so the tab persists; re-render if it's new/changed
246
334
  if(!lastCustomSpec || JSON.stringify(lastCustomSpec)!==JSON.stringify(spec)){ lastCustomSpec=spec; needRerender=true; }
@@ -259,11 +347,22 @@ async function bootDefault(){ try{ const cur=await (await fetch('/spec')).json()
259
347
  bootDefault();
260
348
  // Connection dot driven by an always-on device health check, independent of whether
261
349
  // a dashboard is built (a blank dashboard has no stream, so the dot was otherwise dead).
262
- let lastEventTs=0;
350
+ let lastEventTs=0, healthMisses=0;
351
+ // Clear a stale red "device unreachable / backfill failed" status once health is back. These
352
+ // errors are set once (e.g. at build/backfill time) and were otherwise sticky until a tab
353
+ // switch — so a transient blip left the red banner up long after the device recovered.
354
+ function clearStaleError(){ if(statusEl.classList.contains('err')) setStatus('',''); }
263
355
  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…'); } }
356
+ // Recent stream activity proves liveness without a /health round-trip.
357
+ if(Date.now()-lastEventTs<6000){ healthMisses=0; setConn('connected',''); clearStaleError(); return; }
358
+ try{ const h=await (await fetch('/health')).json();
359
+ if(h.device){ healthMisses=0; setConn('connected',''); clearStaleError(); }
360
+ // Require TWO consecutive misses before flipping the dot — a single slow/missed poll
361
+ // (while the stream is otherwise fine) should not flash "connecting".
362
+ else if(++healthMisses>=2){ setConn('waiting','device offline, retrying…'); }
363
+ }catch{ if(++healthMisses>=2) setConn('waiting','reconnecting…'); } }
266
364
  pollHealth(); setInterval(pollHealth,2000);
365
+ showConnectedProfile(); setInterval(showConnectedProfile,5000); // keep the connected-profile label honest as the socket re-binds
267
366
 
268
367
  // ---- the dashboard engine: build(spec) tears down and rebuilds widgets live ----
269
368
  let es=null, tickTimer=null;
@@ -282,7 +381,21 @@ function build(spec){
282
381
  counterEl=rateEl=sparkEl=null;
283
382
  const insp=document.getElementById('inspector'), stage=document.getElementById('stage');
284
383
  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; }
384
+ const dbv=document.getElementById('dbview');
385
+ const fp=document.getElementById('filterpanel');
386
+ 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; }
387
+ if(SPEC.source==='db'){
388
+ // The DB browser is a distinct, point-in-time view (no stream/widgets/filters). Hide the
389
+ // streaming UI + the Filters panel, show the db list -> tables -> query grid.
390
+ stage.style.display='none'; cards.style.display='none'; insp.style.display='none';
391
+ if(fp) fp.style.display='none';
392
+ const subEl=document.getElementById('sub');
393
+ subEl.innerHTML='<span class="summary">'+esc(SPEC.summary||'Device database browser')+'</span><span class="tech">source=db (on-device SQLite snapshot, read-only)</span>';
394
+ if(dbv) dbv.style.display='flex';
395
+ buildDbView(SPEC);
396
+ return;
397
+ }
398
+ if(dbv) dbv.style.display='none';
286
399
  document.getElementById('stage').style.display='flex'; cards.style.display='grid';
287
400
  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
401
  else { insp.style.display='none'; }
@@ -290,7 +403,7 @@ function build(spec){
290
403
  stage.classList.toggle('split', split); stage.classList.toggle('haswidth', split);
291
404
  setupFeedControls();
292
405
  const subEl=document.getElementById('sub');
293
- const tech='source='+SPEC.source+' match='+JSON.stringify(SPEC.match||{})+(SPEC.extract?' extract='+SPEC.extract.field:'');
406
+ const tech=describeConstraints(SPEC);
294
407
  subEl.innerHTML = SPEC.summary
295
408
  ? '<span class="summary">'+esc(SPEC.summary)+'</span><span class="tech">'+esc(tech)+'</span>'
296
409
  : esc(SPEC.title+' — '+tech);
@@ -426,6 +539,7 @@ async function applyFilters(){
426
539
  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
540
  const rw=Number(g('f_rate').value); if(rw>0) spec.rateWindowSec=rw;
428
541
  const msg=g('f_msg'); msg.textContent='applying…';
542
+ markLocalPush(spec); // a hand-edit of the current view, not a Claude-pushed Custom dashboard
429
543
  try{ const r=await (await fetch('/setspec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(spec)})).json();
430
544
  msg.textContent = r.ok?'applied ✓':('⚠ '+(r.error||'rejected')); }
431
545
  catch{ msg.textContent='⚠ failed'; }
@@ -436,13 +550,58 @@ function setConn(state,msg){ const d=document.getElementById('conn'); if(!d) ret
436
550
  const rc=document.getElementById('reconnect'); if(rc && !rc._busy) rc.style.display = state==='connected' ? 'none' : 'inline-block'; }
437
551
  async function doReconnect(){ const rc=document.getElementById('reconnect'); if(!rc) return; rc._busy=true; rc.disabled=true; rc.textContent='⟳ enabling…';
438
552
  try{ await fetch('/enable',{method:'POST'}); }catch{}
439
- rc._busy=false; rc.disabled=false; rc.textContent='⟳ Reconnect Radar'; pollHealth(); }
553
+ rc._busy=false; rc.disabled=false; rc.textContent='⟳ Reconnect Radar'; showConnectedProfile(); pollHealth(); pollSpec(); }
554
+
555
+ // Show which device profile Radar is connected to. Read-only: on a phone running the
556
+ // debug build on more than one profile (personal user 0 + a work profile) only ONE
557
+ // instance binds the single Radar socket — whichever launched first wins, and a second
558
+ // profile cannot take it while the first holds it. So we cannot SWITCH profiles from here
559
+ // yet (that needs per-profile sockets, a device-side change); we just report the truth.
560
+ // The label is ALWAYS visible (operator: "profile must show even if one"): the bound
561
+ // profile comes from /health's boundUserId/boundProfile (the real socket owner). When the
562
+ // device drops, keep showing the last-known profile dimmed rather than vanishing.
563
+ let lastBoundLabel=null;
564
+ async function showConnectedProfile(){
565
+ const wrap=document.getElementById('profilewrap'), nameEl=document.getElementById('profilename');
566
+ if(!wrap||!nameEl) return;
567
+ try{
568
+ const [profilesR, healthR]=await Promise.all([
569
+ fetch('/_control/profiles').then(r=>r.json()).catch(()=>null),
570
+ fetch('/health').then(r=>r.json()).catch(()=>null),
571
+ ]);
572
+ const uid=healthR&&healthR.boundUserId;
573
+ if(uid==null){
574
+ // Device not connected right now. Do NOT hide (the label is always present), but do
575
+ // NOT claim a current binding either: the socket may re-bind to a DIFFERENT profile
576
+ // while we are down, so "<last> (disconnected)" could name the wrong one. Phrase it as
577
+ // the last profile we SAW, not the one bound now. Cleared so a fresh connect re-resolves.
578
+ nameEl.textContent = lastBoundLabel ? 'last seen '+lastBoundLabel+' — reconnecting…' : 'waiting for device…';
579
+ return;
580
+ }
581
+ const profiles=(profilesR&&profilesR.profiles)||[];
582
+ const match=profiles.find(p=>p.userId===uid);
583
+ // Fallback when the profile list does not name this uid (fetch failed, or ps and the
584
+ // live ping disagree). NEVER hard-code a user id: always print the REAL bound uid, and
585
+ // Title-case the profile word so it matches the matched-path label.
586
+ const cap=(s)=> s ? s.charAt(0).toUpperCase()+s.slice(1) : s;
587
+ const label = match ? match.label
588
+ : (healthR.boundProfile ? cap(healthR.boundProfile)+' (user '+uid+')'
589
+ : 'user '+uid);
590
+ lastBoundLabel=label;
591
+ nameEl.textContent=label;
592
+ }catch{ /* transient fetch error: leave whatever is shown */ }
593
+ }
440
594
  // live defaults true so the SSE handler (es.onmessage=onEvent) keeps firing alerts on
441
595
  // genuine arrivals; backfill() passes live=false so replayed device HISTORY counts toward
442
596
  // widgets without triggering alerts for events that already happened.
443
597
  function onEvent(m, live){ if(live===undefined) live=true; let f; try{f=JSON.parse(m.data);}catch{return;}
444
598
  if(f.type==='_status'){ setConn(f.event.state, f.event.msg); return; }
445
599
  if(f.type==='_error'){ setConn('waiting', f.event.msg); return; }
600
+ // Server-replayed log history is tagged replay:true. Treat it as history (live=false)
601
+ // so it never fires alerts or pulses, even on a fresh browser whose own cache is empty
602
+ // (the server log ring outlives/outsizes the client cache, so reconnect replay can carry
603
+ // frames the client never saw). Live frames are unaffected.
604
+ if(f.replay) live=false;
446
605
  if(!['network','rtm','clog','log'].includes(f.type)) return;
447
606
  lastEventTs=Date.now(); setConn('connected','');
448
607
  const ev=f.event||{}; ev._type=f.type;
@@ -502,6 +661,22 @@ function replayCache(){
502
661
  const rows=CACHE.filter(c=> (SPEC.source==='all'||c.type===SPEC.source));
503
662
  for(const c of rows){ const ev=c.ev; ev._type=c.type; if(matches(ev)) acceptEvent(ev,false); }
504
663
  }
664
+ // Rebuild the FEED rows from the session cache for the current FEED.filter, WITHOUT
665
+ // touching counters/graph (those already counted every event). Needed because addRow now
666
+ // drops non-matching rows from the DOM entirely (not insert-then-hide), so changing or
667
+ // clearing the feed filter cannot just un-hide rows that are no longer there — it must
668
+ // re-add the matching ones from the cache.
669
+ function rebuildFeedFromCache(){
670
+ if(!feed) return;
671
+ feed.innerHTML='';
672
+ const rows=CACHE.filter(c=> (SPEC.source==='all'||c.type===SPEC.source));
673
+ for(const c of rows){ const ev=c.ev; ev._type=c.type;
674
+ if(!matches(ev)) continue;
675
+ const r=addRow(ev, ev.timestamp||Date.now());
676
+ if(r && SPEC.extract) enrich(ev).then(val=>{ if(val!=null&&r) appendExtract(r,val); });
677
+ }
678
+ if(FEED.sort!=='newest') applyFeedControls(); else updateFeedCount();
679
+ }
505
680
  // accept an event into the widgets. live=true for stream arrivals (fires alerts, enrich,
506
681
  // pulse); live=false for cache replay (silent, time-accurate). Buckets by the event's own
507
682
  // timestamp so replayed history lands in the right second of the rate/sparkline/graph.
@@ -603,7 +778,11 @@ function addRow(ev,now){ if(!has('feed')) return null; const r=document.createEl
603
778
  const b=document.createElement('span'); b.className='breachbadge'; b.textContent=fmtNum(v)+unit+' '+(h.op||'>')+' '+h.value+unit;
604
779
  r.appendChild(b); }
605
780
  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');
781
+ // When the feed text filter is active, DROP non-matching rows entirely instead of
782
+ // inserting-then-hiding them. The 200-row DOM cap below counts every child, so hidden
783
+ // non-matching rows would otherwise flood the cap and evict the matching rows the user
784
+ // is watching for (filtered rows flash in, then vanish as unmatched rows push them out).
785
+ if(FEED.filter && !r._search.includes(FEED.filter)) return null;
607
786
  const atTop = feed.scrollTop <= 4;
608
787
  feed.insertBefore(r,feed.firstChild); while(feed.children.length>200) feed.removeChild(feed.lastChild);
609
788
  if(!atTop){ feed.scrollTop += r.offsetHeight; }
@@ -645,9 +824,16 @@ function setupFeedControls(){
645
824
  fs.value=FEED.sort;
646
825
  const fx=document.getElementById('ffilterclear');
647
826
  const syncClear=()=>{ if(fx) fx.style.display = fi.value ? 'block' : 'none'; };
648
- const clearFilterBox=()=>{ fi.value=''; FEED.filter=''; syncClear(); applyFeedControls(); fi.focus(); };
827
+ const clearFilterBox=()=>{ fi.value=''; FEED.filter=''; syncClear(); rebuildFeedFromCache(); fi.focus(); };
649
828
  syncClear();
650
- fi.oninput=()=>{ FEED.filter=fi.value.trim().toLowerCase(); syncClear(); applyFeedControls(); };
829
+ // Rebuild from cache (not just hide/show) because addRow drops non-matching rows from
830
+ // the DOM, so a filter change must re-add matching rows that are no longer present.
831
+ // Debounced: oninput fires per keystroke, and rebuild re-runs enrich() per matching row
832
+ // on extract dashboards, so an undebounced rebuild-per-keystroke can fan out /detail
833
+ // fetches. Settle ~180ms after the last keystroke before rebuilding.
834
+ let filterTimer=null;
835
+ fi.oninput=()=>{ FEED.filter=fi.value.trim().toLowerCase(); syncClear();
836
+ if(filterTimer) clearTimeout(filterTimer); filterTimer=setTimeout(rebuildFeedFromCache,180); };
651
837
  fi.onkeydown=(e)=>{ if(e.key==='Escape') clearFilterBox(); };
652
838
  if(fx) fx.onclick=clearFilterBox;
653
839
  fs.onchange=()=>{ FEED.sort=fs.value; applyFeedControls(); };
@@ -767,8 +953,189 @@ function appendExtract(row,val){ const s=document.createElement('span'); s.class
767
953
  // attribute and inject an event handler (DOM-XSS with no < or > needed). All esc() call
768
954
  // sites in this file feed HTML, so escaping quotes is safe everywhere.
769
955
  function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
956
+ // Human-readable one-line summary of a spec's actual filters + constraints, so every tab
957
+ // (the Custom one especially) explains what it is showing rather than just "source=X".
958
+ // Covers the fields the old "source=X match={}" line dropped: highlight, bodyContains,
959
+ // alert, graph series, rate window. Returns a "·"-joined clause string.
960
+ function describeConstraints(spec){
961
+ const parts=['source '+spec.source];
962
+ if(spec.match && Object.keys(spec.match).length)
963
+ parts.push('where '+Object.entries(spec.match).map(([k,v])=>k+'='+v).join(', '));
964
+ if(spec.bodyContains) parts.push('body contains "'+spec.bodyContains+'"');
965
+ if(spec.highlight && spec.highlight.field)
966
+ parts.push('flag '+spec.highlight.field+' '+(spec.highlight.op||'>')+' '+spec.highlight.value+(spec.highlight.label?' ('+spec.highlight.label+')':''));
967
+ if(Array.isArray(spec.series) && spec.series.length)
968
+ parts.push('graph '+spec.series.map(s=>s.label||s.metric+(s.field?' of '+s.field:'')).join(' vs '));
969
+ if(spec.extract && spec.extract.field) parts.push('extract '+spec.extract.field);
970
+ if(spec.alert){ const a=spec.alert; parts.push('alert'+(a.at?' at '+a.at:'')+(a.say?' "'+a.say+'"':'')); }
971
+ if(spec.rateWindowSec) parts.push('rate window '+spec.rateWindowSec+'s');
972
+ return parts.join(' · ');
973
+ }
770
974
  function card(label,inner){ const d=document.createElement('div'); d.className='card'; d.innerHTML='<div class="label">'+label+'</div>'+inner; return d; }
771
975
  function big(v,id,unit){ return '<div class="big"><span id="'+id+'">'+v+'</span>'+(unit?'<span class="unit">'+unit+'</span>':'')+'</div>'; }
772
976
 
977
+ // ---- DB browser view (source:"db"): db list -> tables -> read-only query grid ----
978
+ let DBV={ db:null, user:null };
979
+ // Build the ?user= suffix so every db call targets the profile the operator picked
980
+ // (or the dashboard's bound profile). Keeps list / pull / query on the same store.
981
+ function dbUserQS(){ return DBV.user!=null ? '&user='+encodeURIComponent(DBV.user) : ''; }
982
+ async function buildDbView(spec){
983
+ const dbsEl=document.getElementById('db_dbs'), tblsEl=document.getElementById('db_tbls');
984
+ document.getElementById('db_run').onclick=dbRun;
985
+ document.getElementById('db_sql').onkeydown=e=>{ if(e.key==='Enter') dbRun(); };
986
+ tblsEl.innerHTML='<div class="msg">pick a database</div>';
987
+ document.getElementById('db_out').innerHTML='<div class="msg">pick a database, then a table</div>';
988
+ document.getElementById('db_meta').textContent='';
989
+ dbsEl.innerHTML='<div class="msg">loading…</div>';
990
+ await loadDbList(spec);
991
+ }
992
+ function profileLabel(u){ return u===''||u==null ? 'device' : (u==='0' ? 'personal (user 0)' : 'secondary (user '+esc(u)+')'); }
993
+ async function loadDbList(spec){
994
+ const dbsEl=document.getElementById('db_dbs');
995
+ try{ const r=await (await fetch('/dbs?'+dbUserQS().slice(1))).json();
996
+ if(r.error){ dbsEl.innerHTML='<div class="msg err">'+esc(r.error)+'</div>'; return; }
997
+ if(!r.names||!r.names.length){ dbsEl.innerHTML='<div class="msg">no databases (device unreachable, or debug build not installed)</div>'; return; }
998
+ DBV.user = r.user;
999
+ // Which profile's store this is. With more than one profile, show ONLY a switcher
1000
+ // (the dropdown's selected option already names the profile — no redundant text line).
1001
+ // With a single profile, show it as plain text.
1002
+ const avail=r.availableUsers||[];
1003
+ let head;
1004
+ if(avail.length>1){
1005
+ head='<div class="dbprofile"><span class="dbprofcap">profile</span> <select id="db_userpick" title="which profile\'s on-device store to browse">'+
1006
+ avail.map(u=>'<option value="'+esc(u)+'"'+(u===r.user?' selected':'')+'>'+profileLabel(u)+'</option>').join('')+'</select></div>';
1007
+ } else {
1008
+ head='<div class="dbprofile">profile: <b>'+profileLabel(r.user)+'</b></div>';
1009
+ }
1010
+ dbsEl.innerHTML=head+r.names.map(n=>'<div class="dbitem" data-n="'+esc(n)+'">'+esc(n)+'</div>').join('');
1011
+ dbsEl.querySelectorAll('.dbitem').forEach(d=>d.onclick=()=>dbSelectDb(d.dataset.n,d));
1012
+ const pick=document.getElementById('db_userpick');
1013
+ if(pick) pick.onchange=()=>{ DBV.user=pick.value; DBV.db=null;
1014
+ document.getElementById('db_tbls').innerHTML='<div class="msg">pick a database</div>';
1015
+ document.getElementById('db_out').innerHTML='<div class="msg">pick a database, then a table</div>';
1016
+ document.getElementById('db_meta').textContent=''; loadDbList(spec); };
1017
+ 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); }
1018
+ }catch(e){ dbsEl.innerHTML='<div class="msg err">'+esc(String(e))+'</div>'; }
1019
+ }
1020
+ async function dbSelectDb(name,el,wantTable,wantSql,refresh){ DBV.db=name;
1021
+ document.querySelectorAll('#db_dbs .dbitem').forEach(x=>x.classList.remove('sel')); if(el) el.classList.add('sel');
1022
+ // clear stale SQL/results so a leftover table query can't run against the newly-picked db
1023
+ document.getElementById('db_sql').value=''; document.getElementById('db_out').innerHTML='<div class="msg">pick a table or write a query</div>';
1024
+ const tblsEl=document.getElementById('db_tbls'); tblsEl.innerHTML='<div class="msg">'+(refresh?'re-pulling':'pulling')+' + reading…</div>';
1025
+ try{ const r=await (await fetch('/dbtables?db='+encodeURIComponent(name)+dbUserQS()+(refresh?'&refresh=1':''))).json();
1026
+ if(r.error){ tblsEl.innerHTML='<div class="msg err">'+esc(r.error)+'</div>'; return; }
1027
+ 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('');
1028
+ tblsEl.querySelectorAll('.dbitem').forEach(d=>d.onclick=()=>dbSelectTbl(d.dataset.t,d));
1029
+ 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>';
1030
+ const rb=document.getElementById('db_refresh'); if(rb) rb.onclick=()=>dbSelectDb(name,el,null,null,true);
1031
+ if(wantSql){ document.getElementById('db_sql').value=wantSql; dbRun(); }
1032
+ else if(wantTable){ const te=[...tblsEl.querySelectorAll('.dbitem')].find(x=>x.dataset.t===wantTable); if(te) dbSelectTbl(wantTable,te); }
1033
+ }catch(e){ tblsEl.innerHTML='<div class="msg err">'+esc(String(e))+'</div>'; }
1034
+ }
1035
+ function dbSelectTbl(t,el){ document.querySelectorAll('#db_tbls .dbitem').forEach(x=>x.classList.remove('sel')); if(el) el.classList.add('sel');
1036
+ document.getElementById('db_sql').value='SELECT * FROM "'+t+'" LIMIT 100'; dbRun(); }
1037
+ async function dbRun(){ if(!DBV.db) return; const sql=document.getElementById('db_sql').value;
1038
+ const out=document.getElementById('db_out'); out.innerHTML='<div class="msg">running…</div>';
1039
+ try{ const r=await (await fetch('/dbquery?db='+encodeURIComponent(DBV.db)+'&sql='+encodeURIComponent(sql)+dbUserQS())).json();
1040
+ if(r.error){ out.innerHTML='<div class="msg err">'+esc(r.error)+'</div>'; return; }
1041
+ if(!r.rows||!r.rows.length){ out.innerHTML='<div class="msg">0 rows</div>'; document.getElementById('db_meta').textContent=DBV.db+' — 0 rows'; return; }
1042
+ const cols=Object.keys(r.rows[0]);
1043
+ let h='<table><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
1044
+ for(const row of r.rows){ h+='<tr>'+cols.map(c=>'<td title="'+esc(String(row[c]??''))+'">'+esc(String(row[c]??''))+'</td>').join('')+'</tr>'; }
1045
+ h+='</tbody></table>'; out.innerHTML=h;
1046
+ document.getElementById('db_meta').textContent=DBV.db+' — '+r.rows.length+' rows';
1047
+ }catch(e){ out.innerHTML='<div class="msg err">'+esc(String(e))+'</div>'; }
1048
+ }
1049
+ // ---- Screen overlay: a global panel that mirrors the live device screen. -----
1050
+ // OFF by default. The live screen shows real DMs and message content, so the panel
1051
+ // opens to a consent gate first; only after the user accepts does it request frames.
1052
+ // Consent is granted server-side (per process) and the stream/record routes enforce it.
1053
+ const SCREEN={ on:false, consent:false, recording:false, caps:null };
1054
+ function scCopy(text,btn){ navigator.clipboard?.writeText(text).then(()=>{ if(btn){ const t=btn.textContent; btn.textContent='copied'; setTimeout(()=>btn.textContent=t,1200); } }).catch(()=>{}); }
1055
+ // refresh=1 re-probes ffmpeg/scrcpy on the server, so reopening this panel after
1056
+ // installing ffmpeg detects it without restarting the dashboard.
1057
+ async function scLoadCaps(){ try{ SCREEN.caps=await (await fetch('/screen/caps?refresh=1')).json(); SCREEN.consent=!!SCREEN.caps.consent; }catch{ SCREEN.caps=null; } }
1058
+ function scRenderConsent(){
1059
+ const body=document.getElementById('screenbody'); const hint=document.getElementById('schint');
1060
+ hint.textContent='';
1061
+ 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>';
1062
+ document.getElementById('scaccept').onclick=async()=>{ try{ await fetch('/screen/consent',{method:'POST'}); SCREEN.consent=true; scRenderLive(); }catch{ hint.textContent='consent failed'; } };
1063
+ }
1064
+ function scRenderLive(){
1065
+ const body=document.getElementById('screenbody'); const hint=document.getElementById('schint');
1066
+ const caps=SCREEN.caps||{};
1067
+ if(caps.liveCast){
1068
+ body.innerHTML='<img id="screenimg" alt="device screen"/>';
1069
+ document.getElementById('screenimg').src='/screen/stream?fps=20&t='+Date.now();
1070
+ hint.textContent='capturing first frame (~3s on a foldable)…';
1071
+ setTimeout(()=>{ if(SCREEN.on) hint.textContent='live. ▣ Screen again to close.'; },3500);
1072
+ } else {
1073
+ // No ffmpeg: live cast is unavailable. Screenshot still works (pure screencap).
1074
+ // Only render a copyable command when the server returned a real platform one.
1075
+ // The fallback used to show the literal string "install ffmpeg" with a copy
1076
+ // button, which is not a runnable command and failed when pasted into a terminal.
1077
+ const cmd=caps.installHint;
1078
+ let html='<div class="scinstall">Live mirroring needs <b>ffmpeg</b>, which is not installed. ';
1079
+ if(cmd){
1080
+ html+='Install it with this command, then reopen this panel:<br><br><code>'+esc(cmd)+'</code><button class="sccopy" id="scffcopy">copy</button>';
1081
+ } else {
1082
+ html+='Install it with your system package manager (for example <code>brew install ffmpeg</code> on macOS, <code>winget install ffmpeg</code> on Windows, or <code>sudo apt install ffmpeg</code> on Debian/Ubuntu), then reopen this panel.';
1083
+ }
1084
+ html+='</div>';
1085
+ html+='<div class="scinstall">📷 Screenshot works without ffmpeg — use the button above.</div>';
1086
+ if(caps.scrcpy) html+='<div class="scinstall">scrcpy is installed; for a native mirror window run <code>scrcpy</code> in a terminal.</div>';
1087
+ body.innerHTML=html;
1088
+ const c=document.getElementById('scffcopy'); if(c) c.onclick=()=>scCopy(cmd,c);
1089
+ hint.textContent='live mirroring unavailable (no ffmpeg); screenshot still works.';
1090
+ }
1091
+ }
1092
+ async function scOpen(){
1093
+ SCREEN.on=true; document.getElementById('screenpanel').classList.add('on');
1094
+ document.body.classList.add('screenopen'); document.getElementById('screenbtn').classList.add('on');
1095
+ await scLoadCaps();
1096
+ if(SCREEN.consent) scRenderLive(); else scRenderConsent();
1097
+ }
1098
+ function scClose(){
1099
+ SCREEN.on=false; document.getElementById('screenpanel').classList.remove('on');
1100
+ document.body.classList.remove('screenopen'); document.getElementById('screenbtn').classList.remove('on');
1101
+ const img=document.getElementById('screenimg'); if(img) img.src=''; // drop the stream viewer
1102
+ if(SCREEN.recording){ fetch('/screen/record/stop',{method:'POST'}).catch(()=>{}); SCREEN.recording=false; document.getElementById('screenrec').textContent='● rec'; }
1103
+ }
1104
+ document.getElementById('screenbtn').onclick=()=>{ SCREEN.on?scClose():scOpen(); };
1105
+ document.getElementById('screenclose').onclick=scClose;
1106
+ document.getElementById('screenshot').onclick=async()=>{
1107
+ if(!SCREEN.consent){ document.getElementById('schint').textContent='accept the notice first'; return; }
1108
+ // Blob download (not a bare <a href> navigation), so taking a screenshot cannot cancel
1109
+ // the in-flight /screen/stream connection and drop the live <img>.
1110
+ try{ const blob=await (await fetch('/screen/shot?t='+Date.now())).blob(); const u=URL.createObjectURL(blob);
1111
+ const a=document.createElement('a'); a.href=u; a.download='radar-screen-'+Date.now()+'.png'; document.body.appendChild(a); a.click(); a.remove();
1112
+ setTimeout(()=>URL.revokeObjectURL(u),10000);
1113
+ }catch{ document.getElementById('schint').textContent='screenshot failed'; }
1114
+ };
1115
+ document.getElementById('screenrec').onclick=async()=>{
1116
+ if(!SCREEN.consent || !(SCREEN.caps&&SCREEN.caps.liveCast)){ document.getElementById('schint').textContent='recording needs ffmpeg + consent'; return; }
1117
+ const rec=document.getElementById('screenrec');
1118
+ if(!SCREEN.recording){ SCREEN.recording=true; rec.textContent='■ stop'; rec.classList.add('rec'); await fetch('/screen/record/start',{method:'POST'}).catch(()=>{}); }
1119
+ 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';
1120
+ if(r.download){
1121
+ // Fetch the mp4 as a blob and download via an object URL. A bare <a href=/screen/download>
1122
+ // click is a same-origin navigation that the browser can use to cancel the in-flight
1123
+ // /screen/stream connection, which silently kills the live <img> (server keeps casting).
1124
+ // A blob download touches no navigation and leaves the stream connection alone.
1125
+ try{ const blob=await (await fetch(r.download)).blob(); const u=URL.createObjectURL(blob);
1126
+ const a=document.createElement('a'); a.href=u; a.download=(r.file||'radar-screen.mp4'); document.body.appendChild(a); a.click(); a.remove();
1127
+ setTimeout(()=>URL.revokeObjectURL(u),10000);
1128
+ }catch{ document.getElementById('schint').textContent='recording saved on host: '+(r.file||''); }
1129
+ // Belt-and-suspenders: re-arm the live <img> in case the browser still dropped it.
1130
+ const img=document.getElementById('screenimg'); if(img) img.src='/screen/stream?fps=20&t='+Date.now();
1131
+ } }
1132
+ };
1133
+ document.getElementById('screenredetect').onclick=async()=>{
1134
+ document.getElementById('schint').textContent='re-detecting active panel…';
1135
+ const r=await (await fetch('/screen/redetect')).json().catch(()=>({}));
1136
+ 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(); }
1137
+ document.getElementById('schint').textContent='display: '+(r.display||'default');
1138
+ };
1139
+
773
1140
  pollSpec(); // initial build
774
1141
  </script></body></html>
@@ -0,0 +1,25 @@
1
+ # Vendored browser runtime (no build step)
2
+
3
+ The dashboard UI uses Preact (+ Hooks) and htm, loaded in the browser as ES modules with
4
+ no bundler. The `.module.js` files are NOT committed here: they are copied from
5
+ `node_modules` into `dist/web/public/vendor/` at build time by `scripts/copy-vendor.mjs`
6
+ and shipped in the npm tarball (`files: ["dist"]`).
7
+
8
+ Third-party, redistributed unmodified, pinned by integrity hash in `package-lock.json`:
9
+
10
+ - Preact + Preact Hooks (MIT) https://preactjs.com
11
+ - htm (Apache-2.0) https://github.com/developit/htm
12
+
13
+ Why this shape:
14
+
15
+ - `preact` and `htm` are **devDependencies**, so Dependabot tracks them and opens
16
+ version-bump PRs. Exact versions are pinned in `package-lock.json`.
17
+ - Consumers still get the `npx @slack/radar-mcp@latest` no-build experience: the files are
18
+ already in the tarball, the browser fetches them, no bundler runs anywhere.
19
+ - The browser resolves the bare `preact` specifier via the import map in `index.html`.
20
+
21
+ To update: merge the Dependabot PR (or bump the devDependency), then `npm install && npm
22
+ run build`. No manual file copying.
23
+
24
+ The upstream files reference source maps that are not shipped; a devtools 404 for the
25
+ `.map` is harmless, and the static route serves `.js` only.
@@ -0,0 +1,2 @@
1
+ import{options as n}from"preact";var t,r,u,i,o=0,f=[],c=n,e=c.__b,a=c.__r,v=c.diffed,l=c.__c,m=c.unmount,p=c.__;function s(n,t){c.__h&&c.__h(r,n,o||t),o=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({}),u.__[n]}function d(n){return o=1,y(D,n)}function y(n,u,i){var o=s(t++,2);if(o.t=n,!o.__c&&(o.__=[i?i(u):D(void 0,u),function(n){var t=o.__N?o.__N[0]:o.__[0],r=o.t(t,n);t!==r&&(o.__N=[r,o.__[1]],o.__c.setState({}))}],o.__c=r,!r.__f)){var f=function(n,t,r){if(!o.__c.__H)return!0;var u=!1,i=o.__c.props!==n;if(o.__c.__H.__.some(function(n){if(n.__N){u=!0;var t=n.__[0];n.__=n.__N,n.__N=void 0,t!==n.__[0]&&(i=!0)}}),c){var f=c.call(this,n,t,r);return u?f||i:f}return!u||i};r.__f=!0;var c=r.shouldComponentUpdate,e=r.componentWillUpdate;r.componentWillUpdate=function(n,t,r){if(this.__e){var u=c;c=void 0,f(n,t,r),c=u}e&&e.call(this,n,t,r)},r.shouldComponentUpdate=f}return o.__N||o.__}function h(n,u){var i=s(t++,3);!c.__s&&C(i.__H,u)&&(i.__=n,i.u=u,r.__H.__h.push(i))}function _(n,u){var i=s(t++,4);!c.__s&&C(i.__H,u)&&(i.__=n,i.u=u,r.__h.push(i))}function A(n){return o=5,T(function(){return{current:n}},[])}function F(n,t,r){o=6,_(function(){if("function"==typeof n){var r=n(t());return function(){n(null),r&&"function"==typeof r&&r()}}if(n)return n.current=t(),function(){return n.current=null}},null==r?r:r.concat(n))}function T(n,r){var u=s(t++,7);return C(u.__H,r)&&(u.__=n(),u.__H=r,u.__h=n),u.__}function q(n,t){return o=8,T(function(){return n},t)}function x(n){var u=r.context[n.__c],i=s(t++,9);return i.c=n,u?(null==i.__&&(i.__=!0,u.sub(r)),u.props.value):n.__}function P(n,t){c.useDebugValue&&c.useDebugValue(t?t(n):n)}function b(n){var u=s(t++,10),i=d();return u.__=n,r.componentDidCatch||(r.componentDidCatch=function(n,t){u.__&&u.__(n,t),i[1](n)}),[i[0],function(){i[1](void 0)}]}function g(){var n=s(t++,11);if(!n.__){for(var u=r.__v;null!==u&&!u.__m&&null!==u.__;)u=u.__;var i=u.__m||(u.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++}return n.__}function j(){for(var n;n=f.shift();){var t=n.__H;if(n.__P&&t)try{t.__h.some(z),t.__h.some(B),t.__h=[]}catch(r){t.__h=[],c.__e(r,n.__v)}}}c.__b=function(n){r=null,e&&e(n)},c.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),p&&p(n,t)},c.__r=function(n){a&&a(n),t=0;var i=(r=n.__c).__H;i&&(u===r?(i.__h=[],r.__h=[],i.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(i.__h.length&&j(),t=0)),u=r},c.diffed=function(n){v&&v(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&(1!==f.push(t)&&i===c.requestAnimationFrame||((i=c.requestAnimationFrame)||w)(j)),t.__H.__.some(function(n){n.u&&(n.__H=n.u,n.u=void 0)})),u=r=null},c.__c=function(n,t){t.some(function(n){try{n.__h.some(z),n.__h=n.__h.filter(function(n){return!n.__||B(n)})}catch(r){t.some(function(n){n.__h&&(n.__h=[])}),t=[],c.__e(r,n.__v)}}),l&&l(n,t)},c.unmount=function(n){m&&m(n);var t,r=n.__c;r&&r.__H&&(r.__H.__.some(function(n){try{z(n)}catch(n){t=n}}),r.__H=void 0,t&&c.__e(t,r.__v))};var k="function"==typeof requestAnimationFrame;function w(n){var t,r=function(){clearTimeout(u),k&&cancelAnimationFrame(t),setTimeout(n)},u=setTimeout(r,35);k&&(t=requestAnimationFrame(r))}function z(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t}function B(n){var t=r;n.__c=n.__(),r=t}function C(n,t){return!n||n.length!==t.length||t.some(function(t,r){return t!==n[r]})}function D(n,t){return"function"==typeof t?t(n):t}export{q as useCallback,x as useContext,P as useDebugValue,h as useEffect,b as useErrorBoundary,g as useId,F as useImperativeHandle,_ as useLayoutEffect,T as useMemo,y as useReducer,A as useRef,d as useState};
2
+ //# sourceMappingURL=hooks.module.js.map