@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,608 @@
|
|
|
1
|
+
// The shared virtualized feed engine. ONE component renders Live / Network / RTM / Clogs /
|
|
2
|
+
// Logs -- they are all the same stream feed with a per-source row renderer. Ported verbatim
|
|
3
|
+
// from the proven vanilla feed: virtualization + Logcat-style range select-copy + scroll-lock
|
|
4
|
+
// + incremental follow. This is both the DATA side (read store.viewFrames + incremental tail)
|
|
5
|
+
// and the RENDER side (windowed rows + keyboard model).
|
|
6
|
+
import { html } from "./html.js";
|
|
7
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from "preact/hooks";
|
|
8
|
+
import { store, shortUrl, shortPkg, MODES, fmtNum } from "./store.js";
|
|
9
|
+
const ROW_H = 24;
|
|
10
|
+
const OVERSCAN = 10;
|
|
11
|
+
// Feed UI prefs PERSIST across tab switches (the Feed unmounts/remounts per source). The text
|
|
12
|
+
// filter is global; sort is per-source (a Network "slowest" is meaningless on RTM).
|
|
13
|
+
const FEED_PREFS = { filter: "", sortBySource: {} };
|
|
14
|
+
// Platform-aware jump-pill labels. macOS has no Home/End, so show the chord that works there.
|
|
15
|
+
const IS_MAC = typeof navigator !== "undefined" && /Mac|iP(hone|ad|od)/.test(navigator.platform || navigator.userAgent || "");
|
|
16
|
+
const KEY_NEWEST = IS_MAC ? "⌘↑" : "Ctrl↑";
|
|
17
|
+
const KEY_OLDEST = IS_MAC ? "⌘↓" : "Ctrl↓";
|
|
18
|
+
function sortOptionsFor(source) {
|
|
19
|
+
const opts = [
|
|
20
|
+
{ key: "newest", label: "newest first", get: (e) => e.timestamp || 0, dir: "desc" },
|
|
21
|
+
{ key: "oldest", label: "oldest first", get: (e) => e.timestamp || 0, dir: "asc" },
|
|
22
|
+
];
|
|
23
|
+
if (source === "all")
|
|
24
|
+
opts.push({ key: "type", label: "group by type", get: (e) => ({ network: 0, rtm: 1, clog: 2, log: 3 }[e._type || ""] ?? 9), dir: "asc" });
|
|
25
|
+
if (source === "network") {
|
|
26
|
+
opts.push({ key: "slow", label: "slowest (response time)", get: (e) => Number(e.duration_ms), dir: "desc" });
|
|
27
|
+
opts.push({ key: "status", label: "status code (high→low)", get: (e) => Number(e.status_code), dir: "desc" });
|
|
28
|
+
}
|
|
29
|
+
if (source === "log")
|
|
30
|
+
opts.push({ key: "severity", label: "severity (errors first)", get: (e) => ({ FATAL: 5, ERROR: 4, WARN: 3, INFO: 2, DEBUG: 1, VERBOSE: 0 }[(e.level || "").toUpperCase()] ?? -1), dir: "desc" });
|
|
31
|
+
if (source === "rtm")
|
|
32
|
+
opts.push({ key: "evtype", label: "event type (A→Z)", get: (e) => e.event_type || "", dir: "asc" });
|
|
33
|
+
if (source === "clog")
|
|
34
|
+
opts.push({ key: "name", label: "clog name (A→Z)", get: (e) => e.event_name || "", dir: "asc" });
|
|
35
|
+
return opts;
|
|
36
|
+
}
|
|
37
|
+
// The spec of the tab `dir` steps away from the active one (clamped). Used by the filter box's
|
|
38
|
+
// empty-arrow tab-switch (the global handler ignores keys typed in an input).
|
|
39
|
+
function neighborTabSpec(dir) {
|
|
40
|
+
const keys = MODES.map((m) => m.key);
|
|
41
|
+
if (store.lastCustomSpec)
|
|
42
|
+
keys.push("__custom__");
|
|
43
|
+
let cur = keys.indexOf(store.activeModeKey() || "");
|
|
44
|
+
if (cur === -1)
|
|
45
|
+
cur = 0;
|
|
46
|
+
const next = Math.max(0, Math.min(keys.length - 1, cur + dir));
|
|
47
|
+
const k = keys[next];
|
|
48
|
+
return k === "__custom__" ? store.lastCustomSpec || undefined : MODES.find((m) => m.key === k)?.spec;
|
|
49
|
+
}
|
|
50
|
+
// One searchable string per frame for the text filter (mirrors row.textContent).
|
|
51
|
+
function searchText(ev) {
|
|
52
|
+
if ((ev._type || "") === "log")
|
|
53
|
+
return [ev.level, ev.package, ev.tag, ev.message].filter(Boolean).join(" ").toLowerCase();
|
|
54
|
+
return [ev.direction, ev.method, ev.event_type, ev.event_name, shortUrl(ev.url), ev.channel].filter(Boolean).join(" ").toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
// One raw copy line per frame (Logcat-style), pulled from the ring on copy.
|
|
57
|
+
function rawLine(ev) {
|
|
58
|
+
const t = new Date(ev.timestamp || Date.now()).toLocaleTimeString();
|
|
59
|
+
if ((ev._type || "") === "log")
|
|
60
|
+
return `${t} ${(ev.level || "").toUpperCase()}/${ev.tag || ""}: ${ev.message || ""}`;
|
|
61
|
+
if (ev._type === "network")
|
|
62
|
+
return `${t} ${ev.method || "GET"} ${shortUrl(ev.url)} ${ev.status_code ?? ""} ${ev.duration_ms ?? ""}ms`;
|
|
63
|
+
return `${t} ${ev._type} ${ev.event_type || ev.event_name || ""} ${ev.channel || ""}`.trimEnd();
|
|
64
|
+
}
|
|
65
|
+
function statusBand(c) {
|
|
66
|
+
if (!c)
|
|
67
|
+
return "";
|
|
68
|
+
if (c >= 500)
|
|
69
|
+
return "st5";
|
|
70
|
+
if (c >= 400)
|
|
71
|
+
return "st4";
|
|
72
|
+
if (c >= 300)
|
|
73
|
+
return "st3";
|
|
74
|
+
return "st2";
|
|
75
|
+
}
|
|
76
|
+
export function isFailedStatus(ev) {
|
|
77
|
+
return Number(ev.status_code) >= 400;
|
|
78
|
+
}
|
|
79
|
+
function breachBadge(ev, h) {
|
|
80
|
+
const v = Number(ev[h.field]);
|
|
81
|
+
const unit = /ms|duration/i.test(h.field) ? "ms" : "";
|
|
82
|
+
return `${fmtNum(v)}${unit} ${h.op || ">"} ${h.value}${unit}`;
|
|
83
|
+
}
|
|
84
|
+
// Per-source row body (inner spans). The outer .row wrapper (position/selection/click) is
|
|
85
|
+
// added by the engine so virtualization stays uniform across sources.
|
|
86
|
+
function RowBody({ ev, source, highlight }) {
|
|
87
|
+
const t = new Date(ev.timestamp || Date.now()).toLocaleTimeString();
|
|
88
|
+
const typeChip = source === "all" && ev._type ? html `<span class=${"tchip t-" + ev._type}>${ev._type}</span>` : null;
|
|
89
|
+
if ((ev._type || source) === "log") {
|
|
90
|
+
const lv = (ev.level || "").toUpperCase();
|
|
91
|
+
return html `<span class="t">${t}</span>${typeChip}<span class=${"lvl lvl-" + lv}>${lv || "LOG"}</span>${ev.package ? html `<span class="logpkg" title=${ev.package}>${shortPkg(ev.package)}</span>` : null}<span class="logtag">${ev.tag || ""}</span><span class="logmsg">${ev.message || ""}</span>`;
|
|
92
|
+
}
|
|
93
|
+
const breach = highlight && store.breaches(ev);
|
|
94
|
+
const isNet = (ev._type || source) === "network" || ev.url != null;
|
|
95
|
+
const code = Number(ev.status_code);
|
|
96
|
+
const statusChip = isNet && ev.status_code != null ? html `<span class=${"stchip " + statusBand(code)}>${ev.status_code}</span>` : null;
|
|
97
|
+
return html `<span class="t">${t}</span>${typeChip}<span class=${"dir-" + (ev.direction || "")}>${ev.direction || ev.method || ""}</span>${statusChip}<span>${ev.event_type || ev.event_name || shortUrl(ev.url)}</span>${ev.channel ? html `<span class="chan">${ev.channel}</span>` : null}${breach && highlight ? html `<span class="breachbadge">${breachBadge(ev, highlight)}</span>` : null}`;
|
|
98
|
+
}
|
|
99
|
+
export function Feed({ spec, onInspect, selectedId }) {
|
|
100
|
+
const source = spec.source;
|
|
101
|
+
const vpRef = useRef(null);
|
|
102
|
+
const filterRef = useRef(null);
|
|
103
|
+
const [filter, setFilterState] = useState(() => FEED_PREFS.filter);
|
|
104
|
+
const [sort, setSortState] = useState(() => FEED_PREFS.sortBySource[source || ""] || "newest");
|
|
105
|
+
const [paused, setPaused] = useState(false);
|
|
106
|
+
const setFilter = useCallback((v) => {
|
|
107
|
+
FEED_PREFS.filter = v;
|
|
108
|
+
setFilterState(v);
|
|
109
|
+
}, []);
|
|
110
|
+
const setSort = useCallback((v) => {
|
|
111
|
+
FEED_PREFS.sortBySource[source || ""] = v;
|
|
112
|
+
setSortState(v);
|
|
113
|
+
}, [source]);
|
|
114
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
115
|
+
const [vpH, setVpH] = useState(600);
|
|
116
|
+
const followRef = useRef(true);
|
|
117
|
+
const [following, setFollowing] = useState(true);
|
|
118
|
+
const [newCount, setNewCount] = useState(0);
|
|
119
|
+
const anchorRef = useRef(null);
|
|
120
|
+
const highlightRef = useRef(null);
|
|
121
|
+
const [sel, setSel] = useState(() => new Set());
|
|
122
|
+
const snapRef = useRef([]);
|
|
123
|
+
const [, bumpSnap] = useState(0);
|
|
124
|
+
const sortOpts = useMemo(() => sortOptionsFor(source), [source]);
|
|
125
|
+
const isLogish = source === "log" || source === "all";
|
|
126
|
+
// On source change: keep the persisted filter, re-derive the per-source sort, reset transient
|
|
127
|
+
// view state (selection, pause, follow). Filter + sort persist; selection does not.
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
setFilterState(FEED_PREFS.filter);
|
|
130
|
+
const persistedSort = FEED_PREFS.sortBySource[source || ""] || "newest";
|
|
131
|
+
setSortState(sortOptionsFor(source).some((o) => o.key === persistedSort) ? persistedSort : "newest");
|
|
132
|
+
setPaused(false);
|
|
133
|
+
setSel(new Set());
|
|
134
|
+
anchorRef.current = null;
|
|
135
|
+
followRef.current = true;
|
|
136
|
+
setFollowing(true);
|
|
137
|
+
setNewCount(0);
|
|
138
|
+
}, [source]);
|
|
139
|
+
// Build the display order from a chronological list of matched frames (text filter + sort +
|
|
140
|
+
// inversion). The full-buffer pass; used on control changes.
|
|
141
|
+
const orderFrames = useCallback((chrono) => {
|
|
142
|
+
const frames = filter ? chrono.filter((ev) => searchText(ev).includes(filter)) : chrono;
|
|
143
|
+
if (sort === "newest")
|
|
144
|
+
return frames.slice().reverse();
|
|
145
|
+
if (sort === "oldest")
|
|
146
|
+
return frames.slice();
|
|
147
|
+
const opt = sortOpts.find((o) => o.key === sort) || sortOpts[0];
|
|
148
|
+
return frames.slice().sort((a, b) => {
|
|
149
|
+
const va = opt.get(a), vb = opt.get(b);
|
|
150
|
+
if (va < vb)
|
|
151
|
+
return opt.dir === "asc" ? -1 : 1;
|
|
152
|
+
if (va > vb)
|
|
153
|
+
return opt.dir === "asc" ? 1 : -1;
|
|
154
|
+
return 0;
|
|
155
|
+
});
|
|
156
|
+
}, [filter, sort, sortOpts]);
|
|
157
|
+
const recompute = useCallback(() => orderFrames(store.viewFrames()), [orderFrames]);
|
|
158
|
+
const forceSnap = useCallback(() => {
|
|
159
|
+
snapRef.current = recompute();
|
|
160
|
+
bumpSnap((n) => n + 1);
|
|
161
|
+
}, [recompute]);
|
|
162
|
+
// THE FREEZE + incremental follow. While frozen the displayed snapshot is untouched (smooth
|
|
163
|
+
// scroll, new events only bump newCount). While following with "newest" sort we prepend only
|
|
164
|
+
// the new tail (O(new), not a full O(N) rebuild over 100k) -- the 100k-buffer enabler. Full
|
|
165
|
+
// recompute() runs only on control change / resume; a non-newest sort rebuilds while tailing.
|
|
166
|
+
const seenLenRef = useRef(0);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
snapRef.current = recompute();
|
|
169
|
+
bumpSnap((n) => n + 1);
|
|
170
|
+
seenLenRef.current = store.cache.length;
|
|
171
|
+
const off = store.on("feed", () => {
|
|
172
|
+
const cacheLen = store.cache.length;
|
|
173
|
+
if (!followRef.current) {
|
|
174
|
+
setNewCount((c) => c + 1);
|
|
175
|
+
seenLenRef.current = cacheLen; // keep the cursor current so resume is incremental
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const src = store.spec.source;
|
|
179
|
+
if (sort === "newest") {
|
|
180
|
+
const startLen = Math.min(seenLenRef.current, cacheLen); // guard against eviction shrink
|
|
181
|
+
const fresh = [];
|
|
182
|
+
for (let i = startLen; i < cacheLen; i++) {
|
|
183
|
+
const c = store.cache[i];
|
|
184
|
+
if (!c)
|
|
185
|
+
continue;
|
|
186
|
+
if (src !== "all" && c.type !== src)
|
|
187
|
+
continue;
|
|
188
|
+
const ev = c.ev;
|
|
189
|
+
ev._type = c.type;
|
|
190
|
+
if (!store.matches(ev))
|
|
191
|
+
continue;
|
|
192
|
+
if (filter && !searchText(ev).includes(filter))
|
|
193
|
+
continue;
|
|
194
|
+
fresh.push(ev);
|
|
195
|
+
}
|
|
196
|
+
if (fresh.length) {
|
|
197
|
+
fresh.reverse();
|
|
198
|
+
snapRef.current = fresh.concat(snapRef.current);
|
|
199
|
+
bumpSnap((n) => n + 1);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
snapRef.current = recompute();
|
|
204
|
+
bumpSnap((n) => n + 1);
|
|
205
|
+
}
|
|
206
|
+
seenLenRef.current = cacheLen;
|
|
207
|
+
const el = vpRef.current;
|
|
208
|
+
if (el)
|
|
209
|
+
el.scrollTop = 0; // glue to newest (top)
|
|
210
|
+
});
|
|
211
|
+
return off;
|
|
212
|
+
}, [recompute, filter, sort, sortOpts]);
|
|
213
|
+
// Rebuild when the SERVER-SIDE filter (match/bodyContains) changes -- that changes what
|
|
214
|
+
// store.matches() admits, so the whole snapshot must refresh. Keyed so it only fires on a
|
|
215
|
+
// real filter change, not every spec poll.
|
|
216
|
+
const matchKey = JSON.stringify(spec.match || {}) + "|" + (spec.bodyContains || "");
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
forceSnap();
|
|
219
|
+
}, [matchKey]);
|
|
220
|
+
// measure the flex-filled viewport height (changes on tab switch / chips growth without a
|
|
221
|
+
// window resize) via ResizeObserver; window resize is the fallback.
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
const el = vpRef.current;
|
|
224
|
+
if (!el)
|
|
225
|
+
return;
|
|
226
|
+
const measure = () => setVpH(el.clientHeight);
|
|
227
|
+
measure();
|
|
228
|
+
let ro;
|
|
229
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
230
|
+
ro = new ResizeObserver(measure);
|
|
231
|
+
ro.observe(el);
|
|
232
|
+
}
|
|
233
|
+
window.addEventListener("resize", measure);
|
|
234
|
+
return () => {
|
|
235
|
+
if (ro)
|
|
236
|
+
ro.disconnect();
|
|
237
|
+
window.removeEventListener("resize", measure);
|
|
238
|
+
};
|
|
239
|
+
}, []);
|
|
240
|
+
const frames = snapRef.current;
|
|
241
|
+
const total = frames.length;
|
|
242
|
+
const totalH = total * ROW_H;
|
|
243
|
+
const first = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN);
|
|
244
|
+
const visCount = Math.ceil(vpH / ROW_H) + OVERSCAN * 2;
|
|
245
|
+
const last = Math.min(total, first + visCount);
|
|
246
|
+
// lock model: at the very top = following (live); scrolled away = frozen. Position drives
|
|
247
|
+
// follow purely so it never fights the user. Pause is a manual override.
|
|
248
|
+
const onScroll = useCallback((e) => {
|
|
249
|
+
const el = e.currentTarget;
|
|
250
|
+
setScrollTop(el.scrollTop);
|
|
251
|
+
if (paused)
|
|
252
|
+
return;
|
|
253
|
+
const atTop = el.scrollTop < ROW_H * 0.75;
|
|
254
|
+
if (atTop && !followRef.current) {
|
|
255
|
+
followRef.current = true;
|
|
256
|
+
setFollowing(true);
|
|
257
|
+
setNewCount(0);
|
|
258
|
+
}
|
|
259
|
+
else if (!atTop && followRef.current) {
|
|
260
|
+
followRef.current = false;
|
|
261
|
+
setFollowing(false);
|
|
262
|
+
}
|
|
263
|
+
}, [paused]);
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (paused) {
|
|
266
|
+
followRef.current = false;
|
|
267
|
+
setFollowing(false);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
const el = vpRef.current;
|
|
271
|
+
const atTop = !el || el.scrollTop < ROW_H * 0.75;
|
|
272
|
+
followRef.current = atTop;
|
|
273
|
+
setFollowing(atTop);
|
|
274
|
+
if (atTop) {
|
|
275
|
+
setNewCount(0);
|
|
276
|
+
forceSnap();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}, [paused, forceSnap]);
|
|
280
|
+
// jumpNewest = "go back to live": clear selection, close inspector, resume tail (UNLOCKED).
|
|
281
|
+
// A selection locks the feed, so jumping to live must drop it.
|
|
282
|
+
const jumpNewest = useCallback(() => {
|
|
283
|
+
setSel(new Set());
|
|
284
|
+
anchorRef.current = null;
|
|
285
|
+
highlightRef.current = null;
|
|
286
|
+
onInspect && onInspect(null);
|
|
287
|
+
const el = vpRef.current;
|
|
288
|
+
if (el)
|
|
289
|
+
el.scrollTop = 0;
|
|
290
|
+
followRef.current = !paused;
|
|
291
|
+
setFollowing(!paused);
|
|
292
|
+
setNewCount(0);
|
|
293
|
+
forceSnap();
|
|
294
|
+
}, [paused, forceSnap, onInspect]);
|
|
295
|
+
// jumpOldest = park at the bottom with NO selection (inspector closed). Stays locked.
|
|
296
|
+
const jumpOldest = useCallback(() => {
|
|
297
|
+
setSel(new Set());
|
|
298
|
+
anchorRef.current = null;
|
|
299
|
+
highlightRef.current = null;
|
|
300
|
+
onInspect && onInspect(null);
|
|
301
|
+
followRef.current = false;
|
|
302
|
+
setFollowing(false);
|
|
303
|
+
const el = vpRef.current;
|
|
304
|
+
if (el)
|
|
305
|
+
el.scrollTop = el.scrollHeight;
|
|
306
|
+
}, [onInspect]);
|
|
307
|
+
// selection (by id) -- Logcat-style. Plain click selects the one row AND opens the inspector.
|
|
308
|
+
// Shift-click = range, Cmd/Ctrl-click = toggle (copy mode, do NOT open the inspector).
|
|
309
|
+
const selectRow = useCallback((displayIdx, ev) => {
|
|
310
|
+
const s = window.getSelection && window.getSelection();
|
|
311
|
+
if (s && s.removeAllRanges)
|
|
312
|
+
s.removeAllRanges();
|
|
313
|
+
const frame = frames[displayIdx];
|
|
314
|
+
const id = frame?.id;
|
|
315
|
+
if (id == null)
|
|
316
|
+
return;
|
|
317
|
+
if (ev.shiftKey && anchorRef.current != null) {
|
|
318
|
+
const aIdx = frames.findIndex((r) => r.id === anchorRef.current);
|
|
319
|
+
if (aIdx !== -1) {
|
|
320
|
+
const range = new Set();
|
|
321
|
+
const [lo, hi] = aIdx < displayIdx ? [aIdx, displayIdx] : [displayIdx, aIdx];
|
|
322
|
+
for (let i = lo; i <= hi; i++)
|
|
323
|
+
if (frames[i])
|
|
324
|
+
range.add(frames[i].id);
|
|
325
|
+
setSel(range);
|
|
326
|
+
onInspect && onInspect(null);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (ev.metaKey || ev.ctrlKey) {
|
|
331
|
+
setSel((prev) => {
|
|
332
|
+
const next = new Set(prev);
|
|
333
|
+
if (next.has(id))
|
|
334
|
+
next.delete(id);
|
|
335
|
+
else
|
|
336
|
+
next.add(id);
|
|
337
|
+
return next;
|
|
338
|
+
});
|
|
339
|
+
anchorRef.current = id;
|
|
340
|
+
onInspect && onInspect(null);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
anchorRef.current = id;
|
|
344
|
+
highlightRef.current = id;
|
|
345
|
+
if (selectedId === id) {
|
|
346
|
+
setSel(new Set());
|
|
347
|
+
onInspect && onInspect(null);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
setSel(new Set([id]));
|
|
351
|
+
onInspect && onInspect(frame);
|
|
352
|
+
}
|
|
353
|
+
}, [frames, onInspect, selectedId]);
|
|
354
|
+
const clearSel = useCallback(() => {
|
|
355
|
+
setSel(new Set());
|
|
356
|
+
anchorRef.current = null;
|
|
357
|
+
highlightRef.current = null;
|
|
358
|
+
onInspect && onInspect(null);
|
|
359
|
+
}, [onInspect]);
|
|
360
|
+
const copySel = useCallback(() => {
|
|
361
|
+
if (!sel.size)
|
|
362
|
+
return;
|
|
363
|
+
// RAW text from the store ring (buffer order), not the DOM -- off-screen rows included.
|
|
364
|
+
const lines = store.viewFrames().filter((r) => r.id != null && sel.has(r.id)).map(rawLine);
|
|
365
|
+
navigator.clipboard?.writeText(lines.join("\n"));
|
|
366
|
+
}, [sel]);
|
|
367
|
+
const scrollIntoView = useCallback((idx) => {
|
|
368
|
+
const el = vpRef.current;
|
|
369
|
+
if (!el)
|
|
370
|
+
return;
|
|
371
|
+
const top = idx * ROW_H;
|
|
372
|
+
const bottom = top + ROW_H;
|
|
373
|
+
if (top < el.scrollTop)
|
|
374
|
+
el.scrollTop = Math.max(0, top - ROW_H);
|
|
375
|
+
else if (bottom > el.scrollTop + el.clientHeight)
|
|
376
|
+
el.scrollTop = bottom - el.clientHeight + ROW_H;
|
|
377
|
+
}, []);
|
|
378
|
+
const framesRef = useRef(frames);
|
|
379
|
+
framesRef.current = frames;
|
|
380
|
+
const selectedIdRef = useRef(selectedId);
|
|
381
|
+
selectedIdRef.current = selectedId;
|
|
382
|
+
// Move the HIGHLIGHT by delta (or to "first"/"last"). Only moves the green row + freezes
|
|
383
|
+
// follow + scrolls into view; does NOT open the inspector (Enter does). FOLLOW MODE: if the
|
|
384
|
+
// inspector is already open, j/k brings it along; if closed, j/k just browses.
|
|
385
|
+
const moveSel = useCallback((delta) => {
|
|
386
|
+
const fr = framesRef.current;
|
|
387
|
+
if (!fr.length)
|
|
388
|
+
return;
|
|
389
|
+
followRef.current = false;
|
|
390
|
+
setFollowing(false);
|
|
391
|
+
let idx;
|
|
392
|
+
if (delta === "first")
|
|
393
|
+
idx = 0;
|
|
394
|
+
else if (delta === "last")
|
|
395
|
+
idx = fr.length - 1;
|
|
396
|
+
else {
|
|
397
|
+
const cur = fr.findIndex((f) => f.id === highlightRef.current);
|
|
398
|
+
if (cur === -1) {
|
|
399
|
+
const el = vpRef.current;
|
|
400
|
+
const topVisible = el ? Math.floor(el.scrollTop / ROW_H) : 0;
|
|
401
|
+
idx = Math.max(0, Math.min(fr.length - 1, topVisible));
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
idx = Math.max(0, Math.min(fr.length - 1, cur + delta));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const frame = fr[idx];
|
|
408
|
+
if (!frame)
|
|
409
|
+
return;
|
|
410
|
+
highlightRef.current = frame.id;
|
|
411
|
+
setSel(new Set([frame.id]));
|
|
412
|
+
anchorRef.current = frame.id;
|
|
413
|
+
if (selectedIdRef.current != null)
|
|
414
|
+
onInspect && onInspect(frame);
|
|
415
|
+
scrollIntoView(idx);
|
|
416
|
+
}, [scrollIntoView, onInspect]);
|
|
417
|
+
const openHighlighted = useCallback(() => {
|
|
418
|
+
const fr = framesRef.current;
|
|
419
|
+
if (!fr.length)
|
|
420
|
+
return;
|
|
421
|
+
let frame = highlightRef.current != null ? fr.find((f) => f.id === highlightRef.current) : null;
|
|
422
|
+
if (!frame) {
|
|
423
|
+
const el = vpRef.current;
|
|
424
|
+
const idx = Math.max(0, Math.min(fr.length - 1, el ? Math.floor(el.scrollTop / ROW_H) : 0));
|
|
425
|
+
frame = fr[idx];
|
|
426
|
+
if (frame) {
|
|
427
|
+
highlightRef.current = frame.id;
|
|
428
|
+
setSel(new Set([frame.id]));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (frame)
|
|
432
|
+
onInspect && onInspect(frame);
|
|
433
|
+
}, [onInspect]);
|
|
434
|
+
const pageScroll = useCallback((dir) => {
|
|
435
|
+
const el = vpRef.current;
|
|
436
|
+
if (!el)
|
|
437
|
+
return;
|
|
438
|
+
followRef.current = false;
|
|
439
|
+
setFollowing(false);
|
|
440
|
+
el.scrollTop += dir * Math.max(ROW_H, el.clientHeight - ROW_H * 2);
|
|
441
|
+
}, []);
|
|
442
|
+
// Keyboard model. Ignored while typing in a control (still allows Cmd/Ctrl-C copy). Tab
|
|
443
|
+
// switching (←/→/h/l) is handled GLOBALLY in app.ts so it also works on DB/Screen.
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
const onKey = (e) => {
|
|
446
|
+
const t = e.target;
|
|
447
|
+
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) {
|
|
448
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "c" && sel.size) {
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
copySel();
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "c") {
|
|
455
|
+
if (sel.size) {
|
|
456
|
+
e.preventDefault();
|
|
457
|
+
copySel();
|
|
458
|
+
}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
// Cmd/Ctrl+↑/↓ = top/bottom of document. These JUMP (clear selection + close inspector).
|
|
462
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
|
463
|
+
if (e.key === "ArrowUp")
|
|
464
|
+
jumpNewest();
|
|
465
|
+
else
|
|
466
|
+
jumpOldest();
|
|
467
|
+
e.preventDefault();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (e.metaKey || e.ctrlKey || e.altKey)
|
|
471
|
+
return;
|
|
472
|
+
switch (e.key) {
|
|
473
|
+
case "ArrowDown":
|
|
474
|
+
case "j":
|
|
475
|
+
moveSel(1);
|
|
476
|
+
break;
|
|
477
|
+
case "ArrowUp":
|
|
478
|
+
case "k":
|
|
479
|
+
moveSel(-1);
|
|
480
|
+
break;
|
|
481
|
+
case "Home":
|
|
482
|
+
jumpNewest();
|
|
483
|
+
break;
|
|
484
|
+
case "End":
|
|
485
|
+
jumpOldest();
|
|
486
|
+
break;
|
|
487
|
+
case "g":
|
|
488
|
+
if (e.shiftKey)
|
|
489
|
+
jumpOldest();
|
|
490
|
+
else
|
|
491
|
+
jumpNewest();
|
|
492
|
+
break;
|
|
493
|
+
case "PageDown":
|
|
494
|
+
case " ":
|
|
495
|
+
pageScroll(1);
|
|
496
|
+
break;
|
|
497
|
+
case "PageUp":
|
|
498
|
+
pageScroll(-1);
|
|
499
|
+
break;
|
|
500
|
+
case "Enter":
|
|
501
|
+
openHighlighted();
|
|
502
|
+
break;
|
|
503
|
+
case "/":
|
|
504
|
+
if (filterRef.current)
|
|
505
|
+
filterRef.current.focus();
|
|
506
|
+
break;
|
|
507
|
+
case "Escape":
|
|
508
|
+
if (selectedId != null || sel.size)
|
|
509
|
+
jumpNewest();
|
|
510
|
+
else
|
|
511
|
+
return;
|
|
512
|
+
break;
|
|
513
|
+
default:
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
e.preventDefault();
|
|
517
|
+
};
|
|
518
|
+
window.addEventListener("keydown", onKey);
|
|
519
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
520
|
+
}, [sel, selectedId, copySel, moveSel, openHighlighted, clearSel, jumpNewest, jumpOldest, pageScroll, onInspect]);
|
|
521
|
+
// package dropdown options (log/all): learned live from the stream
|
|
522
|
+
const pkgOptions = useMemo(() => {
|
|
523
|
+
const observed = [...(store.seen.package || new Set())].sort();
|
|
524
|
+
const cur = (spec.match && spec.match.package) || "__all__";
|
|
525
|
+
if (cur !== "__all__" && !observed.includes(cur))
|
|
526
|
+
observed.unshift(cur);
|
|
527
|
+
return observed;
|
|
528
|
+
}, [spec, store.feedVersion, frames.length]);
|
|
529
|
+
const curPkg = (spec.match && spec.match.package) || "__all__";
|
|
530
|
+
const changePkg = useCallback((v) => {
|
|
531
|
+
const next = JSON.parse(JSON.stringify(spec));
|
|
532
|
+
next.match = next.match || {};
|
|
533
|
+
if (v === "__all__")
|
|
534
|
+
delete next.match.package;
|
|
535
|
+
else
|
|
536
|
+
next.match.package = v;
|
|
537
|
+
if (!Object.keys(next.match).length)
|
|
538
|
+
delete next.match;
|
|
539
|
+
store.setSpec(next, { local: true });
|
|
540
|
+
}, [spec]);
|
|
541
|
+
// build only the visible slice
|
|
542
|
+
const slice = [];
|
|
543
|
+
for (let i = first; i < last; i++) {
|
|
544
|
+
const r = frames[i];
|
|
545
|
+
if (!r)
|
|
546
|
+
continue;
|
|
547
|
+
const highlighted = r.id != null && sel.has(r.id);
|
|
548
|
+
const inspecting = r.id === selectedId;
|
|
549
|
+
const breach = spec.highlight && store.breaches(r);
|
|
550
|
+
const failed = isFailedStatus(r);
|
|
551
|
+
slice.push(html `
|
|
552
|
+
<div
|
|
553
|
+
class=${"row" + (highlighted ? " sel" : "") + (inspecting ? " inspecting" : "") + (breach ? " breach" : "") + (failed ? " failed" : "")}
|
|
554
|
+
style=${{ top: i * ROW_H + "px" }}
|
|
555
|
+
onClick=${(e) => selectRow(i, e)}
|
|
556
|
+
title="click for full detail · ↑/↓ move · Enter inspects · shift/⌘-click multi-select"
|
|
557
|
+
>
|
|
558
|
+
<${RowBody} ev=${r} source=${source} highlight=${spec.highlight} />
|
|
559
|
+
</div>
|
|
560
|
+
`);
|
|
561
|
+
}
|
|
562
|
+
return html `
|
|
563
|
+
<div class="feedwrap-next">
|
|
564
|
+
<div class="feedbar">
|
|
565
|
+
<span class="ffilterwrap">
|
|
566
|
+
<input class="ffilter" ref=${filterRef} placeholder="filter rows… ( / )" autocomplete="off" value=${filter}
|
|
567
|
+
onFocus=${() => { if (sel.size)
|
|
568
|
+
clearSel(); }}
|
|
569
|
+
onKeyDown=${(e) => {
|
|
570
|
+
const el = e.currentTarget;
|
|
571
|
+
if (e.key === "ArrowDown" || e.key === "Enter") {
|
|
572
|
+
e.preventDefault();
|
|
573
|
+
el.blur();
|
|
574
|
+
moveSel(1);
|
|
575
|
+
}
|
|
576
|
+
else if (e.key === "Escape") {
|
|
577
|
+
el.blur();
|
|
578
|
+
}
|
|
579
|
+
else if ((e.key === "ArrowLeft" || e.key === "ArrowRight") && el.value === "") {
|
|
580
|
+
e.preventDefault();
|
|
581
|
+
el.blur();
|
|
582
|
+
const ns = neighborTabSpec(e.key === "ArrowRight" ? 1 : -1);
|
|
583
|
+
if (ns)
|
|
584
|
+
store.setSpec(ns);
|
|
585
|
+
}
|
|
586
|
+
}}
|
|
587
|
+
onInput=${(e) => setFilter(e.currentTarget.value.trim().toLowerCase())} />
|
|
588
|
+
${filter ? html `<span class="ffilterclear" onClick=${() => setFilter("")}>✕</span>` : null}
|
|
589
|
+
</span>
|
|
590
|
+
${isLogish ? html `<label class="fsortlbl">app <select onChange=${(e) => changePkg(e.currentTarget.value)}><option value="__all__" selected=${curPkg === "__all__"}>all apps</option>${pkgOptions.map((p) => html `<option value=${p} selected=${p === curPkg}>${shortPkg(p)}</option>`)}</select></label>` : null}
|
|
591
|
+
<label class="fsortlbl">sort <select value=${sort} onChange=${(e) => setSort(e.currentTarget.value)}>${sortOpts.map((o) => html `<option value=${o.key}>${o.label}</option>`)}</select></label>
|
|
592
|
+
<button class=${"fbtn" + (paused ? " paused" : "")} title="pause / resume the live feed" onClick=${() => setPaused((p) => !p)}>${paused ? "▶ resume" : "⏸ pause"}</button>
|
|
593
|
+
<button class="fbtn" disabled=${!sel.size} onClick=${copySel}>Copy ${sel.size || ""}</button>
|
|
594
|
+
${sel.size ? html `<button class="fbtn" onClick=${clearSel}>Clear sel</button>` : null}
|
|
595
|
+
<span class=${"fstatus " + (paused ? "st-paused" : following ? "st-live" : "st-locked")}>${paused ? "⏸ paused" : following ? "● live" : "⏸ locked"}</span>
|
|
596
|
+
<span class="fcount">${total} rows${sel.size ? " · " + sel.size + " selected" : ""}</span>
|
|
597
|
+
</div>
|
|
598
|
+
<div class="vpbox">
|
|
599
|
+
<div class="vp" ref=${vpRef} onScroll=${onScroll}>
|
|
600
|
+
<div class="spacer" style=${{ height: totalH + "px" }}>${slice}</div>
|
|
601
|
+
</div>
|
|
602
|
+
${!following && (newCount > 0 || scrollTop > ROW_H) ? html `<div class="pill" onClick=${jumpNewest}>↑ newest${newCount ? ` (${newCount} new)` : ""} <span class="pillkey">${KEY_NEWEST}</span></div>` : null}
|
|
603
|
+
${total > Math.ceil(vpH / ROW_H) && scrollTop < totalH - vpH - ROW_H ? html `<div class="pill pill-bottom" onClick=${jumpOldest}>↓ oldest <span class="pillkey">${KEY_OLDEST}</span></div>` : null}
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
`;
|
|
607
|
+
}
|
|
608
|
+
export { ROW_H, sortOptionsFor, searchText, rawLine, statusBand };
|