@shibbirweb/mcp-db-read-only 0.1.0 → 0.2.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/CHANGELOG.md +13 -1
- package/README.dockerhub.md +79 -2
- package/README.md +78 -1
- package/dist/ApplicationFactory.js +111 -14
- package/dist/config/EnvironmentConfigLoader.js +58 -0
- package/dist/drivers/BaseDriver.js +10 -1
- package/dist/drivers/document/MongoDriver.js +27 -30
- package/dist/drivers/keyvalue/RedisDriver.js +20 -5
- package/dist/drivers/search/ElasticsearchDriver.js +13 -9
- package/dist/drivers/sql/ClickHouseDriver.js +12 -13
- package/dist/drivers/sql/MsSqlDriver.js +6 -6
- package/dist/drivers/sql/MySqlDriver.js +7 -5
- package/dist/drivers/sql/MySqlSessionInitializer.js +17 -2
- package/dist/drivers/sql/PostgresDriver.js +6 -6
- package/dist/drivers/sql/SqliteDriver.js +3 -3
- package/dist/formatting/JsonSerializer.js +3 -2
- package/dist/logging/CallLogger.js +139 -0
- package/dist/logging/LogChannel.js +18 -0
- package/dist/logging/LogFormatter.js +80 -0
- package/dist/logging/LogRecords.js +7 -0
- package/dist/logging/LogSink.js +38 -0
- package/dist/logging/RecordJson.js +39 -0
- package/dist/logging/Redactor.js +95 -0
- package/dist/logging/StatementTracer.js +9 -0
- package/dist/logging/ToolCallObserver.js +6 -0
- package/dist/logging/store/FolderLogChannel.js +56 -0
- package/dist/logging/store/FolderLogStore.js +214 -0
- package/dist/logging/store/LogFileNames.js +57 -0
- package/dist/logging/store/LogStore.js +18 -0
- package/dist/logging/store/MemoryLogStore.js +70 -0
- package/dist/logging/viewer/LiveLogViewer.js +256 -0
- package/dist/logging/viewer/LiveViewerObserver.js +62 -0
- package/dist/logging/viewer/ViewerAssets.js +625 -0
- package/dist/server/BackgroundService.js +1 -0
- package/dist/server/McpDbServer.js +16 -2
- package/dist/tools/BaseTool.js +8 -2
- package/dist/tools/connection/CurrentConnectionTool.js +11 -2
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes credentials from tool arguments before they are logged.
|
|
3
|
+
*
|
|
4
|
+
* Always applied, with no switch to turn it off. The call log exists to show
|
|
5
|
+
* what an assistant asked for, and a password is never part of that answer;
|
|
6
|
+
* a log file is also exactly the kind of thing that ends up attached to an
|
|
7
|
+
* issue or pasted into a chat.
|
|
8
|
+
*
|
|
9
|
+
* Two shapes are caught:
|
|
10
|
+
*
|
|
11
|
+
* - **An argument whose name looks like a secret** (`password`, `api_key`,
|
|
12
|
+
* `token`...), at any depth. The value is replaced outright.
|
|
13
|
+
* - **A connection URL anywhere in a string**, whose password and whose
|
|
14
|
+
* secret-looking query parameters are masked. The URL is split the same way
|
|
15
|
+
* ConnectionUrlParser splits it, so what is masked is exactly what the
|
|
16
|
+
* parser would have used as the password.
|
|
17
|
+
*/
|
|
18
|
+
export class Redactor {
|
|
19
|
+
static MASK = "***";
|
|
20
|
+
/** The same test ConnectionUrlParser uses to keep secret options out of displayed strings. */
|
|
21
|
+
static SECRET_NAME = /(pass|secret|token|api[_-]?key|credential)/i;
|
|
22
|
+
static URL_START = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
|
|
23
|
+
static MAX_DEPTH = 32;
|
|
24
|
+
redact(value, depth = 0) {
|
|
25
|
+
if (depth > Redactor.MAX_DEPTH) {
|
|
26
|
+
return "[nested too deeply to log]";
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === "string") {
|
|
29
|
+
return this.redactUrl(value);
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
return value.map((item) => this.redact(item, depth + 1));
|
|
33
|
+
}
|
|
34
|
+
if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [key, child] of Object.entries(value)) {
|
|
37
|
+
out[key] = Redactor.SECRET_NAME.test(key) && child !== "" && child != null
|
|
38
|
+
? Redactor.MASK
|
|
39
|
+
: this.redact(child, depth + 1);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `scheme://user:password@host/db?api_key=...` becomes
|
|
47
|
+
* `scheme://user:***@host/db?api_key=***`. Anything not shaped like a URL
|
|
48
|
+
* is returned unchanged.
|
|
49
|
+
*/
|
|
50
|
+
redactUrl(text) {
|
|
51
|
+
const scheme = Redactor.URL_START.exec(text);
|
|
52
|
+
if (!scheme) {
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
const prefix = scheme[0];
|
|
56
|
+
const rest = text.slice(prefix.length);
|
|
57
|
+
const authorityEnd = this.firstIndexOf(rest, ["/", "?", "#"]);
|
|
58
|
+
const authority = authorityEnd === -1 ? rest : rest.slice(0, authorityEnd);
|
|
59
|
+
const tail = authorityEnd === -1 ? "" : rest.slice(authorityEnd);
|
|
60
|
+
return `${prefix}${this.maskUserInfo(authority)}${this.maskQuery(tail)}`;
|
|
61
|
+
}
|
|
62
|
+
maskUserInfo(authority) {
|
|
63
|
+
const at = authority.lastIndexOf("@");
|
|
64
|
+
if (at === -1) {
|
|
65
|
+
return authority;
|
|
66
|
+
}
|
|
67
|
+
const userInfo = authority.slice(0, at);
|
|
68
|
+
const colon = userInfo.indexOf(":");
|
|
69
|
+
if (colon === -1 || colon === userInfo.length - 1) {
|
|
70
|
+
return authority;
|
|
71
|
+
}
|
|
72
|
+
return `${userInfo.slice(0, colon + 1)}${Redactor.MASK}${authority.slice(at)}`;
|
|
73
|
+
}
|
|
74
|
+
maskQuery(tail) {
|
|
75
|
+
const queryAt = tail.indexOf("?");
|
|
76
|
+
if (queryAt === -1) {
|
|
77
|
+
return tail;
|
|
78
|
+
}
|
|
79
|
+
const hashAt = tail.indexOf("#", queryAt);
|
|
80
|
+
const query = tail.slice(queryAt + 1, hashAt === -1 ? undefined : hashAt);
|
|
81
|
+
const masked = query
|
|
82
|
+
.split("&")
|
|
83
|
+
.map((pair) => {
|
|
84
|
+
const equals = pair.indexOf("=");
|
|
85
|
+
const name = equals === -1 ? pair : pair.slice(0, equals);
|
|
86
|
+
return equals !== -1 && Redactor.SECRET_NAME.test(name) ? `${name}=${Redactor.MASK}` : pair;
|
|
87
|
+
})
|
|
88
|
+
.join("&");
|
|
89
|
+
return `${tail.slice(0, queryAt + 1)}${masked}${hashAt === -1 ? "" : tail.slice(hashAt)}`;
|
|
90
|
+
}
|
|
91
|
+
firstIndexOf(value, needles) {
|
|
92
|
+
const positions = needles.map((needle) => value.indexOf(needle)).filter((index) => index !== -1);
|
|
93
|
+
return positions.length === 0 ? -1 : Math.min(...positions);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { JsonSerializer } from "../../formatting/JsonSerializer.js";
|
|
4
|
+
import { RecordJson } from "../RecordJson.js";
|
|
5
|
+
import { LogFileNames } from "./LogFileNames.js";
|
|
6
|
+
/**
|
|
7
|
+
* Saves every entry as its own JSON file in the permanent log folder.
|
|
8
|
+
*
|
|
9
|
+
* Each file is written under a temporary name and renamed into place, which
|
|
10
|
+
* is atomic on one filesystem, so a reader (this process's viewer, another
|
|
11
|
+
* copy's, or a person with `ls`) never sees a half-written file. Temporaries
|
|
12
|
+
* end in `.tmp` and are ignored by LogFileNames.
|
|
13
|
+
*
|
|
14
|
+
* The folder is created readable by the owner only, and each file likewise:
|
|
15
|
+
* with logging on they hold every query and every result.
|
|
16
|
+
*
|
|
17
|
+
* Synchronous for the same reason as FileSink: shutdown ends with
|
|
18
|
+
* `process.exit`, which would lose buffered writes.
|
|
19
|
+
*
|
|
20
|
+
* Nothing is ever deleted or rotated here. The folder is permanent by design.
|
|
21
|
+
*/
|
|
22
|
+
export class FolderLogChannel {
|
|
23
|
+
directory;
|
|
24
|
+
onWritten;
|
|
25
|
+
serializer;
|
|
26
|
+
sequence = 0;
|
|
27
|
+
/**
|
|
28
|
+
* @param onWritten told of each entry just saved, so this process's own
|
|
29
|
+
* viewer can show it at once instead of waiting for its next scan.
|
|
30
|
+
*/
|
|
31
|
+
constructor(directory, onWritten = () => undefined, serializer = new JsonSerializer()) {
|
|
32
|
+
this.directory = directory;
|
|
33
|
+
this.onWritten = onWritten;
|
|
34
|
+
this.serializer = serializer;
|
|
35
|
+
}
|
|
36
|
+
get description() {
|
|
37
|
+
return this.directory;
|
|
38
|
+
}
|
|
39
|
+
onCall(call) {
|
|
40
|
+
const name = LogFileNames.build(call.at, call.pid, "call", ++this.sequence, call.tool, call.failed);
|
|
41
|
+
this.save(name, "call", RecordJson.call(call));
|
|
42
|
+
}
|
|
43
|
+
onStatement(statement) {
|
|
44
|
+
const name = LogFileNames.build(statement.at, statement.pid, "statement", ++this.sequence, null, statement.failed);
|
|
45
|
+
this.save(name, "statement", RecordJson.statement(statement));
|
|
46
|
+
}
|
|
47
|
+
save(relativePath, kind, data) {
|
|
48
|
+
const target = join(this.directory, relativePath);
|
|
49
|
+
const day = join(this.directory, relativePath.split("/")[0]);
|
|
50
|
+
mkdirSync(day, { recursive: true, mode: 0o700 });
|
|
51
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
52
|
+
writeFileSync(temporary, `${this.serializer.stringify(data)}\n`, { mode: 0o600 });
|
|
53
|
+
renameSync(temporary, target);
|
|
54
|
+
this.onWritten({ key: relativePath, kind, data });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { LogFileNames } from "./LogFileNames.js";
|
|
5
|
+
import { Paging } from "./LogStore.js";
|
|
6
|
+
/**
|
|
7
|
+
* Reads the permanent log folder for the viewer: every entry saved by every
|
|
8
|
+
* copy of the server that shares it, newest first.
|
|
9
|
+
*
|
|
10
|
+
* **An index of file names, not contents.** On first use the folder is
|
|
11
|
+
* listed once, and each name parsed (LogFileNames puts time, pid, tool and
|
|
12
|
+
* outcome in it), so paging, the tool filter and "failed only" never open a
|
|
13
|
+
* file. Only the entries on the page being shown are read, plus, for a text
|
|
14
|
+
* search, the candidates it has to look inside.
|
|
15
|
+
*
|
|
16
|
+
* **New entries.** This process's own writes arrive at once through
|
|
17
|
+
* `noteWritten`. Other copies' writes are found by rescanning today's folder
|
|
18
|
+
* once a second: one small directory listing, dependable on every platform,
|
|
19
|
+
* where `fs.watch` is not (it drops and duplicates events, and behaves
|
|
20
|
+
* differently on network and bind-mounted folders). The timer is unref'd, so
|
|
21
|
+
* it never keeps the process alive.
|
|
22
|
+
*
|
|
23
|
+
* Files deleted by hand disappear from the index the next time they would
|
|
24
|
+
* have been read.
|
|
25
|
+
*/
|
|
26
|
+
export class FolderLogStore {
|
|
27
|
+
directory;
|
|
28
|
+
static SCAN_INTERVAL_MS = 1000;
|
|
29
|
+
index = null;
|
|
30
|
+
known = new Set();
|
|
31
|
+
/** Entries already pushed to the viewer, so the scan and noteWritten never push one twice. */
|
|
32
|
+
emitted = new Set();
|
|
33
|
+
listeners = new Set();
|
|
34
|
+
timer = null;
|
|
35
|
+
constructor(directory) {
|
|
36
|
+
this.directory = directory;
|
|
37
|
+
}
|
|
38
|
+
start() {
|
|
39
|
+
if (this.timer) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.load();
|
|
43
|
+
this.timer = setInterval(() => void this.scanRecent(), FolderLogStore.SCAN_INTERVAL_MS);
|
|
44
|
+
this.timer.unref();
|
|
45
|
+
}
|
|
46
|
+
stop() {
|
|
47
|
+
if (this.timer) {
|
|
48
|
+
clearInterval(this.timer);
|
|
49
|
+
this.timer = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
subscribe(listener) {
|
|
53
|
+
this.listeners.add(listener);
|
|
54
|
+
return () => this.listeners.delete(listener);
|
|
55
|
+
}
|
|
56
|
+
/** An entry this process has just saved: index it and tell the viewer now. */
|
|
57
|
+
noteWritten(entry) {
|
|
58
|
+
const [day, file] = entry.key.split("/");
|
|
59
|
+
const info = LogFileNames.parse(day, file);
|
|
60
|
+
if (!info) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Loaded first, so a write before the first query cannot leave the index
|
|
64
|
+
// holding only this one entry and the rest of the folder unscanned.
|
|
65
|
+
this.load();
|
|
66
|
+
this.add(info);
|
|
67
|
+
this.emit(entry);
|
|
68
|
+
}
|
|
69
|
+
async tools() {
|
|
70
|
+
const names = new Set();
|
|
71
|
+
for (const info of this.load()) {
|
|
72
|
+
if (info.tool) {
|
|
73
|
+
names.add(info.tool);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return Array.from(names).sort();
|
|
77
|
+
}
|
|
78
|
+
async query(query) {
|
|
79
|
+
let candidates = this.load().filter((info) => (!query.tool || info.tool === query.tool) && (!query.failedOnly || info.failed));
|
|
80
|
+
const needle = query.text?.trim().toLowerCase();
|
|
81
|
+
if (needle) {
|
|
82
|
+
const matching = [];
|
|
83
|
+
for (const info of candidates) {
|
|
84
|
+
const text = await this.readText(info);
|
|
85
|
+
if (text !== null && text.toLowerCase().includes(needle)) {
|
|
86
|
+
matching.push(info);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
candidates = matching;
|
|
90
|
+
}
|
|
91
|
+
const paged = Paging.page(candidates, query);
|
|
92
|
+
const entries = [];
|
|
93
|
+
for (const info of paged.slice) {
|
|
94
|
+
const entry = await this.read(info);
|
|
95
|
+
if (entry) {
|
|
96
|
+
entries.push(entry);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { entries, total: paged.total, page: paged.page, size: paged.size, pages: paged.pages };
|
|
100
|
+
}
|
|
101
|
+
/** The index, built on first use: every day folder, every file, newest first. */
|
|
102
|
+
load() {
|
|
103
|
+
if (this.index) {
|
|
104
|
+
return this.index;
|
|
105
|
+
}
|
|
106
|
+
// Collected, then sorted once: inserting one at a time would be
|
|
107
|
+
// quadratic in a folder that has been filling for months.
|
|
108
|
+
const all = [];
|
|
109
|
+
for (const day of this.listDays()) {
|
|
110
|
+
for (const info of this.listDay(day)) {
|
|
111
|
+
if (!this.known.has(info.relativePath)) {
|
|
112
|
+
this.known.add(info.relativePath);
|
|
113
|
+
all.push(info);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
this.index = all.sort((a, b) => (this.newer(a, b) ? -1 : 1));
|
|
118
|
+
return this.index;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Today's folder, and yesterday's around midnight UTC, since a copy may
|
|
122
|
+
* still be finishing a call dated the day before.
|
|
123
|
+
*/
|
|
124
|
+
async scanRecent() {
|
|
125
|
+
const now = new Date();
|
|
126
|
+
const days = new Set([LogFileNames.dayFolder(now), LogFileNames.dayFolder(new Date(now.getTime() - 60000))]);
|
|
127
|
+
for (const day of days) {
|
|
128
|
+
for (const info of this.listDay(day)) {
|
|
129
|
+
if (this.add(info)) {
|
|
130
|
+
const entry = await this.read(info);
|
|
131
|
+
if (entry) {
|
|
132
|
+
this.emit(entry);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** @returns true when the entry was new. Keeps the index newest first. */
|
|
139
|
+
add(info) {
|
|
140
|
+
if (this.known.has(info.relativePath)) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
this.known.add(info.relativePath);
|
|
144
|
+
const index = this.load();
|
|
145
|
+
let at = 0;
|
|
146
|
+
while (at < index.length && this.newer(index[at], info)) {
|
|
147
|
+
at += 1;
|
|
148
|
+
}
|
|
149
|
+
index.splice(at, 0, info);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
/** Newest first by time; the name breaks ties, so the order is total and stable. */
|
|
153
|
+
newer(a, b) {
|
|
154
|
+
const difference = a.at.getTime() - b.at.getTime();
|
|
155
|
+
return difference !== 0 ? difference > 0 : a.relativePath > b.relativePath;
|
|
156
|
+
}
|
|
157
|
+
listDays() {
|
|
158
|
+
if (!existsSync(this.directory)) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
return readdirSync(this.directory).filter((name) => LogFileNames.isDayFolder(name));
|
|
162
|
+
}
|
|
163
|
+
listDay(day) {
|
|
164
|
+
const folder = join(this.directory, day);
|
|
165
|
+
if (!existsSync(folder)) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
const found = [];
|
|
169
|
+
for (const file of readdirSync(folder)) {
|
|
170
|
+
const info = LogFileNames.parse(day, file);
|
|
171
|
+
if (info) {
|
|
172
|
+
found.push(info);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return found;
|
|
176
|
+
}
|
|
177
|
+
async read(info) {
|
|
178
|
+
const text = await this.readText(info);
|
|
179
|
+
if (text === null) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return { key: info.relativePath, kind: info.kind, data: JSON.parse(text) };
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** A file that has gone, deleted by hand, is dropped from the index. */
|
|
190
|
+
async readText(info) {
|
|
191
|
+
try {
|
|
192
|
+
return await readFile(join(this.directory, info.relativePath), "utf8");
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
this.forget(info);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
forget(info) {
|
|
200
|
+
this.known.delete(info.relativePath);
|
|
201
|
+
if (this.index) {
|
|
202
|
+
this.index = this.index.filter((entry) => entry.relativePath !== info.relativePath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
emit(entry) {
|
|
206
|
+
if (this.emitted.has(entry.key)) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.emitted.add(entry.key);
|
|
210
|
+
for (const listener of this.listeners) {
|
|
211
|
+
listener(entry);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names log files so that everything the viewer lists and filters on is in
|
|
3
|
+
* the name itself.
|
|
4
|
+
*
|
|
5
|
+
* `YYYY-MM-DD/HHMMSS-mmmZ_p<pid>_<c|s><sequence>_<tool>_<ok|failed>.json`
|
|
6
|
+
*
|
|
7
|
+
* - **A folder per UTC day**, so no single folder grows to tens of thousands
|
|
8
|
+
* of entries, and old days can be archived or deleted as a whole.
|
|
9
|
+
* - **Time first**, so names sort chronologically; the `Z` says it is UTC.
|
|
10
|
+
* - **The pid and a per-process sequence**, so several copies of the server
|
|
11
|
+
* writing to one folder can never produce the same name.
|
|
12
|
+
* - **The tool and outcome last**, so paging, the tool filter and "failed
|
|
13
|
+
* only" read directory listings and never open a file. Only a text search
|
|
14
|
+
* reads contents.
|
|
15
|
+
*/
|
|
16
|
+
export class LogFileNames {
|
|
17
|
+
static DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
18
|
+
static FILE = /^(\d{2})(\d{2})(\d{2})-(\d{3})Z_p(\d+)_([cs])(\d+)_([A-Za-z0-9_-]+?)_(ok|failed)\.json$/;
|
|
19
|
+
static dayFolder(at) {
|
|
20
|
+
return at.toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
22
|
+
static build(at, pid, kind, sequence, tool, failed) {
|
|
23
|
+
const iso = at.toISOString();
|
|
24
|
+
const time = `${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}-${iso.slice(20, 23)}Z`;
|
|
25
|
+
const marker = kind === "call" ? "c" : "s";
|
|
26
|
+
const label = kind === "call" ? LogFileNames.safe(tool ?? "call") : "statement";
|
|
27
|
+
return `${LogFileNames.dayFolder(at)}/${time}_p${pid}_${marker}${String(sequence).padStart(6, "0")}_${label}_${failed ? "failed" : "ok"}.json`;
|
|
28
|
+
}
|
|
29
|
+
static isDayFolder(name) {
|
|
30
|
+
return LogFileNames.DAY.test(name);
|
|
31
|
+
}
|
|
32
|
+
/** @returns null for anything that is not one of our files, including half-written temporaries. */
|
|
33
|
+
static parse(day, file) {
|
|
34
|
+
const match = LogFileNames.FILE.exec(file);
|
|
35
|
+
if (!match || !LogFileNames.isDayFolder(day)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const [, hh, mm, ss, ms, pid, marker, , label, outcome] = match;
|
|
39
|
+
const at = new Date(`${day}T${hh}:${mm}:${ss}.${ms}Z`);
|
|
40
|
+
if (Number.isNaN(at.getTime())) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const kind = marker === "c" ? "call" : "statement";
|
|
44
|
+
return {
|
|
45
|
+
relativePath: `${day}/${file}`,
|
|
46
|
+
at,
|
|
47
|
+
pid: Number(pid),
|
|
48
|
+
kind,
|
|
49
|
+
tool: kind === "call" ? label : null,
|
|
50
|
+
failed: outcome === "failed",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Tool names are already safe; this keeps any future one from reaching the path as-is. */
|
|
54
|
+
static safe(name) {
|
|
55
|
+
return name.replace(/[^A-Za-z0-9_-]/g, "-") || "call";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Pages and page counts, shared by both stores so they cannot disagree. */
|
|
2
|
+
export class Paging {
|
|
3
|
+
static DEFAULT_SIZE = 20;
|
|
4
|
+
static MAX_SIZE = 100;
|
|
5
|
+
static normalise(query) {
|
|
6
|
+
const size = Math.min(Math.max(Math.trunc(query.size) || Paging.DEFAULT_SIZE, 1), Paging.MAX_SIZE);
|
|
7
|
+
const page = Math.max(Math.trunc(query.page) || 1, 1);
|
|
8
|
+
return { page, size };
|
|
9
|
+
}
|
|
10
|
+
static page(matching, query) {
|
|
11
|
+
const { page, size } = Paging.normalise(query);
|
|
12
|
+
const total = matching.length;
|
|
13
|
+
const pages = Math.max(Math.ceil(total / size), 1);
|
|
14
|
+
const current = Math.min(page, pages);
|
|
15
|
+
const start = (current - 1) * size;
|
|
16
|
+
return { slice: matching.slice(start, start + size), total, page: current, size, pages };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { RecordJson } from "../RecordJson.js";
|
|
2
|
+
import { Paging } from "./LogStore.js";
|
|
3
|
+
/**
|
|
4
|
+
* The viewer's store when no log folder is configured: this process's most
|
|
5
|
+
* recent entries, in memory only, oldest dropped first past the capacity.
|
|
6
|
+
*
|
|
7
|
+
* A LogChannel as well, since without a folder there is nothing else to read
|
|
8
|
+
* entries back from: it is fed directly by the call logger.
|
|
9
|
+
*/
|
|
10
|
+
export class MemoryLogStore {
|
|
11
|
+
capacity;
|
|
12
|
+
description = "the live viewer's memory";
|
|
13
|
+
entries = [];
|
|
14
|
+
listeners = new Set();
|
|
15
|
+
constructor(capacity) {
|
|
16
|
+
this.capacity = capacity;
|
|
17
|
+
}
|
|
18
|
+
onCall(call) {
|
|
19
|
+
this.add({ key: `m:${call.pid}:c${call.id}`, kind: "call", data: RecordJson.call(call) });
|
|
20
|
+
}
|
|
21
|
+
onStatement(statement) {
|
|
22
|
+
this.add({ key: `m:${statement.pid}:s${statement.at.getTime()}:${this.entries.length}`, kind: "statement", data: RecordJson.statement(statement) });
|
|
23
|
+
}
|
|
24
|
+
start() {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
stop() {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
subscribe(listener) {
|
|
31
|
+
this.listeners.add(listener);
|
|
32
|
+
return () => this.listeners.delete(listener);
|
|
33
|
+
}
|
|
34
|
+
async tools() {
|
|
35
|
+
const names = new Set();
|
|
36
|
+
for (const entry of this.entries) {
|
|
37
|
+
if (entry.kind === "call") {
|
|
38
|
+
names.add(String(entry.data.tool));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Array.from(names).sort();
|
|
42
|
+
}
|
|
43
|
+
async query(query) {
|
|
44
|
+
const needle = query.text?.trim().toLowerCase();
|
|
45
|
+
const matching = this.entries
|
|
46
|
+
.filter((entry) => (!query.tool || (entry.kind === "call" && entry.data.tool === query.tool)) &&
|
|
47
|
+
(!query.failedOnly || entry.data.failed === true) &&
|
|
48
|
+
(!needle || entry.search.includes(needle)))
|
|
49
|
+
.reverse();
|
|
50
|
+
const paged = Paging.page(matching, query);
|
|
51
|
+
return {
|
|
52
|
+
entries: paged.slice.map(({ key, kind, data }) => ({ key, kind, data })),
|
|
53
|
+
total: paged.total,
|
|
54
|
+
page: paged.page,
|
|
55
|
+
size: paged.size,
|
|
56
|
+
pages: paged.pages,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
add(entry) {
|
|
60
|
+
if (this.capacity > 0) {
|
|
61
|
+
this.entries.push({ ...entry, search: JSON.stringify(entry.data).toLowerCase() });
|
|
62
|
+
if (this.entries.length > this.capacity) {
|
|
63
|
+
this.entries.splice(0, this.entries.length - this.capacity);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const listener of this.listeners) {
|
|
67
|
+
listener(entry);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|