querysub 0.635.0 → 0.636.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.636.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.106",
83
83
  "socket-function": "^1.2.31",
84
84
  "terser": "^5.31.0",
85
85
  "typenode": "^6.6.1",
@@ -10,20 +10,36 @@ import { 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
 
@@ -111,27 +189,30 @@ export class StorageLogsPage extends qreact.Component {
111
189
  },
112
190
  });
113
191
  let rows: Record<string, unknown>[] = [];
114
- let columns: string[] = [...LEADING_COLUMNS];
115
192
  for (let file of result.files) {
116
193
  for (let entry of file.entries) {
117
194
  let fields: Record<string, unknown> = entry && typeof entry === "object" ? entry as Record<string, unknown> : { value: String(entry) };
118
195
  // The file selection is only as granular as whole files, so the entries themselves still need the range applied
119
196
  let time = fields.time;
120
197
  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
198
  rows.push({ server: file.url, file: file.name, ...fields });
127
199
  }
128
200
  }
129
201
  // Ordered the way the time range UI says: from the end (newest first) by default, oldest first when searching from the start
130
202
  let direction = searchFromStart ? 1 : -1;
131
203
  rows.sort((a, b) => direction * ((typeof a.time === "number" && a.time || 0) - (typeof b.time === "number" && b.time || 0)));
204
+ // Seeded so the fixed fields lead the chip row; ones no entry has are dropped below
205
+ let fieldCounts = new Map<string, number>(LEADING_COLUMNS.map(field => [field, 0]));
206
+ for (let row of rows) {
207
+ for (let key of Object.keys(row)) {
208
+ fieldCounts.set(key, (fieldCounts.get(key) || 0) + 1);
209
+ }
210
+ }
211
+ let fields = [...fieldCounts.entries()].filter(([, count]) => count > 0).map(([field, count]) => ({ field, count }));
132
212
  Querysub.commit(() => {
133
213
  this.state.rows = rows;
134
- this.state.columns = columns;
214
+ this.state.fields = fields;
215
+ this.state.failedFiles = result.failures;
135
216
  this.state.durationTime = result.durationTime;
136
217
  this.state.resultsFetchTime = result.oldestFetchTime;
137
218
  });
@@ -155,8 +236,47 @@ export class StorageLogsPage extends qreact.Component {
155
236
  let oldestListingTime = loadedListings.length && Math.min(...loadedListings.map(listing => listing.fetchTime || 0)) || 0;
156
237
  let now = Querysub.nowDelayed(timeInSecond);
157
238
 
158
- let columnsConfig: { [column: string]: { title?: string; formatter?: (value: unknown) => preact.ComponentChild } } = {};
159
- for (let column of this.state.columns) {
239
+ // Defaults show unless explicitly toggled off, plus whatever was explicitly toggled on
240
+ let selectedSet = new Set<string>();
241
+ for (let field of Object.keys(defaultSelectedFields)) {
242
+ if (atomic(storageLogFieldsParam.value[field]) === undefined) {
243
+ selectedSet.add(field);
244
+ }
245
+ }
246
+ for (let [key, value] of Object.entries(storageLogFieldsParam.value)) {
247
+ if (value) {
248
+ selectedSet.add(key);
249
+ }
250
+ }
251
+ // Columns follow the chip row's order; selections with no matching field this search (ex, persisted from an earlier one) go last
252
+ let selectedFields = this.state.fields.map(x => x.field).filter(field => selectedSet.has(field));
253
+ for (let field of selectedSet) {
254
+ if (!selectedFields.includes(field)) {
255
+ selectedFields.push(field);
256
+ }
257
+ }
258
+
259
+ // Object identity map, as the formatter only receives the row itself and highlighting needs its index
260
+ let rows = this.state.rows;
261
+ let rowIndexes = new Map(rows.map((row, index) => [row, index]));
262
+ let columnsConfig: TableType<Record<string, unknown>>["columns"] = {};
263
+ columnsConfig["view"] = {
264
+ title: "View",
265
+ formatter: (x, context) => {
266
+ let row = context?.row;
267
+ if (!row) return undefined;
268
+ let index = rowIndexes.get(row) ?? -1;
269
+ let viewed = viewedRowState().open && viewedRowState().index === index;
270
+ return <Button
271
+ flavor="tiny"
272
+ hue={viewed ? VIEW_BUTTON_ACTIVE_HUE : undefined}
273
+ onClick={() => showRowModal(rows, index)}
274
+ >
275
+ View
276
+ </Button>;
277
+ },
278
+ };
279
+ for (let column of selectedFields) {
160
280
  if (column === "time") {
161
281
  columnsConfig[column] = {
162
282
  title: "Time",
@@ -177,7 +297,8 @@ export class StorageLogsPage extends qreact.Component {
177
297
  StorageLogsSynced(SocketFunction.browserNodeId()).getServerLogFiles.resetAll();
178
298
  invalidateStorageLogSearchCache();
179
299
  this.state.rows = [];
180
- this.state.columns = [];
300
+ this.state.fields = [];
301
+ this.state.failedFiles = [];
181
302
  this.state.resultsFetchTime = 0;
182
303
  this.state.durationTime = 0;
183
304
  }}
@@ -230,7 +351,34 @@ export class StorageLogsPage extends qreact.Component {
230
351
  </div>
231
352
  </div>
232
353
  {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>
354
+ {!!this.state.failedFiles.length && <div className={css.vbox(2).colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l)}>
355
+ <div className={css.boldStyle}>{formatNumber(this.state.failedFiles.length)} files failed to search (their entries are missing from the results):</div>
356
+ {this.state.failedFiles.map(failure => <div key={`${failure.url}|${failure.name}`} className={css.ellipsis} title={failure.error}>
357
+ {failure.url} {failure.name}
358
+ </div>)}
359
+ </div>}
360
+ {!!this.state.rows.length && <div className={css.vbox(8)}>
361
+ <div className={css.hbox(6, 4).wrap.alignItems("center")}>
362
+ {this.state.fields.map(({ field, count }) => {
363
+ let selected = selectedSet.has(field);
364
+ let color = selected ? FIELD_CHIP_ACTIVE_COLOR : FIELD_CHIP_COLOR;
365
+ return <div
366
+ key={field}
367
+ className={
368
+ css.hbox(5).alignItems("center").pad2(8, 4).button.whiteSpace("nowrap")
369
+ .hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 20)
370
+ }
371
+ onClick={() => {
372
+ let newValues = { ...storageLogFieldsParam.value };
373
+ newValues[field] = !selected;
374
+ storageLogFieldsParam.value = newValues;
375
+ }}
376
+ >
377
+ <span className={css.boldStyle}>{field}</span>
378
+ <span className={css.fontSize(FIELD_COUNT_FONT_SIZE).colorhsl(FIELD_COUNT_COLOR.h, FIELD_COUNT_COLOR.s, FIELD_COUNT_COLOR.l)}>{formatNumber(count)}</span>
379
+ </div>;
380
+ })}
381
+ </div>
234
382
  <Table
235
383
  rows={this.state.rows}
236
384
  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
  };