querysub 0.638.0 → 0.640.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 +3 -0
- package/src/deployManager/components/storage/StorageLogsPage.tsx +19 -0
- package/src/deployManager/components/storage/storageController.ts +1 -1
- package/src/deployManager/components/storage/storageLogsController.ts +61 -0
- package/src/deployManager/components/storage/storagePathView.tsx +128 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.640.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.112",
|
|
83
83
|
"socket-function": "^1.2.31",
|
|
84
84
|
"terser": "^5.31.0",
|
|
85
85
|
"typenode": "^6.6.1",
|
|
@@ -539,6 +539,9 @@ async function runServerSyncLoops() {
|
|
|
539
539
|
if (!isNoNetwork()) {
|
|
540
540
|
// FIRST, verify we didn't delay too long (to make sure we kill any nodes that were disconnected
|
|
541
541
|
// from the internet for too long)
|
|
542
|
+
// NOTE: If get can't contact a server, it will throw. It won't return undefined. So if this does return undefined, It means we actually talk to the server and it says the file doesn't exist, which means we are fairly safe in self-terminating.
|
|
543
|
+
//let lastTime = Number((await archives().get(selfNodeId, { noFallbacks: true }))?.toString());
|
|
544
|
+
// NOTE: We're temporary not using no fallbacks, So, this is a good canary to tell us if something's wrong with storage. We're writing so many heartbeats, at least one of the writes should have gotten to Backblaze.
|
|
542
545
|
let lastTime = Number((await archives().get(selfNodeId))?.toString());
|
|
543
546
|
let suicideThreshold = Date.now() - SUICIDE_HEARTBEAT_THRESHOLD;
|
|
544
547
|
if (!lastTime || lastTime < suicideThreshold) {
|
|
@@ -20,6 +20,7 @@ import { atomic } from "../../../2-proxy/PathValueProxyWatcher";
|
|
|
20
20
|
import { TimeRangeSelector, getTimeRange } from "../../../diagnostics/logs/TimeRangeSelector";
|
|
21
21
|
import { StorageLogsSynced } from "./storageLogsController";
|
|
22
22
|
import { searchStorageLogs, invalidateStorageLogSearchCache, FailedLogFile } from "./storageLogSearch";
|
|
23
|
+
import { showStoragePathModal } from "./storagePathView";
|
|
23
24
|
|
|
24
25
|
module.hotreload = true;
|
|
25
26
|
|
|
@@ -308,12 +309,30 @@ export class StorageLogsPage extends qreact.Component {
|
|
|
308
309
|
</Button>;
|
|
309
310
|
},
|
|
310
311
|
};
|
|
312
|
+
let serverUrls = listings.map(listing => listing.url);
|
|
311
313
|
for (let column of selectedFields) {
|
|
312
314
|
if (column === "time") {
|
|
313
315
|
columnsConfig[column] = {
|
|
314
316
|
title: "Time",
|
|
315
317
|
formatter: value => typeof value === "number" && <span className={css.whiteSpace("nowrap")}>{formatDateTime(value)}</span> || value as preact.ComponentChild,
|
|
316
318
|
};
|
|
319
|
+
} else if (column === "path") {
|
|
320
|
+
columnsConfig[column] = {
|
|
321
|
+
formatter: (value, context) => {
|
|
322
|
+
let bucketName = context?.row?.bucketName;
|
|
323
|
+
if (typeof value !== "string" || !value || typeof bucketName !== "string" || !bucketName) return value as preact.ComponentChild;
|
|
324
|
+
return <div className={css.hbox(6).alignItems("center")}>
|
|
325
|
+
<span>{value}</span>
|
|
326
|
+
<Button
|
|
327
|
+
flavor="tiny"
|
|
328
|
+
title="The live value of this path on every storage server"
|
|
329
|
+
onClick={() => showStoragePathModal(serverUrls, bucketName, value)}
|
|
330
|
+
>
|
|
331
|
+
Values
|
|
332
|
+
</Button>
|
|
333
|
+
</div>;
|
|
334
|
+
},
|
|
335
|
+
};
|
|
317
336
|
} else {
|
|
318
337
|
columnsConfig[column] = {};
|
|
319
338
|
}
|
|
@@ -13,7 +13,7 @@ import { STORAGE_ACCOUNT } from "../../../-a-archives/archives2";
|
|
|
13
13
|
module.hotreload = false;
|
|
14
14
|
|
|
15
15
|
/** The access-stats endpoints have no client wrapper in sliftutils (a pinned dependency), so we reconnect and authenticate the same way its own callServer does. */
|
|
16
|
-
async function callStorageServer<T>(url: string, run: (controller: typeof RemoteStorageController.nodes[string]) => Promise<T>): Promise<T> {
|
|
16
|
+
export async function callStorageServer<T>(url: string, run: (controller: typeof RemoteStorageController.nodes[string]) => Promise<T>): Promise<T> {
|
|
17
17
|
SocketFunction.ENABLE_CLIENT_MODE = true;
|
|
18
18
|
let parsed = parseStorageUrl(url);
|
|
19
19
|
let nodeId = SocketFunction.connect({ address: parsed.address, port: parsed.port });
|
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
2
|
import { listServerLogFiles, getServerLogs, searchServerLogs } from "sliftutils/storage/remoteStorage/createArchives";
|
|
3
3
|
import type { LogFileInfo } from "sliftutils/storage/StreamingLogs";
|
|
4
|
+
import { parseStorageUrl } from "sliftutils/storage/remoteStorage/ArchivesRemote";
|
|
5
|
+
import { ROUTING_FILE, parseRoutingData, normalizeSource, parseHostedUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
|
|
4
6
|
import { getSyncedController } from "../../../library-components/SyncedController";
|
|
5
7
|
import { assertIsManagementUser } from "../../../diagnostics/managementPages";
|
|
6
8
|
import { STORAGE_ACCOUNT } from "../../../-a-archives/archives2";
|
|
9
|
+
import { callStorageServer } from "./storageController";
|
|
7
10
|
|
|
8
11
|
// 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
12
|
module.hotreload = false;
|
|
10
13
|
|
|
14
|
+
// Enough for any value a human is going to eyeball; the full size still comes back, so the UI can say how much was left behind
|
|
15
|
+
const VALUE_PREVIEW_LIMIT = 256 * 1024;
|
|
16
|
+
|
|
17
|
+
export type PathValueRead = {
|
|
18
|
+
writeTime: number;
|
|
19
|
+
/** The full size - data may be a capped prefix of it */
|
|
20
|
+
size: number;
|
|
21
|
+
data: Buffer;
|
|
22
|
+
};
|
|
23
|
+
export type ServerPathValue = {
|
|
24
|
+
/** The server's own newest copy across its stores (an internal read - no store selection, no fallbacks to other machines) */
|
|
25
|
+
internal?: PathValueRead;
|
|
26
|
+
/** What a normal (non-internal) read serves, per store this server's routing config gives it */
|
|
27
|
+
stores: { store: string; error?: string; value?: PathValueRead }[];
|
|
28
|
+
fetchTime: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
11
31
|
export type ServerLogFiles = {
|
|
12
32
|
files: LogFileInfo[];
|
|
13
33
|
/** When this listing was fetched, so the UI can say how stale a cached listing is. */
|
|
@@ -32,6 +52,46 @@ class StorageLogsControllerBase {
|
|
|
32
52
|
let results = await searchServerLogs({ url, account: STORAGE_ACCOUNT, names: [name], searches });
|
|
33
53
|
return results[0]?.entries || [];
|
|
34
54
|
}
|
|
55
|
+
/** One path's live value on ONE server: its own newest copy (internal), plus what each of its configured stores serves normally. One call per server, so a locked-up server only stalls its own row. */
|
|
56
|
+
public async getServerPathValue(url: string, bucketName: string, path: string): Promise<ServerPathValue> {
|
|
57
|
+
let account = STORAGE_ACCOUNT;
|
|
58
|
+
let range = { start: 0, end: VALUE_PREVIEW_LIMIT };
|
|
59
|
+
return await callStorageServer(url, async controller => {
|
|
60
|
+
let parsed = parseStorageUrl(url);
|
|
61
|
+
// get2's internal reads ignore the sourceConfig, but the arg is still required - fabricated the same way getBucketInfo does
|
|
62
|
+
let fabricatedSource = normalizeSource(`${url.replace(/\/+$/, "")}/file/${account}/${bucketName}/storage/storagerouting.json`);
|
|
63
|
+
let internal = await controller.get2({ account, bucketName, path, sourceConfig: fabricatedSource, range, internal: true, includeTombstones: true });
|
|
64
|
+
// Only store names out of the server's OWN routing config go into non-internal reads: asking for a made-up name would CREATE that store
|
|
65
|
+
let stores: ServerPathValue["stores"] = [];
|
|
66
|
+
try {
|
|
67
|
+
let routing = await controller.get2({ account, bucketName, path: ROUTING_FILE, sourceConfig: fabricatedSource, internal: true });
|
|
68
|
+
let sources = routing && parseRoutingData(routing.data).sources.map(normalizeSource) || [];
|
|
69
|
+
let seenNames = new Set<string>();
|
|
70
|
+
for (let source of sources) {
|
|
71
|
+
if (source.type !== "remote") continue;
|
|
72
|
+
let hosted;
|
|
73
|
+
try {
|
|
74
|
+
hosted = parseHostedUrl(source.url);
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (hosted.address !== parsed.address || String(hosted.port) !== String(parsed.port)) continue;
|
|
79
|
+
if (hosted.bucketName !== bucketName) continue;
|
|
80
|
+
if (seenNames.has(source.name)) continue;
|
|
81
|
+
seenNames.add(source.name);
|
|
82
|
+
try {
|
|
83
|
+
let value = await controller.get2({ account, bucketName, path, sourceConfig: source, range, includeTombstones: true });
|
|
84
|
+
stores.push({ store: source.name, value: value || undefined });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
stores.push({ store: source.name, error: String((e as Error).stack ?? e) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
stores.push({ store: "(routing config unreadable)", error: String((e as Error).stack ?? e) });
|
|
91
|
+
}
|
|
92
|
+
return { internal: internal || undefined, stores, fetchTime: Date.now() };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
35
95
|
}
|
|
36
96
|
|
|
37
97
|
export const StorageLogsController = SocketFunction.register(
|
|
@@ -40,6 +100,7 @@ export const StorageLogsController = SocketFunction.register(
|
|
|
40
100
|
() => ({
|
|
41
101
|
getServerLogFiles: {},
|
|
42
102
|
searchLogFile: {},
|
|
103
|
+
getServerPathValue: {},
|
|
43
104
|
}),
|
|
44
105
|
() => ({
|
|
45
106
|
hooks: [assertIsManagementUser],
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
|
+
import { qreact } from "../../../4-dom/qreact";
|
|
3
|
+
import { css } from "typesafecss";
|
|
4
|
+
import { Querysub } from "../../../4-querysub/Querysub";
|
|
5
|
+
import { t } from "../../../2-proxy/schema2";
|
|
6
|
+
import { formatNumber, formatTime, formatDateTimeDetailed } from "socket-function/src/formatting/format";
|
|
7
|
+
import { timeInSecond } from "socket-function/src/misc";
|
|
8
|
+
import { showModal } from "../../../5-diagnostics/Modal";
|
|
9
|
+
import { FullscreenModal } from "../../../5-diagnostics/FullscreenModal";
|
|
10
|
+
import { StorageLogsController, ServerPathValue, PathValueRead } from "./storageLogsController";
|
|
11
|
+
|
|
12
|
+
module.hotreload = true;
|
|
13
|
+
|
|
14
|
+
// The live values of one path, across every storage server: each server's own copy (internal) plus what each of its stores serves normally. One call per server, fired on mount, so a locked-up server shows as in progress while the others fill in.
|
|
15
|
+
|
|
16
|
+
const BUFFER_PREVIEW_CHARS = 100;
|
|
17
|
+
const ERROR_COLOR = { h: 0, s: 60, l: 40 };
|
|
18
|
+
const DETAIL_COLOR = { h: 0, s: 0, l: 45 };
|
|
19
|
+
const DETAIL_FONT_SIZE = 12;
|
|
20
|
+
const ROW_LABEL_MIN_WIDTH = 220;
|
|
21
|
+
|
|
22
|
+
class BufferPreview extends qreact.Component<{ data: Buffer; totalSize: number }> {
|
|
23
|
+
state = t.state({
|
|
24
|
+
expanded: t.boolean,
|
|
25
|
+
});
|
|
26
|
+
render() {
|
|
27
|
+
let data = this.props.data;
|
|
28
|
+
let text = Buffer.from(data).toString("utf8");
|
|
29
|
+
let shown = this.state.expanded ? text : text.slice(0, BUFFER_PREVIEW_CHARS);
|
|
30
|
+
let clipped = text.length > BUFFER_PREVIEW_CHARS;
|
|
31
|
+
return <div className={css.vbox(2)}>
|
|
32
|
+
<div className={css.fontSize(DETAIL_FONT_SIZE).colorhsl(DETAIL_COLOR.h, DETAIL_COLOR.s, DETAIL_COLOR.l)}>
|
|
33
|
+
{formatNumber(this.props.totalSize)}B{data.length < this.props.totalSize && ` (only the first ${formatNumber(data.length)}B were fetched)`}
|
|
34
|
+
</div>
|
|
35
|
+
<div
|
|
36
|
+
className={css.fontFamily("monospace").whiteSpace("pre-wrap").wordBreak("break-all") + (clipped && css.pointer)}
|
|
37
|
+
title={clipped && (this.state.expanded ? "Click to collapse" : "Click to show the rest") || undefined}
|
|
38
|
+
onClick={() => this.state.expanded = !this.state.expanded}
|
|
39
|
+
>
|
|
40
|
+
{shown}{!this.state.expanded && clipped && "…"}
|
|
41
|
+
</div>
|
|
42
|
+
</div>;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class ValueRow extends qreact.Component<{ label: string; value?: PathValueRead; error?: string }> {
|
|
47
|
+
render() {
|
|
48
|
+
let { label, value, error } = this.props;
|
|
49
|
+
let now = Querysub.nowDelayed(timeInSecond);
|
|
50
|
+
return <div className={css.hbox(10).alignItems("flex-start")}>
|
|
51
|
+
<div className={css.boldStyle.minWidth(ROW_LABEL_MIN_WIDTH)}>{label}</div>
|
|
52
|
+
{error && <div className={css.colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l).whiteSpace("pre-wrap")}>{error}</div>}
|
|
53
|
+
{!error && !value && <div className={css.colorhsl(DETAIL_COLOR.h, DETAIL_COLOR.s, DETAIL_COLOR.l)}>missing</div>}
|
|
54
|
+
{value && <div className={css.vbox(2).flexGrow(1)}>
|
|
55
|
+
<div className={css.fontSize(DETAIL_FONT_SIZE).colorhsl(DETAIL_COLOR.h, DETAIL_COLOR.s, DETAIL_COLOR.l)}>
|
|
56
|
+
written {formatDateTimeDetailed(value.writeTime)} ({formatTime(now - value.writeTime)} ago)
|
|
57
|
+
</div>
|
|
58
|
+
<BufferPreview data={value.data} totalSize={value.size} />
|
|
59
|
+
</div>}
|
|
60
|
+
</div>;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class StoragePathViewModal extends qreact.Component<{
|
|
65
|
+
urls: string[];
|
|
66
|
+
bucketName: string;
|
|
67
|
+
path: string;
|
|
68
|
+
onClose: () => void;
|
|
69
|
+
}> {
|
|
70
|
+
state = t.state({
|
|
71
|
+
results: t.atomic<Record<string, { error?: string; value?: ServerPathValue }>>({}),
|
|
72
|
+
});
|
|
73
|
+
componentDidMount() {
|
|
74
|
+
// Props are synchronized state, so the plain values the async work needs are read here
|
|
75
|
+
let { urls, bucketName, path } = this.props;
|
|
76
|
+
for (let url of urls) {
|
|
77
|
+
void (async () => {
|
|
78
|
+
let setResult = (result: { error?: string; value?: ServerPathValue }) => {
|
|
79
|
+
Querysub.commit(() => {
|
|
80
|
+
this.state.results = { ...this.state.results, [url]: result };
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
let value = await StorageLogsController.nodes[SocketFunction.browserNodeId()].getServerPathValue(url, bucketName, path);
|
|
85
|
+
setResult({ value });
|
|
86
|
+
} catch (e) {
|
|
87
|
+
setResult({ error: (e as Error).stack ?? String(e) });
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
render() {
|
|
93
|
+
let now = Querysub.nowDelayed(timeInSecond);
|
|
94
|
+
return <FullscreenModal onCancel={this.props.onClose}>
|
|
95
|
+
<div className={css.vbox(14).pad2(16)}>
|
|
96
|
+
<div className={css.boldStyle}>{this.props.bucketName} / {this.props.path}</div>
|
|
97
|
+
{this.props.urls.map(url => {
|
|
98
|
+
let result = this.state.results[url];
|
|
99
|
+
return <div key={url} className={css.vbox(6)}>
|
|
100
|
+
<div className={css.hbox(8).alignItems("center")}>
|
|
101
|
+
<b>{url}</b>
|
|
102
|
+
{!result && <span>⏳ loading...</span>}
|
|
103
|
+
{result?.value && <span className={css.fontSize(DETAIL_FONT_SIZE).colorhsl(DETAIL_COLOR.h, DETAIL_COLOR.s, DETAIL_COLOR.l)}>
|
|
104
|
+
fetched {formatTime(now - result.value.fetchTime)} ago
|
|
105
|
+
</span>}
|
|
106
|
+
</div>
|
|
107
|
+
{result?.error && <div className={css.colorhsl(ERROR_COLOR.h, ERROR_COLOR.s, ERROR_COLOR.l).whiteSpace("pre-wrap")}>{result.error}</div>}
|
|
108
|
+
{result?.value && <div className={css.vbox(6).pad2(10, 0)}>
|
|
109
|
+
<ValueRow label="internal (server's own copy)" value={result.value.internal} />
|
|
110
|
+
{result.value.stores.map(store => <ValueRow
|
|
111
|
+
key={store.store}
|
|
112
|
+
label={`store ${store.store}`}
|
|
113
|
+
value={store.value}
|
|
114
|
+
error={store.error}
|
|
115
|
+
/>)}
|
|
116
|
+
</div>}
|
|
117
|
+
</div>;
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
</FullscreenModal>;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function showStoragePathModal(urls: string[], bucketName: string, path: string): void {
|
|
125
|
+
let close = showModal({
|
|
126
|
+
content: <StoragePathViewModal urls={urls} bucketName={bucketName} path={path} onClose={() => close.close()} />
|
|
127
|
+
});
|
|
128
|
+
}
|