querysub 0.639.0 → 0.641.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.639.0",
3
+ "version": "0.641.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.109",
82
+ "sliftutils": "^1.7.113",
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
+ const 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
+ }