querysub 0.632.0 → 0.634.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.632.0",
3
+ "version": "0.634.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,8 +79,8 @@
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.102",
83
- "socket-function": "^1.2.30",
82
+ "sliftutils": "^1.7.105",
83
+ "socket-function": "^1.2.31",
84
84
  "terser": "^5.31.0",
85
85
  "typenode": "^6.6.1",
86
86
  "typesafecss": "^0.32.0",
@@ -6,7 +6,7 @@
6
6
 
7
7
  import debugbreak from "debugbreak";
8
8
  import { SocketFunction } from "socket-function/SocketFunction";
9
- import { CallerContext } from "socket-function/SocketFunctionTypes";
9
+ import { CallerContext, ClientHookContext } from "socket-function/SocketFunctionTypes";
10
10
  import { cache, cacheWeak, lazy } from "socket-function/src/caching";
11
11
  import { getClientNodeId, getNodeId, getNodeIdDomain, getNodeIdIP, getNodeIdLocation, isClientNodeId } from "socket-function/src/nodeCache";
12
12
  import { decodeNodeId, getCommonName, getIdentityCA, getMachineId, getOwnMachineId, getPublicIdentifier, getThreadKeyCert, parseCert, sign, validateCertificate, verify } from "sliftutils/misc/https/certs";
@@ -252,7 +252,7 @@ const changeIdentityOnce = cacheWeak(async function changeIdentityOnce(connectio
252
252
  );
253
253
  });
254
254
  let startupTime = Date.now();
