@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.
- package/README.md +4 -3
- package/dist/mcp/api-paths.d.ts +1 -0
- package/dist/mcp/api-paths.js +108 -0
- package/dist/mcp/index.js +51 -79
- package/dist/mcp/logs-result.d.ts +9 -0
- package/dist/mcp/logs-result.js +15 -0
- package/dist/mcp/tools.js +31 -7
- package/dist/shared/android.d.ts +2 -2
- package/dist/shared/android.js +16 -11
- package/dist/shared/screen.d.ts +10 -0
- package/dist/shared/screen.js +13 -1
- package/dist/shared/stream.d.ts +1 -1
- package/dist/shared/transport.d.ts +12 -4
- package/dist/web/public/index.html +242 -1126
- package/dist/web/public/next/app.js +210 -0
- package/dist/web/public/next/db.js +286 -0
- package/dist/web/public/next/esc.js +13 -0
- package/dist/web/public/next/feed.js +608 -0
- package/dist/web/public/next/filters.js +120 -0
- package/dist/web/public/next/hooks.js +57 -0
- package/dist/web/public/next/html.js +10 -0
- package/dist/web/public/next/inspector.js +258 -0
- package/dist/web/public/next/registry.js +65 -0
- package/dist/web/public/next/screen.js +180 -0
- package/dist/web/public/next/spec-types.js +1 -0
- package/dist/web/public/next/store.js +714 -0
- package/dist/web/public/next/widgets.js +194 -0
- package/dist/web/server.js +7 -1
- package/package.json +3 -2
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import { STREAM_KINDS, DETAILABLE_KINDS } from "./registry.js";
|
|
2
|
+
// Logcat-scale retention. At the live firehose (~400+ events/sec on the unified Live tab) a
|
|
3
|
+
// small ring recycles in seconds, so a rare signal (a once-per-5s heartbeat) gets evicted
|
|
4
|
+
// before you can filter for it. Android Studio / Flipper keep the whole session and filter
|
|
5
|
+
// across all of it, so we match that: a big in-memory ring + filter over the entire buffer.
|
|
6
|
+
// 100k events ~ a few minutes of the full firehose, or hours of any single low-rate tag. The
|
|
7
|
+
// cost is heap (bounded, ~tens of MB) + a full-buffer filter pass, which is why the feed only
|
|
8
|
+
// does that pass on a user action and prepends incrementally while tailing (see feed.ts). TTL
|
|
9
|
+
// is the secondary bound for a quiet session. These are named constants, not inline magic, so
|
|
10
|
+
// the retention rationale stays attached to the numbers.
|
|
11
|
+
const CACHE_CAP = 100000;
|
|
12
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
13
|
+
const DETAILABLE = new Set(DETAILABLE_KINDS); // sources with a server /detail endpoint
|
|
14
|
+
const DETAIL_CACHE_CAP = 1500; // bounded full-detail cache (eager-captured + click-fetched)
|
|
15
|
+
const CAPTURE_CONCURRENCY = 3; // max parallel background /detail fetches
|
|
16
|
+
const CAP_SEEN_CAP = 4000; // bound on the dedup set for eager capture
|
|
17
|
+
const POLL_SPEC_MS = 700;
|
|
18
|
+
const POLL_HEALTH_MS = 2000;
|
|
19
|
+
const POLL_PROFILE_MS = 5000;
|
|
20
|
+
const GRAPH_N = 60;
|
|
21
|
+
const PERSEC_N = 30;
|
|
22
|
+
const SUGGEST_FIELDS = ["event_type", "event_name", "tag", "status_code", "channel", "url", "package"];
|
|
23
|
+
const PACKAGE_CAP = 1000; // package values learned off the stream; bounded (was Infinity in the PoC, B4)
|
|
24
|
+
// ---- pre-built dashboards: one tab each. Presets carry only generic placeholders; real
|
|
25
|
+
// endpoints/channels/event-names are learned LIVE from the device stream. The one baked value
|
|
26
|
+
// is com.Slack.internal.debug (the debug build's package), which is already public. ----
|
|
27
|
+
export const MODES = [
|
|
28
|
+
{ 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: ["rate", "sparkline", "feed", "inspector"], match: { package: "com.Slack.internal.debug" } } },
|
|
29
|
+
{ 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: ["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 } },
|
|
30
|
+
{ key: "rtm", label: "RTM", spec: { title: "RTM", summary: "Real-time websocket events as they happen.", source: "rtm", viz: ["rate", "feed", "inspector"] } },
|
|
31
|
+
{ key: "clog", label: "Clogs", spec: { title: "Clogs", summary: "Analytics clog events, click any to see its full payload.", source: "clog", viz: ["rate", "feed", "inspector"] } },
|
|
32
|
+
{ key: "log", label: "Logs", spec: { title: "Device Logs", summary: "Live Slack app logcat, color-coded by level. Scroll the full session buffer, select a range, copy raw lines.", source: "log", viz: ["feed", "inspector"], match: { package: "com.Slack.internal.debug" } } },
|
|
33
|
+
{ key: "db", label: "Database", spec: { title: "Device DB", summary: "Browse the on-device SQLite databases (read-only snapshot).", source: "db" } },
|
|
34
|
+
];
|
|
35
|
+
// A preset is identified by what it DOES, not how it is labelled. canonicalSpecKey reduces a
|
|
36
|
+
// spec to its behavioral identity so a spec that is semantically a preset still matches it
|
|
37
|
+
// despite cosmetic drift:
|
|
38
|
+
// - title / summary are DISPLAY COPY, dropped before comparing. (They legitimately change
|
|
39
|
+
// between releases; an old saved spec carrying last release's summary wording must still
|
|
40
|
+
// resolve to its preset, not orphan into a phantom ✎ custom tab.)
|
|
41
|
+
// - `counter` in viz is a documented no-op the dashboard never renders, dropped.
|
|
42
|
+
// - object keys are sorted so key ORDER never matters.
|
|
43
|
+
// The presets stay mutually distinct under this reduction (verified by test), so dropping the
|
|
44
|
+
// copy never collapses two different dashboards into one.
|
|
45
|
+
function canonicalSpecKey(spec) {
|
|
46
|
+
const norm = (v) => {
|
|
47
|
+
if (Array.isArray(v))
|
|
48
|
+
return v.map(norm);
|
|
49
|
+
if (v && typeof v === "object") {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const k of Object.keys(v).sort()) {
|
|
52
|
+
out[k] = norm(v[k]);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
return v;
|
|
57
|
+
};
|
|
58
|
+
const cleaned = { ...spec };
|
|
59
|
+
delete cleaned.title;
|
|
60
|
+
delete cleaned.summary;
|
|
61
|
+
if (Array.isArray(cleaned.viz))
|
|
62
|
+
cleaned.viz = cleaned.viz.filter((x) => x !== "counter");
|
|
63
|
+
return JSON.stringify(norm(cleaned));
|
|
64
|
+
}
|
|
65
|
+
export function specMatchesMode(spec) {
|
|
66
|
+
if (!spec || spec.blank)
|
|
67
|
+
return null;
|
|
68
|
+
const key = canonicalSpecKey(spec);
|
|
69
|
+
return MODES.find((m) => canonicalSpecKey(m.spec) === key)?.key ?? null;
|
|
70
|
+
}
|
|
71
|
+
class Store {
|
|
72
|
+
spec = { blank: true, viz: [] };
|
|
73
|
+
conn = { state: "waiting", msg: "connecting…" };
|
|
74
|
+
profileLabel = "detecting…";
|
|
75
|
+
profiles = [];
|
|
76
|
+
cache = [];
|
|
77
|
+
cacheKeys = new Set();
|
|
78
|
+
seen = {};
|
|
79
|
+
total = 0;
|
|
80
|
+
// The count cap, as an instance field defaulted from the const, so a test can shrink it to
|
|
81
|
+
// exercise the count-eviction branch without pushing 100k frames. The TTL bound is separate.
|
|
82
|
+
cacheCap = CACHE_CAP;
|
|
83
|
+
times = [];
|
|
84
|
+
perSec = new Array(PERSEC_N).fill(0);
|
|
85
|
+
liveMatches = 0;
|
|
86
|
+
graph = null;
|
|
87
|
+
feedVersion = 0;
|
|
88
|
+
customActive = false;
|
|
89
|
+
lastCustomSpec = null;
|
|
90
|
+
detailCache = new Map();
|
|
91
|
+
_capSeen = new Set();
|
|
92
|
+
_capQueue = [];
|
|
93
|
+
_capInflight = 0;
|
|
94
|
+
_subs = {};
|
|
95
|
+
_es = null;
|
|
96
|
+
_tick = null;
|
|
97
|
+
_lastEventTs = 0;
|
|
98
|
+
_healthMisses = 0;
|
|
99
|
+
_specKey = "";
|
|
100
|
+
_localPushKey = null;
|
|
101
|
+
_lastBoundLabel = null;
|
|
102
|
+
_backfillToken = 0;
|
|
103
|
+
_rafPending = false;
|
|
104
|
+
_intervals = [];
|
|
105
|
+
// ---- shared detail cache (bounded FIFO) ----
|
|
106
|
+
cacheDetail(kind, id, detail) {
|
|
107
|
+
const key = kind + ":" + id;
|
|
108
|
+
this.detailCache.set(key, detail);
|
|
109
|
+
while (this.detailCache.size > DETAIL_CACHE_CAP) {
|
|
110
|
+
this.detailCache.delete(this.detailCache.keys().next().value);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
getDetail(kind, id) {
|
|
114
|
+
return this.detailCache.get(kind + ":" + id);
|
|
115
|
+
}
|
|
116
|
+
// ---- eager body capture (any DETAILABLE tab, while it is open) ----
|
|
117
|
+
// The device keeps only the last ~200 network calls, so a row clicked later is gone. While a
|
|
118
|
+
// detailable tab is active we proactively fetch each NEW frame's full body in the background
|
|
119
|
+
// (concurrency-bounded) and cache it, so clicking an old row never hits eviction. Off on
|
|
120
|
+
// every other tab (mirrors Flipper's shouldStripResponseBody). Forward-only: no backfill.
|
|
121
|
+
//
|
|
122
|
+
// detailCache holds full bodies unredacted (bounded to DETAIL_CACHE_CAP), same as the
|
|
123
|
+
// inspector copy paths: a dev-only tool against a local debug build, where the inspector
|
|
124
|
+
// showing the real body is the point. Nothing to gain from scrubbing on localhost.
|
|
125
|
+
maybeCapture(ev) {
|
|
126
|
+
const src = this.spec.source;
|
|
127
|
+
const kind = ev._type || "";
|
|
128
|
+
if (ev.id == null || !DETAILABLE.has(kind))
|
|
129
|
+
return;
|
|
130
|
+
if (src !== "all" && src !== kind)
|
|
131
|
+
return;
|
|
132
|
+
const key = kind + ":" + ev.id;
|
|
133
|
+
if (this._capSeen.has(key) || this.detailCache.has(key))
|
|
134
|
+
return;
|
|
135
|
+
this._capSeen.add(key);
|
|
136
|
+
if (this._capSeen.size > CAP_SEEN_CAP)
|
|
137
|
+
this._capSeen.delete(this._capSeen.values().next().value);
|
|
138
|
+
this._capQueue.push({ kind, id: ev.id });
|
|
139
|
+
this._drainCapture();
|
|
140
|
+
}
|
|
141
|
+
_drainCapture() {
|
|
142
|
+
while (this._capInflight < CAPTURE_CONCURRENCY && this._capQueue.length) {
|
|
143
|
+
const { kind, id } = this._capQueue.shift();
|
|
144
|
+
this._capInflight++;
|
|
145
|
+
fetch("/detail?kind=" + kind + "&id=" + id)
|
|
146
|
+
.then((r) => r.json())
|
|
147
|
+
.then((d) => {
|
|
148
|
+
// skip the eviction error / empty; only cache a real payload
|
|
149
|
+
if (d && !(d.error && d.url == null && d.method == null)) {
|
|
150
|
+
this.cacheDetail(kind, id, { ...d, _type: kind });
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
.catch(() => { })
|
|
154
|
+
.finally(() => {
|
|
155
|
+
this._capInflight--;
|
|
156
|
+
if (this._capQueue.length)
|
|
157
|
+
this._drainCapture();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ---- pub/sub (payload-aware; satisfies the Emitter contract) ----
|
|
162
|
+
on(evt, fn) {
|
|
163
|
+
const set = (this._subs[evt] || (this._subs[evt] = new Set()));
|
|
164
|
+
set.add(fn);
|
|
165
|
+
return () => set.delete(fn);
|
|
166
|
+
}
|
|
167
|
+
emit(evt, payload) {
|
|
168
|
+
const s = this._subs[evt];
|
|
169
|
+
if (s)
|
|
170
|
+
for (const fn of [...s])
|
|
171
|
+
fn(payload);
|
|
172
|
+
}
|
|
173
|
+
// Test-only: how many live subscribers an event has. Lets a test assert a component
|
|
174
|
+
// unsubscribed on unmount (the per-tab-switch listener-leak class).
|
|
175
|
+
subscriberCount(evt) {
|
|
176
|
+
return this._subs[evt]?.size ?? 0;
|
|
177
|
+
}
|
|
178
|
+
// Test-only: reset all mutable state to construction defaults. Centralized so the reset list
|
|
179
|
+
// cannot drift from the fields (a hand-maintained reset in the test omitted half of them).
|
|
180
|
+
_resetForTest() {
|
|
181
|
+
this.spec = { blank: true, viz: [] };
|
|
182
|
+
this.conn = { state: "waiting", msg: "connecting…" };
|
|
183
|
+
this.profileLabel = "detecting…";
|
|
184
|
+
this.profiles = [];
|
|
185
|
+
this.cache = [];
|
|
186
|
+
this.cacheKeys = new Set();
|
|
187
|
+
this.seen = {};
|
|
188
|
+
this.total = 0;
|
|
189
|
+
this.cacheCap = CACHE_CAP;
|
|
190
|
+
this.times = [];
|
|
191
|
+
this.perSec = new Array(PERSEC_N).fill(0);
|
|
192
|
+
this.liveMatches = 0;
|
|
193
|
+
this.graph = null;
|
|
194
|
+
this.feedVersion = 0;
|
|
195
|
+
this.customActive = false;
|
|
196
|
+
this.lastCustomSpec = null;
|
|
197
|
+
this.detailCache = new Map();
|
|
198
|
+
this._capSeen = new Set();
|
|
199
|
+
this._capQueue = [];
|
|
200
|
+
this._capInflight = 0;
|
|
201
|
+
this._lastEventTs = 0;
|
|
202
|
+
this._healthMisses = 0;
|
|
203
|
+
this._specKey = "";
|
|
204
|
+
this._localPushKey = null;
|
|
205
|
+
this._lastBoundLabel = null;
|
|
206
|
+
this._backfillToken = 0;
|
|
207
|
+
this._rafPending = false;
|
|
208
|
+
}
|
|
209
|
+
// ---- start everything ----
|
|
210
|
+
start() {
|
|
211
|
+
this.bootDefault();
|
|
212
|
+
this.pollSpec();
|
|
213
|
+
this._intervals.push(setInterval(() => this.pollSpec(), POLL_SPEC_MS));
|
|
214
|
+
this.pollHealth();
|
|
215
|
+
this._intervals.push(setInterval(() => this.pollHealth(), POLL_HEALTH_MS));
|
|
216
|
+
this.pollProfile();
|
|
217
|
+
this._intervals.push(setInterval(() => this.pollProfile(), POLL_PROFILE_MS));
|
|
218
|
+
this._intervals.push(setInterval(() => this.evictCache(), 30000));
|
|
219
|
+
}
|
|
220
|
+
// Stop all timers + the stream. Not used by the live page (the store is a singleton for the
|
|
221
|
+
// life of the tab), but lets a test tear the engine down so it does not leak timers.
|
|
222
|
+
stop() {
|
|
223
|
+
this._intervals.forEach((i) => clearInterval(i));
|
|
224
|
+
this._intervals = [];
|
|
225
|
+
if (this._tick) {
|
|
226
|
+
clearInterval(this._tick);
|
|
227
|
+
this._tick = null;
|
|
228
|
+
}
|
|
229
|
+
this.closeStream();
|
|
230
|
+
}
|
|
231
|
+
setConn(state, msg) {
|
|
232
|
+
if (this.conn.state === state && this.conn.msg === (msg || ""))
|
|
233
|
+
return;
|
|
234
|
+
this.conn = { state, msg: msg || "" };
|
|
235
|
+
this.emit("conn", this.conn);
|
|
236
|
+
}
|
|
237
|
+
async pollSpec() {
|
|
238
|
+
try {
|
|
239
|
+
const spec = await (await fetch("/spec")).json();
|
|
240
|
+
const key = JSON.stringify(spec);
|
|
241
|
+
if (key === this._specKey)
|
|
242
|
+
return;
|
|
243
|
+
this._specKey = key;
|
|
244
|
+
const isLocalEdit = key === this._localPushKey;
|
|
245
|
+
if (isLocalEdit)
|
|
246
|
+
this._localPushKey = null;
|
|
247
|
+
this.customActive = !isLocalEdit && !!spec && !spec.blank && specMatchesMode(spec) === null;
|
|
248
|
+
if (this.customActive && (!this.lastCustomSpec || JSON.stringify(this.lastCustomSpec) !== key)) {
|
|
249
|
+
this.lastCustomSpec = spec;
|
|
250
|
+
}
|
|
251
|
+
this.applySpec(spec);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
/* transient; next poll retries */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async bootDefault() {
|
|
258
|
+
try {
|
|
259
|
+
const cur = await (await fetch("/spec")).json();
|
|
260
|
+
if (!cur || cur.blank) {
|
|
261
|
+
await fetch("/setspec", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(MODES[0].spec) }).catch(() => { });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
/* ignore */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
markLocalPush(spec) {
|
|
269
|
+
this._localPushKey = JSON.stringify(spec);
|
|
270
|
+
}
|
|
271
|
+
async setSpec(spec, opts = {}) {
|
|
272
|
+
if (opts.local)
|
|
273
|
+
this.markLocalPush(spec);
|
|
274
|
+
try {
|
|
275
|
+
return await (await fetch("/setspec", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(spec) })).json();
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return { ok: false, error: "request failed" };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
activeModeKey() {
|
|
282
|
+
return this.customActive ? "__custom__" : specMatchesMode(this.spec);
|
|
283
|
+
}
|
|
284
|
+
// Rebuild all derived state for a new spec, reconnect the stream (so the server
|
|
285
|
+
// attaches/detaches the host-side logcat session for log/all sources), replay the cache, and
|
|
286
|
+
// backfill the device ring. Mirrors vanilla build(spec).
|
|
287
|
+
applySpec(spec) {
|
|
288
|
+
this.spec = spec || { blank: true, viz: [] };
|
|
289
|
+
this.total = 0;
|
|
290
|
+
this.liveMatches = 0;
|
|
291
|
+
this.times = [];
|
|
292
|
+
this.perSec = new Array(PERSEC_N).fill(0);
|
|
293
|
+
this.graph = null;
|
|
294
|
+
this._capQueue.length = 0;
|
|
295
|
+
if (!this.spec.blank && this.spec.source !== "db" && Array.isArray(this.spec.viz) && this.spec.viz.includes("graph") && Array.isArray(this.spec.series)) {
|
|
296
|
+
this.graph = { N: GRAPH_N, series: this.spec.series.map((s) => ({ def: s, buckets: new Array(GRAPH_N).fill(0), counts: new Array(GRAPH_N).fill(0) })) };
|
|
297
|
+
}
|
|
298
|
+
this.emit("spec", this.spec);
|
|
299
|
+
// DB browser + blank: fully quiet the streaming engine so it does not compete with the DB
|
|
300
|
+
// pull. Close the SSE stream AND stop the per-second tick. The 100k cache is left intact.
|
|
301
|
+
if (this.spec.blank || this.spec.source === "db") {
|
|
302
|
+
this.closeStream();
|
|
303
|
+
if (this._tick) {
|
|
304
|
+
clearInterval(this._tick);
|
|
305
|
+
this._tick = null;
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
this.openStream();
|
|
310
|
+
if (!this._tick)
|
|
311
|
+
this._tick = setInterval(() => this.tick(), 1000);
|
|
312
|
+
this.replayCache();
|
|
313
|
+
this.backfill();
|
|
314
|
+
}
|
|
315
|
+
// ---- stream ----
|
|
316
|
+
openStream() {
|
|
317
|
+
this.closeStream();
|
|
318
|
+
const es = new EventSource("/stream");
|
|
319
|
+
es.onmessage = (m) => this.onFrame(m);
|
|
320
|
+
es.onerror = () => this.setConn("waiting", "reconnecting…");
|
|
321
|
+
this._es = es;
|
|
322
|
+
}
|
|
323
|
+
closeStream() {
|
|
324
|
+
if (this._es) {
|
|
325
|
+
this._es.close();
|
|
326
|
+
this._es = null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
onFrame(m, live = true) {
|
|
330
|
+
let f;
|
|
331
|
+
try {
|
|
332
|
+
f = JSON.parse(m.data);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (f.type === "_status") {
|
|
338
|
+
const e = f.event;
|
|
339
|
+
this.setConn(e.state, e.msg);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (f.type === "_error") {
|
|
343
|
+
const e = f.event;
|
|
344
|
+
this.setConn("waiting", e.msg);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (f.replay)
|
|
348
|
+
live = false; // server-replayed history: never alerts/counts twice
|
|
349
|
+
if (!STREAM_KINDS.includes(f.type))
|
|
350
|
+
return;
|
|
351
|
+
this._lastEventTs = Date.now();
|
|
352
|
+
this.setConn("connected", "");
|
|
353
|
+
const ev = f.event || {};
|
|
354
|
+
ev._type = f.type;
|
|
355
|
+
const isNew = this.cacheFrame(f.type, ev);
|
|
356
|
+
if (this.spec.source !== "all" && f.type !== this.spec.source)
|
|
357
|
+
return;
|
|
358
|
+
if (!isNew)
|
|
359
|
+
return;
|
|
360
|
+
// eager body capture: live, new, for-this-tab frames of a detailable kind. BEFORE the match
|
|
361
|
+
// filter so even filtered-out rows have their body cached for when the filter clears.
|
|
362
|
+
if (live)
|
|
363
|
+
this.maybeCapture(ev);
|
|
364
|
+
if (!this.matches(ev))
|
|
365
|
+
return;
|
|
366
|
+
this.accept(ev, live);
|
|
367
|
+
}
|
|
368
|
+
// ---- CACHE ring (dedup + bounded by count and age) ----
|
|
369
|
+
frameKey(type, ev) {
|
|
370
|
+
return type + ":" + (ev.id != null ? ev.id : (ev.log_ts || "") + "|" + (ev.pid || "") + "|" + (ev.message || ev.event_type || ev.event_name || ""));
|
|
371
|
+
}
|
|
372
|
+
cacheFrame(type, ev) {
|
|
373
|
+
const k = this.frameKey(type, ev);
|
|
374
|
+
if (this.cacheKeys.has(k))
|
|
375
|
+
return false;
|
|
376
|
+
this.cacheKeys.add(k);
|
|
377
|
+
this.cache.push({ type, ev, ts: ev.timestamp || Date.now() });
|
|
378
|
+
this.observe(ev);
|
|
379
|
+
this.evictCache();
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
evictCache() {
|
|
383
|
+
const cutoff = Date.now() - CACHE_TTL_MS;
|
|
384
|
+
const drop = () => {
|
|
385
|
+
const old = this.cache.shift();
|
|
386
|
+
if (old)
|
|
387
|
+
this.cacheKeys.delete(this.frameKey(old.type, old.ev));
|
|
388
|
+
};
|
|
389
|
+
while (this.cache.length && this.cache[0].ts < cutoff)
|
|
390
|
+
drop();
|
|
391
|
+
while (this.cache.length > this.cacheCap)
|
|
392
|
+
drop();
|
|
393
|
+
}
|
|
394
|
+
observe(ev) {
|
|
395
|
+
for (const f of SUGGEST_FIELDS) {
|
|
396
|
+
let v = ev[f];
|
|
397
|
+
if (v == null || v === "")
|
|
398
|
+
continue;
|
|
399
|
+
if (f === "url") {
|
|
400
|
+
try {
|
|
401
|
+
v = new URL(String(v)).pathname.replace("/api/", "");
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
/* keep raw */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const sv = String(v);
|
|
408
|
+
if (sv.length > 80)
|
|
409
|
+
continue;
|
|
410
|
+
(this.seen[f] || (this.seen[f] = new Set())).add(sv);
|
|
411
|
+
const cap = f === "package" ? PACKAGE_CAP : 500;
|
|
412
|
+
while (this.seen[f].size > cap) {
|
|
413
|
+
this.seen[f].delete(this.seen[f].values().next().value);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
candidates(field) {
|
|
418
|
+
const fixed = { direction: ["OUTGOING", "INCOMING"], level: ["VERBOSE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"], method: ["GET", "POST", "PUT", "DELETE", "PATCH"] };
|
|
419
|
+
if (fixed[field])
|
|
420
|
+
return fixed[field].slice();
|
|
421
|
+
const seen = this.seen[field];
|
|
422
|
+
return seen ? [...seen].sort() : [];
|
|
423
|
+
}
|
|
424
|
+
// Replay recent cache into the fresh spec's WIDGETS (rate / sparkline / graph), live=false.
|
|
425
|
+
// The widgets only span the last ~60s, so we only feed accept() the recent tail (replaying
|
|
426
|
+
// all 100k rows was the tab-switch freeze). Walk newest->oldest, STOP once past the window.
|
|
427
|
+
// 90s covers every widget window (rate 10s, sparkline 30s, graph 60s) with margin.
|
|
428
|
+
replayCache() {
|
|
429
|
+
const src = this.spec.source;
|
|
430
|
+
const WINDOW_MS = 90 * 1000;
|
|
431
|
+
const cutoff = Date.now() - WINDOW_MS;
|
|
432
|
+
const recent = [];
|
|
433
|
+
for (let i = this.cache.length - 1; i >= 0; i--) {
|
|
434
|
+
const c = this.cache[i];
|
|
435
|
+
if (c.ts < cutoff)
|
|
436
|
+
break;
|
|
437
|
+
if (src !== "all" && c.type !== src)
|
|
438
|
+
continue;
|
|
439
|
+
recent.push(c);
|
|
440
|
+
}
|
|
441
|
+
recent.reverse();
|
|
442
|
+
for (const c of recent) {
|
|
443
|
+
const ev = c.ev;
|
|
444
|
+
ev._type = c.type;
|
|
445
|
+
if (this.matches(ev))
|
|
446
|
+
this.accept(ev, false);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async backfill() {
|
|
450
|
+
const myToken = ++this._backfillToken;
|
|
451
|
+
const src = this.spec.source;
|
|
452
|
+
if (!src)
|
|
453
|
+
return;
|
|
454
|
+
if (src === "log")
|
|
455
|
+
return; // host-side capture: nothing device-buffered to replay
|
|
456
|
+
try {
|
|
457
|
+
const r = await (await fetch("/backfill?source=" + encodeURIComponent(src) + "&limit=200")).json();
|
|
458
|
+
if (myToken !== this._backfillToken)
|
|
459
|
+
return;
|
|
460
|
+
for (const f of r?.frames || [])
|
|
461
|
+
this.onFrame({ data: JSON.stringify(f) }, false);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
/* device unreachable; live stream still self-heals */
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// ---- accept a matched event into the live aggregates ----
|
|
468
|
+
accept(ev, live = true) {
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
const ets = ev.timestamp || now;
|
|
471
|
+
const ageSec = Math.max(0, Math.floor((now - ets) / 1000));
|
|
472
|
+
this.total++;
|
|
473
|
+
this.times.push(ets);
|
|
474
|
+
const si = this.perSec.length - 1 - ageSec;
|
|
475
|
+
if (si >= 0 && si < this.perSec.length)
|
|
476
|
+
this.perSec[si]++;
|
|
477
|
+
if (this.graph) {
|
|
478
|
+
const gi = this.graph.N - 1 - ageSec;
|
|
479
|
+
if (gi >= 0 && gi < this.graph.N) {
|
|
480
|
+
for (const s of this.graph.series) {
|
|
481
|
+
const d = s.def;
|
|
482
|
+
if (d.metric === "ratePerSec" || d.metric === "count")
|
|
483
|
+
s.buckets[gi]++;
|
|
484
|
+
else if (d.metric === "avg") {
|
|
485
|
+
const v = Number(ev[d.field]);
|
|
486
|
+
if (!isNaN(v)) {
|
|
487
|
+
s.buckets[gi] += v;
|
|
488
|
+
s.counts[gi]++;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
else if (d.metric === "errorRate") {
|
|
492
|
+
s.counts[gi]++;
|
|
493
|
+
if (Number(ev.status_code) >= 400)
|
|
494
|
+
s.buckets[gi]++;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (live) {
|
|
500
|
+
this.liveMatches++;
|
|
501
|
+
if (this.spec.alert) {
|
|
502
|
+
const at = Number(this.spec.alert.at) || 0;
|
|
503
|
+
if (at > 0) {
|
|
504
|
+
if (this.liveMatches === at)
|
|
505
|
+
this.emit("alert", { spec: this.spec.alert, ev, note: "reached " + at });
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
this.emit("alert", { spec: this.spec.alert, ev });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
this.bumpFeed();
|
|
513
|
+
}
|
|
514
|
+
// rAF-batched feed bump: coalesce N store mutations/sec into one "feed" emit per frame.
|
|
515
|
+
bumpFeed() {
|
|
516
|
+
if (this._rafPending)
|
|
517
|
+
return;
|
|
518
|
+
this._rafPending = true;
|
|
519
|
+
requestAnimationFrame(() => {
|
|
520
|
+
this._rafPending = false;
|
|
521
|
+
this.feedVersion++;
|
|
522
|
+
this.emit("feed", this.feedVersion);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
// Per-second tick: slide the rate window, shift the sparkline + graph buckets.
|
|
526
|
+
tick() {
|
|
527
|
+
const now = Date.now();
|
|
528
|
+
const w = (this.spec.rateWindowSec || 10) * 1000;
|
|
529
|
+
while (this.times.length && this.times[0] < now - w)
|
|
530
|
+
this.times.shift();
|
|
531
|
+
this.perSec.push(0);
|
|
532
|
+
if (this.perSec.length > PERSEC_N)
|
|
533
|
+
this.perSec.shift();
|
|
534
|
+
if (this.graph) {
|
|
535
|
+
for (const s of this.graph.series) {
|
|
536
|
+
s.buckets.push(0);
|
|
537
|
+
s.buckets.shift();
|
|
538
|
+
s.counts.push(0);
|
|
539
|
+
s.counts.shift();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
this.emit("tick", now);
|
|
543
|
+
}
|
|
544
|
+
get rate() {
|
|
545
|
+
const now = Date.now();
|
|
546
|
+
const w = (this.spec.rateWindowSec || 10) * 1000;
|
|
547
|
+
return this.times.filter((t) => t >= now - w).length;
|
|
548
|
+
}
|
|
549
|
+
// Events the current tab's feed should show, chronological (oldest->newest). The feed owns
|
|
550
|
+
// inversion/sort; chronological keeps a locked scroll stable (appends never shift an index).
|
|
551
|
+
viewFrames() {
|
|
552
|
+
const src = this.spec.source;
|
|
553
|
+
const out = [];
|
|
554
|
+
for (const c of this.cache) {
|
|
555
|
+
if (src !== "all" && c.type !== src)
|
|
556
|
+
continue;
|
|
557
|
+
const ev = c.ev;
|
|
558
|
+
ev._type = c.type;
|
|
559
|
+
if (!this.matches(ev))
|
|
560
|
+
continue;
|
|
561
|
+
out.push(ev);
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
// The honest "matched" count: events CURRENTLY retained in the ring (= what the feed holds),
|
|
566
|
+
// not the cumulative-since-open total (which grows past what the buffer holds — misleading).
|
|
567
|
+
get matchedRetained() {
|
|
568
|
+
return this.viewFrames().length;
|
|
569
|
+
}
|
|
570
|
+
matches(ev) {
|
|
571
|
+
const m = this.spec.match;
|
|
572
|
+
if (!m)
|
|
573
|
+
return true;
|
|
574
|
+
return Object.entries(m).every(([k, v]) => {
|
|
575
|
+
// package only exists on log rows; in the "all" timeline a package filter must not drop
|
|
576
|
+
// network/rtm/clog (they have no package, already come from the app).
|
|
577
|
+
if (k === "package" && ev._type && ev._type !== "log")
|
|
578
|
+
return true;
|
|
579
|
+
return String(ev[k] ?? "").toLowerCase().includes(String(v).toLowerCase());
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
breaches(ev) {
|
|
583
|
+
const h = this.spec.highlight;
|
|
584
|
+
if (!h)
|
|
585
|
+
return false;
|
|
586
|
+
const v = Number(ev[h.field]);
|
|
587
|
+
if (isNaN(v))
|
|
588
|
+
return false;
|
|
589
|
+
const op = h.op || ">";
|
|
590
|
+
return op === ">" ? v > h.value : op === ">=" ? v >= h.value : op === "<" ? v < h.value : op === "<=" ? v <= h.value : v === h.value;
|
|
591
|
+
}
|
|
592
|
+
// ---- health + profile ----
|
|
593
|
+
async pollHealth() {
|
|
594
|
+
if (Date.now() - this._lastEventTs < 6000) {
|
|
595
|
+
this._healthMisses = 0;
|
|
596
|
+
this.setConn("connected", "");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
const h = await (await fetch("/health")).json();
|
|
601
|
+
if (h.device) {
|
|
602
|
+
this._healthMisses = 0;
|
|
603
|
+
this.setConn("connected", "");
|
|
604
|
+
}
|
|
605
|
+
else if (++this._healthMisses >= 2) {
|
|
606
|
+
this.setConn("waiting", "device offline, retrying…");
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
if (++this._healthMisses >= 2)
|
|
611
|
+
this.setConn("waiting", "reconnecting…");
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async pollProfile() {
|
|
615
|
+
try {
|
|
616
|
+
const [profilesR, healthR] = await Promise.all([
|
|
617
|
+
fetch("/_control/profiles").then((r) => r.json()).catch(() => null),
|
|
618
|
+
fetch("/health").then((r) => r.json()).catch(() => null),
|
|
619
|
+
]);
|
|
620
|
+
const uid = healthR && healthR.boundUserId;
|
|
621
|
+
this.profiles = (profilesR && profilesR.profiles) || [];
|
|
622
|
+
if (uid == null) {
|
|
623
|
+
this.profileLabel = this._lastBoundLabel ? "last seen " + this._lastBoundLabel + " — reconnecting…" : "waiting for device…";
|
|
624
|
+
this.emit("profile", this.profileLabel);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const match = this.profiles.find((p) => p.userId === uid);
|
|
628
|
+
const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
|
629
|
+
const label = match ? match.label : healthR.boundProfile ? cap(healthR.boundProfile) + " (user " + uid + ")" : "user " + uid;
|
|
630
|
+
this._lastBoundLabel = label;
|
|
631
|
+
this.profileLabel = label;
|
|
632
|
+
this.emit("profile", this.profileLabel);
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
/* leave the last label */
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
async reconnect() {
|
|
639
|
+
try {
|
|
640
|
+
await fetch("/enable", { method: "POST" });
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
/* ignore */
|
|
644
|
+
}
|
|
645
|
+
this.pollHealth();
|
|
646
|
+
this.pollProfile();
|
|
647
|
+
this.pollSpec();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// ---- aggregate math (moved here from widgets per the plan: it belongs WITH the aggregate
|
|
651
|
+
// engine, not the renderer). seriesValues turns a graph series' raw buckets into plotted
|
|
652
|
+
// values; unitFor/fmtNum format them. The widgets import these. ----
|
|
653
|
+
export function seriesValues(s) {
|
|
654
|
+
if (s.def.metric === "count") {
|
|
655
|
+
let run = 0;
|
|
656
|
+
return s.buckets.map((b) => (run += b));
|
|
657
|
+
}
|
|
658
|
+
if (s.def.metric === "avg")
|
|
659
|
+
return s.buckets.map((b, i) => (s.counts[i] ? b / s.counts[i] : null));
|
|
660
|
+
if (s.def.metric === "errorRate")
|
|
661
|
+
return s.buckets.map((b, i) => (s.counts[i] ? b / s.counts[i] : null));
|
|
662
|
+
return s.buckets.slice();
|
|
663
|
+
}
|
|
664
|
+
export function unitFor(def) {
|
|
665
|
+
if (def.unit)
|
|
666
|
+
return def.unit;
|
|
667
|
+
if (def.metric === "ratePerSec")
|
|
668
|
+
return "/s";
|
|
669
|
+
if (def.metric === "avg")
|
|
670
|
+
return def.field && /ms|duration/i.test(def.field) ? "ms" : "";
|
|
671
|
+
if (def.metric === "errorRate")
|
|
672
|
+
return "%";
|
|
673
|
+
return "";
|
|
674
|
+
}
|
|
675
|
+
export function fmtNum(v) {
|
|
676
|
+
if (v == null)
|
|
677
|
+
return "—";
|
|
678
|
+
const a = Math.abs(v);
|
|
679
|
+
return a >= 100 ? Math.round(v) : a >= 10 ? Math.round(v * 10) / 10 : Math.round(v * 100) / 100;
|
|
680
|
+
}
|
|
681
|
+
// Human-readable one-line summary of a spec's filters/constraints (for the sub-header).
|
|
682
|
+
export function describeConstraints(spec) {
|
|
683
|
+
const parts = ["source " + spec.source];
|
|
684
|
+
if (spec.match && Object.keys(spec.match).length)
|
|
685
|
+
parts.push("where " + Object.entries(spec.match).map(([k, v]) => k + "=" + v).join(", "));
|
|
686
|
+
if (spec.bodyContains)
|
|
687
|
+
parts.push('body contains "' + spec.bodyContains + '"');
|
|
688
|
+
if (spec.highlight && spec.highlight.field)
|
|
689
|
+
parts.push("flag " + spec.highlight.field + " " + (spec.highlight.op || ">") + " " + spec.highlight.value + (spec.highlight.label ? " (" + spec.highlight.label + ")" : ""));
|
|
690
|
+
if (Array.isArray(spec.series) && spec.series.length)
|
|
691
|
+
parts.push("graph " + spec.series.map((s) => s.label || s.metric + (s.field ? " of " + s.field : "")).join(" vs "));
|
|
692
|
+
if (spec.extract && spec.extract.field)
|
|
693
|
+
parts.push("extract " + spec.extract.field);
|
|
694
|
+
if (spec.alert)
|
|
695
|
+
parts.push("alert" + (spec.alert.at ? " at " + spec.alert.at : "") + (spec.alert.say ? ' "' + spec.alert.say + '"' : ""));
|
|
696
|
+
if (spec.rateWindowSec)
|
|
697
|
+
parts.push("rate window " + spec.rateWindowSec + "s");
|
|
698
|
+
return parts.join(" · ");
|
|
699
|
+
}
|
|
700
|
+
export function shortUrl(u) {
|
|
701
|
+
try {
|
|
702
|
+
return new URL(String(u)).pathname.replace("/api/", "");
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
return u || "";
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
export function shortPkg(p) {
|
|
709
|
+
if (!p)
|
|
710
|
+
return "";
|
|
711
|
+
const parts = String(p).split(".");
|
|
712
|
+
return parts.length <= 2 ? p : parts.slice(-2).join(".");
|
|
713
|
+
}
|
|
714
|
+
export const store = new Store();
|