@slack/radar-mcp 1.7.0 → 1.8.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/dist/mcp/index.js +4 -7
- package/dist/mcp/radar-response.d.ts +21 -0
- package/dist/mcp/radar-response.js +38 -0
- package/dist/mcp/snapshot.d.ts +4 -2
- package/dist/mcp/snapshot.js +25 -12
- package/dist/mcp/tools.js +3 -3
- package/dist/shared/db.d.ts +13 -0
- package/dist/shared/db.js +30 -3
- package/dist/web/public/next/store.js +54 -2
- package/package.json +1 -1
package/dist/mcp/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { handleListDatabases, handleListTables, handleQueryDatabase, } from "./d
|
|
|
17
17
|
import { cleanupPulledDatabases, initializeDatabasePath, resetDbState, } from "../shared/db.js";
|
|
18
18
|
import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
|
|
19
19
|
import { buildApiPath } from "./api-paths.js";
|
|
20
|
+
import { interpretRadarResponse } from "./radar-response.js";
|
|
20
21
|
// --- Subcommand + bin-name dispatch ---
|
|
21
22
|
//
|
|
22
23
|
// One entrypoint, two front doors, chosen so each invocation does the obvious thing:
|
|
@@ -200,6 +201,7 @@ async function runMcpServer() {
|
|
|
200
201
|
process.once(sig, cleanupPulledDatabases);
|
|
201
202
|
}
|
|
202
203
|
let sessionEnabled = false;
|
|
204
|
+
// --- HTTP requests to the on-device radar server ---
|
|
203
205
|
// Host-side ceiling on a proxied device response. radarRequest forwards the device body straight
|
|
204
206
|
// into the model context, so an unbounded response (large trace/clog payloads) could flood it.
|
|
205
207
|
// When the cap trips we stop reading, tear the socket down, and return a truncation marker
|
|
@@ -249,12 +251,7 @@ async function runMcpServer() {
|
|
|
249
251
|
data += chunk;
|
|
250
252
|
});
|
|
251
253
|
res.on("end", () => {
|
|
252
|
-
|
|
253
|
-
done(JSON.parse(data));
|
|
254
|
-
}
|
|
255
|
-
catch {
|
|
256
|
-
done({ raw: data });
|
|
257
|
-
}
|
|
254
|
+
done(interpretRadarResponse(res.statusCode ?? 0, data));
|
|
258
255
|
});
|
|
259
256
|
});
|
|
260
257
|
req.on("error", fail);
|
|
@@ -552,7 +549,7 @@ async function runMcpServer() {
|
|
|
552
549
|
}
|
|
553
550
|
}
|
|
554
551
|
// --- MCP Server ---
|
|
555
|
-
const server = new Server({ name: "slack-radar", version: "1.
|
|
552
|
+
const server = new Server({ name: "slack-radar", version: "1.8.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
|
|
556
553
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
557
554
|
tools: TOOL_DEFINITIONS,
|
|
558
555
|
}));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface RadarResponse {
|
|
2
|
+
error?: string;
|
|
3
|
+
status?: number;
|
|
4
|
+
stream_connected?: boolean;
|
|
5
|
+
local_network_buffer?: number;
|
|
6
|
+
local_rtm_buffer?: number;
|
|
7
|
+
timeout_minutes?: number;
|
|
8
|
+
raw?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Turn a device HTTP (status, body) into the RadarResponse the tool layer returns. Parse JSON
|
|
13
|
+
* when the body is JSON: this preserves the device's own error bodies (e.g. an in-app 404 sends
|
|
14
|
+
* {"error":"Not Found"}) and every happy-path payload unchanged. The added guard is for a 4xx/5xx
|
|
15
|
+
* whose body is NOT JSON (empty, HTML, a bare string): a build that predates a tool's route, or
|
|
16
|
+
* an upstream proxy error, would otherwise surface as a confusing {raw:""} the caller cannot read.
|
|
17
|
+
* In that case synthesize a clear error naming the status so the model reports something useful
|
|
18
|
+
* (an older debug build can predate a newer tool) instead of an empty blob. A 2xx with a non-JSON
|
|
19
|
+
* body keeps the existing {raw} behavior, a real if unusual success payload.
|
|
20
|
+
*/
|
|
21
|
+
export declare function interpretRadarResponse(status: number, body: string): RadarResponse;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The shape a proxied device call resolves to, and how a raw (status, body) becomes one. Split
|
|
2
|
+
// out of index.ts so it is unit-testable: index.ts connects stdio at module load, so importing it
|
|
3
|
+
// from a test would hang.
|
|
4
|
+
/**
|
|
5
|
+
* Turn a device HTTP (status, body) into the RadarResponse the tool layer returns. Parse JSON
|
|
6
|
+
* when the body is JSON: this preserves the device's own error bodies (e.g. an in-app 404 sends
|
|
7
|
+
* {"error":"Not Found"}) and every happy-path payload unchanged. The added guard is for a 4xx/5xx
|
|
8
|
+
* whose body is NOT JSON (empty, HTML, a bare string): a build that predates a tool's route, or
|
|
9
|
+
* an upstream proxy error, would otherwise surface as a confusing {raw:""} the caller cannot read.
|
|
10
|
+
* In that case synthesize a clear error naming the status so the model reports something useful
|
|
11
|
+
* (an older debug build can predate a newer tool) instead of an empty blob. A 2xx with a non-JSON
|
|
12
|
+
* body keeps the existing {raw} behavior, a real if unusual success payload.
|
|
13
|
+
*/
|
|
14
|
+
export function interpretRadarResponse(status, body) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(body);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
if (status >= 400) {
|
|
21
|
+
const bodyPrefix = body.substring(0, 200).trim();
|
|
22
|
+
const bodyDetail = bodyPrefix ? ` Body: ${bodyPrefix}` : "";
|
|
23
|
+
return {
|
|
24
|
+
error: `Device returned HTTP ${status} with a non-JSON body. The route may not exist on this build (an older debug build can predate a newer tool), or an upstream error occurred.${bodyDetail}`,
|
|
25
|
+
status,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { raw: body };
|
|
29
|
+
}
|
|
30
|
+
// Device routes return JSON OBJECTS. A bare null/number/array/string is valid JSON but not a
|
|
31
|
+
// RadarResponse, and a downstream `result.error` read on a non-object (esp. null) would throw.
|
|
32
|
+
// Keep it as a raw payload instead of casting a lie. (The old code cast straight to
|
|
33
|
+
// RadarResponse and carried this hole; guarding here closes it.)
|
|
34
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
35
|
+
return { raw: body };
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
package/dist/mcp/snapshot.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface SnapshotResult {
|
|
|
13
13
|
network: number;
|
|
14
14
|
rtm: number;
|
|
15
15
|
clogs: number;
|
|
16
|
+
traces: number;
|
|
16
17
|
log_lines: number;
|
|
17
18
|
log_lines_truncated: boolean;
|
|
18
19
|
screenshot: boolean;
|
|
@@ -21,6 +22,7 @@ export interface SnapshotResult {
|
|
|
21
22
|
network: boolean;
|
|
22
23
|
rtm: boolean;
|
|
23
24
|
clogs: boolean;
|
|
25
|
+
traces: boolean;
|
|
24
26
|
};
|
|
25
27
|
errors: string[];
|
|
26
28
|
privacy_notice: string;
|
|
@@ -35,7 +37,7 @@ export declare function buildSnapshotDir(now?: number, root?: string): string;
|
|
|
35
37
|
* intentionally larger than point-query defaults — snapshots are bulk
|
|
36
38
|
* exports and the device-side ring buffers cap their own responses.
|
|
37
39
|
*/
|
|
38
|
-
export declare function buildBufferPath(kind: "network" | "rtm" | "clogs"): string;
|
|
40
|
+
export declare function buildBufferPath(kind: "network" | "rtm" | "clogs" | "traces"): string;
|
|
39
41
|
export interface LogTailResult {
|
|
40
42
|
text: string;
|
|
41
43
|
truncated: boolean;
|
|
@@ -60,4 +62,4 @@ export declare function readRecentLogTail(requestedLimit?: number): LogTailResul
|
|
|
60
62
|
* snapshot still lands on disk with whatever succeeded; failures are
|
|
61
63
|
* collected in `result.errors` so the model can tell the user.
|
|
62
64
|
*/
|
|
63
|
-
export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
|
|
65
|
+
export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs" | "traces") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
|
package/dist/mcp/snapshot.js
CHANGED
|
@@ -10,7 +10,7 @@ import { getCaptureFilePath, readTailBytes } from "../shared/logcat-capture.js";
|
|
|
10
10
|
* tool outputs.
|
|
11
11
|
*
|
|
12
12
|
* Snapshot contents:
|
|
13
|
-
* - `bundle.json` — device-side buffers (net + RTM + clogs) plus metadata
|
|
13
|
+
* - `bundle.json` — device-side buffers (net + RTM + clogs + traces) plus metadata
|
|
14
14
|
* - `screenshot.png` — current screen (optional)
|
|
15
15
|
* - `logcat.tail.log` — last N lines of the active capture file (optional)
|
|
16
16
|
*
|
|
@@ -33,19 +33,19 @@ const BUNDLE_README = `Slack Radar snapshot
|
|
|
33
33
|
This directory is a point-in-time bundle of Radar state.
|
|
34
34
|
|
|
35
35
|
Files:
|
|
36
|
-
- bundle.json: device-side network/RTM/clog buffers, counts, and metadata.
|
|
36
|
+
- bundle.json: device-side network/RTM/clog/trace buffers, counts, and metadata.
|
|
37
37
|
- logcat.tail.log (optional): last N lines of the on-device logcat capture.
|
|
38
38
|
- screenshot.png (optional): current screen.
|
|
39
39
|
|
|
40
40
|
Privacy: bundle.json contains raw API request/response bodies (auth headers,
|
|
41
|
-
tokens), RTM message content, user IDs, channel IDs,
|
|
42
|
-
Review before sharing externally. This bundle lives
|
|
43
|
-
nothing is uploaded anywhere by Radar.
|
|
41
|
+
tokens), RTM message content, user IDs, channel IDs, clog payloads, and
|
|
42
|
+
performance trace spans. Review before sharing externally. This bundle lives
|
|
43
|
+
on your local disk only; nothing is uploaded anywhere by Radar.
|
|
44
44
|
|
|
45
45
|
To inspect: cat bundle.json | jq .
|
|
46
46
|
To share: zip -r snapshot.zip . then attach the zip.
|
|
47
47
|
`;
|
|
48
|
-
const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and
|
|
48
|
+
const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, clog payloads, and performance trace spans. Review before sharing externally.";
|
|
49
49
|
/**
|
|
50
50
|
* Resolve the output directory for a snapshot. Exported for tests.
|
|
51
51
|
*/
|
|
@@ -137,7 +137,8 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
137
137
|
let network = null;
|
|
138
138
|
let rtm = null;
|
|
139
139
|
let clogs = null;
|
|
140
|
-
|
|
140
|
+
let traces = null;
|
|
141
|
+
const fetched = { network: false, rtm: false, clogs: false, traces: false };
|
|
141
142
|
// Device-side buffers. Failures are individually caught so one failing
|
|
142
143
|
// buffer does not nuke the whole snapshot. `fetched` distinguishes
|
|
143
144
|
// "empty because the buffer was empty" from "null because the fetch
|
|
@@ -163,6 +164,17 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
163
164
|
catch (e) {
|
|
164
165
|
errors.push(`clog buffer: ${e.message}`);
|
|
165
166
|
}
|
|
167
|
+
// Traces capture from app start (like clogs), so they are worth
|
|
168
|
+
// bundling. An older debug build without the /api/traces route fails
|
|
169
|
+
// this one fetch and it is recorded in errors[] like any other buffer;
|
|
170
|
+
// the rest of the snapshot still lands.
|
|
171
|
+
try {
|
|
172
|
+
traces = await fetchBuffer("traces");
|
|
173
|
+
fetched.traces = true;
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
errors.push(`trace buffer: ${e.message}`);
|
|
177
|
+
}
|
|
166
178
|
// Logcat tail.
|
|
167
179
|
let logTail = { text: "", truncated: false };
|
|
168
180
|
if (includeLogs) {
|
|
@@ -221,6 +233,7 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
221
233
|
network: countItems(network),
|
|
222
234
|
rtm: countItems(rtm),
|
|
223
235
|
clogs: countItems(clogs),
|
|
236
|
+
traces: countItems(traces),
|
|
224
237
|
log_lines: logTail.text
|
|
225
238
|
? logTail.text.split("\n").filter(Boolean).length
|
|
226
239
|
: 0,
|
|
@@ -228,12 +241,12 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
228
241
|
screenshot: screenshotCaptured,
|
|
229
242
|
};
|
|
230
243
|
const bundle = {
|
|
231
|
-
version:
|
|
244
|
+
version: 2,
|
|
232
245
|
timestamp: now,
|
|
233
246
|
platform: device.platform,
|
|
234
247
|
active_user_id: device.getActiveUserId(),
|
|
235
248
|
capture_file: getCaptureFilePath(),
|
|
236
|
-
buffers: { network, rtm, clogs },
|
|
249
|
+
buffers: { network, rtm, clogs, traces },
|
|
237
250
|
counts,
|
|
238
251
|
fetched,
|
|
239
252
|
errors,
|
|
@@ -285,14 +298,14 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
|
|
|
285
298
|
}
|
|
286
299
|
/**
|
|
287
300
|
* Count items inside a buffer response. Handles the `{ calls: [...] }`,
|
|
288
|
-
* `{ events: [...] }`, `{ clogs: [...] }`
|
|
289
|
-
* uses. Falls back to 0 on malformed responses.
|
|
301
|
+
* `{ events: [...] }`, `{ clogs: [...] }`, `{ traces: [...] }` shapes the
|
|
302
|
+
* radar HTTP server uses. Falls back to 0 on malformed responses.
|
|
290
303
|
*/
|
|
291
304
|
function countItems(body) {
|
|
292
305
|
if (!body || typeof body !== "object")
|
|
293
306
|
return 0;
|
|
294
307
|
const b = body;
|
|
295
|
-
for (const key of ["calls", "events", "clogs", "items"]) {
|
|
308
|
+
for (const key of ["calls", "events", "clogs", "traces", "items"]) {
|
|
296
309
|
const v = b[key];
|
|
297
310
|
if (Array.isArray(v))
|
|
298
311
|
return v.length;
|
package/dist/mcp/tools.js
CHANGED
|
@@ -102,10 +102,10 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
102
102
|
"When capture_snapshot returns, tell the user:",
|
|
103
103
|
" 1. The result.zip_path if non-null (the single shareable file). Fall back to",
|
|
104
104
|
" result.path (a directory) if zip_path is null.",
|
|
105
|
-
" 2. A one-line summary of counts (network/rtm/clogs/log_lines/screenshot).",
|
|
105
|
+
" 2. A one-line summary of counts (network/rtm/clogs/traces/log_lines/screenshot).",
|
|
106
106
|
" 3. Print this EXACT sentence as the privacy line, verbatim — no paraphrase, no",
|
|
107
107
|
" compression, no skipping even under time pressure:",
|
|
108
|
-
' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and
|
|
108
|
+
' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, clog payloads, and performance trace spans. Review before sharing externally."',
|
|
109
109
|
" 4. If result.log_lines_truncated is true, say: 'you got fewer log lines than",
|
|
110
110
|
" requested because the 1 MB read cap was the limiting factor.'",
|
|
111
111
|
" 5. If the user's original ask was 'file this as a bug' / 'attach to ticket' /",
|
|
@@ -302,7 +302,7 @@ export const TOOL_DEFINITIONS = [
|
|
|
302
302
|
},
|
|
303
303
|
{
|
|
304
304
|
name: "capture_snapshot",
|
|
305
|
-
description: "Bundle the current Radar state into a single on-disk artifact for sharing. Writes four files to ~/.slack-radar/snapshots/snapshot-<ts>/: bundle.json (network + RTM + clog buffers, up to 1000 items each — a bulk export, much larger than point queries like get_network_call_detail), logcat.tail.log (last N lines of the active capture), screenshot.png (current screen), and README.txt (receiver-facing explainer). Use when the user says 'save this', 'file this as a bug', 'share this', 'bundle this up', 'attach to a ticket', 'make me a repro', or otherwise wants a shareable reproducible handoff. PRIVACY: bundle.json contains raw API request/response bodies including auth headers, RTM message content, user/team/channel IDs,
|
|
305
|
+
description: "Bundle the current Radar state into a single on-disk artifact for sharing. Writes four files to ~/.slack-radar/snapshots/snapshot-<ts>/: bundle.json (network + RTM + clog + trace buffers, up to 1000 items each — a bulk export, much larger than point queries like get_network_call_detail), logcat.tail.log (last N lines of the active capture), screenshot.png (current screen), and README.txt (receiver-facing explainer). Use when the user says 'save this', 'file this as a bug', 'share this', 'bundle this up', 'attach to a ticket', 'make me a repro', or otherwise wants a shareable reproducible handoff. PRIVACY: bundle.json contains raw API request/response bodies including auth headers, RTM message content, user/team/channel IDs, clog payloads, and performance trace spans — review before sharing externally.",
|
|
306
306
|
inputSchema: {
|
|
307
307
|
type: "object",
|
|
308
308
|
properties: {
|
package/dist/shared/db.d.ts
CHANGED
|
@@ -77,6 +77,19 @@ export declare function pullDatabase(name: string, user?: string | null, force?:
|
|
|
77
77
|
* (1) and (2), not from the open mode.
|
|
78
78
|
*/
|
|
79
79
|
export declare function assertReadOnlyQuery(sql: string): void;
|
|
80
|
+
/**
|
|
81
|
+
* True for EXPLAIN / EXPLAIN QUERY PLAN, which sqlite3 prints as a text table (ignoring -json).
|
|
82
|
+
* Leading-token match; assertReadOnlyQuery gates the same leading keyword, so both reject on
|
|
83
|
+
* comment-prefixed queries before this routing matters.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isExplainQuery(sql: string): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* sqlite3 ignores -json for EXPLAIN (prints text table instead of JSON).
|
|
88
|
+
* Shape it into row objects so the web grid renders the plan readably.
|
|
89
|
+
*/
|
|
90
|
+
export declare function parseExplainOutput(out: string): {
|
|
91
|
+
plan: string;
|
|
92
|
+
}[];
|
|
80
93
|
/**
|
|
81
94
|
* Run a read-only query against a (lazily pulled) local copy. The query is gated by
|
|
82
95
|
* assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
|
package/dist/shared/db.js
CHANGED
|
@@ -353,6 +353,24 @@ export function assertReadOnlyQuery(sql) {
|
|
|
353
353
|
throw new Error("that function/statement is not allowed (read-only, no file access)");
|
|
354
354
|
}
|
|
355
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* True for EXPLAIN / EXPLAIN QUERY PLAN, which sqlite3 prints as a text table (ignoring -json).
|
|
358
|
+
* Leading-token match; assertReadOnlyQuery gates the same leading keyword, so both reject on
|
|
359
|
+
* comment-prefixed queries before this routing matters.
|
|
360
|
+
*/
|
|
361
|
+
export function isExplainQuery(sql) {
|
|
362
|
+
return /^\s*explain\b/i.test(sql);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* sqlite3 ignores -json for EXPLAIN (prints text table instead of JSON).
|
|
366
|
+
* Shape it into row objects so the web grid renders the plan readably.
|
|
367
|
+
*/
|
|
368
|
+
export function parseExplainOutput(out) {
|
|
369
|
+
return out
|
|
370
|
+
.split("\n")
|
|
371
|
+
.filter((line) => line.trim().length > 0)
|
|
372
|
+
.map((line) => ({ plan: line }));
|
|
373
|
+
}
|
|
356
374
|
/**
|
|
357
375
|
* Run a read-only query against a (lazily pulled) local copy. The query is gated by
|
|
358
376
|
* assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
|
|
@@ -365,6 +383,12 @@ export async function queryDatabase(name, sql, user) {
|
|
|
365
383
|
const local = localPath(name, u);
|
|
366
384
|
if (!fs.existsSync(local))
|
|
367
385
|
await pullDatabase(name, u);
|
|
386
|
+
// EXPLAIN / EXPLAIN QUERY PLAN are read-query-shaped (they pass the gate) but sqlite3 IGNORES
|
|
387
|
+
// -json for them: it prints a fixed-width opcode/plan TEXT table, not JSON, so JSON.parse below
|
|
388
|
+
// would throw "Unexpected token" and the user sees a confusing parse error for a query that
|
|
389
|
+
// actually ran. Handle them as text: one result row per output line under a single column, so
|
|
390
|
+
// the existing grid renders the plan readably instead of choking.
|
|
391
|
+
const explain = isExplainQuery(sql);
|
|
368
392
|
let out;
|
|
369
393
|
try {
|
|
370
394
|
// Open the pulled copy NORMALLY (not `immutable`, not `-readonly`): pullDatabase brought
|
|
@@ -375,14 +399,15 @@ export async function queryDatabase(name, sql, user) {
|
|
|
375
399
|
// is touched. `-safe` is the load-bearing read-only/exfil control (blocks ATTACH/readfile/
|
|
376
400
|
// writefile/load_extension at the engine level) and holds in normal mode too. The path is
|
|
377
401
|
// URI-encoded so spaces/specials are safe. Async exec so a large query does not block the
|
|
378
|
-
// event loop.
|
|
402
|
+
// event loop. -json for normal queries; plain text for EXPLAIN (sqlite ignores -json there).
|
|
379
403
|
const uri = `file:${encodeURI(local)}`;
|
|
380
|
-
const
|
|
404
|
+
const args = explain ? ["-safe", uri, sql] : ["-safe", "-json", uri, sql];
|
|
405
|
+
const r = await execFileP(sqliteBin(), args, {
|
|
381
406
|
timeout: 15000,
|
|
382
407
|
maxBuffer: SQLITE_MAX_BUFFER,
|
|
383
408
|
encoding: "utf8",
|
|
384
409
|
});
|
|
385
|
-
out = String(r.stdout) || "[]";
|
|
410
|
+
out = String(r.stdout) || (explain ? "" : "[]");
|
|
386
411
|
}
|
|
387
412
|
catch (e) {
|
|
388
413
|
const err = e;
|
|
@@ -390,6 +415,8 @@ export async function queryDatabase(name, sql, user) {
|
|
|
390
415
|
const m = stderr.match(/(no such table:.*|no such column:.*|near ".*|syntax error.*|.*error.*)/i);
|
|
391
416
|
throw new Error(m ? m[0].trim() : stderr.split("\n")[0] || "query failed");
|
|
392
417
|
}
|
|
418
|
+
if (explain)
|
|
419
|
+
return parseExplainOutput(out);
|
|
393
420
|
return JSON.parse(out);
|
|
394
421
|
}
|
|
395
422
|
/** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
|
|
@@ -100,6 +100,12 @@ class Store {
|
|
|
100
100
|
_localPushKey = null;
|
|
101
101
|
_lastBoundLabel = null;
|
|
102
102
|
_backfillToken = 0;
|
|
103
|
+
// True while the connection is in a dropped/waiting state. Lets pollHealth detect the
|
|
104
|
+
// drop->recover EDGE (device came back) so it can re-open the stream + backfill ONCE on
|
|
105
|
+
// recovery, instead of every poll. Without this, an auto-recovered tab flips the dot to
|
|
106
|
+
// connected but the dead EventSource is never re-opened and the device ring is never pulled,
|
|
107
|
+
// so the tab shows nothing until a manual refresh.
|
|
108
|
+
_dropped = false;
|
|
103
109
|
_rafPending = false;
|
|
104
110
|
_intervals = [];
|
|
105
111
|
// ---- shared detail cache (bounded FIFO) ----
|
|
@@ -204,6 +210,7 @@ class Store {
|
|
|
204
210
|
this._localPushKey = null;
|
|
205
211
|
this._lastBoundLabel = null;
|
|
206
212
|
this._backfillToken = 0;
|
|
213
|
+
this._dropped = false;
|
|
207
214
|
this._rafPending = false;
|
|
208
215
|
}
|
|
209
216
|
// ---- start everything ----
|
|
@@ -317,7 +324,12 @@ class Store {
|
|
|
317
324
|
this.closeStream();
|
|
318
325
|
const es = new EventSource("/stream");
|
|
319
326
|
es.onmessage = (m) => this.onFrame(m);
|
|
320
|
-
|
|
327
|
+
// An SSE error is the most direct signal the stream died; mark dropped so the next healthy
|
|
328
|
+
// poll re-opens + backfills (recovered()), not just flips the dot green.
|
|
329
|
+
es.onerror = () => {
|
|
330
|
+
this._dropped = true;
|
|
331
|
+
this.setConn("waiting", "reconnecting…");
|
|
332
|
+
};
|
|
321
333
|
this._es = es;
|
|
322
334
|
}
|
|
323
335
|
closeStream() {
|
|
@@ -349,6 +361,10 @@ class Store {
|
|
|
349
361
|
if (!STREAM_KINDS.includes(f.type))
|
|
350
362
|
return;
|
|
351
363
|
this._lastEventTs = Date.now();
|
|
364
|
+
// A live frame proves the stream healed itself (the browser EventSource auto-reconnects on a
|
|
365
|
+
// transient blip). Clear _dropped here so recovered() does NOT later tear down this healthy
|
|
366
|
+
// stream and re-open it: recovered() should only fire when the stream did NOT self-heal.
|
|
367
|
+
this._dropped = false;
|
|
352
368
|
this.setConn("connected", "");
|
|
353
369
|
const ev = f.event || {};
|
|
354
370
|
ev._type = f.type;
|
|
@@ -593,6 +609,7 @@ class Store {
|
|
|
593
609
|
async pollHealth() {
|
|
594
610
|
if (Date.now() - this._lastEventTs < 6000) {
|
|
595
611
|
this._healthMisses = 0;
|
|
612
|
+
this.recovered();
|
|
596
613
|
this.setConn("connected", "");
|
|
597
614
|
return;
|
|
598
615
|
}
|
|
@@ -600,17 +617,31 @@ class Store {
|
|
|
600
617
|
const h = await (await fetch("/health")).json();
|
|
601
618
|
if (h.device) {
|
|
602
619
|
this._healthMisses = 0;
|
|
620
|
+
this.recovered();
|
|
603
621
|
this.setConn("connected", "");
|
|
604
622
|
}
|
|
605
623
|
else if (++this._healthMisses >= 2) {
|
|
624
|
+
this._dropped = true;
|
|
606
625
|
this.setConn("waiting", "device offline, retrying…");
|
|
607
626
|
}
|
|
608
627
|
}
|
|
609
628
|
catch {
|
|
610
|
-
if (++this._healthMisses >= 2)
|
|
629
|
+
if (++this._healthMisses >= 2) {
|
|
630
|
+
this._dropped = true;
|
|
611
631
|
this.setConn("waiting", "reconnecting…");
|
|
632
|
+
}
|
|
612
633
|
}
|
|
613
634
|
}
|
|
635
|
+
// Called when health flips back to connected. If we were in a dropped state, the EventSource
|
|
636
|
+
// did not survive the drop and the tab has been silent, so re-open the stream + backfill the
|
|
637
|
+
// device ring ONCE on the recovery edge. The _dropped guard makes this fire on the transition,
|
|
638
|
+
// not on every healthy poll (resyncStream clears the flag). The <6s-since-last-event fast path
|
|
639
|
+
// means a tab that never lost its stream skips this (live frames keep _lastEventTs fresh).
|
|
640
|
+
recovered() {
|
|
641
|
+
if (!this._dropped)
|
|
642
|
+
return;
|
|
643
|
+
this.resyncStream();
|
|
644
|
+
}
|
|
614
645
|
async pollProfile() {
|
|
615
646
|
try {
|
|
616
647
|
const [profilesR, healthR] = await Promise.all([
|
|
@@ -635,6 +666,26 @@ class Store {
|
|
|
635
666
|
/* leave the last label */
|
|
636
667
|
}
|
|
637
668
|
}
|
|
669
|
+
// Re-open the SSE stream and backfill the device ring for the ACTIVE streaming source, so the
|
|
670
|
+
// current tab repopulates from what the device already holds (not just new live events).
|
|
671
|
+
// Needed because the EventSource does not survive a device drop and pollSpec only re-applies
|
|
672
|
+
// (and so re-opens+backfills) on a spec-KEY change, which a reconnect is not. Guarded for
|
|
673
|
+
// streaming sources only, matching applySpec: db + blank own no SSE feed; log opens the stream
|
|
674
|
+
// but backfill() self-skips it (host-side capture, no device ring to replay).
|
|
675
|
+
//
|
|
676
|
+
// Clears _dropped itself so the two recovery entry points -- the manual reconnect() button and
|
|
677
|
+
// the auto recovered() edge -- collapse into one idempotent op: whichever fires first wins, the
|
|
678
|
+
// other sees _dropped false and no-ops. Without this, a manual reconnect on a waiting tab fires
|
|
679
|
+
// resyncStream() here AND again from recovered() once the health poll resolves, so the stream is
|
|
680
|
+
// opened, torn down, reopened, and the ring is backfilled twice.
|
|
681
|
+
resyncStream() {
|
|
682
|
+
this._dropped = false;
|
|
683
|
+
const src = this.spec.source;
|
|
684
|
+
if (this.spec.blank || !src || src === "db")
|
|
685
|
+
return;
|
|
686
|
+
this.openStream();
|
|
687
|
+
this.backfill();
|
|
688
|
+
}
|
|
638
689
|
async reconnect() {
|
|
639
690
|
try {
|
|
640
691
|
await fetch("/enable", { method: "POST" });
|
|
@@ -645,6 +696,7 @@ class Store {
|
|
|
645
696
|
this.pollHealth();
|
|
646
697
|
this.pollProfile();
|
|
647
698
|
this.pollSpec();
|
|
699
|
+
this.resyncStream();
|
|
648
700
|
}
|
|
649
701
|
}
|
|
650
702
|
// ---- aggregate math (moved here from widgets per the plan: it belongs WITH the aggregate
|