querysub 0.631.0 → 0.633.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/deployManager/MachinesPage.tsx +3 -0
- package/src/deployManager/components/storage/StorageLogsPage.tsx +251 -0
- package/src/deployManager/components/storage/StoragePage.tsx +19 -15
- package/src/deployManager/components/storage/bucketCells.tsx +46 -6
- package/src/deployManager/components/storage/bucketRows.ts +1 -5
- package/src/deployManager/components/storage/operationsView.tsx +3 -26
- package/src/deployManager/components/storage/sourceNames.tsx +6 -2
- package/src/deployManager/components/storage/storageLogSearch.ts +90 -0
- package/src/deployManager/components/storage/storageLogsController.ts +54 -0
- package/src/deployManager/urlParams.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.633.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.103",
|
|
83
83
|
"socket-function": "^1.2.30",
|
|
84
84
|
"terser": "^5.31.0",
|
|
85
85
|
"typenode": "^6.6.1",
|
|
@@ -10,6 +10,7 @@ import { MachineDetailPage } from "./components/MachineDetailPage";
|
|
|
10
10
|
import { Anchor } from "../library-components/ATag";
|
|
11
11
|
import { DeployPage } from "./components/DeployPage";
|
|
12
12
|
import { StoragePage } from "./components/storage/StoragePage";
|
|
13
|
+
import { StorageLogsPage } from "./components/storage/StorageLogsPage";
|
|
13
14
|
import { RoutingTablePage } from "../diagnostics/misc-pages/RoutingTablePage";
|
|
14
15
|
|
|
15
16
|
export class MachinesPage extends qreact.Component {
|
|
@@ -23,6 +24,7 @@ export class MachinesPage extends qreact.Component {
|
|
|
23
24
|
{ key: "services", label: "Services", otherKeys: ["service-detail"] },
|
|
24
25
|
{ key: "deploy", label: "Deploy", otherKeys: [] },
|
|
25
26
|
{ key: "storage", label: "Storage", otherKeys: [] },
|
|
27
|
+
{ key: "storage-logs", label: "Storage Logs", otherKeys: [] },
|
|
26
28
|
{ key: "routing", label: "PathValues / Functions Routing", otherKeys: [] },
|
|
27
29
|
].map(tab => {
|
|
28
30
|
let isActive = currentViewParam.value === tab.key || tab.otherKeys.includes(currentViewParam.value);
|
|
@@ -52,6 +54,7 @@ export class MachinesPage extends qreact.Component {
|
|
|
52
54
|
{currentViewParam.value === "machine-detail" && <MachineDetailPage />}
|
|
53
55
|
{currentViewParam.value === "deploy" && <DeployPage />}
|
|
54
56
|
{currentViewParam.value === "storage" && <StoragePage />}
|
|
57
|
+
{currentViewParam.value === "storage-logs" && <StorageLogsPage />}
|
|
55
58
|
{currentViewParam.value === "routing" && <RoutingTablePage />}
|
|
56
59
|
</div>
|
|
57
60
|
</div>;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import preact from "preact";
|
|
2
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
3
|
+
import { qreact } from "../../../4-dom/qreact";
|
|
4
|
+
import { css } from "typesafecss";
|
|
5
|
+
import { Querysub } from "../../../4-querysub/Querysub";
|
|
6
|
+
import { isDefined } from "../../../misc";
|
|
7
|
+
import { formatNumber, formatTime, formatDateTime } from "socket-function/src/formatting/format";
|
|
8
|
+
import { timeInSecond } from "socket-function/src/misc";
|
|
9
|
+
import type { LogFileInfo } from "sliftutils/storage/StreamingLogs";
|
|
10
|
+
import { MachineServiceController } from "../../machineSchema";
|
|
11
|
+
import { getStorageServers } from "./storageServers";
|
|
12
|
+
import { Table } from "../../../5-diagnostics/Table";
|
|
13
|
+
import { URLParam } from "../../../library-components/URLParam";
|
|
14
|
+
import { InputLabelURL } from "../../../library-components/InputLabel";
|
|
15
|
+
import { Button } from "../../../library-components/Button";
|
|
16
|
+
import { TimeRangeSelector, getTimeRange } from "../../../diagnostics/logs/TimeRangeSelector";
|
|
17
|
+
import { StorageLogsSynced } from "./storageLogsController";
|
|
18
|
+
import { searchStorageLogs, invalidateStorageLogSearchCache } from "./storageLogSearch";
|
|
19
|
+
|
|
20
|
+
module.hotreload = true;
|
|
21
|
+
|
|
22
|
+
export const storageLogSearchParam = new URLParam("storageLogSearch", "");
|
|
23
|
+
|
|
24
|
+
// Fixed columns first so every search reads the same way; the entries' own fields follow in the order they appear.
|
|
25
|
+
const LEADING_COLUMNS = ["server", "file", "time", "kind"];
|
|
26
|
+
const ERROR_COLOR = { h: 0, s: 60, l: 40 };
|
|
27
|
+
const AGE_COLOR = { h: 0, s: 0, l: 40 };
|
|
28
|
+
const AGE_FONT_SIZE = 13;
|
|
29
|
+
|
|
30
|
+
type ServerLogListing = {
|
|
31
|
+
url: string;
|
|
32
|
+
loading?: boolean;
|
|
33
|
+
error?: string;
|
|
34
|
+
files?: LogFileInfo[];
|
|
35
|
+
fetchTime?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type SearchState = {
|
|
39
|
+
searching: boolean;
|
|
40
|
+
doneFiles: number;
|
|
41
|
+
totalFiles: number;
|
|
42
|
+
currentFile: string;
|
|
43
|
+
/** How long the last search took */
|
|
44
|
+
durationTime: number;
|
|
45
|
+
/** The oldest fetch time among the results being displayed */
|
|
46
|
+
resultsFetchTime: number;
|
|
47
|
+
rows: Record<string, unknown>[];
|
|
48
|
+
columns: string[];
|
|
49
|
+
error: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Whether a log file could hold entries in the range - a live file (no endTime) runs to now. */
|
|
53
|
+
function fileOverlapsRange(file: LogFileInfo, startTime: number, endTime: number): boolean {
|
|
54
|
+
let fileEnd = file.endTime ?? Date.now();
|
|
55
|
+
return file.startTime <= endTime && fileEnd >= startTime;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class StorageLogsPage extends qreact.Component {
|
|
59
|
+
state: SearchState = {
|
|
60
|
+
searching: false,
|
|
61
|
+
doneFiles: 0,
|
|
62
|
+
totalFiles: 0,
|
|
63
|
+
currentFile: "",
|
|
64
|
+
durationTime: 0,
|
|
65
|
+
resultsFetchTime: 0,
|
|
66
|
+
rows: [],
|
|
67
|
+
columns: [],
|
|
68
|
+
error: "",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
private getListings(): ServerLogListing[] {
|
|
72
|
+
let machineController = MachineServiceController(SocketFunction.browserNodeId());
|
|
73
|
+
let serviceList = machineController.getServiceList();
|
|
74
|
+
let configs = (serviceList || []).map(serviceId => machineController.getServiceConfig(serviceId)).filter(isDefined);
|
|
75
|
+
return getStorageServers(configs).map(server => {
|
|
76
|
+
// Every other server still has something worth showing, so one that cannot be read becomes its own error line instead of an empty page
|
|
77
|
+
try {
|
|
78
|
+
let listing = StorageLogsSynced(SocketFunction.browserNodeId()).getServerLogFiles(server.url);
|
|
79
|
+
if (!listing) return { url: server.url, loading: true };
|
|
80
|
+
return { url: server.url, files: listing.files, fetchTime: listing.fetchTime };
|
|
81
|
+
} catch (e: any) {
|
|
82
|
+
return { url: server.url, error: e.stack ?? String(e) };
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private getSelectedFiles(listings: ServerLogListing[]): { url: string; name: string; size: number }[] {
|
|
88
|
+
let { startTime, endTime } = getTimeRange();
|
|
89
|
+
let selected: { url: string; name: string; size: number }[] = [];
|
|
90
|
+
for (let listing of listings) {
|
|
91
|
+
for (let file of listing.files || []) {
|
|
92
|
+
if (!fileOverlapsRange(file, startTime, endTime)) continue;
|
|
93
|
+
selected.push({ url: listing.url, name: file.name, size: file.size });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return selected;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private search() {
|
|
100
|
+
if (this.state.searching) return;
|
|
101
|
+
// State and synced reads are captured HERE, in the tracked part, before the async work
|
|
102
|
+
let listings = this.getListings();
|
|
103
|
+
let files = this.getSelectedFiles(listings).map(file => ({ url: file.url, name: file.name }));
|
|
104
|
+
let query = storageLogSearchParam.value;
|
|
105
|
+
let { startTime, endTime } = getTimeRange();
|
|
106
|
+
this.state.searching = true;
|
|
107
|
+
this.state.error = "";
|
|
108
|
+
this.state.doneFiles = 0;
|
|
109
|
+
this.state.totalFiles = files.length;
|
|
110
|
+
this.state.currentFile = "";
|
|
111
|
+
Querysub.onCommitFinished(async () => {
|
|
112
|
+
try {
|
|
113
|
+
let result = await searchStorageLogs({
|
|
114
|
+
files,
|
|
115
|
+
query,
|
|
116
|
+
onProgress: progress => {
|
|
117
|
+
Querysub.commit(() => {
|
|
118
|
+
this.state.doneFiles = progress.doneFiles;
|
|
119
|
+
this.state.totalFiles = progress.totalFiles;
|
|
120
|
+
this.state.currentFile = progress.currentFile;
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
let rows: Record<string, unknown>[] = [];
|
|
125
|
+
let columns: string[] = [...LEADING_COLUMNS];
|
|
126
|
+
for (let file of result.files) {
|
|
127
|
+
for (let entry of file.entries) {
|
|
128
|
+
let fields = entry && typeof entry === "object" && entry as Record<string, unknown> || { value: String(entry) };
|
|
129
|
+
// The file selection is only as granular as whole files, so the entries themselves still need the range applied
|
|
130
|
+
let time = fields.time;
|
|
131
|
+
if (typeof time === "number" && (time < startTime || time > endTime)) continue;
|
|
132
|
+
for (let key of Object.keys(fields)) {
|
|
133
|
+
if (!columns.includes(key)) {
|
|
134
|
+
columns.push(key);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
rows.push({ server: file.url, file: file.name, ...fields });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
rows.sort((a, b) => (typeof a.time === "number" && a.time || 0) - (typeof b.time === "number" && b.time || 0));
|
|
141
|
+
Querysub.commit(() => {
|
|
142
|
+
this.state.rows = rows;
|
|
143
|
+
this.state.columns = columns;
|
|
144
|
+
this.state.durationTime = result.durationTime;
|
|
145
|
+
this.state.resultsFetchTime = result.oldestFetchTime;
|
|
146
|
+
});
|
|
147
|
+
} catch (e: any) {
|
|
148
|
+
Querysub.commit(() => {
|
|
149
|
+
this.state.error = e.stack ?? String(e);
|
|
150
|
+
});
|
|
151
|
+
} finally {
|
|
152
|
+
Querysub.commit(() => {
|
|
153
|
+
this.state.searching = false;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
render() {
|
|
160
|
+
let listings = this.getListings();
|
|
161
|
+
let selectedFiles = this.getSelectedFiles(listings);
|
|
162
|
+
let selectedBytes = selectedFiles.reduce((sum, file) => sum + file.size, 0);
|
|
163
|
+
let loadedListings = listings.filter(listing => listing.fetchTime);
|
|
164
|
+
let oldestListingTime = loadedListings.length && Math.min(...loadedListings.map(listing => listing.fetchTime || 0)) || 0;
|
|
165
|
+
let now = Querysub.nowDelayed(timeInSecond);
|
|
166
|
+
|
|
167
|
+
let columnsConfig: { [column: string]: { title?: string; formatter?: (value: unknown) => preact.ComponentChild } } = {};
|
|
168
|
+
for (let column of this.state.columns) {
|
|
169
|
+
if (column === "time") {
|
|
170
|
+
columnsConfig[column] = {
|
|
171
|
+
title: "Time",
|
|
172
|
+
formatter: value => typeof value === "number" && <span className={css.whiteSpace("nowrap")}>{formatDateTime(value)}</span> || value as preact.ComponentChild,
|
|
173
|
+
};
|
|
174
|
+
} else {
|
|
175
|
+
columnsConfig[column] = {};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return <div className={css.vbox(16)}>
|
|
180
|
+
<div className={css.hbox(12).alignItems("center")}>
|
|
181
|
+
<h2 className={css.flexGrow(1)}>Storage logs</h2>
|
|
182
|
+
<button
|
|
183
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
|
|
184
|
+
title="Drops both the cached log file listings and the cached search results"
|
|
185
|
+
onClick={() => {
|
|
186
|
+
StorageLogsSynced(SocketFunction.browserNodeId()).getServerLogFiles.resetAll();
|
|
187
|
+
invalidateStorageLogSearchCache();
|
|
188
|
+
this.state.rows = [];
|
|
189
|
+
this.state.columns = [];
|
|
190
|
+
this.state.resultsFetchTime = 0;
|
|
191
|
+
this.state.durationTime = 0;
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
Invalidate
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
<TimeRangeSelector />
|
|
198
|
+
<div className={css.hbox(10).fillWidth}>
|
|
199
|
+
<InputLabelURL
|
|
200
|
+
flavor="large"
|
|
201
|
+
fillWidth
|
|
202
|
+
label="Search (a | b & c - or of ands, empty downloads everything in range)"
|
|
203
|
+
url={storageLogSearchParam}
|
|
204
|
+
onKeyDown={e => {
|
|
205
|
+
if (e.key === "Enter") {
|
|
206
|
+
storageLogSearchParam.value = e.currentTarget.value;
|
|
207
|
+
this.search();
|
|
208
|
+
}
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
<Button
|
|
212
|
+
flavor="large"
|
|
213
|
+
hue={210}
|
|
214
|
+
onClick={() => this.search()}
|
|
215
|
+
className={this.state.searching && css.opacity(0.5) || undefined}
|
|
216
|
+
>
|
|
217
|
+
{this.state.searching && `Searching ${this.state.doneFiles}/${this.state.totalFiles}...` || "Search"}
|
|
218
|
+
</Button>
|
|
219
|
+
</div>
|
|
220
|
+
<div className={css.vbox(4)}>
|
|
221
|
+
{listings.map(listing => {
|
|
222
|
+
let files = listing.files || [];
|
|
223
|
+
let totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
224
|
+
return <div key={listing.url} className={css.hbox(8).wrap.alignItems("center")}>
|
|
225
|
+
<b>{listing.url}</b>
|
|
226
|
+
{listing.loading && <span>Loading log files...</span>}
|
|
227
|
+
{listing.error && <span className={css.colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l).ellipsis} title={listing.error}>{listing.error}</span>}
|
|
228
|
+
{listing.files && <span>{formatNumber(files.length)} log files, {formatNumber(totalBytes)}B</span>}
|
|
229
|
+
</div>;
|
|
230
|
+
})}
|
|
231
|
+
<div>
|
|
232
|
+
Selected range covers <b>{formatNumber(selectedFiles.length)}</b> files, <b>{formatNumber(selectedBytes)}B</b> (compressed) to search
|
|
233
|
+
</div>
|
|
234
|
+
<div className={css.hbox(12).wrap.fontSize(AGE_FONT_SIZE).colorhsl(AGE_COLOR.h, AGE_COLOR.s, AGE_COLOR.l)}>
|
|
235
|
+
{!!oldestListingTime && <span>File listing from {formatTime(now - oldestListingTime)} ago</span>}
|
|
236
|
+
{!!this.state.resultsFetchTime && <span>Results from {formatTime(now - this.state.resultsFetchTime)} ago</span>}
|
|
237
|
+
{!!this.state.durationTime && <span>Search took {formatTime(this.state.durationTime)}</span>}
|
|
238
|
+
{this.state.searching && !!this.state.currentFile && <span>Searching {this.state.currentFile}</span>}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
{this.state.error && <div className={css.colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l).whiteSpace("pre-wrap")}>{this.state.error}</div>}
|
|
242
|
+
{!!this.state.rows.length && <div>
|
|
243
|
+
<Table
|
|
244
|
+
rows={this.state.rows}
|
|
245
|
+
columns={columnsConfig}
|
|
246
|
+
/>
|
|
247
|
+
</div>}
|
|
248
|
+
{!this.state.rows.length && !this.state.searching && !!this.state.durationTime && <div>No matching log entries.</div>}
|
|
249
|
+
</div>;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -12,7 +12,7 @@ import { StorageSynced } from "./storageController";
|
|
|
12
12
|
import { getBucketRows, INACTIVE_STATE } from "./bucketRows";
|
|
13
13
|
import { ActivateBucketButton, IndexSourcesCell, SyncingCell } from "./bucketCells";
|
|
14
14
|
import { DeployNotices } from "./deployNotices";
|
|
15
|
-
import {
|
|
15
|
+
import { OperationsSection, refreshOperationsData } from "./operationsView";
|
|
16
16
|
import { RouteConfigView } from "./RouteConfigView";
|
|
17
17
|
import { Tag } from "./Tag";
|
|
18
18
|
|
|
@@ -23,6 +23,10 @@ const DISK_BAR_TYPE = "DISK";
|
|
|
23
23
|
// These columns only ever hold a short single token; the default pre-wrap makes them wrap mid-value in a narrow column, which reads badly, so they opt out of wrapping and let the column widen instead.
|
|
24
24
|
const nowrapCell = (value: string) => <span className={css.whiteSpace("nowrap")}>{value}</span>;
|
|
25
25
|
|
|
26
|
+
// Urls and bucket names still wrap, but below this the table squishes them into a sliver while other columns sprawl.
|
|
27
|
+
const NAME_COLUMN_MIN_WIDTH = 150;
|
|
28
|
+
const minWidthCell = (value: string) => <div className={css.minWidth(NAME_COLUMN_MIN_WIDTH)}>{value}</div>;
|
|
29
|
+
|
|
26
30
|
async function resetWriteStats(servers: StorageServerBuckets[]): Promise<void> {
|
|
27
31
|
await Promise.all(servers.map(async server => {
|
|
28
32
|
if (server.loading) return;
|
|
@@ -87,17 +91,21 @@ export class StoragePage extends qreact.Component {
|
|
|
87
91
|
}
|
|
88
92
|
return <div className={css.vbox(16)}>
|
|
89
93
|
{header}
|
|
94
|
+
<OperationsSection
|
|
95
|
+
serverUrls={servers.map(server => server.url)}
|
|
96
|
+
onResetWriteStats={() => void resetWriteStats(servers)}
|
|
97
|
+
/>
|
|
90
98
|
<Table
|
|
91
99
|
rows={getBucketRows(servers)}
|
|
92
100
|
columns={{
|
|
93
|
-
server: { title: "Server" },
|
|
94
|
-
bucket: { title: "Bucket" },
|
|
101
|
+
server: { title: "Server", formatter: minWidthCell },
|
|
102
|
+
bucket: { title: "Bucket", formatter: minWidthCell },
|
|
95
103
|
serverUrl: null,
|
|
96
104
|
state: {
|
|
97
105
|
title: "State",
|
|
98
106
|
formatter: (state, context) => {
|
|
99
107
|
let row = context?.row;
|
|
100
|
-
if (!row || !row.bucket || state !== INACTIVE_STATE) return state;
|
|
108
|
+
if (!row || !row.bucket || state !== INACTIVE_STATE) return nowrapCell(state);
|
|
101
109
|
return <ActivateBucketButton serverUrl={row.serverUrl} bucketName={row.bucket} />;
|
|
102
110
|
}
|
|
103
111
|
},
|
|
@@ -112,25 +120,25 @@ export class StoragePage extends qreact.Component {
|
|
|
112
120
|
},
|
|
113
121
|
indexSources: {
|
|
114
122
|
title: "Index sources",
|
|
115
|
-
formatter: sources => <IndexSourcesCell
|
|
123
|
+
formatter: (sources, context) => <IndexSourcesCell
|
|
124
|
+
sources={sources}
|
|
125
|
+
cellKey={`${context?.row?.serverUrl}|${context?.row?.bucket}`}
|
|
126
|
+
/>
|
|
116
127
|
},
|
|
117
128
|
readerDiskLimit: { title: "Read cache limit", formatter: nowrapCell },
|
|
118
|
-
operationsServerUrl: {
|
|
119
|
-
title: "Operations",
|
|
120
|
-
formatter: url => url && <OperationsCell serverUrl={url} /> || "",
|
|
121
|
-
},
|
|
122
129
|
writeGain: { title: "Fast gain", formatter: nowrapCell },
|
|
123
130
|
disk: {
|
|
124
131
|
title: "Drive",
|
|
125
132
|
formatter: (disk, context) => {
|
|
126
133
|
if (!disk) return context?.row?.diskError || "";
|
|
127
|
-
|
|
134
|
+
// A span, as the Table lifts a top-level div's classes onto the td, where the nowrap would fight the td's own pre-wrap class on stylesheet order
|
|
135
|
+
return <span className={css.whiteSpace("nowrap")}><UsageBar
|
|
128
136
|
label={DISK_BAR_TYPE}
|
|
129
137
|
value={disk.usedBytes}
|
|
130
138
|
max={disk.totalBytes}
|
|
131
139
|
unit="B"
|
|
132
140
|
{...getUsageThresholds(DISK_BAR_TYPE)}
|
|
133
|
-
|
|
141
|
+
/></span>;
|
|
134
142
|
}
|
|
135
143
|
},
|
|
136
144
|
diskError: null,
|
|
@@ -141,10 +149,6 @@ export class StoragePage extends qreact.Component {
|
|
|
141
149
|
error: { title: "Error" },
|
|
142
150
|
}}
|
|
143
151
|
/>
|
|
144
|
-
<OperationsSection
|
|
145
|
-
serverUrls={servers.map(server => server.url)}
|
|
146
|
-
onResetWriteStats={() => void resetWriteStats(servers)}
|
|
147
|
-
/>
|
|
148
152
|
<RouteConfigView servers={servers} />
|
|
149
153
|
</div>;
|
|
150
154
|
}
|
|
@@ -5,6 +5,7 @@ import { formatNumber, formatTime } from "socket-function/src/formatting/format"
|
|
|
5
5
|
import { sort, timeInSecond } from "socket-function/src/misc";
|
|
6
6
|
import { Querysub } from "../../../4-querysub/Querysub";
|
|
7
7
|
import type { ArchivesConfig, SyncActivity } from "sliftutils/storage/IArchives";
|
|
8
|
+
import { t } from "../../../2-proxy/schema2";
|
|
8
9
|
import { StorageSynced } from "./storageController";
|
|
9
10
|
import { INACTIVE_STATE } from "./bucketRows";
|
|
10
11
|
import { parseSourceDebugName, SourceNameParts, SOURCE_PART_FONT_SIZE } from "./sourceNames";
|
|
@@ -76,32 +77,71 @@ const SOURCE_ROW_PADDING = { horizontal: 4, vertical: 3 };
|
|
|
76
77
|
const DISK_SOURCE_TYPE = "disk";
|
|
77
78
|
const REMOTE_SOURCE_TOOLTIP = "Values stored on another server. If this other server goes down, we'll lose access to the data.";
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
const INDEX_SOURCES_COLLAPSED_MAX_WIDTH = 200;
|
|
81
|
+
const INDEX_SOURCE_HOVER_BACKGROUND = "hsla(210, 70%, 90%, 0.5)";
|
|
82
|
+
|
|
83
|
+
// Per-source expand state, keyed by cell + source so the same debugName in another row stays independent. Plain memory plus a synced version counter, as adding keys to a synced map doesn't report as a change (same pattern as the operations view state).
|
|
84
|
+
const expandedSourceKeys = new Set<string>();
|
|
85
|
+
const indexSourcesStateVersion = Querysub.createLocalSchema("storageIndexSourcesStateVersion", {
|
|
86
|
+
version: t.number,
|
|
87
|
+
});
|
|
88
|
+
/** Reading this in a render subscribes it to every expand/collapse toggle. */
|
|
89
|
+
function isSourceExpanded(key: string): boolean {
|
|
90
|
+
indexSourcesStateVersion().version;
|
|
91
|
+
return expandedSourceKeys.has(key);
|
|
92
|
+
}
|
|
93
|
+
function toggleSourceExpanded(key: string): void {
|
|
94
|
+
if (expandedSourceKeys.has(key)) {
|
|
95
|
+
expandedSourceKeys.delete(key);
|
|
96
|
+
} else {
|
|
97
|
+
expandedSourceKeys.add(key);
|
|
98
|
+
}
|
|
99
|
+
Querysub.localCommit(() => indexSourcesStateVersion().version++);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class IndexSourcesCell extends qreact.Component<{
|
|
103
|
+
sources: ArchivesConfig["indexSources"];
|
|
104
|
+
/** Identifies this cell's row, so each source's expand state is its own even when the same source appears in other rows. */
|
|
105
|
+
cellKey: string;
|
|
106
|
+
}> {
|
|
80
107
|
render() {
|
|
81
108
|
let sources = [...this.props.sources || []];
|
|
82
109
|
if (!sources.length) return undefined;
|
|
83
110
|
sort(sources, x => -x.byteCount);
|
|
84
111
|
let totalBytes = sources.reduce((sum, x) => sum + x.byteCount, 0);
|
|
85
|
-
return <div className={css.
|
|
112
|
+
return <div className={css.hbox(4, 0).wrap.fillWidth}>
|
|
86
113
|
{sources.map(source => {
|
|
87
114
|
let fraction = totalBytes && source.byteCount / totalBytes || 0;
|
|
88
115
|
// Only the server's own disk holds bytes we can't lose to another machine going down
|
|
89
116
|
let remote = parseSourceDebugName(source.debugName).type !== DISK_SOURCE_TYPE;
|
|
90
117
|
let barColor = remote && REMOTE_SOURCE_BAR_COLOR || SOURCE_BAR_COLOR;
|
|
118
|
+
let sourceKey = `${this.props.cellKey}|${source.debugName}`;
|
|
119
|
+
let expanded = isSourceExpanded(sourceKey);
|
|
91
120
|
return <div
|
|
92
121
|
key={source.debugName}
|
|
93
|
-
className={
|
|
94
|
-
|
|
122
|
+
className={
|
|
123
|
+
css.relative.pad2(SOURCE_ROW_PADDING.horizontal, SOURCE_ROW_PADDING.vertical)
|
|
124
|
+
.pointer.background(INDEX_SOURCE_HOVER_BACKGROUND, "hover")
|
|
125
|
+
// Expanding forces the entry to a full row of the wrap, so the collapsed entries reflow around it
|
|
126
|
+
+ (expanded
|
|
127
|
+
&& css.fillWidth
|
|
128
|
+
|| css.maxWidth(INDEX_SOURCES_COLLAPSED_MAX_WIDTH).overflowHidden)
|
|
129
|
+
}
|
|
130
|
+
title={[
|
|
131
|
+
remote && REMOTE_SOURCE_TOOLTIP || "",
|
|
132
|
+
expanded && "Click to collapse" || "Click to expand",
|
|
133
|
+
].filter(x => x).join("\n")}
|
|
134
|
+
onClick={() => toggleSourceExpanded(sourceKey)}
|
|
95
135
|
>
|
|
96
136
|
<div className={
|
|
97
137
|
css.absolute.top(0).left(0).size(`${fraction * 100}%`, "100%")
|
|
98
138
|
.hsla(barColor.h, barColor.s, barColor.l, SOURCE_BAR_OPACITY)
|
|
99
139
|
} />
|
|
100
|
-
<div className={css.relative.hbox(6).wrap}>
|
|
140
|
+
<div className={css.relative.hbox(6) + (expanded && css.wrap)}>
|
|
101
141
|
<div className={css.whiteSpace("nowrap")}>
|
|
102
142
|
{formatNumber(source.byteCount)}B, {formatNumber(source.fileCount)} files
|
|
103
143
|
</div>
|
|
104
|
-
<SourceNameParts debugName={source.debugName} />
|
|
144
|
+
<SourceNameParts debugName={source.debugName} nowrap={!expanded} />
|
|
105
145
|
</div>
|
|
106
146
|
</div>;
|
|
107
147
|
})}
|
|
@@ -26,8 +26,6 @@ export type BucketRow = {
|
|
|
26
26
|
flags: SourceTag[];
|
|
27
27
|
indexSources: ArchivesConfig["indexSources"];
|
|
28
28
|
readerDiskLimit: string;
|
|
29
|
-
/** The server url on the first row of each server (operation stats are per-server), so the operations cell renders once per server; empty on the rest. */
|
|
30
|
-
operationsServerUrl: string;
|
|
31
29
|
writeGain: string;
|
|
32
30
|
disk?: BucketDiskInfo;
|
|
33
31
|
diskError: string;
|
|
@@ -89,7 +87,6 @@ export function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
|
|
|
89
87
|
flags: [],
|
|
90
88
|
indexSources: undefined,
|
|
91
89
|
readerDiskLimit: "",
|
|
92
|
-
operationsServerUrl: "",
|
|
93
90
|
writeGain: "",
|
|
94
91
|
disk: undefined,
|
|
95
92
|
diskError: "",
|
|
@@ -107,7 +104,7 @@ export function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
|
|
|
107
104
|
let buckets = [...server.buckets || []];
|
|
108
105
|
sort(buckets, x => x.bucketName);
|
|
109
106
|
if (!buckets.length) {
|
|
110
|
-
rows.push({ ...baseRow, bucket: "No buckets"
|
|
107
|
+
rows.push({ ...baseRow, bucket: "No buckets" });
|
|
111
108
|
continue;
|
|
112
109
|
}
|
|
113
110
|
for (let [index, bucket] of buckets.entries()) {
|
|
@@ -124,7 +121,6 @@ export function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
|
|
|
124
121
|
flags: getBucketFlags(server.url, bucket.bucketName, config?.remoteConfig),
|
|
125
122
|
indexSources: config?.indexSources,
|
|
126
123
|
readerDiskLimit: config?.readerDiskLimit && formatNumber(config.readerDiskLimit) + "B" || "",
|
|
127
|
-
operationsServerUrl: index === 0 && server.url || "",
|
|
128
124
|
writeGain: getWriteGain(bucket.writeStats),
|
|
129
125
|
disk: bucket.disk,
|
|
130
126
|
diskError: bucket.diskError || "",
|
|
@@ -18,7 +18,7 @@ type AccessMode = "count" | "size";
|
|
|
18
18
|
|
|
19
19
|
// The operations breakdown only lives while the page is open, so it's plain memory. Adding keys to a synced map doesn't report as a change, so a separate counter is what the views actually re-render on.
|
|
20
20
|
const opsDrilled = new Set<string>();
|
|
21
|
-
let opsMode: AccessMode = "
|
|
21
|
+
let opsMode: AccessMode = "size";
|
|
22
22
|
const opsStateVersion = Querysub.createLocalSchema("storageOpsStateVersion", {
|
|
23
23
|
version: t.number,
|
|
24
24
|
});
|
|
@@ -93,29 +93,6 @@ function chipClass(color: { h: number; s: number; l: number }) {
|
|
|
93
93
|
.hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 20);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/** The per-server operations, broken out by operation as wrapping chips. Display only - the drillable copy lives in the breakdown below the table. */
|
|
97
|
-
export class OperationsCell extends qreact.Component<{ serverUrl: string }> {
|
|
98
|
-
render() {
|
|
99
|
-
let mode = getOpsMode();
|
|
100
|
-
let operations: OperationEntry[] | undefined;
|
|
101
|
-
try {
|
|
102
|
-
operations = readSortedOperations(this.props.serverUrl, mode);
|
|
103
|
-
} catch (e: any) {
|
|
104
|
-
return <div className={css.colorhsl(ACCESS_ERROR_COLOR.h, ACCESS_ERROR_COLOR.s, ACCESS_ERROR_COLOR.l).ellipsis} title={e.stack ?? String(e)}>
|
|
105
|
-
{e.stack ?? String(e)}
|
|
106
|
-
</div>;
|
|
107
|
-
}
|
|
108
|
-
if (!operations) return "...";
|
|
109
|
-
if (!operations.length) return "";
|
|
110
|
-
return <div className={css.hbox(6).wrap}>
|
|
111
|
-
{operations.map(([operation, op]) => <div key={operation} className={chipClass(OP_CHIP_COLOR)}>
|
|
112
|
-
<span className={css.boldStyle}>{operation}</span>
|
|
113
|
-
<span className={css.fontSize(OP_METRIC_FONT_SIZE).colorhsl(OP_METRIC_COLOR.h, OP_METRIC_COLOR.s, OP_METRIC_COLOR.l)}>{formatNumber(op.count)}</span>
|
|
114
|
-
</div>)}
|
|
115
|
-
</div>;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
96
|
/** One operation's top-N path breakdown, ordered by the current mode. */
|
|
120
97
|
class OperationSummaryTable extends qreact.Component<{ serverUrl: string; operation: string; weightBySize: boolean; asBytes: boolean }> {
|
|
121
98
|
render() {
|
|
@@ -186,7 +163,7 @@ class OperationsMachineBreakdown extends qreact.Component<{ serverUrl: string }>
|
|
|
186
163
|
}
|
|
187
164
|
}
|
|
188
165
|
|
|
189
|
-
/** The always-on operations breakdown
|
|
166
|
+
/** The always-on operations breakdown: a titled, boxed section with its own refresh/reset controls and the count/size order toggle, then one wrapping breakdown per machine. */
|
|
190
167
|
export class OperationsSection extends qreact.Component<{ serverUrls: string[]; onResetWriteStats: () => void }> {
|
|
191
168
|
render() {
|
|
192
169
|
let mode = getOpsMode();
|
|
@@ -201,7 +178,7 @@ export class OperationsSection extends qreact.Component<{ serverUrls: string[];
|
|
|
201
178
|
</div>
|
|
202
179
|
<ButtonSelector<AccessMode>
|
|
203
180
|
title="Order by"
|
|
204
|
-
options={[{ title: "
|
|
181
|
+
options={[{ title: "Size", value: "size" }, { title: "Count", value: "count" }]}
|
|
205
182
|
value={mode}
|
|
206
183
|
onChange={setOpsMode}
|
|
207
184
|
/>
|
|
@@ -24,11 +24,15 @@ export function parseSourceDebugName(debugName: string): { type: string; parts:
|
|
|
24
24
|
return { type: debugName, parts: [] };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export class SourceNameParts extends qreact.Component<{
|
|
27
|
+
export class SourceNameParts extends qreact.Component<{
|
|
28
|
+
debugName: string;
|
|
29
|
+
/** For containers that show a single cut-off line - wrapping there defeats the cut-off by growing vertically instead. */
|
|
30
|
+
nowrap?: boolean;
|
|
31
|
+
}> {
|
|
28
32
|
render() {
|
|
29
33
|
let { type, parts } = parseSourceDebugName(this.props.debugName);
|
|
30
34
|
let partStyle = css.pad2(5, 0).fontSize(SOURCE_PART_FONT_SIZE).whiteSpace("nowrap");
|
|
31
|
-
return <div className={css.hbox(3).wrap}>
|
|
35
|
+
return <div className={css.hbox(3) + (!this.props.nowrap && css.wrap)}>
|
|
32
36
|
<div className={partStyle.hsl(SOURCE_TYPE_COLOR.h, SOURCE_TYPE_COLOR.s, SOURCE_TYPE_COLOR.l).bord2(SOURCE_TYPE_COLOR.h, SOURCE_TYPE_COLOR.s, SOURCE_TYPE_COLOR.l - 20)}>
|
|
33
37
|
{type}
|
|
34
38
|
</div>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
|
+
import { runInParallel } from "socket-function/src/batching";
|
|
3
|
+
import { StorageLogsController } from "./storageLogsController";
|
|
4
|
+
|
|
5
|
+
module.hotreload = true;
|
|
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.
|
|
8
|
+
|
|
9
|
+
const PARALLEL_FILE_SEARCHES = 4;
|
|
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[][] {
|
|
13
|
+
return query.split("|")
|
|
14
|
+
.map(clause => clause.split("&").map(x => x.trim()).filter(x => x))
|
|
15
|
+
.filter(clause => clause.length > 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SearchedLogFile = {
|
|
19
|
+
url: string;
|
|
20
|
+
name: string;
|
|
21
|
+
entries: unknown[];
|
|
22
|
+
/** When these entries were actually fetched - older than the search when they came from the cache. */
|
|
23
|
+
fetchTime: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const searchCache = new Map<string, { entries: unknown[]; fetchTime: number }>();
|
|
27
|
+
export function invalidateStorageLogSearchCache(): void {
|
|
28
|
+
searchCache.clear();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function searchFile(url: string, name: string, query: string): Promise<{ entries: unknown[]; fetchTime: number }> {
|
|
32
|
+
let cacheKey = `${url}|${name}|${query}`;
|
|
33
|
+
let cached = searchCache.get(cacheKey);
|
|
34
|
+
if (cached) return cached;
|
|
35
|
+
let controller = StorageLogsController.nodes[SocketFunction.browserNodeId()];
|
|
36
|
+
let clauses = parseSearchClauses(query);
|
|
37
|
+
let entries: unknown[];
|
|
38
|
+
if (clauses.length <= 1) {
|
|
39
|
+
entries = await controller.searchLogFile(url, name, clauses[0] || []);
|
|
40
|
+
} else {
|
|
41
|
+
// Later clauses drop entries an earlier clause already returned, which unions the or-clauses without collapsing real duplicates
|
|
42
|
+
entries = [];
|
|
43
|
+
for (let [index, clause] of clauses.entries()) {
|
|
44
|
+
let clauseEntries = await controller.searchLogFile(url, name, clause);
|
|
45
|
+
for (let entry of clauseEntries) {
|
|
46
|
+
let line = JSON.stringify(entry);
|
|
47
|
+
if (clauses.slice(0, index).some(prev => prev.every(term => line.includes(term)))) continue;
|
|
48
|
+
entries.push(entry);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let result = { entries, fetchTime: Date.now() };
|
|
53
|
+
searchCache.set(cacheKey, result);
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type StorageLogSearchProgress = {
|
|
58
|
+
doneFiles: number;
|
|
59
|
+
totalFiles: number;
|
|
60
|
+
currentFile: string;
|
|
61
|
+
};
|
|
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). */
|
|
64
|
+
export async function searchStorageLogs(config: {
|
|
65
|
+
files: { url: string; name: string }[];
|
|
66
|
+
query: string;
|
|
67
|
+
onProgress: (progress: StorageLogSearchProgress) => void;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
files: SearchedLogFile[];
|
|
70
|
+
durationTime: number;
|
|
71
|
+
/** The oldest fetch time among the results used, so the UI can say how stale the display is. */
|
|
72
|
+
oldestFetchTime: number;
|
|
73
|
+
}> {
|
|
74
|
+
let startTime = Date.now();
|
|
75
|
+
let doneFiles = 0;
|
|
76
|
+
let results: SearchedLogFile[] = [];
|
|
77
|
+
let runFile = runInParallel({ parallelCount: PARALLEL_FILE_SEARCHES }, async (file: { url: string; name: string }) => {
|
|
78
|
+
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 });
|
|
81
|
+
doneFiles++;
|
|
82
|
+
config.onProgress({ doneFiles, totalFiles: config.files.length, currentFile: file.name });
|
|
83
|
+
});
|
|
84
|
+
await Promise.all(config.files.map(runFile));
|
|
85
|
+
return {
|
|
86
|
+
files: results,
|
|
87
|
+
durationTime: Date.now() - startTime,
|
|
88
|
+
oldestFetchTime: results.length && Math.min(...results.map(x => x.fetchTime)) || Date.now(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
|
+
import { listServerLogFiles, getServerLogs, searchServerLogs } from "sliftutils/storage/remoteStorage/createArchives";
|
|
3
|
+
import type { LogFileInfo } from "sliftutils/storage/StreamingLogs";
|
|
4
|
+
import { getSyncedController } from "../../../library-components/SyncedController";
|
|
5
|
+
import { assertIsManagementUser } from "../../../diagnostics/managementPages";
|
|
6
|
+
import { STORAGE_ACCOUNT } from "../../../-a-archives/archives2";
|
|
7
|
+
|
|
8
|
+
// The registered controller and its synced wrapper have to keep a stable identity, so this file is a full-refresh boundary rather than a hot-reload one (matching SyncedController itself).
|
|
9
|
+
module.hotreload = false;
|
|
10
|
+
|
|
11
|
+
export type ServerLogFiles = {
|
|
12
|
+
files: LogFileInfo[];
|
|
13
|
+
/** When this listing was fetched, so the UI can say how stale a cached listing is. */
|
|
14
|
+
fetchTime: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Every storage call is proxied through here: they authenticate with this machine's identity CA, which the browser has no access to. */
|
|
18
|
+
class StorageLogsControllerBase {
|
|
19
|
+
/** One call per server, so each caches and refreshes on its own. */
|
|
20
|
+
public async getServerLogFiles(url: string): Promise<ServerLogFiles> {
|
|
21
|
+
return {
|
|
22
|
+
files: await listServerLogFiles({ url, account: STORAGE_ACCOUNT }),
|
|
23
|
+
fetchTime: Date.now(),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** 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). */
|
|
27
|
+
public async searchLogFile(url: string, name: string, searches: string[]): Promise<unknown[]> {
|
|
28
|
+
if (!searches.length) {
|
|
29
|
+
let results = await getServerLogs({ url, account: STORAGE_ACCOUNT, names: [name] });
|
|
30
|
+
return results[0]?.entries || [];
|
|
31
|
+
}
|
|
32
|
+
let results = await searchServerLogs({ url, account: STORAGE_ACCOUNT, names: [name], searches });
|
|
33
|
+
return results[0]?.entries || [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const StorageLogsController = SocketFunction.register(
|
|
38
|
+
"StorageLogsController-8b3f52c1-6a4e-4c0d-9d71-2f8e4a5b6c93",
|
|
39
|
+
new StorageLogsControllerBase(),
|
|
40
|
+
() => ({
|
|
41
|
+
getServerLogFiles: {},
|
|
42
|
+
searchLogFile: {},
|
|
43
|
+
}),
|
|
44
|
+
() => ({
|
|
45
|
+
hooks: [assertIsManagementUser],
|
|
46
|
+
}),
|
|
47
|
+
{
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export const StorageLogsSynced = getSyncedController(StorageLogsController, {
|
|
52
|
+
reads: {},
|
|
53
|
+
writes: {},
|
|
54
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { URLParam } from "../library-components/URLParam";
|
|
2
2
|
|
|
3
|
-
export type ViewType = "services" | "machines" | "service-detail" | "machine-detail" | "deploy" | "storage" | "routing";
|
|
3
|
+
export type ViewType = "services" | "machines" | "service-detail" | "machine-detail" | "deploy" | "storage" | "storage-logs" | "routing";
|
|
4
4
|
|
|
5
5
|
export const currentViewParam = new URLParam<ViewType>("machineview", "machines");
|
|
6
6
|
export const selectedServiceIdParam = new URLParam("serviceId", "");
|