@slack/radar-mcp 1.0.0 → 1.1.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 +46 -17
- package/dist/{index.js → mcp/index.js} +3 -3
- package/dist/{tools.d.ts → mcp/tools.d.ts} +5 -0
- package/dist/{tools.js → mcp/tools.js} +16 -0
- package/dist/{android.d.ts → shared/android.d.ts} +5 -0
- package/dist/{android.js → shared/android.js} +33 -0
- package/dist/web/bin.d.ts +2 -0
- package/dist/web/bin.js +35 -0
- package/dist/web/public/index.html +695 -0
- package/dist/web/server.d.ts +4 -0
- package/dist/web/server.js +106 -0
- package/package.json +8 -6
- /package/dist/{index.d.ts → mcp/index.d.ts} +0 -0
- /package/dist/{constants.d.ts → shared/constants.d.ts} +0 -0
- /package/dist/{constants.js → shared/constants.js} +0 -0
- /package/dist/{stream.d.ts → shared/stream.d.ts} +0 -0
- /package/dist/{stream.js → shared/stream.js} +0 -0
- /package/dist/{transport.d.ts → shared/transport.d.ts} +0 -0
- /package/dist/{transport.js → shared/transport.js} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# slack-radar-mcp
|
|
2
2
|
|
|
3
|
-
Platform-agnostic MCP server for on-device debugging of mobile apps.
|
|
3
|
+
Platform-agnostic MCP server and web dashboard for on-device debugging of mobile apps.
|
|
4
4
|
|
|
5
5
|
Gives Claude Code direct access to network calls, RTM events, analytics (clogs), screenshots, logs, and more from a running debug build. Ships with Android support (ADB); the `DeviceTransport` interface makes adding iOS or other platforms straightforward.
|
|
6
6
|
|
|
@@ -8,21 +8,45 @@ Gives Claude Code direct access to network calls, RTM events, analytics (clogs),
|
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
|
+
The recommended path is via the marketplace plugin:
|
|
12
|
+
|
|
11
13
|
```bash
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
npm install
|
|
15
|
-
npm run build
|
|
14
|
+
/plugin install slack-radar@slack-android-marketplace
|
|
15
|
+
/slack-radar-setup
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Or register manually:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
slack claude mcp add -s user slack-radar-mcp --
|
|
21
|
+
slack claude mcp add -s user slack-radar-mcp -- npx -y @slack/radar-mcp@latest
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Restart your Claude Code session for the tools to appear.
|
|
25
25
|
|
|
26
|
+
## Web Dashboard
|
|
27
|
+
|
|
28
|
+
The package also ships a browser-based dashboard that shows the same live device data without Claude Code in the loop.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx -p @slack/radar-mcp slack-radar-web
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or with a global install:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install -g @slack/radar-mcp
|
|
38
|
+
slack-radar-web
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Opens `http://localhost:8100` automatically. Set `SLACK_RADAR_WEB_PORT` to use a different port. Features:
|
|
42
|
+
|
|
43
|
+
* Live Network / RTM / Clogs tabs with real-time SSE streaming
|
|
44
|
+
* Filter bar for URL, channel, event name, payload text
|
|
45
|
+
* Detail pane with Copy JSON, Copy Body, and Copy cURL buttons
|
|
46
|
+
* Profile selector for personal vs work profile when both are running
|
|
47
|
+
* Auto-extend keeps the device-side server alive while the browser tab is open
|
|
48
|
+
* Enable Radar button handles ADB forwarding and device activation without Claude
|
|
49
|
+
|
|
26
50
|
## Prerequisites
|
|
27
51
|
|
|
28
52
|
* Node.js 18+
|
|
@@ -72,15 +96,16 @@ Mobile debug build (device)
|
|
|
72
96
|
|
|
|
73
97
|
| port forwarding (adb forward / iproxy / etc.)
|
|
74
98
|
|
|
|
75
|
-
|
|
76
|
-
+-- DeviceTransport
|
|
77
|
-
+--
|
|
78
|
-
|
|
99
|
+
@slack/radar-mcp (Node.js, runs on developer machine)
|
|
100
|
+
+-- src/shared/ DeviceTransport, AndroidTransport, SSE stream, constants
|
|
101
|
+
+-- src/mcp/ MCP stdio server (Claude Code entry point)
|
|
102
|
+
+-- src/web/ HTTP proxy server + browser dashboard
|
|
103
|
+
+-- public/ Static HTML/CSS/JS served to the browser
|
|
79
104
|
```
|
|
80
105
|
|
|
81
|
-
All device-specific logic lives behind the `DeviceTransport` interface (`src/transport.ts`). The MCP server, tools, SSE streaming, and session management are fully platform-agnostic.
|
|
106
|
+
All device-specific logic lives behind the `DeviceTransport` interface (`src/shared/transport.ts`). The MCP server, web dashboard, tools, SSE streaming, and session management are fully platform-agnostic.
|
|
82
107
|
|
|
83
|
-
**Adding a new platform**: implement `DeviceTransport` in `src/<platform>.ts`, add a case to `createTransport()` in `src/index.ts`. No other files change.
|
|
108
|
+
**Adding a new platform**: implement `DeviceTransport` in `src/shared/<platform>.ts`, add a case to `createTransport()` in `src/mcp/index.ts`. No other files change.
|
|
84
109
|
|
|
85
110
|
## Security
|
|
86
111
|
|
|
@@ -106,7 +131,7 @@ This tool captures unredacted network traffic from your debug Slack build, inclu
|
|
|
106
131
|
* URL query parameters (tokens can appear here)
|
|
107
132
|
* `Authorization` headers and cookies
|
|
108
133
|
* `Set-Cookie` response headers (session tokens)
|
|
109
|
-
* Request and response bodies, up to
|
|
134
|
+
* Request and response bodies, up to 256 KB per body, for JSON, form, text, and XML content types
|
|
110
135
|
* RTM WebSocket payloads (every sent and received message)
|
|
111
136
|
* Analytics (clog) events serialized as JSON
|
|
112
137
|
* Screenshots and short screen recordings on demand
|
|
@@ -122,8 +147,9 @@ The tool is intended for debugging the Slack Android app against your own sessio
|
|
|
122
147
|
|
|
123
148
|
```bash
|
|
124
149
|
npm install
|
|
125
|
-
npm run build # Compile TypeScript to dist/
|
|
126
|
-
npm start # Run the server (stdio transport)
|
|
150
|
+
npm run build # Compile TypeScript + copy static assets to dist/
|
|
151
|
+
npm start # Run the MCP server (stdio transport)
|
|
152
|
+
npm run start:web # Run the web dashboard
|
|
127
153
|
npm run watch # TypeScript compiler in watch mode
|
|
128
154
|
npm test # Unit tests
|
|
129
155
|
npm run test:smoke # Smoke tests
|
|
@@ -142,7 +168,10 @@ The device-side server may have auto-shut down. Use `/radar-enable` to restart i
|
|
|
142
168
|
Two consecutive connection failures disable the session. Use `/radar-enable` to reconnect.
|
|
143
169
|
|
|
144
170
|
**Work profile devices**
|
|
145
|
-
The MCP server auto-detects the Android user ID via `ps` and targets the broadcast accordingly. No manual `--user` flag needed.
|
|
171
|
+
The MCP server auto-detects the Android user ID via `ps` and targets the broadcast accordingly. No manual `--user` flag needed. The web dashboard shows a profile selector when both personal and work profiles are running.
|
|
146
172
|
|
|
147
173
|
**Tools not appearing after setup**
|
|
148
174
|
Restart your Claude Code session. MCP servers are registered at session start.
|
|
175
|
+
|
|
176
|
+
**Port already in use (web dashboard)**
|
|
177
|
+
Another instance may be running. Kill it with `lsof -ti :8100 | xargs kill` or set a different port with `SLACK_RADAR_WEB_PORT=8101 slack-radar-web`.
|
|
@@ -4,9 +4,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import fs from "fs";
|
|
6
6
|
import http from "http";
|
|
7
|
-
import { AndroidTransport } from "
|
|
8
|
-
import { RADAR_HOST, RADAR_PORT } from "
|
|
9
|
-
import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "
|
|
7
|
+
import { AndroidTransport } from "../shared/android.js";
|
|
8
|
+
import { RADAR_HOST, RADAR_PORT } from "../shared/constants.js";
|
|
9
|
+
import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
|
|
10
10
|
import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS } from "./tools.js";
|
|
11
11
|
// --- Platform selection ---
|
|
12
12
|
function createTransport() {
|
|
@@ -7,6 +7,11 @@ export interface ToolDefinition {
|
|
|
7
7
|
properties: Record<string, unknown>;
|
|
8
8
|
required?: string[];
|
|
9
9
|
};
|
|
10
|
+
annotations?: {
|
|
11
|
+
readOnlyHint?: boolean;
|
|
12
|
+
destructiveHint?: boolean;
|
|
13
|
+
idempotentHint?: boolean;
|
|
14
|
+
};
|
|
10
15
|
}
|
|
11
16
|
export declare const TOOL_DEFINITIONS: ToolDefinition[];
|
|
12
17
|
/**
|
|
@@ -46,6 +46,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
46
46
|
},
|
|
47
47
|
},
|
|
48
48
|
},
|
|
49
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
49
50
|
},
|
|
50
51
|
{
|
|
51
52
|
name: "get_recent_network_calls",
|
|
@@ -58,6 +59,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
58
59
|
status_code: { type: "number", description: "Filter by HTTP status code" },
|
|
59
60
|
},
|
|
60
61
|
},
|
|
62
|
+
annotations: { readOnlyHint: true },
|
|
61
63
|
},
|
|
62
64
|
{
|
|
63
65
|
name: "get_network_call_detail",
|
|
@@ -69,6 +71,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
69
71
|
},
|
|
70
72
|
required: ["id"],
|
|
71
73
|
},
|
|
74
|
+
annotations: { readOnlyHint: true },
|
|
72
75
|
},
|
|
73
76
|
{
|
|
74
77
|
name: "get_recent_rtm_events",
|
|
@@ -82,6 +85,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
82
85
|
channel: { type: "string", description: "Filter by channel ID" },
|
|
83
86
|
},
|
|
84
87
|
},
|
|
88
|
+
annotations: { readOnlyHint: true },
|
|
85
89
|
},
|
|
86
90
|
{
|
|
87
91
|
name: "get_rtm_event_detail",
|
|
@@ -93,6 +97,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
93
97
|
},
|
|
94
98
|
required: ["id"],
|
|
95
99
|
},
|
|
100
|
+
annotations: { readOnlyHint: true },
|
|
96
101
|
},
|
|
97
102
|
{
|
|
98
103
|
name: "get_recent_clogs",
|
|
@@ -104,6 +109,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
104
109
|
name: { type: "string", description: "Filter by event name substring" },
|
|
105
110
|
},
|
|
106
111
|
},
|
|
112
|
+
annotations: { readOnlyHint: true },
|
|
107
113
|
},
|
|
108
114
|
{
|
|
109
115
|
name: "get_clog_detail",
|
|
@@ -115,6 +121,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
115
121
|
},
|
|
116
122
|
required: ["id"],
|
|
117
123
|
},
|
|
124
|
+
annotations: { readOnlyHint: true },
|
|
118
125
|
},
|
|
119
126
|
{
|
|
120
127
|
name: "wait_for_events",
|
|
@@ -129,6 +136,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
129
136
|
idle_timeout_ms: { type: "number", description: "Resolve after this many ms of silence (default: 3000)" },
|
|
130
137
|
},
|
|
131
138
|
},
|
|
139
|
+
annotations: { readOnlyHint: true },
|
|
132
140
|
},
|
|
133
141
|
{
|
|
134
142
|
name: "search",
|
|
@@ -141,11 +149,13 @@ export const TOOL_DEFINITIONS = [
|
|
|
141
149
|
},
|
|
142
150
|
required: ["query"],
|
|
143
151
|
},
|
|
152
|
+
annotations: { readOnlyHint: true },
|
|
144
153
|
},
|
|
145
154
|
{
|
|
146
155
|
name: "get_app_state",
|
|
147
156
|
description: "Get the current app state from the device.",
|
|
148
157
|
inputSchema: { type: "object", properties: {} },
|
|
158
|
+
annotations: { readOnlyHint: true },
|
|
149
159
|
},
|
|
150
160
|
{
|
|
151
161
|
name: "get_recent_logs",
|
|
@@ -159,6 +169,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
159
169
|
},
|
|
160
170
|
required: ["tag"],
|
|
161
171
|
},
|
|
172
|
+
annotations: { readOnlyHint: true },
|
|
162
173
|
},
|
|
163
174
|
{
|
|
164
175
|
name: "screenshot",
|
|
@@ -169,6 +180,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
169
180
|
full_res: { type: "boolean", description: "Capture at full resolution (default: false)" },
|
|
170
181
|
},
|
|
171
182
|
},
|
|
183
|
+
annotations: { readOnlyHint: true },
|
|
172
184
|
},
|
|
173
185
|
{
|
|
174
186
|
name: "record_screen",
|
|
@@ -179,11 +191,13 @@ export const TOOL_DEFINITIONS = [
|
|
|
179
191
|
duration_s: { type: "number", description: "Recording duration in seconds (default: 5, max: 30)" },
|
|
180
192
|
},
|
|
181
193
|
},
|
|
194
|
+
annotations: { readOnlyHint: true },
|
|
182
195
|
},
|
|
183
196
|
{
|
|
184
197
|
name: "clear_buffers",
|
|
185
198
|
description: "Clear all captured network calls and RTM events from both device and local buffers.",
|
|
186
199
|
inputSchema: { type: "object", properties: {} },
|
|
200
|
+
annotations: { destructiveHint: true },
|
|
187
201
|
},
|
|
188
202
|
{
|
|
189
203
|
name: "_radar_session_enable",
|
|
@@ -194,11 +208,13 @@ export const TOOL_DEFINITIONS = [
|
|
|
194
208
|
timeout_minutes: { type: "number", description: "Device-side auto-shutdown timeout in minutes (default: 5)" },
|
|
195
209
|
},
|
|
196
210
|
},
|
|
211
|
+
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
197
212
|
},
|
|
198
213
|
{
|
|
199
214
|
name: "_radar_session_disable",
|
|
200
215
|
description: "Disable Slack Radar for this session. Tears down the SSE stream and clears local buffers. Called by /radar-disable skill.",
|
|
201
216
|
inputSchema: { type: "object", properties: {} },
|
|
217
|
+
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
202
218
|
},
|
|
203
219
|
];
|
|
204
220
|
/**
|
|
@@ -21,5 +21,10 @@ export declare class AndroidTransport implements DeviceTransport {
|
|
|
21
21
|
recordScreen(durationS: number): string;
|
|
22
22
|
getAppState(): string;
|
|
23
23
|
getRecentLogs(tag: string, limit: number, grep?: string): string[];
|
|
24
|
+
listProfiles(): {
|
|
25
|
+
userId: string;
|
|
26
|
+
label: string;
|
|
27
|
+
}[];
|
|
28
|
+
activateForUser(userId: string, timeoutMinutes: number): void;
|
|
24
29
|
private detectDebugBuildUser;
|
|
25
30
|
}
|
|
@@ -112,6 +112,39 @@ export class AndroidTransport {
|
|
|
112
112
|
});
|
|
113
113
|
return output.trim().split("\n").filter(Boolean);
|
|
114
114
|
}
|
|
115
|
+
listProfiles() {
|
|
116
|
+
try {
|
|
117
|
+
const ps = execSync(`adb shell "ps -A -o USER,NAME 2>/dev/null | grep Slack | grep debug"`, { timeout: 5000, encoding: "utf-8" }).trim();
|
|
118
|
+
if (!ps)
|
|
119
|
+
return [{ userId: "0", label: "Personal (user 0)" }];
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
const profiles = [];
|
|
122
|
+
for (const line of ps.split("\n")) {
|
|
123
|
+
const match = line.match(/u(\d+)_/);
|
|
124
|
+
if (match && !seen.has(match[1])) {
|
|
125
|
+
seen.add(match[1]);
|
|
126
|
+
const id = match[1];
|
|
127
|
+
profiles.push({
|
|
128
|
+
userId: id,
|
|
129
|
+
label: id === "0" ? "Personal (user 0)" : `Work profile (user ${id})`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return profiles.length > 0
|
|
134
|
+
? profiles
|
|
135
|
+
: [{ userId: "0", label: "Personal (user 0)" }];
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return [{ userId: "0", label: "Personal (user 0)" }];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
activateForUser(userId, timeoutMinutes) {
|
|
142
|
+
const timeout = timeoutMinutes || this.currentTimeoutMinutes;
|
|
143
|
+
execSync(`adb shell am broadcast --user ${userId} -a ${ENABLE_ACTION} --ei timeout_minutes ${timeout}`, { timeout: 5000 });
|
|
144
|
+
this.detectedUserId = userId;
|
|
145
|
+
this.currentTimeoutMinutes = timeout;
|
|
146
|
+
this.radarEnabled = true;
|
|
147
|
+
}
|
|
115
148
|
detectDebugBuildUser() {
|
|
116
149
|
if (this.detectedUserId !== null)
|
|
117
150
|
return this.detectedUserId;
|
package/dist/web/bin.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer, WEB_PORT } from "./server.js";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
const server = createServer();
|
|
5
|
+
server.on("error", (err) => {
|
|
6
|
+
if (err.code === "EADDRINUSE") {
|
|
7
|
+
console.error(`Port ${WEB_PORT} is already in use. Another instance may be running.\n` +
|
|
8
|
+
`Kill it with: lsof -ti :${WEB_PORT} | xargs kill\n` +
|
|
9
|
+
`Or set a different port: SLACK_RADAR_WEB_PORT=8101 slack-radar-web`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
throw err;
|
|
13
|
+
});
|
|
14
|
+
server.listen(WEB_PORT, () => {
|
|
15
|
+
const url = `http://localhost:${WEB_PORT}`;
|
|
16
|
+
console.log(`Slack Radar Web running at ${url}`);
|
|
17
|
+
try {
|
|
18
|
+
if (process.platform === "darwin") {
|
|
19
|
+
execSync(`open ${url}`, { stdio: "ignore" });
|
|
20
|
+
}
|
|
21
|
+
else if (process.platform === "linux") {
|
|
22
|
+
execSync(`xdg-open ${url}`, { stdio: "ignore" });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Browser open is best-effort
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
function shutdown() {
|
|
30
|
+
console.log("\nShutting down…");
|
|
31
|
+
server.close(() => process.exit(0));
|
|
32
|
+
setTimeout(() => process.exit(0), 2000);
|
|
33
|
+
}
|
|
34
|
+
process.on("SIGINT", shutdown);
|
|
35
|
+
process.on("SIGTERM", shutdown);
|
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Slack Radar</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { box-sizing: border-box; }
|
|
8
|
+
html, body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
height: 100%;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
}
|
|
14
|
+
body {
|
|
15
|
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
|
|
16
|
+
font-size: 13px;
|
|
17
|
+
background: #ffffff;
|
|
18
|
+
color: #1d1c1d;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
}
|
|
22
|
+
header {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 12px;
|
|
26
|
+
padding: 10px 16px;
|
|
27
|
+
background: #f8f8f8;
|
|
28
|
+
border-bottom: 1px solid #e0e0e0;
|
|
29
|
+
}
|
|
30
|
+
header h1 {
|
|
31
|
+
margin: 0;
|
|
32
|
+
font-size: 15px;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
color: #1d1c1d;
|
|
35
|
+
}
|
|
36
|
+
.badge {
|
|
37
|
+
display: inline-flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 6px;
|
|
40
|
+
font-size: 11px;
|
|
41
|
+
color: #616061;
|
|
42
|
+
padding: 3px 10px;
|
|
43
|
+
background: #eeeeee;
|
|
44
|
+
border-radius: 10px;
|
|
45
|
+
}
|
|
46
|
+
.status-dot {
|
|
47
|
+
width: 8px;
|
|
48
|
+
height: 8px;
|
|
49
|
+
border-radius: 50%;
|
|
50
|
+
background: #e01e5a;
|
|
51
|
+
}
|
|
52
|
+
.status-dot.connected { background: #2bac76; }
|
|
53
|
+
.status-dot.paused { background: #de9f19; }
|
|
54
|
+
.tabs {
|
|
55
|
+
display: flex;
|
|
56
|
+
background: #f8f8f8;
|
|
57
|
+
border-bottom: 1px solid #e0e0e0;
|
|
58
|
+
}
|
|
59
|
+
.tab {
|
|
60
|
+
padding: 10px 16px;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
color: #616061;
|
|
63
|
+
border-bottom: 2px solid transparent;
|
|
64
|
+
user-select: none;
|
|
65
|
+
font-weight: 500;
|
|
66
|
+
}
|
|
67
|
+
.tab:hover { color: #1d1c1d; }
|
|
68
|
+
.tab.active {
|
|
69
|
+
color: #1d1c1d;
|
|
70
|
+
border-bottom-color: #1264a3;
|
|
71
|
+
}
|
|
72
|
+
.tab .count {
|
|
73
|
+
margin-left: 6px;
|
|
74
|
+
font-size: 11px;
|
|
75
|
+
color: #868686;
|
|
76
|
+
}
|
|
77
|
+
.toolbar {
|
|
78
|
+
display: flex;
|
|
79
|
+
gap: 8px;
|
|
80
|
+
padding: 8px 16px;
|
|
81
|
+
background: #ffffff;
|
|
82
|
+
border-bottom: 1px solid #e0e0e0;
|
|
83
|
+
align-items: center;
|
|
84
|
+
}
|
|
85
|
+
.toolbar input, .toolbar button, .toolbar select {
|
|
86
|
+
padding: 6px 10px;
|
|
87
|
+
border: 1px solid #d0d0d0;
|
|
88
|
+
border-radius: 4px;
|
|
89
|
+
background: #ffffff;
|
|
90
|
+
color: #1d1c1d;
|
|
91
|
+
font-size: 12px;
|
|
92
|
+
font-family: inherit;
|
|
93
|
+
}
|
|
94
|
+
.toolbar input { flex: 1; }
|
|
95
|
+
.toolbar input:focus { outline: none; border-color: #1264a3; }
|
|
96
|
+
.toolbar button {
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
user-select: none;
|
|
99
|
+
}
|
|
100
|
+
.toolbar button:hover { background: #f0f0f0; }
|
|
101
|
+
.toolbar button.active { background: #1264a3; border-color: #1264a3; color: #fff; }
|
|
102
|
+
.main {
|
|
103
|
+
flex: 1;
|
|
104
|
+
min-height: 0;
|
|
105
|
+
display: flex;
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
}
|
|
108
|
+
.list {
|
|
109
|
+
flex: 1;
|
|
110
|
+
min-height: 0;
|
|
111
|
+
overflow-y: auto;
|
|
112
|
+
background: #ffffff;
|
|
113
|
+
}
|
|
114
|
+
.row {
|
|
115
|
+
display: grid;
|
|
116
|
+
grid-template-columns: 90px 60px 1fr 120px;
|
|
117
|
+
gap: 12px;
|
|
118
|
+
padding: 6px 16px;
|
|
119
|
+
border-bottom: 1px solid #f0f0f0;
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
font-family: "SF Mono", Menlo, monospace;
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
align-items: center;
|
|
124
|
+
}
|
|
125
|
+
.row:hover { background: #f7f7f7; }
|
|
126
|
+
.row.selected { background: #e3f1fa; }
|
|
127
|
+
.row .time { color: #868686; }
|
|
128
|
+
.row .status { text-align: right; color: #868686; }
|
|
129
|
+
.row .status.ok { color: #2bac76; }
|
|
130
|
+
.row .status.err { color: #e01e5a; }
|
|
131
|
+
.row .status.warn { color: #de9f19; }
|
|
132
|
+
.row .main-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
133
|
+
.row .method, .row .direction {
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
color: #616061;
|
|
136
|
+
}
|
|
137
|
+
.row .method.POST { color: #1264a3; }
|
|
138
|
+
.row .method.GET { color: #2bac76; }
|
|
139
|
+
.row .method.PUT { color: #de9f19; }
|
|
140
|
+
.row .method.DELETE { color: #e01e5a; }
|
|
141
|
+
.row .direction.IN { color: #2bac76; }
|
|
142
|
+
.row .direction.OUT { color: #1264a3; }
|
|
143
|
+
.detail {
|
|
144
|
+
flex: 1;
|
|
145
|
+
min-height: 0;
|
|
146
|
+
overflow-y: auto;
|
|
147
|
+
background: #fafafa;
|
|
148
|
+
border-left: 1px solid #e0e0e0;
|
|
149
|
+
padding: 16px;
|
|
150
|
+
font-family: "SF Mono", Menlo, monospace;
|
|
151
|
+
font-size: 12px;
|
|
152
|
+
white-space: pre-wrap;
|
|
153
|
+
word-break: break-word;
|
|
154
|
+
color: #1d1c1d;
|
|
155
|
+
}
|
|
156
|
+
.detail .placeholder {
|
|
157
|
+
color: #868686;
|
|
158
|
+
font-family: inherit;
|
|
159
|
+
font-style: italic;
|
|
160
|
+
}
|
|
161
|
+
.detail h3 {
|
|
162
|
+
margin: 12px 0 6px 0;
|
|
163
|
+
font-family: inherit;
|
|
164
|
+
font-size: 12px;
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
color: #616061;
|
|
167
|
+
text-transform: uppercase;
|
|
168
|
+
letter-spacing: 0.5px;
|
|
169
|
+
}
|
|
170
|
+
.detail h3:first-child { margin-top: 0; }
|
|
171
|
+
.detail-actions {
|
|
172
|
+
position: sticky;
|
|
173
|
+
top: 0;
|
|
174
|
+
background: #fafafa;
|
|
175
|
+
padding: 4px 0 8px;
|
|
176
|
+
z-index: 1;
|
|
177
|
+
display: flex;
|
|
178
|
+
gap: 6px;
|
|
179
|
+
}
|
|
180
|
+
.detail-actions button {
|
|
181
|
+
font-size: 11px;
|
|
182
|
+
padding: 3px 10px;
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
background: #e8e8e8;
|
|
185
|
+
border: 1px solid #ccc;
|
|
186
|
+
border-radius: 4px;
|
|
187
|
+
color: #333;
|
|
188
|
+
}
|
|
189
|
+
.detail-actions button:hover { background: #ddd; }
|
|
190
|
+
.detail-actions button.copied { background: #2bac76; color: #fff; border-color: #2bac76; }
|
|
191
|
+
.empty {
|
|
192
|
+
padding: 40px 16px;
|
|
193
|
+
text-align: center;
|
|
194
|
+
color: #868686;
|
|
195
|
+
}
|
|
196
|
+
.marker {
|
|
197
|
+
padding: 6px 16px;
|
|
198
|
+
color: #868686;
|
|
199
|
+
font-style: italic;
|
|
200
|
+
font-size: 11px;
|
|
201
|
+
text-align: center;
|
|
202
|
+
border-top: 1px dashed #d0d0d0;
|
|
203
|
+
border-bottom: 1px dashed #d0d0d0;
|
|
204
|
+
background: #fafafa;
|
|
205
|
+
}
|
|
206
|
+
.tab-panel { display: none; flex: 1; flex-direction: column; overflow: hidden; }
|
|
207
|
+
.tab-panel.active { display: flex; }
|
|
208
|
+
</style>
|
|
209
|
+
</head>
|
|
210
|
+
<body>
|
|
211
|
+
<header>
|
|
212
|
+
<h1>Slack Radar</h1>
|
|
213
|
+
<span class="badge"><span id="status-dot" class="status-dot"></span> <span id="status-text">connecting</span></span>
|
|
214
|
+
<span class="badge" id="uptime-badge"></span>
|
|
215
|
+
<button id="extend-btn" style="display: none; font-size: 11px; padding: 2px 8px; margin-left: 4px; cursor: pointer; background: #2bac76; color: #fff; border: none; border-radius: 10px;">Extend 5m</button>
|
|
216
|
+
</header>
|
|
217
|
+
|
|
218
|
+
<div class="tabs">
|
|
219
|
+
<div class="tab active" data-tab="network">Network <span class="count" id="network-count">0</span></div>
|
|
220
|
+
<div class="tab" data-tab="rtm">RTM <span class="count" id="rtm-count">0</span></div>
|
|
221
|
+
<div class="tab" data-tab="clog">Clogs <span class="count" id="clog-count">0</span></div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="toolbar">
|
|
225
|
+
<input type="text" id="filter" placeholder="Filter by URL, channel, event name, payload..." />
|
|
226
|
+
<select id="profile-select" style="display: none; margin-right: 4px; font-size: 12px; padding: 2px 4px; background: #2a2a2a; color: #ccc; border: 1px solid #444; border-radius: 3px;"></select>
|
|
227
|
+
<button id="enable-btn" style="display: none;">Enable Radar</button>
|
|
228
|
+
<button id="pause-btn">Pause</button>
|
|
229
|
+
<button id="clear-btn">Clear</button>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div class="main">
|
|
233
|
+
<div class="list" id="list"></div>
|
|
234
|
+
<div class="detail" id="detail">
|
|
235
|
+
<div class="placeholder">Select a row to see the full payload.</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<script>
|
|
240
|
+
const state = {
|
|
241
|
+
tab: "network",
|
|
242
|
+
filter: "",
|
|
243
|
+
paused: false,
|
|
244
|
+
connected: false,
|
|
245
|
+
hadConnection: false, // true once we've ever connected successfully
|
|
246
|
+
selected: null, // { type, id }
|
|
247
|
+
buffers: {
|
|
248
|
+
network: [],
|
|
249
|
+
rtm: [],
|
|
250
|
+
clog: [],
|
|
251
|
+
},
|
|
252
|
+
MAX_BUFFER: 1000,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const listEl = document.getElementById("list");
|
|
256
|
+
const detailEl = document.getElementById("detail");
|
|
257
|
+
const filterEl = document.getElementById("filter");
|
|
258
|
+
const enableBtn = document.getElementById("enable-btn");
|
|
259
|
+
const profileSelect = document.getElementById("profile-select");
|
|
260
|
+
const pauseBtn = document.getElementById("pause-btn");
|
|
261
|
+
const clearBtn = document.getElementById("clear-btn");
|
|
262
|
+
const statusDot = document.getElementById("status-dot");
|
|
263
|
+
const statusText = document.getElementById("status-text");
|
|
264
|
+
const uptimeBadge = document.getElementById("uptime-badge");
|
|
265
|
+
|
|
266
|
+
// --- Tabs ---
|
|
267
|
+
document.querySelectorAll(".tab").forEach((el) => {
|
|
268
|
+
el.addEventListener("click", () => {
|
|
269
|
+
state.tab = el.dataset.tab;
|
|
270
|
+
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === el));
|
|
271
|
+
state.selected = null;
|
|
272
|
+
renderList();
|
|
273
|
+
renderDetail();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// --- Toolbar ---
|
|
278
|
+
filterEl.addEventListener("input", () => {
|
|
279
|
+
state.filter = filterEl.value.toLowerCase();
|
|
280
|
+
renderList();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
pauseBtn.addEventListener("click", () => {
|
|
284
|
+
state.paused = !state.paused;
|
|
285
|
+
pauseBtn.textContent = state.paused ? "Resume" : "Pause";
|
|
286
|
+
pauseBtn.classList.toggle("active", state.paused);
|
|
287
|
+
statusDot.classList.toggle("paused", state.paused);
|
|
288
|
+
statusText.textContent = state.paused ? "paused" : (state.connected ? "connected" : "disconnected");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
clearBtn.addEventListener("click", async () => {
|
|
292
|
+
state.buffers.network = [];
|
|
293
|
+
state.buffers.rtm = [];
|
|
294
|
+
state.buffers.clog = [];
|
|
295
|
+
state.selected = null;
|
|
296
|
+
try {
|
|
297
|
+
await fetch("/api/clear");
|
|
298
|
+
} catch (e) {
|
|
299
|
+
// Ignore — device may have shut down
|
|
300
|
+
}
|
|
301
|
+
updateCounts();
|
|
302
|
+
renderList();
|
|
303
|
+
renderDetail();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
enableBtn.addEventListener("click", async () => {
|
|
307
|
+
enableBtn.disabled = true;
|
|
308
|
+
enableBtn.textContent = "Enabling…";
|
|
309
|
+
try {
|
|
310
|
+
const selectedUser = profileSelect.value;
|
|
311
|
+
const qs = selectedUser ? `?user_id=${selectedUser}` : "";
|
|
312
|
+
const res = await fetch(`/_control/activate${qs}`, { method: "POST" });
|
|
313
|
+
const data = await res.json();
|
|
314
|
+
if (!data.ok) {
|
|
315
|
+
statusText.textContent = "enable failed";
|
|
316
|
+
alert(data.error || "Failed to enable radar");
|
|
317
|
+
} else {
|
|
318
|
+
statusText.textContent = "connecting";
|
|
319
|
+
}
|
|
320
|
+
} catch (e) {
|
|
321
|
+
alert("Failed to reach server: " + e.message);
|
|
322
|
+
} finally {
|
|
323
|
+
enableBtn.disabled = false;
|
|
324
|
+
enableBtn.textContent = "Enable Radar";
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
async function fetchProfiles() {
|
|
329
|
+
try {
|
|
330
|
+
const res = await fetch("/_control/profiles");
|
|
331
|
+
const data = await res.json();
|
|
332
|
+
const profiles = data.profiles || [];
|
|
333
|
+
profileSelect.innerHTML = "";
|
|
334
|
+
if (profiles.length > 1) {
|
|
335
|
+
for (const p of profiles) {
|
|
336
|
+
const opt = document.createElement("option");
|
|
337
|
+
opt.value = p.userId;
|
|
338
|
+
opt.textContent = p.label;
|
|
339
|
+
profileSelect.appendChild(opt);
|
|
340
|
+
}
|
|
341
|
+
profileSelect.style.display = "";
|
|
342
|
+
} else {
|
|
343
|
+
profileSelect.style.display = "none";
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
profileSelect.style.display = "none";
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function setDisconnectedUi(disconnected) {
|
|
351
|
+
enableBtn.style.display = disconnected ? "" : "none";
|
|
352
|
+
if (disconnected) {
|
|
353
|
+
fetchProfiles();
|
|
354
|
+
} else {
|
|
355
|
+
profileSelect.style.display = "none";
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// --- SSE ---
|
|
360
|
+
function insertReconnectMarker() {
|
|
361
|
+
const marker = {
|
|
362
|
+
__marker: true,
|
|
363
|
+
timestamp: Date.now(),
|
|
364
|
+
label: `reconnected @ ${new Date().toLocaleTimeString("en-US", { hour12: false })}`,
|
|
365
|
+
};
|
|
366
|
+
for (const type of ["network", "rtm", "clog"]) {
|
|
367
|
+
const buf = state.buffers[type];
|
|
368
|
+
if (buf.length > 0) buf.push({ ...marker }); // avoid empty-list markers
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function connectStream() {
|
|
373
|
+
const es = new EventSource("/api/stream");
|
|
374
|
+
es.onopen = () => {
|
|
375
|
+
const wasDisconnected = !state.connected && state.hadConnection;
|
|
376
|
+
state.connected = true;
|
|
377
|
+
state.hadConnection = true;
|
|
378
|
+
if (wasDisconnected) insertReconnectMarker();
|
|
379
|
+
statusDot.classList.add("connected");
|
|
380
|
+
statusText.textContent = state.paused ? "paused" : "connected";
|
|
381
|
+
setDisconnectedUi(false);
|
|
382
|
+
renderList();
|
|
383
|
+
};
|
|
384
|
+
es.onmessage = (ev) => {
|
|
385
|
+
if (state.paused) return;
|
|
386
|
+
try {
|
|
387
|
+
const parsed = JSON.parse(ev.data);
|
|
388
|
+
ingest(parsed.type, parsed.event);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
// Skip malformed
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
es.onerror = () => {
|
|
394
|
+
state.connected = false;
|
|
395
|
+
statusDot.classList.remove("connected");
|
|
396
|
+
statusText.textContent = "disconnected";
|
|
397
|
+
setDisconnectedUi(true);
|
|
398
|
+
renderList();
|
|
399
|
+
es.close();
|
|
400
|
+
setTimeout(connectStream, 2000);
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function ingest(type, event) {
|
|
405
|
+
const buf = state.buffers[type];
|
|
406
|
+
if (!buf) return;
|
|
407
|
+
buf.push(event);
|
|
408
|
+
if (buf.length > state.MAX_BUFFER) buf.shift();
|
|
409
|
+
updateCounts();
|
|
410
|
+
if (state.tab === type) renderList();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function updateCounts() {
|
|
414
|
+
document.getElementById("network-count").textContent = state.buffers.network.length;
|
|
415
|
+
document.getElementById("rtm-count").textContent = state.buffers.rtm.length;
|
|
416
|
+
document.getElementById("clog-count").textContent = state.buffers.clog.length;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// --- Rendering ---
|
|
420
|
+
function filterRows(rows) {
|
|
421
|
+
if (!state.filter) return rows;
|
|
422
|
+
return rows.filter((r) => {
|
|
423
|
+
if (r.__marker) return true; // always keep markers so history stays anchored
|
|
424
|
+
return JSON.stringify(r).toLowerCase().includes(state.filter);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function renderList() {
|
|
429
|
+
const rows = filterRows(state.buffers[state.tab]);
|
|
430
|
+
// Count real (non-marker) rows when deciding empty-state
|
|
431
|
+
const nonMarkerCount = rows.filter((r) => !r.__marker).length;
|
|
432
|
+
if (nonMarkerCount === 0) {
|
|
433
|
+
let msg;
|
|
434
|
+
if (state.filter) {
|
|
435
|
+
msg = "No matches. Clear the filter or try a different query.";
|
|
436
|
+
} else if (!state.connected) {
|
|
437
|
+
msg = "Radar is not connected. Click Enable Radar in the toolbar to activate.";
|
|
438
|
+
} else {
|
|
439
|
+
msg = `No ${state.tab} events yet. Interact with the app to capture events.`;
|
|
440
|
+
}
|
|
441
|
+
listEl.innerHTML = `<div class="empty">${msg}</div>`;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// Newest first
|
|
445
|
+
const html = rows.slice().reverse().map((row) => {
|
|
446
|
+
if (row.__marker) return `<div class="marker">─── ${row.label} ───</div>`;
|
|
447
|
+
return renderRow(state.tab, row);
|
|
448
|
+
}).join("");
|
|
449
|
+
listEl.innerHTML = html;
|
|
450
|
+
listEl.querySelectorAll(".row").forEach((el) => {
|
|
451
|
+
el.addEventListener("click", () => {
|
|
452
|
+
const id = Number(el.dataset.id);
|
|
453
|
+
state.selected = { type: state.tab, id };
|
|
454
|
+
listEl.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r === el));
|
|
455
|
+
renderDetail();
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function renderRow(type, row) {
|
|
461
|
+
const time = new Date(row.timestamp).toLocaleTimeString("en-US", { hour12: false });
|
|
462
|
+
const selected = state.selected && state.selected.type === type && state.selected.id === row.id ? "selected" : "";
|
|
463
|
+
if (type === "network") {
|
|
464
|
+
const statusClass = row.status_code >= 500 ? "err" : row.status_code >= 400 ? "warn" : "ok";
|
|
465
|
+
return `
|
|
466
|
+
<div class="row ${selected}" data-id="${row.id}">
|
|
467
|
+
<span class="time">${time}</span>
|
|
468
|
+
<span class="method ${row.method}">${row.method}</span>
|
|
469
|
+
<span class="main-col">${escapeHtml(row.url)}</span>
|
|
470
|
+
<span class="status ${statusClass}">${row.status_code} · ${row.duration_ms}ms</span>
|
|
471
|
+
</div>`;
|
|
472
|
+
}
|
|
473
|
+
if (type === "rtm") {
|
|
474
|
+
return `
|
|
475
|
+
<div class="row ${selected}" data-id="${row.id}">
|
|
476
|
+
<span class="time">${time}</span>
|
|
477
|
+
<span class="direction ${row.direction === "INCOMING" ? "IN" : "OUT"}">${row.direction === "INCOMING" ? "←" : "→"}</span>
|
|
478
|
+
<span class="main-col">${escapeHtml(row.event_type ?? "(no type)")}${row.channel ? " · " + escapeHtml(row.channel) : ""}</span>
|
|
479
|
+
<span class="status"></span>
|
|
480
|
+
</div>`;
|
|
481
|
+
}
|
|
482
|
+
// clog
|
|
483
|
+
return `
|
|
484
|
+
<div class="row ${selected}" data-id="${row.id}">
|
|
485
|
+
<span class="time">${time}</span>
|
|
486
|
+
<span></span>
|
|
487
|
+
<span class="main-col">${escapeHtml(row.event_name ?? "(unknown)")}</span>
|
|
488
|
+
<span class="status"></span>
|
|
489
|
+
</div>`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let lastDetailData = null;
|
|
493
|
+
|
|
494
|
+
async function renderDetail() {
|
|
495
|
+
if (!state.selected) {
|
|
496
|
+
lastDetailData = null;
|
|
497
|
+
detailEl.innerHTML = '<div class="placeholder">Select a row to see the full payload.</div>';
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const { type, id } = state.selected;
|
|
501
|
+
detailEl.innerHTML = '<div class="placeholder">Loading…</div>';
|
|
502
|
+
try {
|
|
503
|
+
const endpoint = type === "network" ? "/api/network/" : type === "rtm" ? "/api/rtm/" : "/api/clogs/";
|
|
504
|
+
const res = await fetch(endpoint + id);
|
|
505
|
+
const data = await res.json();
|
|
506
|
+
lastDetailData = data;
|
|
507
|
+
detailEl.innerHTML = detailActions() + formatDetail(type, data);
|
|
508
|
+
wireDetailActions();
|
|
509
|
+
} catch (e) {
|
|
510
|
+
lastDetailData = null;
|
|
511
|
+
detailEl.innerHTML = '<div class="placeholder">Failed to load detail: ' + escapeHtml(e.message) + '</div>';
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function detailActions() {
|
|
516
|
+
const isNetwork = state.selected && state.selected.type === "network";
|
|
517
|
+
return `<div class="detail-actions">
|
|
518
|
+
<button id="copy-json-btn">Copy JSON</button>
|
|
519
|
+
<button id="copy-body-btn">Copy Body</button>
|
|
520
|
+
${isNetwork ? '<button id="copy-curl-btn">Copy cURL</button>' : ''}
|
|
521
|
+
</div>`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function wireDetailActions() {
|
|
525
|
+
const copyJsonBtn = document.getElementById("copy-json-btn");
|
|
526
|
+
const copyBodyBtn = document.getElementById("copy-body-btn");
|
|
527
|
+
if (copyJsonBtn) {
|
|
528
|
+
copyJsonBtn.addEventListener("click", () => {
|
|
529
|
+
if (!lastDetailData) return;
|
|
530
|
+
navigator.clipboard.writeText(JSON.stringify(lastDetailData, null, 2));
|
|
531
|
+
copyJsonBtn.textContent = "Copied!";
|
|
532
|
+
copyJsonBtn.classList.add("copied");
|
|
533
|
+
setTimeout(() => { copyJsonBtn.textContent = "Copy JSON"; copyJsonBtn.classList.remove("copied"); }, 1500);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
if (copyBodyBtn) {
|
|
537
|
+
copyBodyBtn.addEventListener("click", () => {
|
|
538
|
+
if (!lastDetailData) return;
|
|
539
|
+
const body = lastDetailData.response_body ?? lastDetailData.data ?? lastDetailData.request_body ?? "";
|
|
540
|
+
const text = tryPretty(body);
|
|
541
|
+
navigator.clipboard.writeText(text);
|
|
542
|
+
copyBodyBtn.textContent = "Copied!";
|
|
543
|
+
copyBodyBtn.classList.add("copied");
|
|
544
|
+
setTimeout(() => { copyBodyBtn.textContent = "Copy Body"; copyBodyBtn.classList.remove("copied"); }, 1500);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
const copyCurlBtn = document.getElementById("copy-curl-btn");
|
|
548
|
+
if (copyCurlBtn) {
|
|
549
|
+
copyCurlBtn.addEventListener("click", () => {
|
|
550
|
+
if (!lastDetailData) return;
|
|
551
|
+
navigator.clipboard.writeText(buildCurl(lastDetailData));
|
|
552
|
+
copyCurlBtn.textContent = "Copied!";
|
|
553
|
+
copyCurlBtn.classList.add("copied");
|
|
554
|
+
setTimeout(() => { copyCurlBtn.textContent = "Copy cURL"; copyCurlBtn.classList.remove("copied"); }, 1500);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function shellEscape(s) {
|
|
560
|
+
if (!s) return "''";
|
|
561
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function buildCurl(d) {
|
|
565
|
+
const parts = [`curl -X ${d.method} ${shellEscape(d.url)}`];
|
|
566
|
+
const headers = d.request_headers ?? {};
|
|
567
|
+
let hasAuth = false;
|
|
568
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
569
|
+
const lower = k.toLowerCase();
|
|
570
|
+
if (lower === "accept-encoding" || lower === "host" || lower === "content-length") continue;
|
|
571
|
+
if (lower === "authorization" || lower === "cookie") hasAuth = true;
|
|
572
|
+
parts.push(` -H ${shellEscape(k + ": " + v)}`);
|
|
573
|
+
}
|
|
574
|
+
if (d.request_body) {
|
|
575
|
+
parts.push(` --data-raw ${shellEscape(d.request_body)}`);
|
|
576
|
+
}
|
|
577
|
+
let cmd = parts.join(" \\\n");
|
|
578
|
+
if (!hasAuth) {
|
|
579
|
+
cmd += "\n# Note: auth cookies are injected by OkHttp after the app interceptor and are not captured. Add -b 'cookie=...' manually to replay.";
|
|
580
|
+
}
|
|
581
|
+
return cmd;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function formatDetail(type, data) {
|
|
585
|
+
if (type === "network") {
|
|
586
|
+
return `
|
|
587
|
+
<h3>Request</h3>
|
|
588
|
+
${escapeHtml(data.method)} ${escapeHtml(data.url)}
|
|
589
|
+
|
|
590
|
+
<h3>Request Headers</h3>
|
|
591
|
+
${escapeHtml(JSON.stringify(data.request_headers ?? {}, null, 2))}
|
|
592
|
+
|
|
593
|
+
<h3>Request Body</h3>
|
|
594
|
+
${escapeHtml(data.request_body ?? "(none)")}
|
|
595
|
+
|
|
596
|
+
<h3>Response · ${data.status_code} · ${data.duration_ms}ms</h3>
|
|
597
|
+
|
|
598
|
+
<h3>Response Headers</h3>
|
|
599
|
+
${escapeHtml(JSON.stringify(data.response_headers ?? {}, null, 2))}
|
|
600
|
+
|
|
601
|
+
<h3>Response Body</h3>
|
|
602
|
+
${escapeHtml(tryPretty(data.response_body))}
|
|
603
|
+
`;
|
|
604
|
+
}
|
|
605
|
+
if (type === "rtm") {
|
|
606
|
+
return `
|
|
607
|
+
<h3>${escapeHtml(data.direction)} · ${escapeHtml(data.event_type ?? "(no type)")}</h3>
|
|
608
|
+
Channel: ${escapeHtml(data.channel ?? "(none)")}
|
|
609
|
+
Timestamp: ${new Date(data.timestamp).toISOString()}
|
|
610
|
+
Base64: ${data.is_base64}
|
|
611
|
+
|
|
612
|
+
<h3>Payload</h3>
|
|
613
|
+
${escapeHtml(tryPretty(data.data))}
|
|
614
|
+
`;
|
|
615
|
+
}
|
|
616
|
+
// clog
|
|
617
|
+
return `
|
|
618
|
+
<h3>${escapeHtml(data.event_name)}</h3>
|
|
619
|
+
Timestamp: ${new Date(data.timestamp).toISOString()}
|
|
620
|
+
|
|
621
|
+
<h3>Payload</h3>
|
|
622
|
+
${escapeHtml(tryPretty(data.data))}
|
|
623
|
+
`;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function tryPretty(s) {
|
|
627
|
+
if (!s) return "(empty)";
|
|
628
|
+
try {
|
|
629
|
+
return JSON.stringify(JSON.parse(s), null, 2);
|
|
630
|
+
} catch {
|
|
631
|
+
return s;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function escapeHtml(s) {
|
|
636
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// --- Uptime ping ---
|
|
640
|
+
const extendBtn = document.getElementById("extend-btn");
|
|
641
|
+
|
|
642
|
+
function formatUptime(ms) {
|
|
643
|
+
const totalSec = Math.floor(ms / 1000);
|
|
644
|
+
const h = Math.floor(totalSec / 3600);
|
|
645
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
646
|
+
const s = totalSec % 60;
|
|
647
|
+
if (h > 0) return `up ${h}h ${m}m`;
|
|
648
|
+
if (m > 0) return `up ${m}m ${s}s`;
|
|
649
|
+
return `up ${s}s`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function extendSession() {
|
|
653
|
+
try {
|
|
654
|
+
await fetch("/api/ping?timeout_minutes=5");
|
|
655
|
+
} catch {}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
extendBtn.addEventListener("click", async () => {
|
|
659
|
+
extendBtn.disabled = true;
|
|
660
|
+
extendBtn.textContent = "Extending…";
|
|
661
|
+
await extendSession();
|
|
662
|
+
await pollPing();
|
|
663
|
+
extendBtn.disabled = false;
|
|
664
|
+
extendBtn.textContent = "Extend 5m";
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
async function pollPing() {
|
|
668
|
+
try {
|
|
669
|
+
const res = await fetch("/api/ping");
|
|
670
|
+
const data = await res.json();
|
|
671
|
+
if (data.uptime_ms != null) {
|
|
672
|
+
const remaining = data.remaining_minutes ?? 0;
|
|
673
|
+
const timerLabel = remaining >= 4 ? "active" : `${remaining}m left`;
|
|
674
|
+
uptimeBadge.textContent = `${formatUptime(data.uptime_ms)} · ${timerLabel}`;
|
|
675
|
+
extendBtn.style.display = state.connected ? "" : "none";
|
|
676
|
+
extendBtn.style.background = remaining < 4 ? "#de9f19" : "#2bac76";
|
|
677
|
+
if (remaining <= 1 && state.connected) {
|
|
678
|
+
await extendSession();
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
} catch {
|
|
682
|
+
uptimeBadge.textContent = "";
|
|
683
|
+
extendBtn.style.display = "none";
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
pollPing();
|
|
687
|
+
setInterval(pollPing, 5000);
|
|
688
|
+
|
|
689
|
+
// --- Go ---
|
|
690
|
+
connectStream();
|
|
691
|
+
updateCounts();
|
|
692
|
+
renderList();
|
|
693
|
+
</script>
|
|
694
|
+
</body>
|
|
695
|
+
</html>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { AndroidTransport } from "../shared/android.js";
|
|
6
|
+
import { RADAR_HOST, RADAR_PORT } from "../shared/constants.js";
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ?? 8100);
|
|
9
|
+
const INDEX_PATH = path.join(__dirname, "public", "index.html");
|
|
10
|
+
const transport = new AndroidTransport();
|
|
11
|
+
export function createServer() {
|
|
12
|
+
const server = http.createServer((req, res) => {
|
|
13
|
+
if (req.url === "/" || req.url === "/index.html") {
|
|
14
|
+
try {
|
|
15
|
+
const html = fs.readFileSync(INDEX_PATH);
|
|
16
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
17
|
+
res.end(html);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
21
|
+
res.end(`Failed to read ${INDEX_PATH}: ${e.message}`);
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (req.url === "/_control/profiles" && req.method === "GET") {
|
|
26
|
+
listProfiles(res);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (req.url?.startsWith("/_control/activate") && req.method === "POST") {
|
|
30
|
+
const url = new URL(req.url, `http://localhost:${WEB_PORT}`);
|
|
31
|
+
const userId = url.searchParams.get("user_id") ?? undefined;
|
|
32
|
+
activateRadar(res, userId);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (req.url?.startsWith("/api/")) {
|
|
36
|
+
proxyToRadar(req, res);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
res.writeHead(404);
|
|
40
|
+
res.end("Not found");
|
|
41
|
+
});
|
|
42
|
+
return server;
|
|
43
|
+
}
|
|
44
|
+
function listProfiles(res) {
|
|
45
|
+
const profiles = transport.listProfiles();
|
|
46
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
47
|
+
res.end(JSON.stringify({ profiles }));
|
|
48
|
+
}
|
|
49
|
+
function activateRadar(res, userId) {
|
|
50
|
+
const forwarded = transport.ensureForward();
|
|
51
|
+
if (!forwarded) {
|
|
52
|
+
res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
|
|
53
|
+
res.end(JSON.stringify({
|
|
54
|
+
ok: false,
|
|
55
|
+
error: "ADB forward failed. Is a device connected?",
|
|
56
|
+
}));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
if (userId) {
|
|
61
|
+
transport.activateForUser(userId, 5);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
transport.activate(5);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
|
|
69
|
+
res.end(JSON.stringify({
|
|
70
|
+
ok: false,
|
|
71
|
+
error: `Broadcast failed: ${e.message}. Debug build not running?`,
|
|
72
|
+
}));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
76
|
+
res.end(JSON.stringify({ ok: true, user_id: userId ?? "auto" }));
|
|
77
|
+
}
|
|
78
|
+
function proxyToRadar(req, res) {
|
|
79
|
+
const upstream = http.request({
|
|
80
|
+
host: RADAR_HOST,
|
|
81
|
+
port: RADAR_PORT,
|
|
82
|
+
path: req.url,
|
|
83
|
+
method: req.method,
|
|
84
|
+
headers: req.headers,
|
|
85
|
+
timeout: 0,
|
|
86
|
+
}, (upstreamRes) => {
|
|
87
|
+
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
88
|
+
upstreamRes.on("data", (chunk) => res.write(chunk));
|
|
89
|
+
upstreamRes.on("end", () => res.end());
|
|
90
|
+
upstreamRes.on("error", () => res.end());
|
|
91
|
+
});
|
|
92
|
+
upstream.on("error", (err) => {
|
|
93
|
+
res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" });
|
|
94
|
+
res.end(JSON.stringify({
|
|
95
|
+
error: `Cannot reach radar at ${RADAR_HOST}:${RADAR_PORT}. Is ADB forwarding active? ${err.message}`,
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
req.on("close", () => upstream.destroy());
|
|
99
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
100
|
+
req.pipe(upstream);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
upstream.end();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export { WEB_PORT };
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@slack/radar-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP server for on-device debugging of the Slack Android app via ADB",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "MCP server and web dashboard for on-device debugging of the Slack Android app via ADB",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "dist/index.js",
|
|
6
|
+
"main": "dist/mcp/index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"slack-radar-mcp": "dist/index.js"
|
|
8
|
+
"slack-radar-mcp": "dist/mcp/index.js",
|
|
9
|
+
"slack-radar-web": "dist/web/bin.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"dist"
|
|
@@ -13,8 +14,9 @@
|
|
|
13
14
|
"scripts": {
|
|
14
15
|
"clean": "rm -rf dist",
|
|
15
16
|
"prebuild": "npm run clean",
|
|
16
|
-
"build": "tsc && chmod +x dist/index.js",
|
|
17
|
-
"start": "node dist/index.js",
|
|
17
|
+
"build": "tsc && cp -r src/web/public dist/web/public && chmod +x dist/mcp/index.js dist/web/bin.js",
|
|
18
|
+
"start": "node dist/mcp/index.js",
|
|
19
|
+
"start:web": "node dist/web/bin.js",
|
|
18
20
|
"watch": "tsc --watch",
|
|
19
21
|
"test": "npm run build && node --test tests/*.test.mjs",
|
|
20
22
|
"test:smoke": "npm run build && bash tests/smoke.sh",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|