querysub 0.635.0 → 0.637.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.635.0",
3
+ "version": "0.637.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.105",
82
+ "sliftutils": "^1.7.107",
83
83
  "socket-function": "^1.2.31",
84
84
  "terser": "^5.31.0",
85
85
  "typenode": "^6.6.1",
@@ -6,24 +6,40 @@ 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 { timeInSecond } from "socket-function/src/misc";
9
+ import { 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";
13
- import { Table } from "../../../5-diagnostics/Table";
13
+ import { Table, TableType } from "../../../5-diagnostics/Table";
14
+ import { showModal } from "../../../5-diagnostics/Modal";
15
+ import { FullscreenModal } from "../../../5-diagnostics/FullscreenModal";
14
16
  import { URLParam } from "../../../library-components/URLParam";
15
17
  import { InputLabelURL } from "../../../library-components/InputLabel";
16
18
  import { Button } from "../../../library-components/Button";
19
+ import { atomic } from "../../../2-proxy/PathValueProxyWatcher";
17
20
  import { TimeRangeSelector, getTimeRange } from "../../../diagnostics/logs/TimeRangeSelector";
18
21
  import { StorageLogsSynced } from "./storageLogsController";
19
- import { searchStorageLogs, invalidateStorageLogSearchCache } from "./storageLogSearch";
22
+ import { searchStorageLogs, invalidateStorageLogSearchCache, FailedLogFile } from "./storageLogSearch";
20
23
 
21
24
  module.hotreload = true;
22
25
 
23
26
  export const storageLogSearchParam = new URLParam("storageLogSearch", "");
27
+ // true adds a field as a column, false removes a default - same shape as the log viewer's selectedFields
28
+ const storageLogFieldsParam = new URLParam("storageLogFields", {} as Record<string, boolean>);
29
+
30
+ const defaultSelectedFields = {
31
+ time: true,
32
+ path: true,
33
+ kind: true,
34
+ bucketName: true,
35
+ };
24
36
 
25
37
  // Fixed columns first so every search reads the same way; the entries' own fields follow in the order they appear.
26
38
  const LEADING_COLUMNS = ["server", "file", "time", "kind"];
39
+ const FIELD_CHIP_COLOR = { h: 0, s: 0, l: 92 };
40
+ const FIELD_CHIP_ACTIVE_COLOR = { h: 210, s: 60, l: 78 };
41
+ const FIELD_COUNT_COLOR = { h: 0, s: 0, l: 40 };
42
+ const FIELD_COUNT_FONT_SIZE = 12;
27
43
  const ERROR_COLOR = { h: 0, s: 60, l: 40 };
28
44
  const AGE_COLOR = { h: 0, s: 0, l: 40 };
29
45
  const AGE_FONT_SIZE = 13;
@@ -36,6 +52,67 @@ type ServerLogListing = {
36
52
  fetchTime?: number;
37
53
  };
38
54
 
55
+ const MODAL_FIELD_NAME_MIN_WIDTH = 140;
56
+ const VIEW_BUTTON_ACTIVE_HUE = 210;
57
+
58
+ // 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
59
+ const viewedRowState = Querysub.createLocalSchema("storageLogsViewedRow", {
60
+ open: t.boolean,
61
+ index: t.number,
62
+ });
63
+ // The rows the open modal navigates. Plain memory: the modal re-renders on index changes, this is just the data those changes index into.
64
+ let viewedRows: Record<string, unknown>[] = [];
65
+
66
+ class RowViewModal extends qreact.Component<{ onClose: () => void }> {
67
+ componentDidMount() {
68
+ document.addEventListener("keydown", this.onKeyDown);
69
+ }
70
+ componentWillUnmount() {
71
+ document.removeEventListener("keydown", this.onKeyDown);
72
+ }
73
+ private onKeyDown = (e: KeyboardEvent) => {
74
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
75
+ e.preventDefault();
76
+ let delta = e.key === "ArrowDown" ? 1 : -1;
77
+ // A document listener is not a Querysub-dispatched event, so the synced write needs an explicit commit
78
+ Querysub.commit(() => {
79
+ let index = viewedRowState().index + delta;
80
+ if (index < 0 || index >= viewedRows.length) return;
81
+ viewedRowState().index = index;
82
+ });
83
+ };
84
+ render() {
85
+ let index = viewedRowState().index;
86
+ let row = viewedRows[index];
87
+ if (!row) return undefined;
88
+ return <FullscreenModal onCancel={this.props.onClose}>
89
+ <div className={css.vbox(6).pad2(16)}>
90
+ <div className={css.colorhsl(0, 0, 45)}>Row {index + 1} of {formatNumber(viewedRows.length)} (↑/↓ to move)</div>
91
+ {Object.entries(row).map(([field, value]) => <div key={field} className={css.hbox(10)}>
92
+ <div className={css.boldStyle.minWidth(MODAL_FIELD_NAME_MIN_WIDTH)}>{field}</div>
93
+ <div className={css.fontFamily("monospace").whiteSpace("pre-wrap")}>
94
+ {field === "time" && typeof value === "number" && `${formatDateTime(value)} (${value})`
95
+ || typeof value === "string" && value
96
+ || JSON.stringify(value)}
97
+ </div>
98
+ </div>)}
99
+ </div>
100
+ </FullscreenModal>;
101
+ }
102
+ }
103
+
104
+ function showRowModal(rows: Record<string, unknown>[], index: number): void {
105
+ viewedRows = rows;
106
+ viewedRowState().open = true;
107
+ viewedRowState().index = index;
108
+ let close = showModal({
109
+ content: <RowViewModal onClose={() => {
110
+ viewedRowState().open = false;
111
+ close.close();
112
+ }} />
113
+ });
114
+ }
115
+
39
116
  /** Whether a log file could hold entries in the range - a live file (no endTime) runs to now. */
40
117
  function fileOverlapsRange(file: LogFileInfo, startTime: number, endTime: number): boolean {
41
118
  let fileEnd = file.endTime ?? Date.now();
@@ -53,7 +130,8 @@ export class StorageLogsPage extends qreact.Component {
53
130
  /** The oldest fetch time among the results being displayed */
54
131
  resultsFetchTime: t.number,
55
132
  rows: t.atomic<Record<string, unknown>[]>([]),
56
- columns: t.atomic<string[]>([]),
133
+ fields: t.atomic<{ field: string; count: number }[]>([]),
134
+ failedFiles: t.atomic<FailedLogFile[]>([]),
57
135
  error: t.string,
58
136
  });
59
137
 
@@ -85,19 +163,51 @@ export class StorageLogsPage extends qreact.Component {
85
163
  return selected;
86
164
  }
87
165
 
88
- private search() {
166
+ /** fresh drops the log file listings and the per-file search cache first, so everything searched is refetched. */
167
+ private search(fresh?: boolean) {
89
168
  if (this.state.searching) return;
90
- // State and synced reads are captured HERE, in the tracked part, before the async work
169
+ // State and synced reads are captured HERE, in the tracked part, before the async work (and before the invalidation below, which would turn the listings back into loading)
91
170
  let listings = this.getListings();
92
- let files = this.getSelectedFiles(listings).map(file => ({ url: file.url, name: file.name }));
171
+ let cachedFiles = this.getSelectedFiles(listings).map(file => ({ url: file.url, name: file.name }));
172
+ let urls = listings.map(listing => listing.url);
93
173
  let query = storageLogSearchParam.value;
94
174
  let { startTime, endTime, searchFromStart } = getTimeRange();
175
+ let synced = StorageLogsSynced(SocketFunction.browserNodeId());
176
+ if (fresh) {
177
+ invalidateStorageLogSearchCache();
178
+ synced.getServerLogFiles.resetAll();
179
+ }
95
180
  this.state.searching = true;
96
181
  this.state.error = "";
97
182
  this.state.doneFiles = 0;
98
- this.state.totalFiles = files.length;
183
+ this.state.totalFiles = fresh ? 0 : cachedFiles.length;
99
184
  this.state.currentFile = "";
185
+ // Cleared up front, so anything on screen during the search is never stale results from the previous one
186
+ this.state.rows = [];
187
+ this.state.fields = [];
188
+ this.state.failedFiles = [];
189
+ this.state.durationTime = 0;
190
+ this.state.resultsFetchTime = 0;
100
191
  Querysub.onCommitFinished(async () => {
192
+ let files = cachedFiles;
193
+ if (fresh) {
194
+ // The selection has to wait for the fresh listings; a server that fails costs only its own files (its error also shows on its listing row, which refetches the same way)
195
+ files = [];
196
+ await Promise.all(urls.map(async url => {
197
+ try {
198
+ let listing = await synced.getServerLogFiles.promise(url);
199
+ for (let file of listing.files) {
200
+ if (!fileOverlapsRange(file, startTime, endTime)) continue;
201
+ files.push({ url, name: file.name });
202
+ }
203
+ } catch (e) {
204
+ console.error(`Listing log files on ${url} failed, continuing with the other servers:`, (e as Error).stack ?? e);
205
+ }
206
+ }));
207
+ Querysub.commit(() => {
208
+ this.state.totalFiles = files.length;
209
+ });
210
+ }
101
211
  try {
102
212
  let result = await searchStorageLogs({
103
213
  files,
@@ -111,27 +221,30 @@ export class StorageLogsPage extends qreact.Component {
111
221
  },
112
222
  });
113
223
  let rows: Record<string, unknown>[] = [];
114
- let columns: string[] = [...LEADING_COLUMNS];
115
224
  for (let file of result.files) {
116
225
  for (let entry of file.entries) {
117
226
  let fields: Record<string, unknown> = entry && typeof entry === "object" ? entry as Record<string, unknown> : { value: String(entry) };
118
227
  // The file selection is only as granular as whole files, so the entries themselves still need the range applied
119
228
  let time = fields.time;
120
229
  if (typeof time === "number" && (time < startTime || time > endTime)) continue;
121
- for (let key of Object.keys(fields)) {
122
- if (!columns.includes(key)) {
123
- columns.push(key);
124
- }
125
- }
126
230
  rows.push({ server: file.url, file: file.name, ...fields });
127
231
  }
128
232
  }
129
233
  // Ordered the way the time range UI says: from the end (newest first) by default, oldest first when searching from the start
130
234
  let direction = searchFromStart ? 1 : -1;
131
235
  rows.sort((a, b) => direction * ((typeof a.time === "number" && a.time || 0) - (typeof b.time === "number" && b.time || 0)));
236
+ // Seeded so the fixed fields lead the chip row; ones no entry has are dropped below
237
+ let fieldCounts = new Map<string, number>(LEADING_COLUMNS.map(field => [field, 0]));
238
+ for (let row of rows) {
239
+ for (let key of Object.keys(row)) {
240
+ fieldCounts.set(key, (fieldCounts.get(key) || 0) + 1);
241
+ }
242
+ }
243
+ let fields = [...fieldCounts.entries()].filter(([, count]) => count > 0).map(([field, count]) => ({ field, count }));
132
244
  Querysub.commit(() => {
133
245
  this.state.rows = rows;
134
- this.state.columns = columns;
246
+ this.state.fields = fields;
247
+ this.state.failedFiles = result.failures;
135
248
  this.state.durationTime = result.durationTime;
136
249
  this.state.resultsFetchTime = result.oldestFetchTime;
137
250
  });
@@ -153,10 +266,49 @@ export class StorageLogsPage extends qreact.Component {
153
266
  let selectedBytes = selectedFiles.reduce((sum, file) => sum + file.size, 0);
154
267
  let loadedListings = listings.filter(listing => listing.fetchTime);
155
268
  let oldestListingTime = loadedListings.length && Math.min(...loadedListings.map(listing => listing.fetchTime || 0)) || 0;
156
- let now = Querysub.nowDelayed(timeInSecond);
269
+ let now = Querysub.nowDelayed(timeInMinute);
270
+
271
+ // Defaults show unless explicitly toggled off, plus whatever was explicitly toggled on
272
+ let selectedSet = new Set<string>();
273
+ for (let field of Object.keys(defaultSelectedFields)) {
274
+ if (atomic(storageLogFieldsParam.value[field]) === undefined) {
275
+ selectedSet.add(field);
276
+ }
277
+ }
278
+ for (let [key, value] of Object.entries(storageLogFieldsParam.value)) {
279
+ if (value) {
280
+ selectedSet.add(key);
281
+ }
282
+ }
283
+ // Columns follow the chip row's order; selections with no matching field this search (ex, persisted from an earlier one) go last
284
+ let selectedFields = this.state.fields.map(x => x.field).filter(field => selectedSet.has(field));
285
+ for (let field of selectedSet) {
286
+ if (!selectedFields.includes(field)) {
287
+ selectedFields.push(field);
288
+ }
289
+ }
157
290
 
158
- let columnsConfig: { [column: string]: { title?: string; formatter?: (value: unknown) => preact.ComponentChild } } = {};
159
- for (let column of this.state.columns) {
291
+ // Object identity map, as the formatter only receives the row itself and highlighting needs its index
292
+ let rows = this.state.rows;
293
+ let rowIndexes = new Map(rows.map((row, index) => [row, index]));
294
+ let columnsConfig: TableType<Record<string, unknown>>["columns"] = {};
295
+ columnsConfig["view"] = {
296
+ title: "View",
297
+ formatter: (x, context) => {
298
+ let row = context?.row;
299
+ if (!row) return undefined;
300
+ let index = rowIndexes.get(row) ?? -1;
301
+ let viewed = viewedRowState().open && viewedRowState().index === index;
302
+ return <Button
303
+ flavor="tiny"
304
+ hue={viewed ? VIEW_BUTTON_ACTIVE_HUE : undefined}
305
+ onClick={() => showRowModal(rows, index)}
306
+ >
307
+ View
308
+ </Button>;
309
+ },
310
+ };
311
+ for (let column of selectedFields) {
160
312
  if (column === "time") {
161
313
  columnsConfig[column] = {
162
314
  title: "Time",
@@ -177,7 +329,8 @@ export class StorageLogsPage extends qreact.Component {
177
329
  StorageLogsSynced(SocketFunction.browserNodeId()).getServerLogFiles.resetAll();
178
330
  invalidateStorageLogSearchCache();
179
331
  this.state.rows = [];
180
- this.state.columns = [];
332
+ this.state.fields = [];
333
+ this.state.failedFiles = [];
181
334
  this.state.resultsFetchTime = 0;
182
335
  this.state.durationTime = 0;
183
336
  }}
@@ -207,6 +360,15 @@ export class StorageLogsPage extends qreact.Component {
207
360
  >
208
361
  {this.state.searching && `Searching ${this.state.doneFiles}/${this.state.totalFiles}...` || "Search"}
209
362
  </Button>
363
+ <Button
364
+ flavor="large"
365
+ hue={120}
366
+ title="Invalidates the log file listings and the cached search results, then searches"
367
+ onClick={() => this.search(true)}
368
+ className={this.state.searching && css.opacity(0.5) || undefined}
369
+ >
370
+ Fresh Search
371
+ </Button>
210
372
  </div>
211
373
  <div className={css.vbox(4)}>
212
374
  {listings.map(listing => {
@@ -230,7 +392,34 @@ export class StorageLogsPage extends qreact.Component {
230
392
  </div>
231
393
  </div>
232
394
  {this.state.error && <div className={css.colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l).whiteSpace("pre-wrap")}>{this.state.error}</div>}
233
- {!!this.state.rows.length && <div>
395
+ {!!this.state.failedFiles.length && <div className={css.vbox(2).colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l)}>
396
+ <div className={css.boldStyle}>{formatNumber(this.state.failedFiles.length)} files failed to search (their entries are missing from the results):</div>
397
+ {this.state.failedFiles.map(failure => <div key={`${failure.url}|${failure.name}`} className={css.ellipsis} title={failure.error}>
398
+ {failure.url} {failure.name}
399
+ </div>)}
400
+ </div>}
401
+ {!!this.state.rows.length && <div className={css.vbox(8)}>
402
+ <div className={css.hbox(6, 4).wrap.alignItems("center")}>
403
+ {this.state.fields.map(({ field, count }) => {
404
+ let selected = selectedSet.has(field);
405
+ let color = selected ? FIELD_CHIP_ACTIVE_COLOR : FIELD_CHIP_COLOR;
406
+ return <div
407
+ key={field}
408
+ className={
409
+ css.hbox(5).alignItems("center").pad2(8, 4).button.whiteSpace("nowrap")
410
+ .hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 20)
411
+ }
412
+ onClick={() => {
413
+ let newValues = { ...storageLogFieldsParam.value };
414
+ newValues[field] = !selected;
415
+ storageLogFieldsParam.value = newValues;
416
+ }}
417
+ >
418
+ <span className={css.boldStyle}>{field}</span>
419
+ <span className={css.fontSize(FIELD_COUNT_FONT_SIZE).colorhsl(FIELD_COUNT_COLOR.h, FIELD_COUNT_COLOR.s, FIELD_COUNT_COLOR.l)}>{formatNumber(count)}</span>
420
+ </div>;
421
+ })}
422
+ </div>
234
423
  <Table
235
424
  rows={this.state.rows}
236
425
  columns={columnsConfig}
@@ -60,13 +60,20 @@ export type StorageLogSearchProgress = {
60
60
  currentFile: string;
61
61
  };
62
62
 
63
- /** Searches the query across the given files (per-file, so progress is per-file and each file's result caches on its own). */
63
+ export type FailedLogFile = {
64
+ url: string;
65
+ name: string;
66
+ error: string;
67
+ };
68
+
69
+ /** Searches the query across the given files (per-file, so progress is per-file and each file's result caches on its own). A file that fails costs only its own results - it lands in failures and the rest of the search continues. */
64
70
  export async function searchStorageLogs(config: {
65
71
  files: { url: string; name: string }[];
66
72
  query: string;
67
73
  onProgress: (progress: StorageLogSearchProgress) => void;
68
74
  }): Promise<{
69
75
  files: SearchedLogFile[];
76
+ failures: FailedLogFile[];
70
77
  durationTime: number;
71
78
  /** The oldest fetch time among the results used, so the UI can say how stale the display is. */
72
79
  oldestFetchTime: number;
@@ -74,16 +81,24 @@ export async function searchStorageLogs(config: {
74
81
  let startTime = Date.now();
75
82
  let doneFiles = 0;
76
83
  let results: SearchedLogFile[] = [];
84
+ let failures: FailedLogFile[] = [];
77
85
  let runFile = runInParallel({ parallelCount: PARALLEL_FILE_SEARCHES }, async (file: { url: string; name: string }) => {
78
86
  config.onProgress({ doneFiles, totalFiles: config.files.length, currentFile: file.name });
79
- let result = await searchFile(file.url, file.name, config.query);
80
- results.push({ url: file.url, name: file.name, entries: result.entries, fetchTime: result.fetchTime });
87
+ try {
88
+ let result = await searchFile(file.url, file.name, config.query);
89
+ results.push({ url: file.url, name: file.name, entries: result.entries, fetchTime: result.fetchTime });
90
+ } catch (e) {
91
+ let error = (e as Error).stack ?? String(e);
92
+ console.error(`Searching log file ${file.name} on ${file.url} failed, continuing with the other files:`, error);
93
+ failures.push({ url: file.url, name: file.name, error });
94
+ }
81
95
  doneFiles++;
82
96
  config.onProgress({ doneFiles, totalFiles: config.files.length, currentFile: file.name });
83
97
  });
84
98
  await Promise.all(config.files.map(runFile));
85
99
  return {
86
100
  files: results,
101
+ failures,
87
102
  durationTime: Date.now() - startTime,
88
103
  oldestFetchTime: results.length && Math.min(...results.map(x => x.fetchTime)) || Date.now(),
89
104
  };