@shibbirweb/mcp-db-read-only 0.1.0 → 1.0.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.dockerhub.md +233 -234
  3. package/README.md +221 -269
  4. package/dist/ApplicationFactory.js +111 -14
  5. package/dist/cli/ViewerCommand.js +117 -0
  6. package/dist/config/EnvironmentConfigLoader.js +58 -0
  7. package/dist/drivers/BaseDriver.js +10 -1
  8. package/dist/drivers/document/MongoDriver.js +27 -30
  9. package/dist/drivers/keyvalue/RedisDriver.js +20 -5
  10. package/dist/drivers/search/ElasticsearchDriver.js +13 -9
  11. package/dist/drivers/sql/ClickHouseDriver.js +12 -13
  12. package/dist/drivers/sql/MsSqlDriver.js +6 -6
  13. package/dist/drivers/sql/MySqlDriver.js +7 -5
  14. package/dist/drivers/sql/MySqlSessionInitializer.js +17 -2
  15. package/dist/drivers/sql/PostgresDriver.js +6 -6
  16. package/dist/drivers/sql/SqliteDriver.js +3 -3
  17. package/dist/formatting/JsonSerializer.js +3 -2
  18. package/dist/index.js +16 -7
  19. package/dist/logging/CallLogger.js +139 -0
  20. package/dist/logging/LogChannel.js +18 -0
  21. package/dist/logging/LogFormatter.js +80 -0
  22. package/dist/logging/LogRecords.js +7 -0
  23. package/dist/logging/LogSink.js +38 -0
  24. package/dist/logging/RecordJson.js +39 -0
  25. package/dist/logging/Redactor.js +95 -0
  26. package/dist/logging/StatementTracer.js +9 -0
  27. package/dist/logging/ToolCallObserver.js +6 -0
  28. package/dist/logging/store/FolderLogChannel.js +56 -0
  29. package/dist/logging/store/FolderLogStore.js +214 -0
  30. package/dist/logging/store/LogFileNames.js +57 -0
  31. package/dist/logging/store/LogStore.js +18 -0
  32. package/dist/logging/store/MemoryLogStore.js +70 -0
  33. package/dist/logging/viewer/LiveLogViewer.js +264 -0
  34. package/dist/logging/viewer/LiveViewerObserver.js +62 -0
  35. package/dist/logging/viewer/ViewerAssets.js +625 -0
  36. package/dist/server/BackgroundService.js +1 -0
  37. package/dist/server/McpDbServer.js +16 -2
  38. package/dist/tools/BaseTool.js +8 -2
  39. package/dist/tools/connection/CurrentConnectionTool.js +11 -2
  40. package/package.json +1 -1
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Brings the live viewer up on the first tool call, and says so when it cannot.
3
+ *
4
+ * A decorator around the call logger's observer. Before each call it asks the
5
+ * viewer to be running, so the call itself appears in the page, and a port
6
+ * freed since the last attempt is picked up without a restart.
7
+ *
8
+ * The person using the chat is told, in the tool result itself, twice at most:
9
+ *
10
+ * - **the first time the port is unavailable**, with who holds it and what
11
+ * to do about it, since otherwise the viewer silently shows nothing;
12
+ * - **when it comes up after having been unavailable**, with its URL.
13
+ *
14
+ * Both go to the diagnostic log as well. Later failures while the port stays
15
+ * taken are not repeated in the chat, where the same line on every result
16
+ * would be noise the model has to read past.
17
+ */
18
+ export class LiveViewerObserver {
19
+ inner;
20
+ viewer;
21
+ fallback;
22
+ logger;
23
+ lastState = "idle";
24
+ toldUnavailable = false;
25
+ constructor(inner, viewer,
26
+ /** Where calls are still going while the viewer is down, e.g. "stderr". */
27
+ fallback, logger) {
28
+ this.inner = inner;
29
+ this.viewer = viewer;
30
+ this.fallback = fallback;
31
+ this.logger = logger;
32
+ }
33
+ async observe(tool, args, run) {
34
+ const status = await this.viewer.ensureRunning().catch(() => ({ state: "idle" }));
35
+ const notice = this.noticeFor(status);
36
+ const result = await this.inner.observe(tool, args, run);
37
+ if (!notice) {
38
+ return result;
39
+ }
40
+ return { ...result, content: [...result.content, { type: "text", text: notice }] };
41
+ }
42
+ /** @returns a line for the chat, or null. Logs every change of state either way. */
43
+ noticeFor(status) {
44
+ const previous = this.lastState;
45
+ this.lastState = status.state;
46
+ if (status.state === "unavailable") {
47
+ const message = `Live log viewer unavailable: ${status.reason}. Free that port, or set DB_LOG_PORT to another one. Calls are still logged to ${this.fallback}; the viewer starts on the next call once the port is free.`;
48
+ if (previous !== "unavailable") {
49
+ this.logger(message);
50
+ }
51
+ if (!this.toldUnavailable) {
52
+ this.toldUnavailable = true;
53
+ return `[mcp-db-read-only] ${message}`;
54
+ }
55
+ return null;
56
+ }
57
+ if (status.state === "running" && previous === "unavailable") {
58
+ return `[mcp-db-read-only] Live log viewer is now running at ${status.url}`;
59
+ }
60
+ return null;
61
+ }
62
+ }
@@ -0,0 +1,625 @@
1
+ /**
2
+ * The live viewer's page, stylesheet and script.
3
+ *
4
+ * Kept as strings in a module rather than as files beside it, so `tsc` alone
5
+ * ships them in `dist/` and the npm tarball, the Docker image and a local
6
+ * build all carry them without a copy step that could be forgotten.
7
+ *
8
+ * Self-contained on purpose: no framework, no CDN, no web font. The page runs
9
+ * on data that may be private, so it fetches nothing from anywhere but this
10
+ * server, and the server's Content-Security-Policy enforces that.
11
+ *
12
+ * Every piece of logged data is placed with `textContent`, never `innerHTML`.
13
+ * A row read from a database can contain `<script>`; here it is only ever
14
+ * text. A test asserts the script never uses `innerHTML`.
15
+ *
16
+ * The strings avoid backticks and `${`, since they sit inside template
17
+ * literals.
18
+ */
19
+ export class ViewerAssets {
20
+ html = HTML;
21
+ stylesheet = STYLESHEET;
22
+ script = SCRIPT;
23
+ }
24
+ const HTML = `<!doctype html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="utf-8">
28
+ <meta name="viewport" content="width=device-width, initial-scale=1">
29
+ <title>Live call log</title>
30
+ <link rel="stylesheet" href="/viewer.css">
31
+ <script src="/viewer.js" defer></script>
32
+ </head>
33
+ <body>
34
+ <header>
35
+ <div class="bar">
36
+ <div class="title">
37
+ <span class="dot" id="dot"></span>
38
+ <h1>Live call log</h1>
39
+ <span class="status" id="status">Connecting</span>
40
+ </div>
41
+ <div class="stats" id="stats"></div>
42
+ </div>
43
+ <div class="controls">
44
+ <input id="filter" type="search" placeholder="Filter by any text" autocomplete="off">
45
+ <select id="tool"><option value="">All tools</option></select>
46
+ <label class="check"><input id="failed" type="checkbox"> Failed only</label>
47
+ <button id="pause" type="button">Pause</button>
48
+ <button id="expand" type="button">Expand all</button>
49
+ </div>
50
+ </header>
51
+ <main>
52
+ <button class="banner" id="banner" type="button" hidden></button>
53
+ <nav class="pager" id="pager-top"></nav>
54
+ <p class="empty" id="empty" hidden>No calls yet. Each call appears here the moment it finishes.</p>
55
+ <div id="entries"></div>
56
+ <nav class="pager" id="pager-bottom"></nav>
57
+ </main>
58
+ </body>
59
+ </html>
60
+ `;
61
+ const STYLESHEET = `:root {
62
+ color-scheme: light dark;
63
+ --bg: #f6f7f9;
64
+ --panel: #ffffff;
65
+ --text: #1c2330;
66
+ --muted: #667085;
67
+ --border: #e3e6eb;
68
+ --code: #f1f3f6;
69
+ --accent: #2f6fed;
70
+ --ok: #16803c;
71
+ --ok-bg: #e5f5eb;
72
+ --bad: #c0362c;
73
+ --bad-bg: #fdecea;
74
+ --warn: #9a6700;
75
+ }
76
+ @media (prefers-color-scheme: dark) {
77
+ :root {
78
+ --bg: #0f1216;
79
+ --panel: #171b21;
80
+ --text: #e6e9ee;
81
+ --muted: #8b95a5;
82
+ --border: #262c35;
83
+ --code: #11151a;
84
+ --accent: #6e9bff;
85
+ --ok: #4cc27a;
86
+ --ok-bg: #11291b;
87
+ --bad: #ff7b72;
88
+ --bad-bg: #2d1515;
89
+ --warn: #e3b341;
90
+ }
91
+ }
92
+ * { box-sizing: border-box; }
93
+ /* Author styles such as .banner's display: block would otherwise override the hidden attribute. */
94
+ [hidden] { display: none !important; }
95
+ body {
96
+ margin: 0;
97
+ background: var(--bg);
98
+ color: var(--text);
99
+ font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
100
+ }
101
+ header {
102
+ position: sticky;
103
+ top: 0;
104
+ z-index: 1;
105
+ background: var(--panel);
106
+ border-bottom: 1px solid var(--border);
107
+ padding: 12px 20px;
108
+ }
109
+ .bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
110
+ .title { display: flex; align-items: center; gap: 10px; }
111
+ h1 { font-size: 16px; margin: 0; }
112
+ .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--warn); }
113
+ .dot.live { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-bg); }
114
+ .dot.down { background: var(--bad); }
115
+ .status, .stats { color: var(--muted); font-size: 13px; }
116
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; align-items: center; }
117
+ input[type=search], select, button {
118
+ font: inherit;
119
+ color: inherit;
120
+ background: var(--bg);
121
+ border: 1px solid var(--border);
122
+ border-radius: 6px;
123
+ padding: 5px 10px;
124
+ }
125
+ input[type=search] { flex: 1 1 240px; min-width: 0; }
126
+ button { cursor: pointer; }
127
+ button:hover { border-color: var(--accent); }
128
+ button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
129
+ .check { display: flex; align-items: center; gap: 6px; color: var(--muted); }
130
+ main { padding: 16px 20px 40px; max-width: 1200px; margin: 0 auto; }
131
+ .empty { color: var(--muted); text-align: center; margin-top: 60px; }
132
+ .entry {
133
+ background: var(--panel);
134
+ border: 1px solid var(--border);
135
+ border-left: 3px solid var(--ok);
136
+ border-radius: 8px;
137
+ margin-bottom: 8px;
138
+ }
139
+ .entry.failed { border-left-color: var(--bad); }
140
+ .entry.statement { border-left-color: var(--muted); }
141
+ .entry.fresh { animation: flash 1.2s ease-out; }
142
+ @keyframes flash { from { box-shadow: 0 0 0 3px var(--accent); } to { box-shadow: 0 0 0 0 transparent; } }
143
+ summary {
144
+ display: flex;
145
+ flex-wrap: wrap;
146
+ align-items: baseline;
147
+ gap: 4px 12px;
148
+ padding: 9px 14px;
149
+ cursor: pointer;
150
+ list-style: none;
151
+ }
152
+ summary::-webkit-details-marker { display: none; }
153
+ summary::before { content: "\\25B8"; color: var(--muted); width: 10px; }
154
+ details[open] > summary::before { content: "\\25BE"; }
155
+ .id { color: var(--muted); font-variant-numeric: tabular-nums; }
156
+ .tool { font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
157
+ .badge { font-size: 12px; padding: 1px 8px; border-radius: 10px; background: var(--ok-bg); color: var(--ok); }
158
+ .badge.failed { background: var(--bad-bg); color: var(--bad); }
159
+ .meta { color: var(--muted); font-size: 13px; }
160
+ .conn { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 520px; }
161
+ time { margin-left: auto; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
162
+ .body { padding: 0 14px 14px; }
163
+ h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 14px 0 6px; }
164
+ pre {
165
+ margin: 0;
166
+ padding: 10px 12px;
167
+ background: var(--code);
168
+ border: 1px solid var(--border);
169
+ border-radius: 6px;
170
+ overflow: auto;
171
+ max-height: 480px;
172
+ font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
173
+ white-space: pre-wrap;
174
+ word-break: break-word;
175
+ }
176
+ .code { position: relative; }
177
+ .code pre { padding-right: 40px; }
178
+ .copy-icon {
179
+ position: absolute;
180
+ top: 6px;
181
+ right: 6px;
182
+ display: inline-flex;
183
+ align-items: center;
184
+ justify-content: center;
185
+ width: 26px;
186
+ height: 26px;
187
+ padding: 0;
188
+ color: var(--muted);
189
+ background: var(--panel);
190
+ border: 1px solid var(--border);
191
+ border-radius: 6px;
192
+ opacity: .55;
193
+ transition: opacity .15s, color .15s, border-color .15s;
194
+ }
195
+ .code:hover .copy-icon, .copy-icon:focus-visible { opacity: 1; }
196
+ .copy-icon:hover { color: var(--accent); border-color: var(--accent); }
197
+ .copy-icon.done { opacity: 1; color: var(--ok); border-color: var(--ok); }
198
+ .copy-icon.error { opacity: 1; color: var(--bad); border-color: var(--bad); }
199
+ .copy-icon svg { fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
200
+ .label { font-size: 12px; color: var(--muted); margin: 6px 0 2px; }
201
+ .pager { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 12px; color: var(--muted); font-size: 13px; }
202
+ .pager .spacer { flex: 1; }
203
+ .pager button[disabled] { opacity: .4; cursor: default; }
204
+ .pager button.current { background: var(--accent); border-color: var(--accent); color: #fff; }
205
+ .pager select { padding: 3px 8px; }
206
+ .banner {
207
+ display: block;
208
+ width: 100%;
209
+ margin-bottom: 12px;
210
+ padding: 8px;
211
+ color: #fff;
212
+ background: var(--accent);
213
+ border-color: var(--accent);
214
+ border-radius: 8px;
215
+ font-weight: 600;
216
+ }
217
+ .origin { color: var(--muted); font-size: 12px; }
218
+ ol.statements { margin: 0; padding-left: 22px; }
219
+ ol.statements li { margin-bottom: 8px; }
220
+ .stmt-head { font-size: 13px; color: var(--muted); margin-bottom: 4px; }
221
+ .stmt-head .failed { color: var(--bad); }
222
+ .actions { margin-top: 12px; }
223
+ `;
224
+ const SCRIPT = `(function () {
225
+ "use strict";
226
+
227
+ var list = document.getElementById("entries");
228
+ var empty = document.getElementById("empty");
229
+ var dot = document.getElementById("dot");
230
+ var statusText = document.getElementById("status");
231
+ var stats = document.getElementById("stats");
232
+ var filterInput = document.getElementById("filter");
233
+ var toolSelect = document.getElementById("tool");
234
+ var failedOnly = document.getElementById("failed");
235
+ var pauseButton = document.getElementById("pause");
236
+ var expandButton = document.getElementById("expand");
237
+
238
+ var tools = new Set();
239
+ var paused = false;
240
+ var expanded = false;
241
+
242
+ function el(tag, className, text) {
243
+ var node = document.createElement(tag);
244
+ if (className) { node.className = className; }
245
+ if (text !== undefined && text !== null) { node.textContent = String(text); }
246
+ return node;
247
+ }
248
+
249
+ function pretty(value) {
250
+ if (value === undefined) { return ""; }
251
+ try { return JSON.stringify(value, null, 2); } catch (error) { return String(value); }
252
+ }
253
+
254
+ function clock(iso) {
255
+ var date = new Date(iso);
256
+ function pad(n, size) { return String(n).padStart(size, "0"); }
257
+ return pad(date.getHours(), 2) + ":" + pad(date.getMinutes(), 2) + ":" + pad(date.getSeconds(), 2) + "." + pad(date.getMilliseconds(), 3);
258
+ }
259
+
260
+ // Which copy of the server made the entry: several share one log folder.
261
+ function origin(record) {
262
+ return (record.client || "unknown client") + " \\u00b7 pid " + record.pid;
263
+ }
264
+
265
+ function section(title, content) {
266
+ var wrap = el("section");
267
+ wrap.appendChild(el("h3", null, title));
268
+ wrap.appendChild(content);
269
+ return wrap;
270
+ }
271
+
272
+ function statementItem(statement) {
273
+ var item = el("li");
274
+ var head = el("div", "stmt-head");
275
+ head.appendChild(el("span", null, statement.engine + " \\u00b7 " + statement.durationMs + " ms \\u00b7 "));
276
+ head.appendChild(el("span", statement.failed ? "failed" : null, statement.failed ? "FAILED: " + statement.outcome : statement.outcome));
277
+ item.appendChild(head);
278
+ item.appendChild(codeBlock(statement.text));
279
+ if (statement.params !== undefined && statement.params !== null) {
280
+ item.appendChild(el("div", "label", "params"));
281
+ item.appendChild(codeBlock(pretty(statement.params)));
282
+ }
283
+ return item;
284
+ }
285
+
286
+ // The clipboard API only exists on a secure origin. 127.0.0.1 is one, but
287
+ // the page opened from another device by its LAN address is not, so the
288
+ // old selection-based copy is the fallback there.
289
+ function copyText(text) {
290
+ if (navigator.clipboard && window.isSecureContext) {
291
+ return navigator.clipboard.writeText(text);
292
+ }
293
+ return new Promise(function (resolve, reject) {
294
+ var area = document.createElement("textarea");
295
+ area.value = text;
296
+ area.setAttribute("readonly", "");
297
+ area.style.position = "fixed";
298
+ area.style.opacity = "0";
299
+ document.body.appendChild(area);
300
+ area.select();
301
+ var copied = false;
302
+ try { copied = document.execCommand("copy"); } catch (error) { copied = false; }
303
+ area.remove();
304
+ if (copied) { resolve(); } else { reject(new Error("copy failed")); }
305
+ });
306
+ }
307
+
308
+ var SVG_NS = "http://www.w3.org/2000/svg";
309
+
310
+ function icon(kind) {
311
+ var svg = document.createElementNS(SVG_NS, "svg");
312
+ svg.setAttribute("viewBox", "0 0 16 16");
313
+ svg.setAttribute("width", "14");
314
+ svg.setAttribute("height", "14");
315
+ svg.setAttribute("aria-hidden", "true");
316
+ function shape(tag, attributes) {
317
+ var node = document.createElementNS(SVG_NS, tag);
318
+ Object.keys(attributes).forEach(function (name) { node.setAttribute(name, attributes[name]); });
319
+ svg.appendChild(node);
320
+ }
321
+ if (kind === "check") {
322
+ shape("polyline", { points: "3,8.5 6.5,12 13,4.5" });
323
+ } else {
324
+ shape("rect", { x: "5.5", y: "5.5", width: "8", height: "8", rx: "1.5" });
325
+ shape("path", { d: "M10.5 5.5V3.5A1.5 1.5 0 0 0 9 2H3.5A1.5 1.5 0 0 0 2 3.5V9A1.5 1.5 0 0 0 3.5 10.5H5.5" });
326
+ }
327
+ return svg;
328
+ }
329
+
330
+ function flash(button, ok, idle) {
331
+ button.replaceChildren(ok ? icon("check") : icon("copy"));
332
+ button.classList.toggle("done", ok);
333
+ button.classList.toggle("error", !ok);
334
+ button.title = ok ? "Copied" : "Copy failed";
335
+ setTimeout(function () {
336
+ button.replaceChildren(icon("copy"));
337
+ button.classList.remove("done", "error");
338
+ button.title = idle;
339
+ }, 1400);
340
+ }
341
+
342
+ function copyIcon(read, label) {
343
+ var button = el("button", "copy-icon");
344
+ button.type = "button";
345
+ button.title = label;
346
+ button.setAttribute("aria-label", label);
347
+ button.appendChild(icon("copy"));
348
+ button.addEventListener("click", function (event) {
349
+ event.preventDefault();
350
+ event.stopPropagation();
351
+ copyText(read()).then(function () { flash(button, true, label); }, function () { flash(button, false, label); });
352
+ });
353
+ return button;
354
+ }
355
+
356
+ // A code block with a copy icon in its corner, copying exactly the text shown.
357
+ function codeBlock(text) {
358
+ var wrap = el("div", "code");
359
+ var pre = el("pre", null, text);
360
+ wrap.appendChild(pre);
361
+ wrap.appendChild(copyIcon(function () { return pre.textContent; }, "Copy"));
362
+ return wrap;
363
+ }
364
+
365
+ function copyButton(payload) {
366
+ var button = el("button", null, "Copy entry as JSON");
367
+ button.type = "button";
368
+ button.addEventListener("click", function (event) {
369
+ event.preventDefault();
370
+ copyText(JSON.stringify(payload, null, 2)).then(function () {
371
+ button.textContent = "Copied";
372
+ setTimeout(function () { button.textContent = "Copy entry as JSON"; }, 1500);
373
+ }, function () {
374
+ button.textContent = "Copy failed";
375
+ setTimeout(function () { button.textContent = "Copy entry as JSON"; }, 1500);
376
+ });
377
+ });
378
+ var wrap = el("div", "actions");
379
+ wrap.appendChild(button);
380
+ return wrap;
381
+ }
382
+
383
+ function renderCall(call) {
384
+ var card = el("details", "entry call" + (call.failed ? " failed" : ""));
385
+ var summary = el("summary");
386
+ summary.appendChild(el("span", "id", "#" + call.id));
387
+ summary.appendChild(el("span", "tool", call.tool));
388
+ summary.appendChild(el("span", "badge" + (call.failed ? " failed" : ""), call.failed ? "failed" : "ok"));
389
+ summary.appendChild(el("span", "meta", call.durationMs + " ms"));
390
+ if (call.statements.length > 0) {
391
+ summary.appendChild(el("span", "meta", call.statements.length + " statement" + (call.statements.length === 1 ? "" : "s")));
392
+ }
393
+ summary.appendChild(el("span", "conn", call.connection || "no connection"));
394
+ summary.appendChild(el("span", "origin", origin(call)));
395
+ summary.appendChild(el("time", null, clock(call.at)));
396
+ card.appendChild(summary);
397
+
398
+ var body = el("div", "body");
399
+ body.appendChild(section("Connection", codeBlock(call.connection || "none")));
400
+ body.appendChild(section("Input", codeBlock(pretty(call.input))));
401
+ if (call.statements.length > 0) {
402
+ var ordered = el("ol", "statements");
403
+ call.statements.forEach(function (statement) { ordered.appendChild(statementItem(statement)); });
404
+ body.appendChild(section("Statements (" + call.statements.length + ")", ordered));
405
+ }
406
+ body.appendChild(section(call.failed ? "Error" : "Output", codeBlock(call.output)));
407
+ body.appendChild(copyButton(call));
408
+ card.appendChild(body);
409
+ return card;
410
+ }
411
+
412
+ function renderStatement(statement) {
413
+ var card = el("details", "entry statement" + (statement.failed ? " failed" : ""));
414
+ var summary = el("summary");
415
+ summary.appendChild(el("span", "tool", statement.engine));
416
+ summary.appendChild(el("span", "badge" + (statement.failed ? " failed" : ""), statement.failed ? "failed" : statement.outcome));
417
+ summary.appendChild(el("span", "meta", statement.durationMs + " ms \\u00b7 outside a tool call"));
418
+ summary.appendChild(el("span", "origin", origin(statement)));
419
+ summary.appendChild(el("time", null, clock(statement.at)));
420
+ card.appendChild(summary);
421
+ var body = el("div", "body");
422
+ var ordered = el("ol", "statements");
423
+ ordered.appendChild(statementItem(statement));
424
+ body.appendChild(ordered);
425
+ body.appendChild(copyButton(statement));
426
+ card.appendChild(body);
427
+ return card;
428
+ }
429
+
430
+ // Keeps the tool filter in step with the tools seen, in alphabetical order.
431
+ function addTool(name) {
432
+ if (!name || tools.has(name)) { return; }
433
+ tools.add(name);
434
+ var option = el("option", null, name);
435
+ option.value = name;
436
+ var options = Array.prototype.slice.call(toolSelect.options, 1);
437
+ var before = options.find(function (existing) { return existing.value > name; });
438
+ toolSelect.insertBefore(option, before || null);
439
+ }
440
+
441
+ var SIZES = [10, 20, 30, 50];
442
+ var DEFAULT_SIZE = 20;
443
+ var SIZE_KEY = "mcp-db-read-only.pageSize";
444
+ var pagerTop = document.getElementById("pager-top");
445
+ var pagerBottom = document.getElementById("pager-bottom");
446
+ var banner = document.getElementById("banner");
447
+
448
+ // Remembered per browser; storage can be unavailable, so every access is guarded.
449
+ function savedSize() {
450
+ try {
451
+ var stored = Number(window.localStorage.getItem(SIZE_KEY));
452
+ return SIZES.indexOf(stored) === -1 ? DEFAULT_SIZE : stored;
453
+ } catch (error) { return DEFAULT_SIZE; }
454
+ }
455
+ function saveSize(size) {
456
+ try { window.localStorage.setItem(SIZE_KEY, String(size)); } catch (error) { return; }
457
+ }
458
+
459
+ var state = { page: 1, size: savedSize(), total: 0, pages: 1, loading: false, pendingNew: 0 };
460
+ var openKeys = new Set();
461
+ var reloadTimer = null;
462
+
463
+ function query() {
464
+ var params = new URLSearchParams();
465
+ params.set("page", String(state.page));
466
+ params.set("size", String(state.size));
467
+ if (toolSelect.value) { params.set("tool", toolSelect.value); }
468
+ if (failedOnly.checked) { params.set("failed", "1"); }
469
+ if (filterInput.value.trim()) { params.set("q", filterInput.value.trim()); }
470
+ return params.toString();
471
+ }
472
+
473
+ function pagerButton(label, page, disabled, current) {
474
+ var button = el("button", current ? "current" : null, label);
475
+ button.type = "button";
476
+ button.disabled = Boolean(disabled);
477
+ button.addEventListener("click", function () { goTo(page); });
478
+ return button;
479
+ }
480
+
481
+ // First, previous, a window of page numbers, next, last.
482
+ function renderPager(nav) {
483
+ nav.replaceChildren();
484
+ var from = state.total === 0 ? 0 : (state.page - 1) * state.size + 1;
485
+ var to = Math.min(state.page * state.size, state.total);
486
+ nav.appendChild(el("span", null, "Showing " + from + "\u2013" + to + " of " + state.total));
487
+ nav.appendChild(el("span", "spacer"));
488
+
489
+ nav.appendChild(pagerButton("\u00ab", 1, state.page <= 1));
490
+ nav.appendChild(pagerButton("\u2039 Prev", state.page - 1, state.page <= 1));
491
+ var first = Math.max(1, state.page - 2);
492
+ var last = Math.min(state.pages, first + 4);
493
+ first = Math.max(1, last - 4);
494
+ for (var page = first; page <= last; page += 1) {
495
+ nav.appendChild(pagerButton(String(page), page, false, page === state.page));
496
+ }
497
+ nav.appendChild(pagerButton("Next \u203a", state.page + 1, state.page >= state.pages));
498
+ nav.appendChild(pagerButton("\u00bb", state.pages, state.page >= state.pages));
499
+
500
+ var select = el("select");
501
+ SIZES.forEach(function (size) {
502
+ var option = el("option", null, size + " per page");
503
+ option.value = String(size);
504
+ option.selected = size === state.size;
505
+ select.appendChild(option);
506
+ });
507
+ select.addEventListener("change", function () {
508
+ state.size = Number(select.value);
509
+ saveSize(state.size);
510
+ goTo(1);
511
+ });
512
+ nav.appendChild(select);
513
+ }
514
+
515
+ function render(entries) {
516
+ list.replaceChildren();
517
+ entries.forEach(function (entry) {
518
+ var node = entry.kind === "call" ? renderCall(entry.data) : renderStatement(entry.data);
519
+ node.dataset.key = entry.key;
520
+ if (expanded || openKeys.has(entry.key)) { node.open = true; }
521
+ node.addEventListener("toggle", function () {
522
+ if (node.open) { openKeys.add(entry.key); } else { openKeys.delete(entry.key); }
523
+ });
524
+ list.appendChild(node);
525
+ });
526
+ empty.hidden = entries.length > 0;
527
+ renderPager(pagerTop);
528
+ renderPager(pagerBottom);
529
+ stats.textContent = state.total + (state.total === 1 ? " entry" : " entries") + (filtersActive() ? " matching" : "");
530
+ }
531
+
532
+ function filtersActive() {
533
+ return Boolean(toolSelect.value || failedOnly.checked || filterInput.value.trim());
534
+ }
535
+
536
+ function load() {
537
+ state.loading = true;
538
+ return fetch("/api/entries?" + query())
539
+ .then(function (response) { return response.json(); })
540
+ .then(function (page) {
541
+ state.page = page.page;
542
+ state.pages = page.pages;
543
+ state.total = page.total;
544
+ render(page.entries);
545
+ })
546
+ .catch(function () { setStatus("down", "Could not load entries"); })
547
+ .then(function () { state.loading = false; });
548
+ }
549
+
550
+ function loadTools() {
551
+ return fetch("/api/tools")
552
+ .then(function (response) { return response.json(); })
553
+ .then(function (body) { body.tools.forEach(addTool); })
554
+ .catch(function () { return; });
555
+ }
556
+
557
+ function goTo(page) {
558
+ state.page = Math.max(1, page);
559
+ state.pendingNew = 0;
560
+ banner.hidden = true;
561
+ load().then(function () { window.scrollTo(0, 0); });
562
+ }
563
+
564
+ // Page 1 refreshes itself as calls arrive; any other page would shift
565
+ // under the reader, so it offers a jump to the newest instead.
566
+ function onNewEntry(entry) {
567
+ if (entry.kind === "call") { addTool(entry.data.tool); }
568
+ if (state.page === 1 && !paused) {
569
+ clearTimeout(reloadTimer);
570
+ reloadTimer = setTimeout(function () { load().then(function () { flashKey(entry.key); }); }, 150);
571
+ return;
572
+ }
573
+ state.pendingNew += 1;
574
+ banner.textContent = state.pendingNew + " new " + (state.pendingNew === 1 ? "entry" : "entries") + " \u00b7 show newest";
575
+ banner.hidden = false;
576
+ }
577
+
578
+ function flashKey(key) {
579
+ var node = list.querySelector('[data-key="' + CSS.escape(key) + '"]');
580
+ if (node) {
581
+ node.classList.add("fresh");
582
+ setTimeout(function () { node.classList.remove("fresh"); }, 1300);
583
+ }
584
+ }
585
+
586
+ function setStatus(stateName, text) {
587
+ dot.className = "dot" + (stateName ? " " + stateName : "");
588
+ statusText.textContent = text;
589
+ }
590
+
591
+ var source = new EventSource("/events");
592
+ source.addEventListener("ready", function () { setStatus("live", "Live"); load(); loadTools(); });
593
+ source.addEventListener("entry", function (event) {
594
+ var entry;
595
+ try { entry = JSON.parse(event.data); } catch (error) { return; }
596
+ onNewEntry(entry);
597
+ });
598
+ source.addEventListener("error", function () { setStatus("down", "Disconnected, retrying"); });
599
+
600
+ var filterTimer = null;
601
+ filterInput.addEventListener("input", function () {
602
+ clearTimeout(filterTimer);
603
+ filterTimer = setTimeout(function () { goTo(1); }, 250);
604
+ });
605
+ toolSelect.addEventListener("change", function () { goTo(1); });
606
+ failedOnly.addEventListener("change", function () { goTo(1); });
607
+ banner.addEventListener("click", function () { goTo(1); });
608
+
609
+ pauseButton.addEventListener("click", function () {
610
+ paused = !paused;
611
+ pauseButton.classList.toggle("active", paused);
612
+ pauseButton.textContent = paused ? "Resume" : "Pause";
613
+ if (!paused && state.pendingNew > 0 && state.page === 1) { goTo(1); }
614
+ });
615
+
616
+ expandButton.addEventListener("click", function () {
617
+ expanded = !expanded;
618
+ expandButton.textContent = expanded ? "Collapse all" : "Expand all";
619
+ list.querySelectorAll("details.entry").forEach(function (node) { node.open = expanded; });
620
+ });
621
+
622
+ load();
623
+ loadTools();
624
+ })();
625
+ `;
@@ -0,0 +1 @@
1
+ export {};