@slack/radar-mcp 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +52 -83
- package/dist/mcp/logs-result.d.ts +9 -0
- package/dist/mcp/logs-result.js +15 -0
- package/dist/mcp/radar-response.d.ts +21 -0
- package/dist/mcp/radar-response.js +38 -0
- package/dist/mcp/snapshot.d.ts +4 -2
- package/dist/mcp/snapshot.js +25 -12
- package/dist/mcp/tools.js +34 -10
- package/dist/shared/android.d.ts +2 -2
- package/dist/shared/android.js +16 -11
- package/dist/shared/db.d.ts +13 -0
- package/dist/shared/db.js +30 -3
- 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 +766 -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,210 @@
|
|
|
1
|
+
// App root. Owns: header (conn dot, profile label, reconnect, theme toggle, ▣ Screen toggle),
|
|
2
|
+
// modebar tabs (incl. the persistent Custom tab), the alert flash/banner, and the view router.
|
|
3
|
+
// Holds NO device data -- that all lives in store.ts; this composes the components and reacts
|
|
4
|
+
// to store events. The DB browser (source:db) renders its own self-contained container, which
|
|
5
|
+
// does NOT import the store (it is point-in-time, not a live feed). The screen overlay is a
|
|
6
|
+
// router sibling toggled by ▣ Screen: a fixed right-docked panel that floats over any view.
|
|
7
|
+
import { html } from "./html.js";
|
|
8
|
+
import { render } from "preact";
|
|
9
|
+
import { useState, useEffect, useCallback } from "preact/hooks";
|
|
10
|
+
import { useStoreValue } from "./hooks.js";
|
|
11
|
+
import { store, MODES, describeConstraints } from "./store.js";
|
|
12
|
+
import { Feed } from "./feed.js";
|
|
13
|
+
import { Cards } from "./widgets.js";
|
|
14
|
+
import { Inspector } from "./inspector.js";
|
|
15
|
+
import { Filters } from "./filters.js";
|
|
16
|
+
import { DbBrowser } from "./db.js";
|
|
17
|
+
import { ScreenOverlay } from "./screen.js";
|
|
18
|
+
function readTheme() {
|
|
19
|
+
try {
|
|
20
|
+
return document.documentElement.classList.contains("light") ? "light" : "dark";
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return "dark";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function Header({ screenOpen, onScreenToggle }) {
|
|
27
|
+
const conn = useStoreValue(store, "conn", () => store.conn);
|
|
28
|
+
const profile = useStoreValue(store, "profile", () => store.profileLabel);
|
|
29
|
+
const [busy, setBusy] = useState(false);
|
|
30
|
+
const [theme, setTheme] = useState(readTheme);
|
|
31
|
+
const reconnect = useCallback(async () => {
|
|
32
|
+
setBusy(true);
|
|
33
|
+
await store.reconnect();
|
|
34
|
+
setBusy(false);
|
|
35
|
+
}, []);
|
|
36
|
+
const toggleTheme = useCallback(() => {
|
|
37
|
+
const next = readTheme() === "light" ? "dark" : "light";
|
|
38
|
+
document.documentElement.classList.toggle("light", next === "light");
|
|
39
|
+
try {
|
|
40
|
+
localStorage.setItem("radar-theme", next);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* private mode: theme resets next load */
|
|
44
|
+
}
|
|
45
|
+
setTheme(next);
|
|
46
|
+
}, []);
|
|
47
|
+
const connected = conn.state === "connected";
|
|
48
|
+
return html `
|
|
49
|
+
<h1>
|
|
50
|
+
SLACK RADAR DASHBOARD
|
|
51
|
+
<span class=${"conn " + conn.state}>${connected ? "● live" : "● " + (conn.msg || "reconnecting…")}</span>
|
|
52
|
+
<span class="profilewrap" title="the device profile Radar is connected to (first app to launch wins the socket)">profile: <b>${profile}</b></span>
|
|
53
|
+
${!connected ? html `<button class="reconnect" disabled=${busy} onClick=${reconnect}>${busy ? "⟳ enabling…" : "⟳ Reconnect Radar"}</button>` : null}
|
|
54
|
+
<button class="themebtn" onClick=${toggleTheme} title="Toggle light / dark theme">${theme === "light" ? "🌙 Dark" : "☀ Light"}</button>
|
|
55
|
+
<button class=${"screenbtn" + (screenOpen ? " on" : "")} onClick=${onScreenToggle} title="Mirror the live device screen">▣ Screen</button>
|
|
56
|
+
</h1>
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
function ModeBar() {
|
|
60
|
+
const [, force] = useState(0);
|
|
61
|
+
useEffect(() => store.on("spec", () => force((n) => n + 1)), []);
|
|
62
|
+
const activeKey = store.activeModeKey();
|
|
63
|
+
const pick = (specToPush) => store.setSpec(specToPush);
|
|
64
|
+
return html `
|
|
65
|
+
<div class="modebar">
|
|
66
|
+
${MODES.map((m) => html `<button class=${"modebtn" + (activeKey === m.key ? " active" : "")} onClick=${() => pick(m.spec)}>${m.label}</button>`)}
|
|
67
|
+
${store.lastCustomSpec ? html `<button class=${"modebtn custom" + (activeKey === "__custom__" ? " active" : "")} title="pushed from a terminal Claude Code session; persists until refresh" onClick=${() => pick(store.lastCustomSpec)}>✎ ${store.lastCustomSpec.title || "Custom"}</button>` : null}
|
|
68
|
+
</div>
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
let audioCtx = null;
|
|
72
|
+
function beep() {
|
|
73
|
+
try {
|
|
74
|
+
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
75
|
+
audioCtx = audioCtx || new Ctx();
|
|
76
|
+
const o = audioCtx.createOscillator();
|
|
77
|
+
const g = audioCtx.createGain();
|
|
78
|
+
o.type = "sine";
|
|
79
|
+
o.frequency.value = 880;
|
|
80
|
+
o.connect(g);
|
|
81
|
+
g.connect(audioCtx.destination);
|
|
82
|
+
g.gain.setValueAtTime(0.0001, audioCtx.currentTime);
|
|
83
|
+
g.gain.exponentialRampToValueAtTime(0.25, audioCtx.currentTime + 0.01);
|
|
84
|
+
g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.25);
|
|
85
|
+
o.start();
|
|
86
|
+
o.stop(audioCtx.currentTime + 0.26);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* no audio */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function Alert() {
|
|
93
|
+
const [banner, setBanner] = useState(null);
|
|
94
|
+
const [flash, setFlash] = useState(false);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
let bTimer = null;
|
|
97
|
+
const off = store.on("alert", ({ spec, ev, note }) => {
|
|
98
|
+
if (spec.flash !== false) {
|
|
99
|
+
setFlash(true);
|
|
100
|
+
setTimeout(() => setFlash(false), 140);
|
|
101
|
+
}
|
|
102
|
+
if (spec.sound !== false)
|
|
103
|
+
beep();
|
|
104
|
+
const tail = note || ev.event_type || ev.event_name || (ev.url ? new URL(ev.url, "http://x").pathname : "") || "";
|
|
105
|
+
setBanner({ say: spec.say || "ALERT", tail: String(tail) });
|
|
106
|
+
if (bTimer)
|
|
107
|
+
clearTimeout(bTimer);
|
|
108
|
+
bTimer = setTimeout(() => setBanner(null), 2500);
|
|
109
|
+
});
|
|
110
|
+
return () => {
|
|
111
|
+
off();
|
|
112
|
+
if (bTimer)
|
|
113
|
+
clearTimeout(bTimer);
|
|
114
|
+
};
|
|
115
|
+
}, []);
|
|
116
|
+
return html `
|
|
117
|
+
<div id="flash" class=${flash ? "on" : ""}></div>
|
|
118
|
+
${banner ? html `<div id="alertbanner" class="on"><span>${banner.say} — ${banner.tail}</span><span class="x" onClick=${() => setBanner(null)}>dismiss ✕</span></div>` : null}
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
// The streaming view: cards on top, then feed + (selected) inspector side by side.
|
|
122
|
+
function StreamView({ spec }) {
|
|
123
|
+
const [selected, setSelected] = useState(null);
|
|
124
|
+
useEffect(() => setSelected(null), [spec.source]);
|
|
125
|
+
const tech = describeConstraints(spec);
|
|
126
|
+
return html `
|
|
127
|
+
<div class="sub">
|
|
128
|
+
<span class="summary">${spec.summary || spec.title}</span>
|
|
129
|
+
<span class="tech">${tech}</span>
|
|
130
|
+
</div>
|
|
131
|
+
<${Filters} spec=${spec} />
|
|
132
|
+
<${Cards} spec=${spec} />
|
|
133
|
+
<div class=${"stage" + (selected ? " split" : "")}>
|
|
134
|
+
<${Feed} spec=${spec} onInspect=${(ev) => setSelected(ev)} selectedId=${selected && selected.id} />
|
|
135
|
+
${selected ? html `<${Inspector} ev=${selected} onClose=${() => setSelected(null)} />` : null}
|
|
136
|
+
</div>
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
function Blank() {
|
|
140
|
+
return html `<div class="sub"><span class="summary">Pick a dashboard above.</span><span class="tech">Live, Network, RTM, Clogs, Logs — or ask a terminal Claude Code session to build a custom view.</span></div>`;
|
|
141
|
+
}
|
|
142
|
+
function App() {
|
|
143
|
+
const spec = useStoreValue(store, "spec", () => store.spec);
|
|
144
|
+
const [screenOpen, setScreenOpen] = useState(false);
|
|
145
|
+
// The screen overlay is a fixed right-docked panel; widen the page padding while it is open so
|
|
146
|
+
// it never covers the feed/widgets.
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
document.body.classList.toggle("screenopen", screenOpen);
|
|
149
|
+
}, [screenOpen]);
|
|
150
|
+
// GLOBAL tab switching (← / → / h / l) -- at the app root so it works on EVERY tab. The
|
|
151
|
+
// per-row j/k nav stays inside the Feed. Ignored while typing in a control.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const onKey = (e) => {
|
|
154
|
+
if (e.metaKey || e.ctrlKey || e.altKey)
|
|
155
|
+
return;
|
|
156
|
+
const t = e.target;
|
|
157
|
+
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable))
|
|
158
|
+
return;
|
|
159
|
+
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft" && e.key !== "h" && e.key !== "l")
|
|
160
|
+
return;
|
|
161
|
+
const dir = e.key === "ArrowRight" || e.key === "l" ? 1 : -1;
|
|
162
|
+
const keys = MODES.map((m) => m.key);
|
|
163
|
+
if (store.lastCustomSpec)
|
|
164
|
+
keys.push("__custom__");
|
|
165
|
+
let cur = keys.indexOf(store.activeModeKey() || "");
|
|
166
|
+
if (cur === -1)
|
|
167
|
+
cur = 0;
|
|
168
|
+
const next = Math.max(0, Math.min(keys.length - 1, cur + dir));
|
|
169
|
+
if (next === cur)
|
|
170
|
+
return;
|
|
171
|
+
const nextKey = keys[next];
|
|
172
|
+
const nextSpec = nextKey === "__custom__" ? store.lastCustomSpec : MODES.find((m) => m.key === nextKey)?.spec;
|
|
173
|
+
if (nextSpec)
|
|
174
|
+
store.setSpec(nextSpec);
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
};
|
|
177
|
+
window.addEventListener("keydown", onKey);
|
|
178
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
179
|
+
}, []);
|
|
180
|
+
let view;
|
|
181
|
+
if (!spec || spec.blank) {
|
|
182
|
+
view = html `<${Blank} />`;
|
|
183
|
+
}
|
|
184
|
+
else if (spec.source === "db") {
|
|
185
|
+
// The DB browser is a self-contained, point-in-time container: it does NOT import the store
|
|
186
|
+
// (no SSE engine / timers on a tab that has no live feed). key=db so it remounts cleanly
|
|
187
|
+
// when switching to the DB tab.
|
|
188
|
+
view = html `
|
|
189
|
+
<div class="sub">
|
|
190
|
+
<span class="summary">${spec.summary || "Device database browser"}</span>
|
|
191
|
+
<span class="tech">source=db (on-device SQLite snapshot, read-only)</span>
|
|
192
|
+
</div>
|
|
193
|
+
<${DbBrowser} spec=${spec} key="db" />
|
|
194
|
+
`;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
view = html `<${StreamView} spec=${spec} key=${spec.source} />`;
|
|
198
|
+
}
|
|
199
|
+
return html `
|
|
200
|
+
<${Alert} />
|
|
201
|
+
<${Header} screenOpen=${screenOpen} onScreenToggle=${() => setScreenOpen((o) => !o)} />
|
|
202
|
+
<${ModeBar} />
|
|
203
|
+
${view}
|
|
204
|
+
<${ScreenOverlay} open=${screenOpen} onClose=${() => setScreenOpen(false)} />
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
207
|
+
store.start();
|
|
208
|
+
const root = document.getElementById("app");
|
|
209
|
+
if (root)
|
|
210
|
+
render(html `<${App} />`, root);
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// DB browser (source:"db") -- read-only on-device SQLite browser: database list -> tables ->
|
|
2
|
+
// query grid, with a profile picker on dual-profile devices. Ported from the PoC db.js
|
|
3
|
+
// (vanilla buildDbView/loadDbList/dbSelectDb/dbSelectTbl/dbRun). Server contract (unchanged):
|
|
4
|
+
// /dbs?user= -> { user, availableUsers: string[], names: string[] } | { error, names:[], availableUsers:[] }
|
|
5
|
+
// /dbtables?db=&user=&refresh=1 -> { tables: {name, rows}[], sizeBytes } | { error }
|
|
6
|
+
// /dbquery?db=&sql=&user= -> { rows: object[] } (200) | { error } (400)
|
|
7
|
+
//
|
|
8
|
+
// IMPORTANT (plan requirement): this module must NOT import the streaming store. The DB tab is
|
|
9
|
+
// point-in-time, not a live feed; pulling the store would start the SSE engine + timers on a
|
|
10
|
+
// tab that has no use for them. It carries its own keyboard nav since there is no Feed here.
|
|
11
|
+
import { html } from "./html.js";
|
|
12
|
+
import { useState, useEffect, useCallback, useRef } from "preact/hooks";
|
|
13
|
+
import { useLatestRef, useGlobalKeydown } from "./hooks.js";
|
|
14
|
+
function profileLabel(u) {
|
|
15
|
+
return u === "" || u == null ? "device" : u === "0" ? "personal (user 0)" : "secondary (user " + u + ")";
|
|
16
|
+
}
|
|
17
|
+
export function DbBrowser({ spec }) {
|
|
18
|
+
const [user, setUser] = useState(null);
|
|
19
|
+
const [dbs, setDbs] = useState(null); // null = loading
|
|
20
|
+
const [db, setDb] = useState(null);
|
|
21
|
+
const [tables, setTables] = useState(null); // null = loading
|
|
22
|
+
const [sql, setSql] = useState("");
|
|
23
|
+
const [out, setOut] = useState({ msg: "pick a database, then a table" });
|
|
24
|
+
const [meta, setMeta] = useState("");
|
|
25
|
+
// keyboard nav: which pane has focus ("db" list or "tbl" list) + the highlighted index in
|
|
26
|
+
// each. j/k and Up/Down move the highlight within a pane, Enter drills in, Esc backs out of
|
|
27
|
+
// the tables pane, Tab toggles panes. Arrow keys are consumed here (preventDefault) so list
|
|
28
|
+
// navigation does not also scroll the page.
|
|
29
|
+
const [pane, setPane] = useState("db");
|
|
30
|
+
const [dbIdx, setDbIdx] = useState(0);
|
|
31
|
+
const [tblIdx, setTblIdx] = useState(0);
|
|
32
|
+
const userQS = useCallback((u) => (u != null ? "&user=" + encodeURIComponent(u) : ""), []);
|
|
33
|
+
const loadList = useCallback(async (u) => {
|
|
34
|
+
setDbs(null);
|
|
35
|
+
try {
|
|
36
|
+
const r = await (await fetch("/dbs?" + (u != null ? "user=" + encodeURIComponent(u) : ""))).json();
|
|
37
|
+
setDbs(r);
|
|
38
|
+
if (!r.error)
|
|
39
|
+
setUser(r.user ?? null);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
setDbs({ error: "network error loading databases" });
|
|
43
|
+
}
|
|
44
|
+
}, []);
|
|
45
|
+
// initial load
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
loadList(null);
|
|
48
|
+
}, []);
|
|
49
|
+
// keep the keyboard highlight in range when the lists change
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setDbIdx((i) => Math.max(0, Math.min(((dbs && dbs.names) || []).length - 1, i)));
|
|
52
|
+
}, [dbs]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
setTblIdx((i) => Math.max(0, Math.min(((tables && tables.tables) || []).length - 1, i)));
|
|
55
|
+
}, [tables]);
|
|
56
|
+
const pickDb = useCallback(async (name, refresh) => {
|
|
57
|
+
setDb(name);
|
|
58
|
+
setSql("");
|
|
59
|
+
setOut({ msg: "pick a table or write a query" });
|
|
60
|
+
setTables(null);
|
|
61
|
+
try {
|
|
62
|
+
const r = await (await fetch("/dbtables?db=" + encodeURIComponent(name) + userQS(user) + (refresh ? "&refresh=1" : ""))).json();
|
|
63
|
+
setTables(r);
|
|
64
|
+
if (!r.error)
|
|
65
|
+
setMeta(name + " — " + (((r.sizeBytes ?? 0) / 1024) | 0) + " KB snapshot, " + (r.tables ?? []).length + " tables · " + profileLabel(user));
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
setTables({ error: "network error loading tables" });
|
|
69
|
+
}
|
|
70
|
+
}, [user, userQS]);
|
|
71
|
+
const RENDER_LIMIT = 10000;
|
|
72
|
+
const run = useCallback(async (q) => {
|
|
73
|
+
const theSql = q ?? sql;
|
|
74
|
+
if (!db || !theSql)
|
|
75
|
+
return;
|
|
76
|
+
setOut({ msg: "running…" });
|
|
77
|
+
try {
|
|
78
|
+
const r = await (await fetch("/dbquery?db=" + encodeURIComponent(db) + "&sql=" + encodeURIComponent(theSql) + userQS(user))).json();
|
|
79
|
+
if (r.error)
|
|
80
|
+
setOut({ error: r.error });
|
|
81
|
+
else if (!r.rows || !r.rows.length) {
|
|
82
|
+
setOut({ rows: [] });
|
|
83
|
+
setMeta(db + " — 0 rows");
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const totalRows = r.rows.length;
|
|
87
|
+
const displayRows = r.rows.slice(0, RENDER_LIMIT);
|
|
88
|
+
setOut({ rows: displayRows });
|
|
89
|
+
const suffix = totalRows > RENDER_LIMIT ? " (showing first " + RENDER_LIMIT + " of " + totalRows + ")" : "";
|
|
90
|
+
setMeta(db + " — " + totalRows + " rows" + suffix);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
setOut({ error: "network error running query" });
|
|
95
|
+
}
|
|
96
|
+
}, [db, sql, user, userQS]);
|
|
97
|
+
const pickTable = useCallback((t) => {
|
|
98
|
+
const q = 'SELECT * FROM "' + t + '" LIMIT 100';
|
|
99
|
+
setSql(q);
|
|
100
|
+
run(q);
|
|
101
|
+
}, [run]);
|
|
102
|
+
// Scroll the keyboard-highlighted item into view as the focus index moves, so paging a long
|
|
103
|
+
// list with j/k keeps the green-outlined row on screen. block:nearest avoids a jump when it
|
|
104
|
+
// is already visible.
|
|
105
|
+
const sideRef = useRef(null);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const el = sideRef.current && sideRef.current.querySelector(".dbitem.kbd");
|
|
108
|
+
if (el && el.scrollIntoView)
|
|
109
|
+
el.scrollIntoView({ block: "nearest" });
|
|
110
|
+
}, [pane, dbIdx, tblIdx]);
|
|
111
|
+
// Keyboard navigation. The latest lists/indices/handlers live in a ref so the single window
|
|
112
|
+
// listener (attached once by useGlobalKeydown) never goes stale. Mirrors the PoC's
|
|
113
|
+
// stale-closure-ref pattern, now via the shared Foundation hooks.
|
|
114
|
+
const dbNames = (dbs && dbs.names) || [];
|
|
115
|
+
const tblNames = (tables && tables.tables) || [];
|
|
116
|
+
const st = useLatestRef({ dbNames, tblNames, pane, dbIdx, tblIdx, pickDb, pickTable });
|
|
117
|
+
useGlobalKeydown((e) => {
|
|
118
|
+
// never hijack the SQL box or the profile dropdown
|
|
119
|
+
const t = e.target;
|
|
120
|
+
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable))
|
|
121
|
+
return;
|
|
122
|
+
if (e.metaKey || e.ctrlKey || e.altKey)
|
|
123
|
+
return;
|
|
124
|
+
const s = st.current;
|
|
125
|
+
const list = s.pane === "tbl" ? s.tblNames : s.dbNames;
|
|
126
|
+
switch (e.key) {
|
|
127
|
+
case "ArrowDown":
|
|
128
|
+
case "j":
|
|
129
|
+
if (!list.length)
|
|
130
|
+
return;
|
|
131
|
+
if (s.pane === "tbl")
|
|
132
|
+
setTblIdx((i) => Math.min(s.tblNames.length - 1, i + 1));
|
|
133
|
+
else
|
|
134
|
+
setDbIdx((i) => Math.min(s.dbNames.length - 1, i + 1));
|
|
135
|
+
break;
|
|
136
|
+
case "ArrowUp":
|
|
137
|
+
case "k":
|
|
138
|
+
if (!list.length)
|
|
139
|
+
return;
|
|
140
|
+
if (s.pane === "tbl")
|
|
141
|
+
setTblIdx((i) => Math.max(0, i - 1));
|
|
142
|
+
else
|
|
143
|
+
setDbIdx((i) => Math.max(0, i - 1));
|
|
144
|
+
break;
|
|
145
|
+
case "Tab":
|
|
146
|
+
if (s.pane === "db" && s.tblNames.length)
|
|
147
|
+
setPane("tbl");
|
|
148
|
+
else
|
|
149
|
+
setPane("db");
|
|
150
|
+
break;
|
|
151
|
+
case "Enter":
|
|
152
|
+
if (s.pane === "db") {
|
|
153
|
+
const name = s.dbNames[s.dbIdx];
|
|
154
|
+
if (name) {
|
|
155
|
+
s.pickDb(name);
|
|
156
|
+
setPane("tbl");
|
|
157
|
+
setTblIdx(0);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const tbl = s.tblNames[s.tblIdx];
|
|
162
|
+
if (tbl)
|
|
163
|
+
s.pickTable(tbl.name);
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
case "Escape":
|
|
167
|
+
// back out of the tables pane to the db list; from the db pane Escape is a no-op
|
|
168
|
+
if (s.pane === "tbl")
|
|
169
|
+
setPane("db");
|
|
170
|
+
else
|
|
171
|
+
return;
|
|
172
|
+
break;
|
|
173
|
+
default:
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
});
|
|
178
|
+
return html `
|
|
179
|
+
<div class="dbview">
|
|
180
|
+
<div class="dbside" ref=${sideRef}>
|
|
181
|
+
<div class="dbsec">databases</div>
|
|
182
|
+
${dbs == null
|
|
183
|
+
? html `<div class="msg">loading…</div>`
|
|
184
|
+
: dbs.error
|
|
185
|
+
? html `<div class="msg err">${dbs.error}</div>`
|
|
186
|
+
: !dbs.names || !dbs.names.length
|
|
187
|
+
? html `<div class="msg">no databases (device unreachable, or debug build not installed)</div>`
|
|
188
|
+
: html `
|
|
189
|
+
${(dbs.availableUsers || []).length > 1
|
|
190
|
+
? html `<div class="dbprofile">
|
|
191
|
+
<span class="dbprofcap">profile</span>
|
|
192
|
+
<select
|
|
193
|
+
onChange=${(e) => {
|
|
194
|
+
const v = e.currentTarget.value;
|
|
195
|
+
setUser(v);
|
|
196
|
+
setDb(null);
|
|
197
|
+
setTables(null);
|
|
198
|
+
setOut({ msg: "pick a database" });
|
|
199
|
+
loadList(v);
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
${dbs.availableUsers.map((u) => html `<option value=${u} selected=${u === dbs.user}>${profileLabel(u)}</option>`)}
|
|
203
|
+
</select>
|
|
204
|
+
</div>`
|
|
205
|
+
: html `<div class="dbprofile">profile: <b>${profileLabel(dbs.user)}</b></div>`}
|
|
206
|
+
${dbs.names.map((n, i) => html `<div
|
|
207
|
+
class=${"dbitem" + (n === db ? " sel" : "") + (pane === "db" && i === dbIdx ? " kbd" : "")}
|
|
208
|
+
onClick=${() => {
|
|
209
|
+
setPane("db");
|
|
210
|
+
setDbIdx(i);
|
|
211
|
+
pickDb(n);
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
${n}
|
|
215
|
+
</div>`)}
|
|
216
|
+
`}
|
|
217
|
+
<div class="dbsec">tables</div>
|
|
218
|
+
${tables == null
|
|
219
|
+
? db
|
|
220
|
+
? html `<div class="msg">pulling + reading…</div>`
|
|
221
|
+
: html `<div class="msg">pick a database</div>`
|
|
222
|
+
: tables.error
|
|
223
|
+
? html `<div class="msg err">${tables.error}</div>`
|
|
224
|
+
: html `${(tables.tables ?? []).map((t, i) => html `<div
|
|
225
|
+
class=${"dbitem" + (pane === "tbl" && i === tblIdx ? " kbd" : "")}
|
|
226
|
+
onClick=${() => {
|
|
227
|
+
setPane("tbl");
|
|
228
|
+
setTblIdx(i);
|
|
229
|
+
pickTable(t.name);
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
${t.name}<span class="rows">${String(t.rows)}</span>
|
|
233
|
+
</div>`)}`}
|
|
234
|
+
</div>
|
|
235
|
+
<div class="dbmain">
|
|
236
|
+
<div class="dbprivacy">
|
|
237
|
+
Privacy: opening a database copies the on-device Slack store (message text,
|
|
238
|
+
request/response bodies, user, channel, and team IDs) in plaintext to a temp file on
|
|
239
|
+
this machine. The copy is read-only and auto-deleted when this dashboard stops. Review
|
|
240
|
+
before sharing anything from here.
|
|
241
|
+
</div>
|
|
242
|
+
<div class="dbqbar">
|
|
243
|
+
<input
|
|
244
|
+
value=${sql}
|
|
245
|
+
placeholder="SELECT * FROM table LIMIT 100 (read-only)"
|
|
246
|
+
autocomplete="off"
|
|
247
|
+
onInput=${(e) => setSql(e.currentTarget.value)}
|
|
248
|
+
onKeyDown=${(e) => {
|
|
249
|
+
if (e.key === "Enter")
|
|
250
|
+
run();
|
|
251
|
+
}}
|
|
252
|
+
/>
|
|
253
|
+
<button onClick=${() => run()}>Run</button>
|
|
254
|
+
${db ? html `<button class="dbrefresh" onClick=${() => pickDb(db, true)}>↻ refresh</button>` : null}
|
|
255
|
+
</div>
|
|
256
|
+
<div class="dbmeta">${meta}</div>
|
|
257
|
+
<div class="dbout">
|
|
258
|
+
${out.msg
|
|
259
|
+
? html `<div class="msg">${out.msg}</div>`
|
|
260
|
+
: out.error
|
|
261
|
+
? html `<div class="msg err">${out.error}</div>`
|
|
262
|
+
: !out.rows || !out.rows.length
|
|
263
|
+
? html `<div class="msg">0 rows</div>`
|
|
264
|
+
: html `<${Grid} rows=${out.rows} />`}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
270
|
+
function Grid({ rows }) {
|
|
271
|
+
const cols = Object.keys(rows[0]);
|
|
272
|
+
return html `
|
|
273
|
+
<table>
|
|
274
|
+
<thead>
|
|
275
|
+
<tr>
|
|
276
|
+
${cols.map((c) => html `<th>${c}</th>`)}
|
|
277
|
+
</tr>
|
|
278
|
+
</thead>
|
|
279
|
+
<tbody>
|
|
280
|
+
${rows.map((row) => html `<tr>
|
|
281
|
+
${cols.map((c) => html `<td title=${String(row[c] ?? "")}>${String(row[c] ?? "")}</td>`)}
|
|
282
|
+
</tr>`)}
|
|
283
|
+
</tbody>
|
|
284
|
+
</table>
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// One HTML escaper, shared by every dangerouslySetInnerHTML sink in the dashboard (the
|
|
2
|
+
// syntax-highlighted JSON, the graph SVG, the legend). Preact auto-escapes normal
|
|
3
|
+
// interpolation, so this is ONLY for the handful of innerHTML paths that build markup as a
|
|
4
|
+
// string. Escapes all five HTML-significant characters; missing any one reopens the DOM-XSS
|
|
5
|
+
// class the vanilla dashboard had. Do NOT inline a second copy in a component -- import this.
|
|
6
|
+
//
|
|
7
|
+
// SINK-CONTEXT ASSUMPTION: this escaper is safe for TEXT content and DOUBLE-QUOTED attribute
|
|
8
|
+
// values, which is what every current sink uses. It does NOT escape space, `=`, backtick, or
|
|
9
|
+
// `/`, so it is NOT safe in an UNQUOTED attribute or a template-literal context. Any new sink
|
|
10
|
+
// must keep interpolated attributes double-quoted (attr="${esc(x)}"), never attr=${esc(x)}.
|
|
11
|
+
export function esc(s) {
|
|
12
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
13
|
+
}
|