querysub 0.634.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 +2 -2
- package/src/-f-node-discovery/NodeDiscovery.ts +2 -3
- package/src/5-diagnostics/Table.tsx +2 -1
- package/src/deployManager/components/ProcessesView.tsx +22 -5
- package/src/deployManager/components/storage/StorageLogsPage.tsx +167 -17
- package/src/deployManager/components/storage/storageLogSearch.ts +18 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "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.
|
|
82
|
+
"sliftutils": "^1.7.106",
|
|
83
83
|
"socket-function": "^1.2.31",
|
|
84
84
|
"terser": "^5.31.0",
|
|
85
85
|
"typenode": "^6.6.1",
|
|
@@ -532,7 +532,8 @@ async function runServerSyncLoops() {
|
|
|
532
532
|
|
|
533
533
|
console.log(magenta(`Node discovery is loaded`));
|
|
534
534
|
|
|
535
|
-
|
|
535
|
+
await runInfinitePollCallAtStart(HEARTBEAT_INTERVAL, writeHeartbeat);
|
|
536
|
+
// Delayed by a bit as it might take a little bit for our heartbeat to show up on startup, especially if we are the storage node that is supposed to store our own heartbeat.
|
|
536
537
|
delay(HEARTBEAT_INTERVAL * 5).then(() => runInfinitePoll(HEARTBEAT_INTERVAL, async function nodeDiscoverHeartbeat() {
|
|
537
538
|
// If we waited too long, other nodes might think we are dead. In which case, we SHOULD terminate.
|
|
538
539
|
if (!isNoNetwork()) {
|
|
@@ -556,8 +557,6 @@ async function runServerSyncLoops() {
|
|
|
556
557
|
ignoreErrors(NodeDiscoveryController.nodes[nodeId].addNode(getOwnNodeId()));
|
|
557
558
|
}
|
|
558
559
|
}
|
|
559
|
-
|
|
560
|
-
await writeHeartbeat();
|
|
561
560
|
}));
|
|
562
561
|
}
|
|
563
562
|
|
|
@@ -63,7 +63,8 @@ export class Table<RowT extends RowType> extends qreact.Component<TableType<RowT
|
|
|
63
63
|
return (
|
|
64
64
|
<table class={css.borderCollapse("collapse") + this.props.class}>
|
|
65
65
|
<tr class={
|
|
66
|
-
|
|
66
|
+
// Above the rows, as they hold positioned content (bars, palettes) which otherwise paints over the sticky header as it scrolls under it
|
|
67
|
+
css.position("sticky").top(0).zIndex(1)
|
|
67
68
|
+ (this.props.dark && css.hsla(0, 0, 10, 0.9))
|
|
68
69
|
+ (!this.props.dark && css.hsla(0, 0, 95, 0.9))
|
|
69
70
|
}>
|
|
@@ -17,6 +17,8 @@ const DEAD_COLOR = { h: 0, s: 0, l: 92 };
|
|
|
17
17
|
const OUTPUT_BUFFER_LIMIT = 1_000_000;
|
|
18
18
|
const OUTPUT_BUFFER_KEPT = 100_000;
|
|
19
19
|
const OUTPUT_MAX_HEIGHT = "40vh";
|
|
20
|
+
// Within this of the bottom still counts as "at the bottom", as scroll positions land a few pixels off the exact end
|
|
21
|
+
const OUTPUT_PIN_THRESHOLD_PX = 40;
|
|
20
22
|
const DETAIL_FONT_SIZE = 12;
|
|
21
23
|
const ANSI_SATURATION = 70;
|
|
22
24
|
const ANSI_LIGHTNESS = 70;
|
|
@@ -68,12 +70,27 @@ class ProcessOutput extends qreact.Component<{ record: ProcessRecord; machineNod
|
|
|
68
70
|
await stopWatchingProcessOutput({ callbackId });
|
|
69
71
|
});
|
|
70
72
|
}
|
|
73
|
+
private outputDiv: HTMLDivElement | undefined;
|
|
74
|
+
// The pane opens showing the newest output and follows it as it streams; scrolling up to read releases the pin, scrolling back down re-engages it
|
|
75
|
+
private pinnedToBottom = true;
|
|
76
|
+
componentDidUpdate() {
|
|
77
|
+
if (this.pinnedToBottom && this.outputDiv) {
|
|
78
|
+
this.outputDiv.scrollTop = this.outputDiv.scrollHeight;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
71
81
|
render() {
|
|
72
|
-
return <div
|
|
73
|
-
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
return <div
|
|
83
|
+
className={
|
|
84
|
+
css.fontFamily("monospace").whiteSpace("pre-wrap").fillWidth
|
|
85
|
+
.maxHeight(OUTPUT_MAX_HEIGHT).overflow("auto").pad2(10, 8)
|
|
86
|
+
.hsl(0, 0, 12).colorhsl(0, 0, 90)
|
|
87
|
+
}
|
|
88
|
+
ref={element => this.outputDiv = element as HTMLDivElement || undefined}
|
|
89
|
+
onScroll={element => {
|
|
90
|
+
let div = element.currentTarget;
|
|
91
|
+
this.pinnedToBottom = div.scrollHeight - div.scrollTop - div.clientHeight < OUTPUT_PIN_THRESHOLD_PX;
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
77
94
|
{parseAnsiColors(this.state.data).map(({ text, color }) => {
|
|
78
95
|
if (!color) return <span>{text}</span>;
|
|
79
96
|
// The pane is dark, so the ansi colour only sets the hue - the lightness has to stay readable against it
|
|
@@ -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
|
-
|
|
133
|
+
fields: t.atomic<{ field: string; count: number }[]>([]),
|
|
134
|
+
failedFiles: t.atomic<FailedLogFile[]>([]),
|
|
57
135
|
error: t.string,
|
|
58
136
|
});
|
|
59
137
|
|
|
@@ -91,7 +169,7 @@ export class StorageLogsPage extends qreact.Component {
|
|
|
91
169
|
let listings = this.getListings();
|
|
92
170
|
let files = this.getSelectedFiles(listings).map(file => ({ url: file.url, name: file.name }));
|
|
93
171
|
let query = storageLogSearchParam.value;
|
|
94
|
-
let { startTime, endTime } = getTimeRange();
|
|
172
|
+
let { startTime, endTime, searchFromStart } = getTimeRange();
|
|
95
173
|
this.state.searching = true;
|
|
96
174
|
this.state.error = "";
|
|
97
175
|
this.state.doneFiles = 0;
|
|
@@ -111,25 +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
|
|
202
|
+
let direction = searchFromStart ? 1 : -1;
|
|
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 }));
|
|
130
212
|
Querysub.commit(() => {
|
|
131
213
|
this.state.rows = rows;
|
|
132
|
-
this.state.
|
|
214
|
+
this.state.fields = fields;
|
|
215
|
+
this.state.failedFiles = result.failures;
|
|
133
216
|
this.state.durationTime = result.durationTime;
|
|
134
217
|
this.state.resultsFetchTime = result.oldestFetchTime;
|
|
135
218
|
});
|
|
@@ -153,8 +236,47 @@ export class StorageLogsPage extends qreact.Component {
|
|
|
153
236
|
let oldestListingTime = loadedListings.length && Math.min(...loadedListings.map(listing => listing.fetchTime || 0)) || 0;
|
|
154
237
|
let now = Querysub.nowDelayed(timeInSecond);
|
|
155
238
|
|
|
156
|
-
|
|
157
|
-
|
|
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) {
|
|
158
280
|
if (column === "time") {
|
|
159
281
|
columnsConfig[column] = {
|
|
160
282
|
title: "Time",
|
|
@@ -175,7 +297,8 @@ export class StorageLogsPage extends qreact.Component {
|
|
|
175
297
|
StorageLogsSynced(SocketFunction.browserNodeId()).getServerLogFiles.resetAll();
|
|
176
298
|
invalidateStorageLogSearchCache();
|
|
177
299
|
this.state.rows = [];
|
|
178
|
-
this.state.
|
|
300
|
+
this.state.fields = [];
|
|
301
|
+
this.state.failedFiles = [];
|
|
179
302
|
this.state.resultsFetchTime = 0;
|
|
180
303
|
this.state.durationTime = 0;
|
|
181
304
|
}}
|
|
@@ -188,7 +311,7 @@ export class StorageLogsPage extends qreact.Component {
|
|
|
188
311
|
<InputLabelURL
|
|
189
312
|
flavor="large"
|
|
190
313
|
fillWidth
|
|
191
|
-
label="Search
|
|
314
|
+
label="Search"
|
|
192
315
|
url={storageLogSearchParam}
|
|
193
316
|
onKeyDown={e => {
|
|
194
317
|
if (e.key === "Enter") {
|
|
@@ -228,7 +351,34 @@ export class StorageLogsPage extends qreact.Component {
|
|
|
228
351
|
</div>
|
|
229
352
|
</div>
|
|
230
353
|
{this.state.error && <div className={css.colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l).whiteSpace("pre-wrap")}>{this.state.error}</div>}
|
|
231
|
-
{!!this.state.
|
|
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>
|
|
232
382
|
<Table
|
|
233
383
|
rows={this.state.rows}
|
|
234
384
|
columns={columnsConfig}
|
|
@@ -60,13 +60,20 @@ export type StorageLogSearchProgress = {
|
|
|
60
60
|
currentFile: string;
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
};
|