@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,120 @@
|
|
|
1
|
+
// Filters: cancellable chips above the feed + a hand-editable <details> panel that renders the
|
|
2
|
+
// live spec's filters as form controls; Apply POSTs a new spec to /setspec (marked local so it
|
|
3
|
+
// does not spawn a Custom tab). Ported from vanilla renderChips/renderFilters/applyFilters.
|
|
4
|
+
import { html } from "./html.js";
|
|
5
|
+
import { useState, useEffect, useRef } from "preact/hooks";
|
|
6
|
+
import { store } from "./store.js";
|
|
7
|
+
import { MATCH_FIELDS, FIELD_VALUES, FILTER_SOURCES } from "./registry.js";
|
|
8
|
+
function Chips({ spec }) {
|
|
9
|
+
const m = spec.match && typeof spec.match === "object" ? spec.match : {};
|
|
10
|
+
const items = Object.entries(m).map(([k, v]) => ({ k, v, kind: "match" }));
|
|
11
|
+
if (spec.bodyContains)
|
|
12
|
+
items.push({ k: "body contains", v: spec.bodyContains, kind: "body" });
|
|
13
|
+
if (!items.length)
|
|
14
|
+
return null;
|
|
15
|
+
const clear = (it) => {
|
|
16
|
+
const next = JSON.parse(JSON.stringify(spec));
|
|
17
|
+
if (it.kind === "body")
|
|
18
|
+
delete next.bodyContains;
|
|
19
|
+
else {
|
|
20
|
+
if (next.match)
|
|
21
|
+
delete next.match[it.k];
|
|
22
|
+
if (next.match && !Object.keys(next.match).length)
|
|
23
|
+
delete next.match;
|
|
24
|
+
}
|
|
25
|
+
store.setSpec(next, { local: true });
|
|
26
|
+
};
|
|
27
|
+
return html `<div class="chips">${items.map((it) => html `<span class="chip"><b>${it.k}:</b><span class="v">${String(it.v)}</span><span class="x" onClick=${() => clear(it)}>✕</span></span>`)}</div>`;
|
|
28
|
+
}
|
|
29
|
+
function MatchRow({ field, value, fields, onChange, onRemove }) {
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
const cands = store.candidates(field).filter((v) => !value || String(v).toLowerCase().includes(String(value).toLowerCase())).slice(0, 50);
|
|
32
|
+
return html `
|
|
33
|
+
<div class="frow matchrow">
|
|
34
|
+
<select class="f_mk" value=${field} onChange=${(e) => onChange({ field: e.currentTarget.value, value: "" })}>
|
|
35
|
+
${fields.map((f) => html `<option value=${f}>${f}</option>`)}
|
|
36
|
+
${fields.includes(field) ? null : html `<option value=${field} selected>${field}</option>`}
|
|
37
|
+
</select>
|
|
38
|
+
<span class="combo">
|
|
39
|
+
<input class="f_mv" autocomplete="off" value=${value}
|
|
40
|
+
placeholder=${FIELD_VALUES[field] ? "pick or type…" : cands.length ? "type or pick" : "contains…"}
|
|
41
|
+
onInput=${(e) => onChange({ field, value: e.currentTarget.value })}
|
|
42
|
+
onFocus=${() => setOpen(true)} onBlur=${() => setTimeout(() => setOpen(false), 120)} />
|
|
43
|
+
<span class="combocaret" onMouseDown=${(e) => { e.preventDefault(); setOpen((o) => !o); }}>▾</span>
|
|
44
|
+
${open && cands.length ? html `<div class="combolist open">${cands.map((v) => html `<div class="comboitem" onMouseDown=${(e) => { e.preventDefault(); onChange({ field, value: String(v) }); setOpen(false); }}>${v}</div>`)}</div>` : null}
|
|
45
|
+
</span>
|
|
46
|
+
<button class="fdel" onClick=${onRemove}>✕</button>
|
|
47
|
+
</div>
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
export function Filters({ spec }) {
|
|
51
|
+
const [open, setOpen] = useState(false);
|
|
52
|
+
const fields = MATCH_FIELDS[spec.source] || [];
|
|
53
|
+
const [rows, setRows] = useState([]);
|
|
54
|
+
const [source, setSource] = useState(spec.source || "all");
|
|
55
|
+
const [body, setBody] = useState(spec.bodyContains || "");
|
|
56
|
+
const [hl, setHl] = useState(spec.highlight || {});
|
|
57
|
+
const [rate, setRate] = useState(spec.rateWindowSec || 10);
|
|
58
|
+
const [msg, setMsg] = useState("");
|
|
59
|
+
const lastSpecKey = useRef("");
|
|
60
|
+
// resync the editable copy whenever the live spec changes (the resync guard prevents
|
|
61
|
+
// clobbering an in-progress edit on every spec poll).
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const key = JSON.stringify(spec);
|
|
64
|
+
if (key === lastSpecKey.current)
|
|
65
|
+
return;
|
|
66
|
+
lastSpecKey.current = key;
|
|
67
|
+
const m = spec.match && typeof spec.match === "object" ? spec.match : {};
|
|
68
|
+
setRows(Object.entries(m).map(([field, value]) => ({ field, value: String(value) })));
|
|
69
|
+
setSource(spec.source || "all");
|
|
70
|
+
setBody(spec.bodyContains || "");
|
|
71
|
+
setHl(spec.highlight || {});
|
|
72
|
+
setRate(spec.rateWindowSec || 10);
|
|
73
|
+
}, [spec]);
|
|
74
|
+
const apply = async () => {
|
|
75
|
+
const next = JSON.parse(JSON.stringify(spec));
|
|
76
|
+
next.source = source;
|
|
77
|
+
const match = {};
|
|
78
|
+
for (const r of rows)
|
|
79
|
+
if (r.field && r.value.trim())
|
|
80
|
+
match[r.field] = r.value.trim();
|
|
81
|
+
next.match = match;
|
|
82
|
+
if (body.trim())
|
|
83
|
+
next.bodyContains = body.trim();
|
|
84
|
+
else
|
|
85
|
+
delete next.bodyContains;
|
|
86
|
+
if (hl.field && hl.value != null && String(hl.value) !== "" && !isNaN(Number(hl.value))) {
|
|
87
|
+
next.highlight = { field: hl.field, op: hl.op || ">", value: Number(hl.value), label: (spec.highlight && spec.highlight.label) || hl.field };
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
delete next.highlight;
|
|
91
|
+
}
|
|
92
|
+
if (Number(rate) > 0)
|
|
93
|
+
next.rateWindowSec = Number(rate);
|
|
94
|
+
setMsg("applying…");
|
|
95
|
+
const r = await store.setSpec(next, { local: true });
|
|
96
|
+
setMsg(r.ok ? "applied ✓" : "⚠ " + (r.error || "rejected"));
|
|
97
|
+
};
|
|
98
|
+
return html `
|
|
99
|
+
<${Chips} spec=${spec} />
|
|
100
|
+
<details class="filters" open=${open} onToggle=${(e) => setOpen(e.currentTarget.open)}>
|
|
101
|
+
<summary>Filters</summary>
|
|
102
|
+
<div class="frow"><label>source</label>
|
|
103
|
+
<select value=${source} onChange=${(e) => setSource(e.currentTarget.value)}>${FILTER_SOURCES.map((s) => html `<option value=${s}>${s}</option>`)}</select>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="fsec">match (all must hold)</div>
|
|
106
|
+
${rows.length ? null : html `<div class="fhint">no match filter — showing everything</div>`}
|
|
107
|
+
${rows.map((r, i) => html `<${MatchRow} field=${r.field} value=${r.value} fields=${fields} onChange=${(nx) => setRows(rows.map((x, j) => (j === i ? nx : x)))} onRemove=${() => setRows(rows.filter((_, j) => j !== i))} />`)}
|
|
108
|
+
<button class="fadd" onClick=${() => setRows([...rows, { field: fields[0] || "", value: "" }])}>+ add match field</button>
|
|
109
|
+
${source === "network" || source === "all" ? html `<div class="frow"><label>body contains</label><input value=${body} placeholder="substring in request/response body" onInput=${(e) => setBody(e.currentTarget.value)} /></div>` : null}
|
|
110
|
+
<div class="fsec">highlight (flag rows)</div>
|
|
111
|
+
<div class="frow">
|
|
112
|
+
<input value=${hl.field || ""} placeholder="field e.g. duration_ms" style="flex:2" onInput=${(e) => setHl({ ...hl, field: e.currentTarget.value })} />
|
|
113
|
+
<select value=${hl.op || ">"} onChange=${(e) => setHl({ ...hl, op: e.currentTarget.value })}>${[">", ">=", "<", "<=", "=="].map((o) => html `<option value=${o}>${o}</option>`)}</select>
|
|
114
|
+
<input value=${hl.value != null ? String(hl.value) : ""} placeholder="value" style="flex:1" onInput=${(e) => setHl({ ...hl, value: Number(e.currentTarget.value) })} />
|
|
115
|
+
</div>
|
|
116
|
+
<div class="frow"><label>rate window (s)</label><input type="number" value=${String(rate)} style="flex:1" onInput=${(e) => setRate(e.currentTarget.value)} /></div>
|
|
117
|
+
<div class="frow"><button class="fapply" onClick=${apply}>Apply changes</button><span class="fmsg">${msg}</span></div>
|
|
118
|
+
</details>
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Shared Preact hooks used by every dashboard container (feed, db, widgets, the shell).
|
|
2
|
+
// Foundation-owned so feed.js and db.js do not each re-implement the stale-closure-ref
|
|
3
|
+
// keyboard pattern, and the store-subscription bridges live in one place.
|
|
4
|
+
//
|
|
5
|
+
// These are deliberately STORE-AGNOSTIC: the subscription hooks take an `Emitter` argument
|
|
6
|
+
// rather than importing store.js, so this module has zero dependency on the streaming engine
|
|
7
|
+
// (which lands in a later PR) and the DB container can reuse the keyboard hooks without
|
|
8
|
+
// pulling the store at all.
|
|
9
|
+
import { useState, useEffect, useRef } from "preact/hooks";
|
|
10
|
+
// A ref that always holds the LATEST value, updated every render. The backbone of the
|
|
11
|
+
// "subscribe the window listener ONCE, but always see fresh state" pattern: the keydown
|
|
12
|
+
// handler closes over the ref, not the value, so it never goes stale even though the
|
|
13
|
+
// listener is attached a single time. feed.js and db.js both depend on this.
|
|
14
|
+
export function useLatestRef(value) {
|
|
15
|
+
const ref = useRef(value);
|
|
16
|
+
ref.current = value;
|
|
17
|
+
return ref;
|
|
18
|
+
}
|
|
19
|
+
// Attach a window keydown listener exactly ONCE for the life of the component, dispatching to
|
|
20
|
+
// the latest handler via useLatestRef. Avoids the add/remove-listener churn (and dropped
|
|
21
|
+
// keystrokes) you get from re-subscribing whenever a dependency changes. The handler itself
|
|
22
|
+
// owns its input-tag guard and meta-chord precedence -- this hook only manages the lifecycle.
|
|
23
|
+
export function useGlobalKeydown(handler) {
|
|
24
|
+
const ref = useLatestRef(handler);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const onKey = (e) => ref.current(e);
|
|
27
|
+
window.addEventListener("keydown", onKey);
|
|
28
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
29
|
+
}, []);
|
|
30
|
+
}
|
|
31
|
+
// Subscribe to ONE emitter event and return the value produced by read(). Re-reads (and
|
|
32
|
+
// re-renders) whenever the event fires. Used for single-value header bits (conn dot, profile
|
|
33
|
+
// label, active spec).
|
|
34
|
+
//
|
|
35
|
+
// read() is routed through useLatestRef rather than captured in the effect, matching
|
|
36
|
+
// useGlobalKeydown above: the subscription is created once per (emitter, event), but it always
|
|
37
|
+
// calls the freshest read. Capturing read directly would freeze it at subscribe time, so a
|
|
38
|
+
// caller whose read closes over changing props would re-render with a stale value.
|
|
39
|
+
export function useStoreValue(emitter, event, read) {
|
|
40
|
+
const readRef = useLatestRef(read);
|
|
41
|
+
const [v, setV] = useState(read);
|
|
42
|
+
useEffect(() => emitter.on(event, () => setV(readRef.current())), [emitter, event, readRef]);
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
// Force a re-render of this subtree whenever ANY of the named emitter events fire, without
|
|
46
|
+
// threading a value through. Used by the widgets/cards, which read the live aggregates
|
|
47
|
+
// directly off the store on each render and just need a nudge on "tick"/"feed".
|
|
48
|
+
export function useStorePulse(emitter, ...events) {
|
|
49
|
+
const [, force] = useState(0);
|
|
50
|
+
// events is spread fresh each render; join to a stable dep so the effect re-subscribes only
|
|
51
|
+
// when the actual event set changes, not on every render.
|
|
52
|
+
const key = events.join("|");
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const offs = events.map((e) => emitter.on(e, () => force((n) => n + 1)));
|
|
55
|
+
return () => offs.forEach((off) => off());
|
|
56
|
+
}, [emitter, key]);
|
|
57
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Bind htm to Preact's h() once and share the tagged-template `html` to every UI module.
|
|
2
|
+
// `preact` and `htm` resolve via the index.html import map (to the vendored ES modules);
|
|
3
|
+
// every other module imports `html` from here by relative path.
|
|
4
|
+
//
|
|
5
|
+
// htm renders template TEXT literally (no HTML-entity decode): use a plain space, never
|
|
6
|
+
// ` `, and keep esc() on every interpolated value that reaches a dangerouslySetInnerHTML
|
|
7
|
+
// sink. See ./esc.ts.
|
|
8
|
+
import { h } from "preact";
|
|
9
|
+
import htm from "htm";
|
|
10
|
+
export const html = htm.bind(h);
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Inspector: a structured, type-aware detail view (not a raw JSON blob). Only mounted when a
|
|
2
|
+
// row is selected. Summary header with key facts + badges, then collapsible sections with
|
|
3
|
+
// syntax-highlighted JSON. Seeds from the feed row instantly, then enriches from the device
|
|
4
|
+
// /detail (read-through the store's shared cache, so an evicted row still shows its body).
|
|
5
|
+
import { html } from "./html.js";
|
|
6
|
+
import { useState, useEffect } from "preact/hooks";
|
|
7
|
+
import { esc } from "./esc.js";
|
|
8
|
+
import { shortUrl, store } from "./store.js";
|
|
9
|
+
import { hasDetailEndpoint } from "./registry.js";
|
|
10
|
+
function shq(s) {
|
|
11
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
12
|
+
}
|
|
13
|
+
// Copy cURL / Copy JSON emit request headers (including Authorization / Bearer / Cookie) and
|
|
14
|
+
// bodies unredacted, so the copied cURL pastes and RUNS against the real device session --
|
|
15
|
+
// that is the point of the feature. Redacting auth would break that, and Radar is a dev-only
|
|
16
|
+
// tool against a local debug build over an ADB-forwarded socket, so there is nothing to gain.
|
|
17
|
+
function toCurl(d) {
|
|
18
|
+
if (!d.url)
|
|
19
|
+
return "";
|
|
20
|
+
const parts = ["curl -i"];
|
|
21
|
+
if (d.method && d.method !== "GET")
|
|
22
|
+
parts.push("-X " + d.method);
|
|
23
|
+
const h = (d.request_headers || d.requestHeaders || {});
|
|
24
|
+
for (const k of Object.keys(h))
|
|
25
|
+
parts.push("-H " + shq(k + ": " + h[k]));
|
|
26
|
+
const body = d.request_body ?? d.requestBody;
|
|
27
|
+
if (body)
|
|
28
|
+
parts.push("--data " + shq(typeof body === "string" ? body : JSON.stringify(body)));
|
|
29
|
+
parts.push(shq(d.url));
|
|
30
|
+
return parts.join(" \\\n ");
|
|
31
|
+
}
|
|
32
|
+
// Syntax-highlight a JSON value into an HTML string. Built as a string (then
|
|
33
|
+
// dangerouslySetInnerHTML) so it is one fast pass; every interpolated value goes through the
|
|
34
|
+
// shared esc(), so no injection. (One of the three audited innerHTML sinks.)
|
|
35
|
+
export function highlightJson(value, indent = 0) {
|
|
36
|
+
const pad = " ".repeat(indent);
|
|
37
|
+
const padIn = " ".repeat(indent + 1);
|
|
38
|
+
if (value === null)
|
|
39
|
+
return '<span class="j-null">null</span>';
|
|
40
|
+
const t = typeof value;
|
|
41
|
+
if (t === "number")
|
|
42
|
+
return '<span class="j-num">' + esc(String(value)) + "</span>";
|
|
43
|
+
if (t === "boolean")
|
|
44
|
+
return '<span class="j-bool">' + value + "</span>";
|
|
45
|
+
if (t === "string")
|
|
46
|
+
return '<span class="j-str">"' + esc(value) + '"</span>';
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
if (!value.length)
|
|
49
|
+
return "[]";
|
|
50
|
+
const items = value.map((v) => padIn + highlightJson(v, indent + 1));
|
|
51
|
+
return "[\n" + items.join(",\n") + "\n" + pad + "]";
|
|
52
|
+
}
|
|
53
|
+
if (t === "object") {
|
|
54
|
+
const obj = value;
|
|
55
|
+
const keys = Object.keys(obj);
|
|
56
|
+
if (!keys.length)
|
|
57
|
+
return "{}";
|
|
58
|
+
const items = keys.map((k) => padIn + '<span class="j-key">"' + esc(k) + '"</span>: ' + highlightJson(obj[k], indent + 1));
|
|
59
|
+
return "{\n" + items.join(",\n") + "\n" + pad + "}";
|
|
60
|
+
}
|
|
61
|
+
return esc(String(value));
|
|
62
|
+
}
|
|
63
|
+
function Section({ title, sub, children, open }) {
|
|
64
|
+
const [isOpen, setOpen] = useState(open !== false);
|
|
65
|
+
return html `
|
|
66
|
+
<div class="insec">
|
|
67
|
+
<div class="insec-hd" onClick=${() => setOpen((o) => !o)}>
|
|
68
|
+
<span class="insec-caret">${isOpen ? "▾" : "▸"}</span>
|
|
69
|
+
<span class="insec-title">${title}</span>
|
|
70
|
+
${sub != null ? html `<span class="insec-sub">${sub}</span>` : null}
|
|
71
|
+
</div>
|
|
72
|
+
${isOpen ? html `<div class="insec-body">${children}</div>` : null}
|
|
73
|
+
</div>
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
function Body({ parsed }) {
|
|
77
|
+
const v = parsed.value;
|
|
78
|
+
if (v && typeof v === "object") {
|
|
79
|
+
return html `<pre class="json" dangerouslySetInnerHTML=${{ __html: highlightJson(v, 0) }}></pre>`;
|
|
80
|
+
}
|
|
81
|
+
return html `
|
|
82
|
+
${parsed.truncated ? html `<div class="trunc">⚠ truncated by the device cap</div>` : null}
|
|
83
|
+
<pre class="json rawbody">${String(v)}</pre>
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
function KV({ map }) {
|
|
87
|
+
const keys = Object.keys(map || {});
|
|
88
|
+
if (!keys.length)
|
|
89
|
+
return null;
|
|
90
|
+
return html `<div class="kv">${keys.map((k) => html `<div class="kv-k">${k}</div><div class="kv-v">${String(map[k])}</div>`)}</div>`;
|
|
91
|
+
}
|
|
92
|
+
// Parse a body. A string that does not parse AND looks cut off (starts like JSON, ends
|
|
93
|
+
// mid-token) is flagged truncated -- the on-device interceptor caps bodies (installed build at
|
|
94
|
+
// 8KB), so large JSON arrives clipped. We show it as readable text with a banner.
|
|
95
|
+
export function parseBody(v) {
|
|
96
|
+
if (typeof v !== "string")
|
|
97
|
+
return { value: v, truncated: false };
|
|
98
|
+
const s = v.trim();
|
|
99
|
+
try {
|
|
100
|
+
return { value: JSON.parse(s), truncated: false };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
const looksJson = s.startsWith("{") || s.startsWith("[");
|
|
104
|
+
const looksCut = looksJson && !(s.endsWith("}") || s.endsWith("]"));
|
|
105
|
+
return { value: v, truncated: looksCut, bytes: v.length };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function statusClass(code) {
|
|
109
|
+
const c = Number(code);
|
|
110
|
+
if (!c)
|
|
111
|
+
return "";
|
|
112
|
+
if (c >= 500)
|
|
113
|
+
return "st5";
|
|
114
|
+
if (c >= 400)
|
|
115
|
+
return "st4";
|
|
116
|
+
if (c >= 300)
|
|
117
|
+
return "st3";
|
|
118
|
+
return "st2";
|
|
119
|
+
}
|
|
120
|
+
export function Inspector({ ev, onClose }) {
|
|
121
|
+
const [detail, setDetail] = useState(ev);
|
|
122
|
+
const [loading, setLoading] = useState(false);
|
|
123
|
+
const [evicted, setEvicted] = useState(false);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const kind = ev._type || "";
|
|
126
|
+
setEvicted(false);
|
|
127
|
+
setDetail(ev); // seed from the row the feed already has (correct summary instantly)
|
|
128
|
+
// Only kinds with a /detail endpoint fetch; logs are host-side (everything on the row).
|
|
129
|
+
if (ev.id == null || !hasDetailEndpoint(kind))
|
|
130
|
+
return;
|
|
131
|
+
// read-through the store's shared cache (eager-capture + prior click-fetches land there).
|
|
132
|
+
const cached = store.getDetail(kind, ev.id);
|
|
133
|
+
if (cached) {
|
|
134
|
+
setDetail({ ...ev, ...cached, _type: kind });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
setLoading(true);
|
|
138
|
+
let live = true;
|
|
139
|
+
fetch("/detail?kind=" + kind + "&id=" + ev.id)
|
|
140
|
+
.then((r) => r.json())
|
|
141
|
+
.then((d) => {
|
|
142
|
+
if (!live)
|
|
143
|
+
return;
|
|
144
|
+
// {error:"Not Found"} = the id was evicted from the device ring (older than ~200). Do
|
|
145
|
+
// NOT overwrite the good summary; keep the feed row + flag the body is gone.
|
|
146
|
+
if (d && d.error && d.url == null && d.method == null) {
|
|
147
|
+
setEvicted(true);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const merged = { ...ev, ...d, _type: kind };
|
|
151
|
+
store.cacheDetail(kind, ev.id, merged);
|
|
152
|
+
setDetail(merged);
|
|
153
|
+
})
|
|
154
|
+
.catch(() => { })
|
|
155
|
+
.finally(() => live && setLoading(false));
|
|
156
|
+
return () => {
|
|
157
|
+
live = false;
|
|
158
|
+
};
|
|
159
|
+
}, [ev]);
|
|
160
|
+
const kind = ev._type || "";
|
|
161
|
+
const isNet = kind === "network" || detail.url != null || detail.method != null;
|
|
162
|
+
const title = ev.event_name || ev.event_type || shortUrl(ev.url) || kind || "detail";
|
|
163
|
+
const copy = (which, e) => {
|
|
164
|
+
let text = "";
|
|
165
|
+
if (which === "json")
|
|
166
|
+
text = JSON.stringify(detail, null, 2);
|
|
167
|
+
else if (which === "body")
|
|
168
|
+
text = String(detail.response_body ?? detail.request_body ?? "");
|
|
169
|
+
else if (which === "curl")
|
|
170
|
+
text = toCurl(detail);
|
|
171
|
+
navigator.clipboard?.writeText(text);
|
|
172
|
+
const btn = e.currentTarget;
|
|
173
|
+
const o = btn.textContent;
|
|
174
|
+
btn.textContent = "copied ✓";
|
|
175
|
+
setTimeout(() => (btn.textContent = o), 1200);
|
|
176
|
+
};
|
|
177
|
+
// ---- badges: at-a-glance facts, per type. Lead with the record TYPE. ----
|
|
178
|
+
const badges = [];
|
|
179
|
+
const typeKind = isNet ? "network" : kind || "event";
|
|
180
|
+
badges.push(html `<span class=${"ibadge tchip t-" + typeKind}>${typeKind}</span>`);
|
|
181
|
+
const ts = ev.timestamp || detail.timestamp;
|
|
182
|
+
if (ts)
|
|
183
|
+
badges.push(html `<span class="ibadge">${new Date(ts).toLocaleTimeString()}</span>`);
|
|
184
|
+
if (isNet) {
|
|
185
|
+
if (detail.method)
|
|
186
|
+
badges.push(html `<span class="ibadge meth">${detail.method}</span>`);
|
|
187
|
+
if (detail.status_code != null)
|
|
188
|
+
badges.push(html `<span class=${"ibadge " + statusClass(detail.status_code)}>${detail.status_code}</span>`);
|
|
189
|
+
if (detail.duration_ms != null)
|
|
190
|
+
badges.push(html `<span class=${"ibadge" + (Number(detail.duration_ms) > 800 ? " slow" : "")}>${detail.duration_ms} ms</span>`);
|
|
191
|
+
}
|
|
192
|
+
else if (kind === "rtm") {
|
|
193
|
+
if (ev.direction)
|
|
194
|
+
badges.push(html `<span class=${"ibadge dir-" + ev.direction}>${ev.direction}</span>`);
|
|
195
|
+
if (ev.event_type)
|
|
196
|
+
badges.push(html `<span class="ibadge">${ev.event_type}</span>`);
|
|
197
|
+
if (ev.channel)
|
|
198
|
+
badges.push(html `<span class="ibadge">${ev.channel}</span>`);
|
|
199
|
+
}
|
|
200
|
+
else if (kind === "clog") {
|
|
201
|
+
if (ev.event_name)
|
|
202
|
+
badges.push(html `<span class="ibadge">${ev.event_name}</span>`);
|
|
203
|
+
}
|
|
204
|
+
else if (kind === "log") {
|
|
205
|
+
if (ev.level)
|
|
206
|
+
badges.push(html `<span class=${"ibadge lvl-" + String(ev.level).toUpperCase()}>${ev.level}</span>`);
|
|
207
|
+
if (ev.tag)
|
|
208
|
+
badges.push(html `<span class="ibadge">${ev.tag}</span>`);
|
|
209
|
+
if (ev.package)
|
|
210
|
+
badges.push(html `<span class="ibadge">${ev.package}</span>`);
|
|
211
|
+
}
|
|
212
|
+
const reqBody = parseBody(detail.request_body ?? detail.requestBody);
|
|
213
|
+
const resBody = parseBody(detail.response_body ?? detail.responseBody);
|
|
214
|
+
const reqHeaders = (detail.request_headers || detail.requestHeaders);
|
|
215
|
+
const resHeaders = (detail.response_headers || detail.responseHeaders);
|
|
216
|
+
const data = parseBody(detail.data);
|
|
217
|
+
const logMessage = kind === "log" ? detail.message || ev.message : null;
|
|
218
|
+
// generic "other fields" dump, minus the specially-rendered ones
|
|
219
|
+
const omit = new Set([
|
|
220
|
+
"_type", "id", "timestamp", "url", "method", "status_code", "duration_ms",
|
|
221
|
+
"request_body", "requestBody", "response_body", "responseBody",
|
|
222
|
+
"request_headers", "requestHeaders", "response_headers", "responseHeaders",
|
|
223
|
+
"data", "message", "event_type", "event_name", "direction", "channel", "level", "tag", "package", "pid", "tid", "log_ts",
|
|
224
|
+
]);
|
|
225
|
+
const rest = {};
|
|
226
|
+
for (const k of Object.keys(detail))
|
|
227
|
+
if (!omit.has(k))
|
|
228
|
+
rest[k] = detail[k];
|
|
229
|
+
const hasRest = Object.keys(rest).length > 0;
|
|
230
|
+
return html `
|
|
231
|
+
<div class="inspector">
|
|
232
|
+
<div class="inspector-hd">
|
|
233
|
+
<span class="ttl">${title}</span>
|
|
234
|
+
<span class="x" onClick=${onClose} title="close (Esc)">✕</span>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="ibadges">${badges}</div>
|
|
237
|
+
${isNet && detail.url ? html `<div class="iurl" title=${detail.url}>${detail.url}</div>` : null}
|
|
238
|
+
${evicted ? html `<div class="trunc">This call scrolled off the device buffer (it keeps only the last ~200), so the full request/response body is no longer available. The summary above is from the live feed. Open a call sooner to capture its body.</div>` : null}
|
|
239
|
+
<div class="copybar">
|
|
240
|
+
<button class="copybtn" onClick=${(e) => copy("json", e)}>Copy JSON</button>
|
|
241
|
+
${isNet ? html `<button class="copybtn" onClick=${(e) => copy("body", e)}>Copy body</button><button class="copybtn" onClick=${(e) => copy("curl", e)}>Copy cURL</button>` : null}
|
|
242
|
+
${isNet ? html `<span class="copynote" title="Copy cURL and Copy body include the request's real headers and tokens, unredacted, so the copied command runs against the live session. Do not paste into a shared channel or ticket.">includes live auth</span>` : null}
|
|
243
|
+
</div>
|
|
244
|
+
${loading
|
|
245
|
+
? html `<div class="empty">loading…</div>`
|
|
246
|
+
: html `
|
|
247
|
+
${logMessage != null ? html `<${Section} title="message"><pre class="logmsg-full">${logMessage}</pre><//>` : null}
|
|
248
|
+
${reqBody.value != null ? html `<${Section} title="request body"><${Body} parsed=${reqBody} /><//>` : null}
|
|
249
|
+
${resBody.value != null ? html `<${Section} title="response body"><${Body} parsed=${resBody} /><//>` : null}
|
|
250
|
+
${data.value != null ? html `<${Section} title="data"><${Body} parsed=${data} /><//>` : null}
|
|
251
|
+
${reqHeaders ? html `<${Section} title="request headers" sub=${Object.keys(reqHeaders).length} open=${false}><${KV} map=${reqHeaders} /><//>` : null}
|
|
252
|
+
${resHeaders ? html `<${Section} title="response headers" sub=${Object.keys(resHeaders).length} open=${false}><${KV} map=${resHeaders} /><//>` : null}
|
|
253
|
+
${hasRest ? html `<${Section} title="other fields" open=${!isNet}><${Body} parsed=${{ value: rest, truncated: false }} /><//>` : null}
|
|
254
|
+
`}
|
|
255
|
+
</div>
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
export { toCurl, shq };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Source + widget registries. Adding a source or a widget should be ONE table entry, not
|
|
2
|
+
// edits across many hardcoded conditionals (the vanilla index.html debt). The app reads these
|
|
3
|
+
// to drive the modebar, the Filters source select + field lists, the feed sort options, the
|
|
4
|
+
// per-source detail fetch, and which widget components to mount.
|
|
5
|
+
// Match-fields the Filters panel offers per source. `all` is derived as the union below, so a
|
|
6
|
+
// new source's fields show up in the unified view with no extra edit.
|
|
7
|
+
export const MATCH_FIELDS = {
|
|
8
|
+
network: ["url", "method", "status_code"],
|
|
9
|
+
rtm: ["event_type", "direction", "channel"],
|
|
10
|
+
clog: ["event_name"],
|
|
11
|
+
log: ["tag", "level", "message", "pid", "package"],
|
|
12
|
+
};
|
|
13
|
+
// `all` = the union of every streaming source's fields (deduped, order-stable). Derived, not
|
|
14
|
+
// hand-maintained, so adding a source's fields above surfaces them in the unified Live tab too.
|
|
15
|
+
MATCH_FIELDS.all = (() => {
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const src of ["network", "rtm", "clog", "log"]) {
|
|
19
|
+
for (const f of MATCH_FIELDS[src])
|
|
20
|
+
if (!seen.has(f)) {
|
|
21
|
+
seen.add(f);
|
|
22
|
+
out.push(f);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
})();
|
|
27
|
+
// Fixed value lists for combobox candidates (others are learned live from the stream).
|
|
28
|
+
export const FIELD_VALUES = {
|
|
29
|
+
direction: ["OUTGOING", "INCOMING"],
|
|
30
|
+
level: ["VERBOSE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"],
|
|
31
|
+
method: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
32
|
+
};
|
|
33
|
+
// SOURCE registry: one row per source. `kind` routes the view (streaming feed vs the
|
|
34
|
+
// point-in-time DB browser) without a hardcoded source check.
|
|
35
|
+
export const SOURCES = {
|
|
36
|
+
network: { label: "Network", kind: "stream", hasDetailEndpoint: true },
|
|
37
|
+
rtm: { label: "RTM", kind: "stream", hasDetailEndpoint: true },
|
|
38
|
+
clog: { label: "Clogs", kind: "stream", hasDetailEndpoint: true },
|
|
39
|
+
log: { label: "Logs", kind: "stream", hasDetailEndpoint: false },
|
|
40
|
+
all: { label: "Live", kind: "stream" },
|
|
41
|
+
db: { label: "Database", kind: "db" },
|
|
42
|
+
};
|
|
43
|
+
// The set of streaming source kinds the store admits off the SSE stream (derived from SOURCES,
|
|
44
|
+
// excluding the non-stream db + the unified "all" alias).
|
|
45
|
+
export const STREAM_KINDS = Object.entries(SOURCES)
|
|
46
|
+
.filter(([k, v]) => v.kind === "stream" && k !== "all")
|
|
47
|
+
.map(([k]) => k);
|
|
48
|
+
// The kinds that have a /detail endpoint (eager body capture + inspector fetch). Derived from
|
|
49
|
+
// hasDetailEndpoint so adding a detailable source is one SOURCES edit.
|
|
50
|
+
export const DETAILABLE_KINDS = Object.entries(SOURCES)
|
|
51
|
+
.filter(([, v]) => v.hasDetailEndpoint)
|
|
52
|
+
.map(([k]) => k);
|
|
53
|
+
export function hasDetailEndpoint(kind) {
|
|
54
|
+
return !!SOURCES[kind]?.hasDetailEndpoint;
|
|
55
|
+
}
|
|
56
|
+
// WIDGET registry: the viz keys the Cards strip understands. `counter` stays a valid key for
|
|
57
|
+
// back-compat but renders nothing (a cumulative count is the wrong signal for a live stream;
|
|
58
|
+
// see widgets.ts). A new widget = one key here + one component.
|
|
59
|
+
export const WIDGETS = ["counter", "rate", "sparkline", "graph"];
|
|
60
|
+
// Source order for the modebar / global tab-switch (matches the MODES preset order).
|
|
61
|
+
export const SOURCE_ORDER = ["all", "network", "rtm", "clog", "log", "db"];
|
|
62
|
+
// The source options the Filters panel offers: every streaming source (db is its own container,
|
|
63
|
+
// not a feed filter). Derived from SOURCES so adding a streaming source surfaces it in Filters
|
|
64
|
+
// with no edit there. Ordered by SOURCE_ORDER (the modebar order) for a stable, familiar list.
|
|
65
|
+
export const FILTER_SOURCES = SOURCE_ORDER.filter((s) => SOURCES[s]?.kind === "stream");
|