@slack/radar-mcp 1.6.0 → 1.7.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.
@@ -0,0 +1,194 @@
1
+ // Card widgets: rate, sparkline, graph (counter is a back-compat no-op -- see below). Each
2
+ // reads the live aggregates the store maintains and re-renders on the store "tick" + "feed"
3
+ // events. The graph SVG geometry is ported verbatim from the proven vanilla drawGraph.
4
+ import { html } from "./html.js";
5
+ import { useState } from "preact/hooks";
6
+ import { esc } from "./esc.js";
7
+ import { useStorePulse } from "./hooks.js";
8
+ import { store, seriesValues, unitFor, fmtNum } from "./store.js";
9
+ // The stats are a single THIN strip, not tall cards. Rate is an inline number, the sparkline a
10
+ // small inline bar chart, and the graph (when present) flex-fills the rest of the row.
11
+ export function Cards({ spec }) {
12
+ useStorePulse(store, "tick", "feed");
13
+ const viz = spec.viz || [];
14
+ // NOTE: the "counter" widget is intentionally NOT rendered. A raw count is the wrong signal
15
+ // for a live stream -- cumulative total disagrees with the scrollable feed, and buffer
16
+ // occupancy wobbles as the ring evicts. Rate is the live pulse; the feed is the data.
17
+ // "counter" stays a valid spec key (back-compat) but is a no-op here. Use matchedRetained
18
+ // for any count that must agree with the feed.
19
+ const hasRate = viz.includes("rate");
20
+ const hasSpark = viz.includes("sparkline");
21
+ const hasGraph = viz.includes("graph") && store.graph;
22
+ if (!hasRate && !hasSpark && !hasGraph)
23
+ return null;
24
+ const w = spec.rateWindowSec || 10;
25
+ return html `
26
+ <div class="statstrip">
27
+ ${hasRate ? html `<span class="stat"><span class="statlabel">rate</span><span class="statval">${store.rate}</span><span class="statunit">/${w}s</span></span>` : null}
28
+ ${hasSpark ? html `<${MiniSpark} />` : null}
29
+ ${hasGraph ? html `<${CollapsibleGraph} spec=${spec} />` : null}
30
+ </div>
31
+ `;
32
+ }
33
+ // A compact always-on mini spark that expands to the full chart as a FLOATING overlay (never
34
+ // steals feed height); click-outside collapses.
35
+ function CollapsibleGraph({ spec }) {
36
+ const [expanded, setExpanded] = useState(false);
37
+ return html `
38
+ <span class="cgraph">
39
+ <span class="cgraph-mini" title="click to expand the chart" onClick=${() => setExpanded(true)}>
40
+ <${MiniGraph} spec=${spec} />
41
+ <${GraphLegendInline} spec=${spec} />
42
+ <span class="cgraph-exp">⤢</span>
43
+ </span>
44
+ ${expanded
45
+ ? html `<div class="cgraph-overlay" onClick=${() => setExpanded(false)}>
46
+ <div class="cgraph-panel" onClick=${(e) => e.stopPropagation()}>
47
+ <div class="cgraph-head">
48
+ <span class="label">${(spec.series || []).map((s) => s.label || s.metric).join(" vs ")}</span>
49
+ <span class="cgraph-collapse" title="collapse" onClick=${() => setExpanded(false)}>✕</span>
50
+ </div>
51
+ <${Graph} spec=${spec} />
52
+ </div>
53
+ </div>`
54
+ : null}
55
+ </span>
56
+ `;
57
+ }
58
+ function GraphLegendInline() {
59
+ const G = store.graph;
60
+ if (!G)
61
+ return null;
62
+ return html `<span class="cgraph-vals">${G.series.map((s, i) => {
63
+ const vals = seriesValues(s);
64
+ let last = null;
65
+ for (const v of vals)
66
+ if (v != null)
67
+ last = v;
68
+ const unit = unitFor(s.def);
69
+ return html `<span class="cgraph-val" style=${{ color: GCOLORS[i] }}>${fmtNum(last)}${unit ? " " + unit : ""}</span>`;
70
+ })}</span>`;
71
+ }
72
+ // Small normalized multi-line spark; red dashed threshold line when highlight is set. SVG body
73
+ // is a generated string (geometry, no user text) via dangerouslySetInnerHTML -- one of the
74
+ // three audited sinks; the only interpolated value (the threshold) is numeric.
75
+ function MiniGraph({ spec }) {
76
+ const G = store.graph;
77
+ if (!G)
78
+ return null;
79
+ const W = 120, H = 28, N = G.N;
80
+ let out = "";
81
+ G.series.forEach((s, si) => {
82
+ const raw = seriesValues(s);
83
+ let last = null;
84
+ const vals = raw.map((v) => (v == null ? last : (last = v)));
85
+ const real = vals.filter((v) => v != null);
86
+ const mx = Math.max(1, ...real);
87
+ const pts = vals
88
+ .map((v, i) => (v == null ? null : (i / (N - 1)) * W + "," + (H - (v / mx) * (H - 2) - 1).toFixed(1)))
89
+ .filter(Boolean)
90
+ .join(" ");
91
+ if (pts)
92
+ out += '<polyline fill="none" stroke="' + GCOLORS[si] + '" stroke-width="1.5" points="' + pts + '"/>';
93
+ const h = spec.highlight;
94
+ if (h && s.def.field && h.field === s.def.field && h.value <= mx) {
95
+ const ty = H - (h.value / mx) * (H - 2) - 1;
96
+ out += '<line x1="0" y1="' + ty.toFixed(1) + '" x2="' + W + '" y2="' + ty.toFixed(1) + '" stroke="#ff5b50" stroke-width="1" stroke-dasharray="3 2"/>';
97
+ }
98
+ });
99
+ return html `<svg class="minigraph" viewBox=${"0 0 " + W + " " + H} preserveAspectRatio="none" dangerouslySetInnerHTML=${{ __html: out }}></svg>`;
100
+ }
101
+ function MiniSpark() {
102
+ const ps = store.perSec;
103
+ const mx = Math.max(1, ...ps);
104
+ // plain "per sec": Preact renders template text literally (no entity decode), and
105
+ // .ministat is white-space:nowrap, so a normal space does not wrap.
106
+ return html `<span class="ministat"><span class="statlabel">per sec</span><span class="minispark">${ps.map((n) => html `<span class="minibar" style=${{ height: 2 + (n / mx) * 20 + "px" }}></span>`)}</span></span>`;
107
+ }
108
+ const GCOLORS = ["#39d98a", "#ffd479"];
109
+ // Chart CHROME (frame, ticks, axis labels) reads live theme tokens so the graph follows
110
+ // light/dark. The data SERIES + the red threshold stay vivid in both themes by design.
111
+ function themeColor(varName, fallback) {
112
+ try {
113
+ const v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
114
+ return v || fallback;
115
+ }
116
+ catch {
117
+ return fallback;
118
+ }
119
+ }
120
+ // Live SVG line chart, 1-2 series, dual labeled axes + threshold reference line. Built as an
121
+ // innerHTML SVG string (same as vanilla drawGraph) so the geometry math is byte-for-byte the
122
+ // proven version; every interpolated string (units/labels) passes through the shared esc().
123
+ function Graph({ spec }) {
124
+ const G = store.graph;
125
+ if (!G)
126
+ return null;
127
+ const VB_W = 640, VB_H = 200, N = G.N;
128
+ const L = 46, R = G.series.length > 1 ? 52 : 14, T = 10, B = 26;
129
+ const x0 = L, x1 = VB_W - R, y0 = T, y1 = VB_H - B, pw = x1 - x0, ph = y1 - y0;
130
+ const cFrame = themeColor("--border", "#1e2a3a");
131
+ const cTick = themeColor("--border-3", "#33455c");
132
+ const cAxis = themeColor("--muted", "#5f7186");
133
+ let out = "", legend = "";
134
+ out += '<rect x="' + x0 + '" y="' + y0 + '" width="' + pw + '" height="' + ph + '" fill="none" stroke="' + cFrame + '"/>';
135
+ for (let k = 0; k <= 4; k++) {
136
+ const fx = x0 + (k / 4) * pw;
137
+ const secsAgo = Math.round((1 - k / 4) * N);
138
+ const lbl = secsAgo === 0 ? "now" : "-" + secsAgo + "s";
139
+ out += '<line x1="' + fx.toFixed(1) + '" y1="' + y1 + '" x2="' + fx.toFixed(1) + '" y2="' + (y1 + 4) + '" stroke="' + cTick + '"/>';
140
+ out += '<text x="' + fx.toFixed(1) + '" y="' + (y1 + 16) + '" fill="' + cAxis + '" font-size="10" text-anchor="middle">' + lbl + "</text>";
141
+ }
142
+ G.series.forEach((s, si) => {
143
+ const raw = seriesValues(s);
144
+ const scale = s.def.metric === "errorRate" ? 100 : 1;
145
+ let last = null;
146
+ const vals = raw.map((v) => {
147
+ const sv = v == null ? null : v * scale;
148
+ if (sv != null) {
149
+ last = sv;
150
+ return sv;
151
+ }
152
+ return last;
153
+ });
154
+ const real = vals.filter((v) => v != null);
155
+ const mx = Math.max(1, ...real);
156
+ const col = GCOLORS[si], unit = unitFor(s.def);
157
+ const axX = si === 0 ? x0 : x1, anchor = si === 0 ? "end" : "start", tx = si === 0 ? x0 - 6 : x1 + 6;
158
+ for (let k = 0; k <= 2; k++) {
159
+ const val = (mx * k) / 2;
160
+ const fy = y1 - (k / 2) * ph;
161
+ out += '<text x="' + tx + '" y="' + (fy + 3).toFixed(1) + '" fill="' + col + '" font-size="10" text-anchor="' + anchor + '">' + fmtNum(val) + "</text>";
162
+ }
163
+ out += '<text x="' + axX + '" y="' + (y0 - 1) + '" fill="' + col + '" font-size="9" text-anchor="' + (si === 0 ? "start" : "end") + '">' + esc(unit || s.def.metric) + "</text>";
164
+ const pts = vals
165
+ .map((v, i) => {
166
+ if (v == null)
167
+ return null;
168
+ const px = x0 + (i / (N - 1)) * pw;
169
+ const py = y1 - (v / mx) * ph;
170
+ return px.toFixed(1) + "," + py.toFixed(1);
171
+ })
172
+ .filter(Boolean)
173
+ .join(" ");
174
+ if (pts)
175
+ out += '<polyline fill="none" stroke="' + col + '" stroke-width="2" points="' + pts + '"/>';
176
+ const h = spec.highlight;
177
+ if (h && s.def.field && h.field === s.def.field && h.value <= mx) {
178
+ const ty = y1 - (h.value / mx) * ph;
179
+ out += '<line x1="' + x0 + '" y1="' + ty.toFixed(1) + '" x2="' + x1 + '" y2="' + ty.toFixed(1) + '" stroke="#ff3b30" stroke-width="1" stroke-dasharray="4 3"/>';
180
+ out += '<text x="' + (x0 + 4) + '" y="' + (ty - 3).toFixed(1) + '" fill="#ff6b60" font-size="9">' + esc(h.label || ">" + h.value) + " " + esc(unit || "") + "</text>";
181
+ }
182
+ const shown = fmtNum(last) + (unit ? " " + unit : "");
183
+ legend += '<span style="color:' + col + '">■ ' + esc(s.def.label || s.def.metric) + ": " + shown + "</span> ";
184
+ });
185
+ const labelText = G.series.map((s) => s.def.label || s.def.metric).join(" vs ");
186
+ return html `
187
+ <div class="graphcard">
188
+ <div class="label">${labelText}</div>
189
+ <svg viewBox="0 0 640 200" dangerouslySetInnerHTML=${{ __html: out }}></svg>
190
+ <div class="legend" dangerouslySetInnerHTML=${{ __html: legend + ' <span style="color:var(--muted)">x: time (last ' + N + "s, right=now)</span>" }}></div>
191
+ </div>
192
+ `;
193
+ }
194
+ export { seriesValues, unitFor, fmtNum };
@@ -9,7 +9,7 @@ import { RADAR_HOST, RADAR_PORT, RADAR_VERSION, WEB_PORT_DEFAULT } from "../shar
9
9
  import { cleanupPulledDatabases, initializeDatabasePath, listDatabases, pullDatabase, pulledSize, queryDatabase, resetDbState, } from "../shared/db.js";
