querysub 0.644.0 → 0.646.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.644.0",
3
+ "version": "0.646.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -79,7 +79,7 @@
79
79
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
80
80
  "pako": "^2.1.0",
81
81
  "peggy": "^5.0.6",
82
- "sliftutils": "^1.7.116",
82
+ "sliftutils": "^1.7.119",
83
83
  "socket-function": "^1.2.33",
84
84
  "terser": "^5.31.0",
85
85
  "typenode": "^6.6.1",
@@ -6,7 +6,7 @@ import { Querysub } from "../../../4-querysub/Querysub";
6
6
  import { t } from "../../../2-proxy/schema2";
7
7
  import { isDefined } from "../../../misc";
8
8
  import { formatNumber, formatTime, formatDateTime } from "socket-function/src/formatting/format";
9
- import { timeInMinute, timeInSecond } from "socket-function/src/misc";
9
+ import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
10
10
  import type { LogFileInfo } from "sliftutils/storage/StreamingLogs";
11
11
  import { MachineServiceController } from "../../machineSchema";
12
12
  import { getStorageServers } from "./storageServers";
@@ -55,6 +55,30 @@ type ServerLogListing = {
55
55
 
56
56
  const MODAL_FIELD_NAME_MIN_WIDTH = 140;
57
57
  const VIEW_BUTTON_ACTIVE_HUE = 210;
58
+ const FAILED_STACK_FONT_SIZE = 12;
59
+
60
+ class FailedFileRow extends qreact.Component<{ failure: FailedLogFile }> {
61
+ state = t.state({
62
+ expanded: t.boolean,
63
+ });
64
+ render() {
65
+ let failure = this.props.failure;
66
+ return <div className={css.vbox(2)}>
67
+ <div
68
+ className={css.hbox(8).pointer}
69
+ title={this.state.expanded ? "Click to collapse" : "Click to show the full stack"}
70
+ onClick={() => this.state.expanded = !this.state.expanded}
71
+ >
72
+ <span>{this.state.expanded ? "▾" : "▸"}</span>
73
+ <span className={css.boldStyle.whiteSpace("nowrap")}>{failure.url} {failure.name}</span>
74
+ {!this.state.expanded && <span className={css.ellipsis}>{failure.error.split("\n")[0]}</span>}
75
+ </div>
76
+ {this.state.expanded && <div className={css.whiteSpace("pre-wrap").fontFamily("monospace").fontSize(FAILED_STACK_FONT_SIZE)}>
77
+ {failure.error}
78
+ </div>}
79
+ </div>;
80
+ }
81
+ }
58
82
 
59
83
  // Shared between the modal and the table's View buttons, so the button of the row on display highlights and arrow keys move the selection
60
84
  const viewedRowState = Querysub.createLocalSchema("storageLogsViewedRow", {
@@ -114,6 +138,39 @@ function showRowModal(rows: Record<string, unknown>[], index: number): void {
114
138
  });
115
139
  }
116
140
 
141
+ /** Every distinct value of one column across the results, most common first. */
142
+ function showColumnInspectModal(rows: Record<string, unknown>[], field: string): void {
143
+ let counts = new Map<string, number>();
144
+ let missing = 0;
145
+ for (let row of rows) {
146
+ if (!(field in row)) {
147
+ missing++;
148
+ continue;
149
+ }
150
+ let value = row[field];
151
+ let text = typeof value === "string" ? value : JSON.stringify(value);
152
+ counts.set(text, (counts.get(text) || 0) + 1);
153
+ }
154
+ let inspectRows = [...counts.entries()].map(([value, count]) => ({ count, value }));
155
+ sort(inspectRows, x => -x.count);
156
+ let close = showModal({
157
+ content: <FullscreenModal onCancel={() => close.close()}>
158
+ <div className={css.vbox(8).pad2(16)}>
159
+ <div className={css.boldStyle}>
160
+ {field}: {formatNumber(inspectRows.length)} distinct values across {formatNumber(rows.length)} rows{missing > 0 && `, missing on ${formatNumber(missing)}`}
161
+ </div>
162
+ <Table
163
+ rows={inspectRows}
164
+ columns={{
165
+ count: { title: "Count" },
166
+ value: { title: "Value" },
167
+ }}
168
+ />
169
+ </div>
170
+ </FullscreenModal>
171
+ });
172
+ }
173
+
117
174
  /** Whether a log file could hold entries in the range - a live file (no endTime) runs to now. */
118
175
  function fileOverlapsRange(file: LogFileInfo, startTime: number, endTime: number): boolean {
119
176
  let fileEnd = file.endTime ?? Date.now();
@@ -128,6 +185,9 @@ export class StorageLogsPage extends qreact.Component {
128
185
  currentFile: t.string,
129
186
  /** How long the last search took */
130
187
  durationTime: t.number,
188
+ /** Summed per file across the last search's fresh (uncached) fetches */
189
+ downloadTime: t.number,
190
+ searchTime: t.number,
131
191
  /** The oldest fetch time among the results being displayed */
132
192
  resultsFetchTime: t.number,
133
193
  rows: t.atomic<Record<string, unknown>[]>([]),
@@ -188,6 +248,8 @@ export class StorageLogsPage extends qreact.Component {
188
248
  this.state.fields = [];
189
249
  this.state.failedFiles = [];
190
250
  this.state.durationTime = 0;
251
+ this.state.downloadTime = 0;
252
+ this.state.searchTime = 0;
191
253
  this.state.resultsFetchTime = 0;
192
254
  Querysub.onCommitFinished(async () => {
193
255
  let files = cachedFiles;
@@ -247,6 +309,8 @@ export class StorageLogsPage extends qreact.Component {
247
309
  this.state.fields = fields;
248
310
  this.state.failedFiles = result.failures;
249
311
  this.state.durationTime = result.durationTime;
312
+ this.state.downloadTime = result.downloadTime;
313
+ this.state.searchTime = result.searchTime;
250
314
  this.state.resultsFetchTime = result.oldestFetchTime;
251
315
  });
252
316
  } catch (e: any) {
@@ -352,6 +416,8 @@ export class StorageLogsPage extends qreact.Component {
352
416
  this.state.failedFiles = [];
353
417
  this.state.resultsFetchTime = 0;
354
418
  this.state.durationTime = 0;
419
+ this.state.downloadTime = 0;
420
+ this.state.searchTime = 0;
355
421
  }}
356
422
  >
357
423
  Invalidate
@@ -393,11 +459,15 @@ export class StorageLogsPage extends qreact.Component {
393
459
  {listings.map(listing => {
394
460
  let files = listing.files || [];
395
461
  let totalBytes = files.reduce((sum, file) => sum + file.size, 0);
462
+ let oldest = files.length && Math.min(...files.map(file => file.startTime)) || 0;
396
463
  return <div key={listing.url} className={css.hbox(8).wrap.alignItems("center")}>
397
464
  <b>{listing.url}</b>
398
465
  {listing.loading && <span>Loading log files...</span>}
399
466
  {listing.error && <span className={css.colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l).ellipsis} title={listing.error}>{listing.error}</span>}
400
467
  {listing.files && <span>{formatNumber(files.length)} log files, {formatNumber(totalBytes)}B</span>}
468
+ {!!oldest && <span className={css.colorhsl(AGE_COLOR.h, AGE_COLOR.s, AGE_COLOR.l)}>
469
+ oldest {formatTime(now - oldest)} ago
470
+ </span>}
401
471
  </div>;
402
472
  })}
403
473
  <div>
@@ -406,36 +476,49 @@ export class StorageLogsPage extends qreact.Component {
406
476
  <div className={css.hbox(12).wrap.fontSize(AGE_FONT_SIZE).colorhsl(AGE_COLOR.h, AGE_COLOR.s, AGE_COLOR.l)}>
407
477
  {!!oldestListingTime && <span>File listing from {formatTime(now - oldestListingTime)} ago</span>}
408
478
  {!!this.state.resultsFetchTime && <span>Results from {formatTime(now - this.state.resultsFetchTime)} ago</span>}
409
- {!!this.state.durationTime && <span>Search took {formatTime(this.state.durationTime)}</span>}
479
+ {!!this.state.durationTime && <span>
480
+ Search took {formatTime(this.state.durationTime)} (downloading {formatTime(this.state.downloadTime)}, searching {formatTime(this.state.searchTime)}, summed per file)
481
+ </span>}
410
482
  {this.state.searching && !!this.state.currentFile && <span>Searching {this.state.currentFile}</span>}
411
483
  </div>
412
484
  </div>
413
485
  {this.state.error && <div className={css.colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l).whiteSpace("pre-wrap")}>{this.state.error}</div>}
414
486
  {!!this.state.failedFiles.length && <div className={css.vbox(2).colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l)}>
415
487
  <div className={css.boldStyle}>{formatNumber(this.state.failedFiles.length)} files failed to search (their entries are missing from the results):</div>
416
- {this.state.failedFiles.map(failure => <div key={`${failure.url}|${failure.name}`} className={css.ellipsis} title={failure.error}>
417
- {failure.url} {failure.name}
418
- </div>)}
488
+ {this.state.failedFiles.map(failure => <FailedFileRow key={`${failure.url}|${failure.name}`} failure={failure} />)}
419
489
  </div>}
420
490
  {!!this.state.rows.length && <div className={css.vbox(8)}>
421
491
  <div className={css.hbox(6, 4).wrap.alignItems("center")}>
422
492
  {this.state.fields.map(({ field, count }) => {
423
493
  let selected = selectedSet.has(field);
424
494
  let color = selected ? FIELD_CHIP_ACTIVE_COLOR : FIELD_CHIP_COLOR;
425
- return <div
426
- key={field}
427
- className={
428
- css.hbox(5).alignItems("center").pad2(8, 4).button.whiteSpace("nowrap")
429
- .hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 20)
430
- }
431
- onClick={() => {
432
- let newValues = { ...storageLogFieldsParam.value };
433
- newValues[field] = !selected;
434
- storageLogFieldsParam.value = newValues;
435
- }}
436
- >
437
- <span className={css.boldStyle}>{field}</span>
438
- <span className={css.fontSize(FIELD_COUNT_FONT_SIZE).colorhsl(FIELD_COUNT_COLOR.h, FIELD_COUNT_COLOR.s, FIELD_COUNT_COLOR.l)}>{formatNumber(count)}</span>
495
+ // The chip and its inspect button sit flush as one segmented control - the chip toggles the column, the magnifier inspects it
496
+ return <div key={field} className={css.hbox(0).alignItems("stretch")}>
497
+ <div
498
+ className={
499
+ css.hbox(5).alignItems("center").pad2(8, 4).button.whiteSpace("nowrap")
500
+ .hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 20)
501
+ }
502
+ onClick={() => {
503
+ let newValues = { ...storageLogFieldsParam.value };
504
+ newValues[field] = !selected;
505
+ storageLogFieldsParam.value = newValues;
506
+ }}
507
+ >
508
+ <span className={css.boldStyle}>{field}</span>
509
+ <span className={css.fontSize(FIELD_COUNT_FONT_SIZE).colorhsl(FIELD_COUNT_COLOR.h, FIELD_COUNT_COLOR.s, FIELD_COUNT_COLOR.l)}>{formatNumber(count)}</span>
510
+ </div>
511
+ <div
512
+ className={
513
+ css.hbox(0).alignItems("center").pad2(6, 4).button
514
+ .hsl(0, 0, 100).bord2(color.h, color.s, color.l - 20)
515
+ .background(`hsl(${color.h}, ${color.s}%, 96%)`, "hover")
516
+ }
517
+ title="All values of this column, most common first"
518
+ onClick={() => showColumnInspectModal(this.state.rows, field)}
519
+ >
520
+ 🔍
521
+ </div>
439
522
  </div>;
440
523
  })}
441
524
  </div>
@@ -0,0 +1,203 @@
1
+ import { runInParallel } from "socket-function/src/batching";
2
+ import { listServerLogFiles } from "sliftutils/storage/remoteStorage/createArchives";
3
+ import { decodeLogFile } from "sliftutils/storage/StreamingLogs";
4
+ import type { LogFileInfo } from "sliftutils/storage/StreamingLogs";
5
+ import { getAllServiceConfigs } from "../../machineSchema";
6
+ import { getStorageServers } from "./storageServers";
7
+ import { STORAGE_ACCOUNT } from "../../../-a-archives/archives2";
8
+ import { callStorageServer } from "./storageController";
9
+ import { parseSearchClauses, clauseMatches } from "./storageLogSearch";
10
+ import type { SearchClause } from "./storageLogSearch";
11
+
12
+ // Server-side (MCP) storage-log search. The file listing is re-requested on every call (so a caller always sees the current set of files), but the expensive parts - the raw download and the decompress/decode - are cached across calls for files that can no longer change. An in-progress file (still being streamed to) is never cached, since its bytes keep growing.
13
+ module.hotreload = false;
14
+
15
+ const PARALLEL_FILE_SEARCHES = 4;
16
+ const DEFAULT_LIMIT = 100;
17
+ // Compressed input bytes of downloaded files to keep resident.
18
+ const DOWNLOAD_CACHE_MAX_BYTES = 1024 ** 3;
19
+ // Decoded output bytes (estimated via serialized length) of decoded files to keep resident. Sized independently of the download cache because the decoded form is much larger than the compressed download.
20
+ const DECODE_CACHE_MAX_BYTES = 1024 ** 3;
21
+
22
+ // An LRU keyed by string, evicting oldest-used entries once the summed byte weight exceeds maxBytes. A Map keeps insertion order, so re-inserting on read moves an entry to the newest position and the oldest key is always the head.
23
+ class ByteLruCache<T> {
24
+ private entries = new Map<string, { value: T; bytes: number }>();
25
+ private totalBytes = 0;
26
+ constructor(private maxBytes: number) { }
27
+
28
+ public get(key: string): T | undefined {
29
+ let entry = this.entries.get(key);
30
+ if (!entry) return undefined;
31
+ this.entries.delete(key);
32
+ this.entries.set(key, entry);
33
+ return entry.value;
34
+ }
35
+
36
+ public set(key: string, value: T, bytes: number): void {
37
+ let existing = this.entries.get(key);
38
+ if (existing) {
39
+ this.totalBytes -= existing.bytes;
40
+ this.entries.delete(key);
41
+ }
42
+ // A single item bigger than the whole budget would evict everything and still not fit, so it is simply not cached.
43
+ if (bytes > this.maxBytes) return;
44
+ this.entries.set(key, { value, bytes });
45
+ this.totalBytes += bytes;
46
+ while (this.totalBytes > this.maxBytes) {
47
+ let oldestKey = this.entries.keys().next().value;
48
+ if (oldestKey === undefined) break;
49
+ let oldest = this.entries.get(oldestKey);
50
+ if (oldest) this.totalBytes -= oldest.bytes;
51
+ this.entries.delete(oldestKey);
52
+ }
53
+ }
54
+ }
55
+
56
+ // Keyed by `${url}|${name}`. Completed (compressed) files carry the whole identity in their name (pid/thread/time/count/id), so the name alone is a stable key for immutable content.
57
+ const downloadCache = new ByteLruCache<Buffer>(DOWNLOAD_CACHE_MAX_BYTES);
58
+ const decodeCache = new ByteLruCache<unknown[]>(DECODE_CACHE_MAX_BYTES);
59
+
60
+ export type StorageLogSearchResult = {
61
+ // Union of every field seen on any returned row (including the injected `server`/`file`). Lets a caller pick `columns` for a follow-up call.
62
+ allColumns: string[];
63
+ results: Record<string, unknown>[];
64
+ // Files across all servers that overlapped the range, and how many were actually searched (equal unless a download failed).
65
+ files: { total: number; searched: number };
66
+ // One entry per storage server discovered, with an error if that whole server's listing could not be read - one bad server never fails the search.
67
+ servers: { url: string; error?: string }[];
68
+ // Only present when the result was cut to `limit`; its presence signals truncation.
69
+ limitHit?: true;
70
+ note?: string;
71
+ };
72
+
73
+ /** Whether a log file could hold entries in the range - a live file (no endTime) runs to now. Mirrors the browser page's fileOverlapsRange. */
74
+ function fileOverlapsRange(file: LogFileInfo, startTime: number, endTime: number): boolean {
75
+ let fileEnd = file.endTime ?? Date.now();
76
+ return file.startTime <= endTime && fileEnd >= startTime;
77
+ }
78
+
79
+ /** Only a compressed, no-longer-written file has immutable content worth caching; anything still being streamed keeps growing. */
80
+ function isCacheable(file: LogFileInfo): boolean {
81
+ return file.compressed && !file.active;
82
+ }
83
+
84
+ /** An entry matches the query if ANY or-clause matches its serialized line (an empty query matches everything). One pass over real entries, so there is no per-clause de-duplication to do. */
85
+ function matchesQuery(entry: unknown, clauses: SearchClause[]): boolean {
86
+ if (!clauses.length) return true;
87
+ let line = JSON.stringify(entry);
88
+ return clauses.some(clause => clauseMatches(line, clause));
89
+ }
90
+
91
+ /** The decoded entries of one file, served from (and populated into) the download + decode caches. Cacheable files are downloaded and decoded at most once across calls; an in-progress file is fetched and decoded fresh every time. */
92
+ async function getFileEntries(file: { url: string; name: string; cacheable: boolean }): Promise<unknown[]> {
93
+ let key = `${file.url}|${file.name}`;
94
+
95
+ if (file.cacheable) {
96
+ let cachedEntries = decodeCache.get(key);
97
+ if (cachedEntries) return cachedEntries;
98
+ }
99
+
100
+ let data = file.cacheable ? downloadCache.get(key) : undefined;
101
+ if (!data) {
102
+ data = Buffer.from(await callStorageServer(file.url, controller => controller.getLogFile({ account: STORAGE_ACCOUNT, name: file.name })));
103
+ if (file.cacheable) downloadCache.set(key, data, data.length);
104
+ }
105
+
106
+ let entries = decodeLogFile(data);
107
+ if (file.cacheable) {
108
+ decodeCache.set(key, entries, Buffer.byteLength(JSON.stringify(entries)));
109
+ }
110
+ return entries;
111
+ }
112
+
113
+ /** One MCP call: re-lists every storage server's log files, searches each overlapping file (reusing cached downloads/decodes where possible), and returns the matching rows projected to `columns` (or every field when `columns` is omitted). */
114
+ export async function searchStorageLogsForMCP(config: {
115
+ query: string;
116
+ startTime: number;
117
+ endTime: number;
118
+ // Which entry fields to project onto each row. Omit to return every field. `time` is always included (the sort needs it). `server`/`file` are always added.
119
+ columns?: string[];
120
+ limit?: number;
121
+ direction?: "fromStart" | "fromEnd";
122
+ }): Promise<StorageLogSearchResult> {
123
+ let { query, startTime, endTime } = config;
124
+ if (startTime >= endTime) {
125
+ throw new Error(`startTime (${startTime}) must be < endTime (${endTime})`);
126
+ }
127
+ let limit = config.limit ?? DEFAULT_LIMIT;
128
+ let direction = config.direction ?? "fromEnd";
129
+ let columns = config.columns && (config.columns.includes("time") ? config.columns : ["time", ...config.columns]);
130
+ let clauses = parseSearchClauses(query);
131
+
132
+ let servers = getStorageServers(await getAllServiceConfigs());
133
+ let serverResults: { url: string; error?: string }[] = servers.map(server => ({ url: server.url }));
134
+
135
+ // Flatten every overlapping file across every server into one work list, so the parallelism is over files (not servers) and one slow server does not serialize the rest.
136
+ let work: { url: string; name: string; cacheable: boolean }[] = [];
137
+ await Promise.all(servers.map(async (server, serverIndex) => {
138
+ try {
139
+ let files = await listServerLogFiles({ url: server.url, account: STORAGE_ACCOUNT });
140
+ for (let file of files) {
141
+ if (!fileOverlapsRange(file, startTime, endTime)) continue;
142
+ work.push({ url: server.url, name: file.name, cacheable: isCacheable(file) });
143
+ }
144
+ } catch (e) {
145
+ serverResults[serverIndex].error = (e as Error).stack ?? String(e);
146
+ console.error(`MCP storage-log search: listing ${server.url} failed, continuing with the other servers:`, (e as Error).stack ?? e);
147
+ }
148
+ }));
149
+
150
+ let rows: Record<string, unknown>[] = [];
151
+ let searched = 0;
152
+ let runFile = runInParallel({ parallelCount: PARALLEL_FILE_SEARCHES }, async (file: { url: string; name: string; cacheable: boolean }) => {
153
+ let entries: unknown[];
154
+ try {
155
+ entries = await getFileEntries(file);
156
+ } catch (e) {
157
+ console.error(`MCP storage-log search: reading ${file.name} on ${file.url} failed, continuing with the other files:`, (e as Error).stack ?? e);
158
+ return;
159
+ }
160
+ searched++;
161
+ for (let entry of entries) {
162
+ let fields = entry && typeof entry === "object" ? entry as Record<string, unknown> : { value: entry };
163
+ // File selection is only file-granular, so the range still has to be applied to each entry.
164
+ let time = fields.time;
165
+ if (typeof time === "number" && (time < startTime || time > endTime)) continue;
166
+ if (!matchesQuery(entry, clauses)) continue;
167
+ let projected: Record<string, unknown> = { server: file.url, file: file.name };
168
+ if (columns) {
169
+ for (let col of columns) {
170
+ if (col in fields) projected[col] = fields[col];
171
+ }
172
+ } else {
173
+ Object.assign(projected, fields);
174
+ }
175
+ rows.push(projected);
176
+ }
177
+ });
178
+ await Promise.all(work.map(runFile));
179
+
180
+ let dir = direction === "fromStart" ? 1 : -1;
181
+ rows.sort((a, b) => dir * ((typeof a.time === "number" && a.time || 0) - (typeof b.time === "number" && b.time || 0)));
182
+ let totalMatched = rows.length;
183
+ let limitHit = totalMatched > limit;
184
+ if (limitHit) rows = rows.slice(0, limit);
185
+
186
+ let allColumns = new Set<string>();
187
+ for (let row of rows) {
188
+ for (let key of Object.keys(row)) allColumns.add(key);
189
+ }
190
+
191
+ console.log(`[storage-search] query=${JSON.stringify(query)} servers=${servers.length} files=${work.length} searched=${searched} matched=${totalMatched} returned=${rows.length} limit=${limit}${limitHit ? " HIT" : ""}`);
192
+
193
+ return {
194
+ allColumns: Array.from(allColumns),
195
+ results: rows,
196
+ files: { total: work.length, searched },
197
+ servers: serverResults,
198
+ limitHit: limitHit ? true : undefined,
199
+ note: limitHit
200
+ ? `Stopped at limit=${limit}. Results are truncated - there are likely more matches outside what's returned. This is NOT missing data; raise the limit or narrow the time range to see more.`
201
+ : undefined,
202
+ };
203
+ }
@@ -4,15 +4,31 @@ import { StorageLogsController } from "./storageLogsController";
4
4
 
5
5
  module.hotreload = true;
6
6
 
7
- // searchServerLogs only ANDs its search strings, so the `|` level of a query has to be decomposed on our side: one server-side search per or-clause, unioned. The union check uses the entry's own JSON (the same text the server searched), so it never collapses genuinely duplicate log lines the way a dedupe-by-content would.
7
+ // searchServerLogs only ANDs its positive search strings, so everything else is decomposed on our side: one server-side search per or-clause (unioned), and `!` excludes filtered off each clause's results here. The union and exclude checks use the entry's own JSON (the same text the server searched), so they never collapse genuinely duplicate log lines the way a dedupe-by-content would.
8
8
 
9
9
  const PARALLEL_FILE_SEARCHES = 4;
10
10
 
11
- /** A query's or-clauses, each the list of terms that must all appear - the same `|` / `&` semantics matchFilter (misc.ts) applies locally, minus its lowercasing, as the server search is case-sensitive. */
12
- export function parseSearchClauses(query: string): string[][] {
11
+ export type SearchClause = {
12
+ includes: string[];
13
+ excludes: string[];
14
+ };
15
+
16
+ /** A query's or-clauses: `|` between clauses, `&` between a clause's terms, `!` in front of a term to require its ABSENCE. Like matchFilter (misc.ts) minus its lowercasing, as the server search is case-sensitive. */
17
+ export function parseSearchClauses(query: string): SearchClause[] {
13
18
  return query.split("|")
14
- .map(clause => clause.split("&").map(x => x.trim()).filter(x => x))
15
- .filter(clause => clause.length > 0);
19
+ .map(clauseText => {
20
+ let terms = clauseText.split("&").map(x => x.trim()).filter(x => x);
21
+ return {
22
+ includes: terms.filter(x => !x.startsWith("!")),
23
+ excludes: terms.filter(x => x.startsWith("!")).map(x => x.slice(1).trim()).filter(x => x),
24
+ };
25
+ })
26
+ .filter(clause => clause.includes.length > 0 || clause.excludes.length > 0);
27
+ }
28
+
29
+ export function clauseMatches(line: string, clause: SearchClause): boolean {
30
+ return clause.includes.every(term => line.includes(term))
31
+ && clause.excludes.every(term => !line.includes(term));
16
32
  }
17
33
 
18
34
  export type SearchedLogFile = {
@@ -23,35 +39,55 @@ export type SearchedLogFile = {
23
39
  fetchTime: number;
24
40
  };
25
41
 
26
- const searchCache = new Map<string, { entries: unknown[]; fetchTime: number }>();
42
+ type FileSearchResult = {
43
+ entries: unknown[];
44
+ fetchTime: number;
45
+ downloadTime: number;
46
+ searchTime: number;
47
+ };
48
+ const searchCache = new Map<string, FileSearchResult>();
27
49
  export function invalidateStorageLogSearchCache(): void {
28
50
  searchCache.clear();
29
51
  }
30
52
 
31
- async function searchFile(url: string, name: string, query: string): Promise<{ entries: unknown[]; fetchTime: number }> {
53
+ // An old server still returns the bare entries array (the controller is a full-refresh module, and deploys overlap versions), which must not break the whole search
54
+ function normalizeSearchCall(call: { entries: unknown[]; downloadTime: number; searchTime: number } | unknown[]): { entries: unknown[]; downloadTime: number; searchTime: number } {
55
+ if (Array.isArray(call)) return { entries: call, downloadTime: 0, searchTime: 0 };
56
+ return call;
57
+ }
58
+
59
+ async function searchFile(url: string, name: string, query: string): Promise<FileSearchResult & { cached: boolean }> {
32
60
  let cacheKey = `${url}|${name}|${query}`;
33
61
  let cached = searchCache.get(cacheKey);
34
- if (cached) return cached;
62
+ if (cached) return { ...cached, cached: true };
35
63
  let controller = StorageLogsController.nodes[SocketFunction.browserNodeId()];
36
64
  let clauses = parseSearchClauses(query);
37
- let entries: unknown[];
38
- if (clauses.length <= 1) {
39
- entries = await controller.searchLogFile(url, name, clauses[0] || []);
65
+ let entries: unknown[] = [];
66
+ let downloadTime = 0;
67
+ let searchTime = 0;
68
+ if (!clauses.length) {
69
+ let call = normalizeSearchCall(await controller.searchLogFile(url, name, []));
70
+ entries = call.entries;
71
+ downloadTime = call.downloadTime;
72
+ searchTime = call.searchTime;
40
73
  } else {
41
74
  // Later clauses drop entries an earlier clause already returned, which unions the or-clauses without collapsing real duplicates
42
- entries = [];
43
75
  for (let [index, clause] of clauses.entries()) {
44
- let clauseEntries = await controller.searchLogFile(url, name, clause);
45
- for (let entry of clauseEntries) {
76
+ // The server only sees the positive terms (an exclude-only clause downloads the whole file); the excludes are applied to the returned lines below
77
+ let call = normalizeSearchCall(await controller.searchLogFile(url, name, clause.includes));
78
+ downloadTime += call.downloadTime;
79
+ searchTime += call.searchTime;
80
+ for (let entry of call.entries) {
46
81
  let line = JSON.stringify(entry);
47
- if (clauses.slice(0, index).some(prev => prev.every(term => line.includes(term)))) continue;
82
+ if (!clauseMatches(line, clause)) continue;
83
+ if (clauses.slice(0, index).some(prev => clauseMatches(line, prev))) continue;
48
84
  entries.push(entry);
49
85
  }
50
86
  }
51
87
  }
52
- let result = { entries, fetchTime: Date.now() };
88
+ let result: FileSearchResult = { entries, fetchTime: Date.now(), downloadTime, searchTime };
53
89
  searchCache.set(cacheKey, result);
54
- return result;
90
+ return { ...result, cached: false };
55
91
  }
56
92
 
57
93
  export type StorageLogSearchProgress = {
@@ -75,17 +111,26 @@ export async function searchStorageLogs(config: {
75
111
  files: SearchedLogFile[];
76
112
  failures: FailedLogFile[];
77
113
  durationTime: number;
114
+ /** Summed per file (files run concurrently, so these can exceed durationTime). Cached files contribute nothing - only work this search actually did counts. */
115
+ downloadTime: number;
116
+ searchTime: number;
78
117
  /** The oldest fetch time among the results used, so the UI can say how stale the display is. */
79
118
  oldestFetchTime: number;
80
119
  }> {
81
120
  let startTime = Date.now();
82
121
  let doneFiles = 0;
122
+ let downloadTime = 0;
123
+ let searchTime = 0;
83
124
  let results: SearchedLogFile[] = [];
84
125
  let failures: FailedLogFile[] = [];
85
126
  let runFile = runInParallel({ parallelCount: PARALLEL_FILE_SEARCHES }, async (file: { url: string; name: string }) => {
86
127
  config.onProgress({ doneFiles, totalFiles: config.files.length, currentFile: file.name });
87
128
  try {
88
129
  let result = await searchFile(file.url, file.name, config.query);
130
+ if (!result.cached) {
131
+ downloadTime += result.downloadTime;
132
+ searchTime += result.searchTime;
133
+ }
89
134
  results.push({ url: file.url, name: file.name, entries: result.entries, fetchTime: result.fetchTime });
90
135
  } catch (e) {
91
136
  let error = (e as Error).stack ?? String(e);
@@ -100,6 +145,8 @@ export async function searchStorageLogs(config: {
100
145
  files: results,
101
146
  failures,
102
147
  durationTime: Date.now() - startTime,
148
+ downloadTime,
149
+ searchTime,
103
150
  oldestFetchTime: results.length && Math.min(...results.map(x => x.fetchTime)) || Date.now(),
104
151
  };
105
152
  }
@@ -1,5 +1,6 @@
1
1
  import { SocketFunction } from "socket-function/SocketFunction";
2
- import { listServerLogFiles, getServerLogs, searchServerLogs } from "sliftutils/storage/remoteStorage/createArchives";
2
+ import { listServerLogFiles } from "sliftutils/storage/remoteStorage/createArchives";
3
+ import { decodeLogFile, createLogSearcher } from "sliftutils/storage/StreamingLogs";
3
4
  import type { LogFileInfo } from "sliftutils/storage/StreamingLogs";
4
5
  import { parseStorageUrl } from "sliftutils/storage/remoteStorage/ArchivesRemote";
5
6
  import { ROUTING_FILE, parseRoutingData, normalizeSource, parseHostedUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
@@ -43,14 +44,14 @@ class StorageLogsControllerBase {
43
44
  fetchTime: Date.now(),
44
45
  };
45
46
  }
46
- /** One file per call, so the client can report per-file progress. Empty searches downloads and decodes the whole file (searchServerLogs requires at least one search string). */
47
- public async searchLogFile(url: string, name: string, searches: string[]): Promise<unknown[]> {
48
- if (!searches.length) {
49
- let results = await getServerLogs({ url, account: STORAGE_ACCOUNT, names: [name] });
50
- return results[0]?.entries || [];
51
- }
52
- let results = await searchServerLogs({ url, account: STORAGE_ACCOUNT, names: [name], searches });
53
- return results[0]?.entries || [];
47
+ /** One file per call, so the client can report per-file progress. Empty searches decodes the whole file. Done as separate download and search steps (rather than sliftutils' searchServerLogs, which bundles them) so each is timed on its own. */
48
+ public async searchLogFile(url: string, name: string, searches: string[]): Promise<{ entries: unknown[]; downloadTime: number; searchTime: number }> {
49
+ let downloadStart = Date.now();
50
+ let data = await callStorageServer(url, controller => controller.getLogFile({ account: STORAGE_ACCOUNT, name }));
51
+ let downloadTime = Date.now() - downloadStart;
52
+ let searchStart = Date.now();
53
+ let entries = searches.length ? createLogSearcher(Buffer.from(data))(searches) : decodeLogFile(Buffer.from(data));
54
+ return { entries, downloadTime, searchTime: Date.now() - searchStart };
54
55
  }
55
56
  /** One path's live value on ONE server: its own newest copy (internal), plus what each of its configured stores serves normally. One call per server, so a locked-up server only stalls its own row. */
56
57
  public async getServerPathValue(url: string, bucketName: string, path: string): Promise<ServerPathValue> {
@@ -192,6 +192,13 @@ function normalizeServiceConfig(config: ServiceConfig): ServiceConfig {
192
192
  return config;
193
193
  }
194
194
 
195
+ /** Every service config, read straight from the archive. Server-side callers (that hold the machine identity, so they aren't a synced browser) use this instead of the synced MachineServiceController.getServiceList/getServiceConfig pair. */
196
+ export async function getAllServiceConfigs(): Promise<ServiceConfig[]> {
197
+ let ids = await serviceConfigs.keys();
198
+ let configs = await Promise.all(ids.map(id => serviceConfigs.get(id)));
199
+ return configs.filter((config): config is ServiceConfig => !!config).map(normalizeServiceConfig);
200
+ }
201
+
195
202
 
196
203
  export type LaunchRecord = {
197
204
  serviceId: string;
@@ -118,7 +118,7 @@ type SearchSink = {
118
118
  // Accept epoch ms (number) or any string `new Date(...)` understands. String
119
119
  // inputs without a timezone designator are interpreted as local time, which is
120
120
  // what callers typically have on hand (e.g. "2026-05-09 03:00").
121
- function normalizeTime(value: string | number, label: string): number {
121
+ export function normalizeTime(value: string | number, label: string): number {
122
122
  if (typeof value === "number") return value;
123
123
  let asNumber = Number(value);
124
124
  if (!Number.isNaN(asNumber) && value.trim() !== "") return asNumber;
@@ -16,10 +16,12 @@ import "../../../inject";
16
16
  import * as http from "http";
17
17
  import { logErrors, timeoutToUndefinedSilent } from "../../../errors";
18
18
  import { Querysub } from "../../../4-querysub/Querysub";
19
- import { MCPIndexedLogs } from "./MCPIndexedLogs";
19
+ import { MCPIndexedLogs, normalizeTime } from "./MCPIndexedLogs";
20
+ import { searchStorageLogsForMCP } from "../../../deployManager/components/storage/storageLogMCPSearch";
20
21
  import { getAllNodeIds } from "../../../-f-node-discovery/NodeDiscovery";
21
22
  import { NodeCapabilitiesController } from "../../../-g-core-values/NodeCapabilities";
22
23
  import { formatTime } from "socket-function/src/formatting/format";
24
+ import { SocketFunction } from "socket-function/SocketFunction";
23
25
 
24
26
  const DEFAULT_MCP_HTTP_PORT = 4487;
25
27
  const NODE_INFO_TIMEOUT_MS = 5000;
@@ -61,6 +63,32 @@ Note: each segment between operators ideally has at least 4 contiguous character
61
63
  required: ["query", "machine", "startTime", "endTime", "direction", "columns"],
62
64
  },
63
65
  },
66
+ {
67
+ name: "searchStorage",
68
+ description: `Search the storage-server logs (a separate stream from the indexed logs above) across every storage server in the cluster, for entries in a time range matching a query, projected to the requested columns.
69
+
70
+ Returns { allColumns, results, files: {total, searched}, servers: [{url, error?}] }. \`allColumns\` is the union of every field seen on any returned row (each row also carries \`server\` and \`file\`), so callers can pick additional \`columns\` for a follow-up call. If a \`limitHit\` field is present, results were truncated by the limit and a \`note\` will explain — this is NOT missing data; raise the limit or narrow the time range. A server that could not be listed shows up in \`servers\` with an \`error\` and costs only its own rows.
71
+
72
+ The file listing is re-requested on every call, so newly written files are always seen. Completed files' downloads and decodes are cached in memory across calls; in-progress (still-being-written) files are re-read fresh each time.
73
+
74
+ Query syntax (case-sensitive substring match against each entry's JSON):
75
+ | — OR between clauses. \`cat|dog\` matches if either clause matches.
76
+ & — AND within a clause. \`error&timeout\` matches if both substrings are present.
77
+ ! — require ABSENCE. \`error&!timeout\` matches lines with "error" but not "timeout".
78
+ Omit the query (empty string) to return every entry in the range.`,
79
+ inputSchema: {
80
+ type: "object",
81
+ properties: {
82
+ query: { type: "string", description: "Empty string returns every entry in the range." },
83
+ startTime: { type: ["number", "string"], description: "epoch ms or any string Date can parse (no-tz strings are local time)" },
84
+ endTime: { type: ["number", "string"], description: "epoch ms or any string Date can parse" },
85
+ columns: { type: "array", items: { type: "string" }, description: "Which entry fields to project onto each row. Omit to return every field. \`time\` is always included; \`server\`/\`file\` are always added." },
86
+ limit: { type: "number", default: 100 },
87
+ direction: { type: "string", enum: ["fromStart", "fromEnd"], description: "Which end of the range to keep when truncating to limit. Default fromEnd (newest first)." },
88
+ },
89
+ required: ["query", "startTime", "endTime"],
90
+ },
91
+ },
64
92
  {
65
93
  name: "listNodes",
66
94
  description: `List every node in the Querysub cluster, with each node's process entry point. Returns an array of { nodeId, entryPoint }. A node that does not answer in time has entryPoint left undefined.`,
@@ -137,6 +165,23 @@ async function dispatch(method: string, params: unknown, mcp: MCPIndexedLogs): P
137
165
  let result: unknown;
138
166
  if (toolName === "search") {
139
167
  result = await mcp.search(args as Parameters<MCPIndexedLogs["search"]>[0]);
168
+ } else if (toolName === "searchStorage") {
169
+ let a = args as {
170
+ query: string;
171
+ startTime: string | number;
172
+ endTime: string | number;
173
+ columns?: string[];
174
+ limit?: number;
175
+ direction?: "fromStart" | "fromEnd";
176
+ };
177
+ result = await searchStorageLogsForMCP({
178
+ query: a.query,
179
+ startTime: normalizeTime(a.startTime, "startTime"),
180
+ endTime: normalizeTime(a.endTime, "endTime"),
181
+ columns: a.columns,
182
+ limit: a.limit,
183
+ direction: a.direction,
184
+ });
140
185
  } else if (toolName === "listNodes") {
141
186
  result = await getNodeInfos();
142
187
  } else {
@@ -216,7 +261,9 @@ async function main() {
216
261
  });
217
262
  console.log(`MCPIndexedLogs HTTP server listening on http://127.0.0.1:${port}`);
218
263
 
219
- await Querysub.hostService("MCPIndexedLogs");
264
+ if (!SocketFunction.isMounted()) {
265
+ await Querysub.hostService("MCPIndexedLogs");
266
+ }
220
267
  }
221
268
 
222
269
  main().catch(console.error);