255
- SocketFunction.addGlobalClientHook(async function identityHook(context) {
255
+ async function identityHook(context: ClientHookContext) {
256
256
  if (context.call.classGuid === IdentityController._classGuid) return;
257
257
  // This is for US to tell them our identity. And if they established the connection the identity will come from their original connection url (that they used to connect to us), and they validated it either being a real certificate, or they added the cert from the trusted backblaze bucket. If it just from a real certificate it means we identified them, but they might not have network trust. But that's fine, as IdentityController is JUST for identification, and if it's a real certificate we know who they are! (Which doesn't mean we trust them).
258
258
  if (isClientNodeId(context.call.nodeId)) {
@@ -265,4 +265,7 @@ SocketFunction.addGlobalClientHook(async function identityHook(context) {
265
265
  if (duration > 200 && now - startupTime > timeInMinute) {
266
266
  console.log(red(`IdentityHook took ${formatTime(duration)} for ${context.connectionId.nodeId} ${context.call.classGuid}.${context.call.functionName}`));
267
267
  }
268
- });
268
+ }
269
+ // Don't measure this, as oftentimes it blocks on other servers, and so it isn't interesting to measure it.
270
+ identityHook.noMeasure = true;
271
+ SocketFunction.addGlobalClientHook(identityHook);
@@ -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>;
@@ -13,6 +13,8 @@ import { assertIsManagementUser } from "../../diagnostics/managementPages";
13
13
  const NODE_CALL_TIMEOUT = timeInSecond * 10;
14
14
  const BAR_HEIGHT_PX = 6;
15
15
  const MIN_BAR_WIDTH_PX = 30;
16
+ // A percentage width would be against the bar's own content-sized wrapper, which has no width, so it resolves to zero and every bar sits at the minimum. The width is instead this many pixels at 100% CPU.
17
+ const FULL_BAR_WIDTH_PX = 300;
16
18
  // Green at idle, red at fully busy.
17
19
  const BAR_IDLE_HUE = 120;
18
20
  const BAR_FLASH_THRESHOLD = 0.6;
@@ -73,7 +75,7 @@ export class ServiceMeasureBars extends qreact.Component<{
73
75
  <div
74
76
  className={
75
77
  css.height(BAR_HEIGHT_PX)
76
- .width(`${fraction * 100}%`)
78
+ .width(Math.round(clampedFraction * FULL_BAR_WIDTH_PX))
77
79
  .minWidth(MIN_BAR_WIDTH_PX)
78
80
  .button
79
81
  .hsl(hue, 70, isExpanded ? 40 : 55)
@@ -0,0 +1,240 @@
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 { t } from "../../../2-proxy/schema2";
7
+ import { isDefined } from "../../../misc";
8
+ import { formatNumber, formatTime, formatDateTime } from "socket-function/src/formatting/format";
9
+ import { timeInSecond } from "socket-function/src/misc";
10
+ import type { LogFileInfo } from "sliftutils/storage/StreamingLogs";
11
+ import { MachineServiceController } from "../../machineSchema";
12
+ import { getStorageServers } from "./storageServers";
13
+ import { Table } from "../../../5-diagnostics/Table";
14
+ import { URLParam } from "../../../library-components/URLParam";
15
+ import { InputLabelURL } from "../../../library-components/InputLabel";
16
+ import { Button } from "../../../library-components/Button";
17
+ import { TimeRangeSelector, getTimeRange } from "../../../diagnostics/logs/TimeRangeSelector";
18
+ import { StorageLogsSynced } from "./storageLogsController";
19
+ import { searchStorageLogs, invalidateStorageLogSearchCache } from "./storageLogSearch";
20
+
21
+ module.hotreload = true;
22
+
23
+ export const storageLogSearchParam = new URLParam("storageLogSearch", "");
24
+
25
+ // Fixed columns first so every search reads the same way; the entries' own fields follow in the order they appear.
26
+ const LEADING_COLUMNS = ["server", "file", "time", "kind"];
27
+ const ERROR_COLOR = { h: 0, s: 60, l: 40 };
28
+ const AGE_COLOR = { h: 0, s: 0, l: 40 };
29
+ const AGE_FONT_SIZE = 13;
30
+
31
+ type ServerLogListing = {
32
+ url: string;
33
+ loading?: boolean;
34
+ error?: string;
35
+ files?: LogFileInfo[];
36
+ fetchTime?: number;
37
+ };
38
+
39
+ /** Whether a log file could hold entries in the range - a live file (no endTime) runs to now. */
40
+ function fileOverlapsRange(file: LogFileInfo, startTime: number, endTime: number): boolean {
41
+ let fileEnd = file.endTime ?? Date.now();
42
+ return file.startTime <= endTime && fileEnd >= startTime;
43
+ }
44
+
45
+ export class StorageLogsPage extends qreact.Component {
46
+ state = t.state({
47
+ searching: t.boolean,
48
+ doneFiles: t.number,
49
+ totalFiles: t.number,
50
+ currentFile: t.string,
51
+ /** How long the last search took */
52
+ durationTime: t.number,
53
+ /** The oldest fetch time among the results being displayed */
54
+ resultsFetchTime: t.number,
55
+ rows: t.atomic<Record<string, unknown>[]>([]),
56
+ columns: t.atomic<string[]>([]),
57
+ error: t.string,
58
+ });
59
+
60
+ private getListings(): ServerLogListing[] {
61
+ let machineController = MachineServiceController(SocketFunction.browserNodeId());
62
+ let serviceList = machineController.getServiceList();
63
+ let configs = (serviceList || []).map(serviceId => machineController.getServiceConfig(serviceId)).filter(isDefined);
64
+ return getStorageServers(configs).map(server => {
65
+ // Every other server still has something worth showing, so one that cannot be read becomes its own error line instead of an empty page
66
+ try {
67
+ let listing = StorageLogsSynced(SocketFunction.browserNodeId()).getServerLogFiles(server.url);
68
+ if (!listing) return { url: server.url, loading: true };
69
+ return { url: server.url, files: listing.files, fetchTime: listing.fetchTime };
70
+ } catch (e: any) {
71
+ return { url: server.url, error: e.stack ?? String(e) };
72
+ }
73
+ });
74
+ }
75
+
76
+ private getSelectedFiles(listings: ServerLogListing[]): { url: string; name: string; size: number }[] {
77
+ let { startTime, endTime } = getTimeRange();
78
+ let selected: { url: string; name: string; size: number }[] = [];
79
+ for (let listing of listings) {
80
+ for (let file of listing.files || []) {
81
+ if (!fileOverlapsRange(file, startTime, endTime)) continue;
82
+ selected.push({ url: listing.url, name: file.name, size: file.size });
83
+ }
84
+ }
85
+ return selected;
86
+ }
87
+
88
+ private search() {
89
+ if (this.state.searching) return;
90
+ // State and synced reads are captured HERE, in the tracked part, before the async work
91
+ let listings = this.getListings();
92
+ let files = this.getSelectedFiles(listings).map(file => ({ url: file.url, name: file.name }));
93
+ let query = storageLogSearchParam.value;
94
+ let { startTime, endTime } = getTimeRange();
95
+ this.state.searching = true;
96
+ this.state.error = "";
97
+ this.state.doneFiles = 0;
98
+ this.state.totalFiles = files.length;
99
+ this.state.currentFile = "";
100
+ Querysub.onCommitFinished(async () => {
101
+ try {
102
+ let result = await searchStorageLogs({
103
+ files,
104
+ query,
105
+ onProgress: progress => {
106
+ Querysub.commit(() => {
107
+ this.state.doneFiles = progress.doneFiles;
108
+ this.state.totalFiles = progress.totalFiles;
109
+ this.state.currentFile = progress.currentFile;
110
+ });
111
+ },
112
+ });
113
+ let rows: Record<string, unknown>[] = [];
114
+ let columns: string[] = [...LEADING_COLUMNS];
115
+ for (let file of result.files) {
116
+ for (let entry of file.entries) {
117
+ let fields: Record<string, unknown> = entry && typeof entry === "object" ? entry as Record<string, unknown> : { value: String(entry) };
118
+ // The file selection is only as granular as whole files, so the entries themselves still need the range applied
119
+ let time = fields.time;
120
+ 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
+ rows.push({ server: file.url, file: file.name, ...fields });
127
+ }
128
+ }
129
+ rows.sort((a, b) => (typeof a.time === "number" && a.time || 0) - (typeof b.time === "number" && b.time || 0));
130
+ Querysub.commit(() => {
131
+ this.state.rows = rows;
132
+ this.state.columns = columns;
133
+ this.state.durationTime = result.durationTime;
134
+ this.state.resultsFetchTime = result.oldestFetchTime;
135
+ });
136
+ } catch (e: any) {
137
+ Querysub.commit(() => {
138
+ this.state.error = e.stack ?? String(e);
139
+ });
140
+ } finally {
141
+ Querysub.commit(() => {
142
+ this.state.searching = false;
143
+ });
144
+ }
145
+ });
146
+ }
147
+
148
+ render() {
149
+ let listings = this.getListings();
150
+ let selectedFiles = this.getSelectedFiles(listings);
151
+ let selectedBytes = selectedFiles.reduce((sum, file) => sum + file.size, 0);
152
+ let loadedListings = listings.filter(listing => listing.fetchTime);
153
+ let oldestListingTime = loadedListings.length && Math.min(...loadedListings.map(listing => listing.fetchTime || 0)) || 0;
154
+ let now = Querysub.nowDelayed(timeInSecond);
155
+
156
+ let columnsConfig: { [column: string]: { title?: string; formatter?: (value: unknown) => preact.ComponentChild } } = {};
157
+ for (let column of this.state.columns) {
158
+ if (column === "time") {
159
+ columnsConfig[column] = {
160
+ title: "Time",
161
+ formatter: value => typeof value === "number" && <span className={css.whiteSpace("nowrap")}>{formatDateTime(value)}</span> || value as preact.ComponentChild,
162
+ };
163
+ } else {
164
+ columnsConfig[column] = {};
165
+ }
166
+ }
167
+
168
+ return <div className={css.vbox(16)}>
169
+ <div className={css.hbox(12).alignItems("center")}>
170
+ <h2 className={css.flexGrow(1)}>Storage logs</h2>
171
+ <button
172
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100)}
173
+ title="Drops both the cached log file listings and the cached search results"
174
+ onClick={() => {
175
+ StorageLogsSynced(SocketFunction.browserNodeId()).getServerLogFiles.resetAll();
176
+ invalidateStorageLogSearchCache();
177
+ this.state.rows = [];
178
+ this.state.columns = [];
179
+ this.state.resultsFetchTime = 0;
180
+ this.state.durationTime = 0;
181
+ }}
182
+ >
183
+ Invalidate
184
+ </button>
185
+ </div>
186
+ <TimeRangeSelector />
187
+ <div className={css.hbox(10).fillWidth}>
188
+ <InputLabelURL
189
+ flavor="large"
190
+ fillWidth
191
+ label="Search (a | b & c - or of ands, empty downloads everything in range)"
192
+ url={storageLogSearchParam}
193
+ onKeyDown={e => {
194
+ if (e.key === "Enter") {
195
+ storageLogSearchParam.value = e.currentTarget.value;
196
+ this.search();
197
+ }
198
+ }}
199
+ />
200
+ <Button
201
+ flavor="large"
202
+ hue={210}
203
+ onClick={() => this.search()}
204
+ className={this.state.searching && css.opacity(0.5) || undefined}
205
+ >
206
+ {this.state.searching && `Searching ${this.state.doneFiles}/${this.state.totalFiles}...` || "Search"}
207
+ </Button>
208
+ </div>
209
+ <div className={css.vbox(4)}>
210
+ {listings.map(listing => {
211
+ let files = listing.files || [];
212
+ let totalBytes = files.reduce((sum, file) => sum + file.size, 0);
213
+ return <div key={listing.url} className={css.hbox(8).wrap.alignItems("center")}>
214
+ <b>{listing.url}</b>
215
+ {listing.loading && <span>Loading log files...</span>}
216
+ {listing.error && <span className={css.colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l).ellipsis} title={listing.error}>{listing.error}</span>}
217
+ {listing.files && <span>{formatNumber(files.length)} log files, {formatNumber(totalBytes)}B</span>}
218
+ </div>;
219
+ })}
220
+ <div>
221
+ Selected range covers <b>{formatNumber(selectedFiles.length)}</b> files, <b>{formatNumber(selectedBytes)}B</b> (compressed) to search
222
+ </div>
223
+ <div className={css.hbox(12).wrap.fontSize(AGE_FONT_SIZE).colorhsl(AGE_COLOR.h, AGE_COLOR.s, AGE_COLOR.l)}>
224
+ {!!oldestListingTime && <span>File listing from {formatTime(now - oldestListingTime)} ago</span>}
225
+ {!!this.state.resultsFetchTime && <span>Results from {formatTime(now - this.state.resultsFetchTime)} ago</span>}
226
+ {!!this.state.durationTime && <span>Search took {formatTime(this.state.durationTime)}</span>}
227
+ {this.state.searching && !!this.state.currentFile && <span>Searching {this.state.currentFile}</span>}
228
+ </div>
229
+ </div>
230
+ {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.rows.length && <div>
232
+ <Table
233
+ rows={this.state.rows}
234
+ columns={columnsConfig}
235
+ />
236
+ </div>}
237
+ {!this.state.rows.length && !this.state.searching && !!this.state.durationTime && <div>No matching log entries.</div>}
238
+ </div>;
239
+ }
240
+ }
@@ -1,7 +1,7 @@
1
1
  import { formatNumber } from "socket-function/src/formatting/format";
2
2
  import { sort } from "socket-function/src/misc";
3
3
  import type { ArchivesConfig, RemoteConfig, HostedConfig } from "sliftutils/storage/IArchives";
4
- import type { BucketWriteStats } from "sliftutils/storage/remoteStorage/storageServerState";
4
+ import type { BucketWriteStats } from "sliftutils/storage/remoteStorage/accessStats";
5
5
  import type { BucketDiskInfo } from "sliftutils/storage/remoteStorage/bucketDisk";
6
6
  import { parseHostedUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
7
7
  import { SourceTag, getSourceTags } from "./Tag";
@@ -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", "");