10
10
  import { LogSession } from "./log-session.js";
11
11
  import { BLANK_SPEC, coerceSpec, validateSpec } from "./spec.js";
12
- import { screenCapabilities, refreshScreenCapabilities, grantScreenConsent, hasScreenConsent, detectDisplay, displayNote, captureScreenshot, startLive, addViewer, removeViewer, primeIfNeeded, startRecording, stopRecording, recordingPath, resetScreenState, registerScreenCleanup, resolveFfmpeg, ffmpegInstallHint, SCREEN_BOUNDARY, } from "../shared/screen.js";
12
+ import { screenCapabilities, refreshScreenCapabilities, grantScreenConsent, hasScreenConsent, detectDisplay, displayNote, captureScreenshot, startLive, addViewer, removeViewer, reapLiveIfIdle, primeIfNeeded, startRecording, stopRecording, recordingPath, resetScreenState, registerScreenCleanup, resolveFfmpeg, ffmpegInstallHint, SCREEN_BOUNDARY, } from "../shared/screen.js";
13
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ?? WEB_PORT_DEFAULT);
15
15
  const INDEX_PATH = path.join(__dirname, "public", "index.html");
@@ -139,6 +139,8 @@ export function createServer() {
139
139
  const url = req.url ?? "";
140
140
  const urlPath = url.split("?")[0];
141
141
  if (urlPath === "/" || urlPath === "/index.html") {
142
+ // The Preact dashboard is the default (and only) UI. The old vanilla dashboard + its
143
+ // ?next opt-in seam were retired in the flip; index.html IS the Preact shell now.
142
144
  serveIndex(res);
143
145
  return;
144
146
  }
@@ -797,6 +799,10 @@ function handleScreen(req, res) {
797
799
  if (url === "/screen/record/stop" && req.method === "POST") {
798
800
  void (async () => {
799
801
  const p = await stopRecording();
802
+ // If the viewer already left (the close-while-recording path drops the <img> before
803
+ // this POST lands), the recording was the only thing keeping the cast alive — reap it
804
+ // now so screenrecord + ffmpeg do not run on with no viewer.
805
+ reapLiveIfIdle();
800
806
  const file = p ? path.basename(p) : null;
801
807
  let sizeBytes = 0;
802
808
  if (p) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slack/radar-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "MCP server and web dashboard for on-device debugging of the Slack Android app via ADB",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/index.js",
@@ -17,7 +17,8 @@
17
17
  "clean": "rm -rf dist",
18
18
  "prebuild": "npm run clean",
19
19
  "copy-vendor": "node scripts/copy-vendor.mjs",
20
- "build": "tsc && cp -r src/web/public dist/web/public && npm run copy-vendor && chmod +x dist/mcp/index.js dist/web/bin.js",
20
+ "build:web": "tsc -p tsconfig.web.json",
21
+ "build": "tsc && npm run build:web && node scripts/copy-public.mjs && npm run copy-vendor && chmod +x dist/mcp/index.js dist/web/bin.js",
21
22
  "start": "node dist/mcp/index.js",
22
23
  "start:web": "node dist/web/bin.js",
23
24
  "watch": "tsc --watch",