@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.
- package/README.md +10 -2
- package/dist/mcp/db-tools.d.ts +46 -0
- package/dist/mcp/db-tools.js +160 -0
- package/dist/mcp/index.js +34 -2
- package/dist/mcp/tools.js +52 -2
- package/dist/shared/android.d.ts +8 -0
- package/dist/shared/android.js +25 -3
- package/dist/shared/constants.d.ts +29 -0
- package/dist/shared/constants.js +38 -0
- package/dist/shared/db.d.ts +92 -0
- package/dist/shared/db.js +415 -0
- package/dist/shared/debug-log.d.ts +50 -0
- package/dist/shared/debug-log.js +108 -0
- package/dist/shared/screen.d.ts +155 -0
- package/dist/shared/screen.js +633 -0
- package/dist/shared/transport.d.ts +10 -0
- package/dist/web/bin.d.ts +16 -1
- package/dist/web/bin.js +153 -20
- package/dist/web/log-session.d.ts +89 -0
- package/dist/web/log-session.js +144 -0
- package/dist/web/public/index.html +381 -14
- package/dist/web/public/vendor/README.md +25 -0
- package/dist/web/public/vendor/hooks.module.js +2 -0
- package/dist/web/public/vendor/htm.LICENSE +202 -0
- package/dist/web/public/vendor/htm.module.js +1 -0
- package/dist/web/public/vendor/preact.LICENSE +21 -0
- package/dist/web/public/vendor/preact.module.js +2 -0
- package/dist/web/server.d.ts +30 -11
- package/dist/web/server.js +575 -52
- package/dist/web/spec.d.ts +13 -5
- package/dist/web/spec.js +23 -5
- package/package.json +5 -2
|
@@ -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
|
-
|
|
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">● 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">● 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:'
|
|
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
|
-
//
|
|
243
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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();
|
|
827
|
+
const clearFilterBox=()=>{ fi.value=''; FEED.filter=''; syncClear(); rebuildFeedFromCache(); fi.focus(); };
|
|
649
828
|
syncClear();
|
|
650
|
-
|
|
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|