querysub 0.630.0 → 0.632.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.630.0",
3
+ "version": "0.632.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.98",
82
+ "sliftutils": "^1.7.102",
83
83
  "socket-function": "^1.2.30",
84
84
  "terser": "^5.31.0",
85
85
  "typenode": "^6.6.1",
@@ -532,7 +532,8 @@ async function runServerSyncLoops() {
532
532
 
533
533
  console.log(magenta(`Node discovery is loaded`));
534
534
 
535
- await runInfinitePollCallAtStart(HEARTBEAT_INTERVAL, async function nodeDiscoverHeartbeat() {
535
+ // Delayed by a bit as it might take a little bit for our heartbeat to show up on startup, especially if we are the storage node that is supposed to store our own heartbeat.
536
+ delay(HEARTBEAT_INTERVAL * 5).then(() => runInfinitePoll(HEARTBEAT_INTERVAL, async function nodeDiscoverHeartbeat() {
536
537
  // If we waited too long, other nodes might think we are dead. In which case, we SHOULD terminate.
537
538
  if (!isNoNetwork()) {
538
539
  // FIRST, verify we didn't delay too long (to make sure we kill any nodes that were disconnected
@@ -557,7 +558,7 @@ async function runServerSyncLoops() {
557
558
  }
558
559
 
559
560
  await writeHeartbeat();
560
- });
561
+ }));
561
562
  }
562
563
 
563
564
  let discoveryReady = new PromiseObj<void>();
@@ -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 { OperationsCell, OperationsSection, refreshOperationsData } from "./operationsView";
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 sources={sources} />
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
- return <UsageBar
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
- export class IndexSourcesCell extends qreact.Component<{ sources: ArchivesConfig["indexSources"] }> {
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.vbox(2).fillWidth}>
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={css.relative.fillWidth.pad2(SOURCE_ROW_PADDING.horizontal, SOURCE_ROW_PADDING.vertical)}
94
- title={remote && REMOTE_SOURCE_TOOLTIP || undefined}
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", operationsServerUrl: server.url });
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 = "count";
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 below the table: a titled, boxed section with its own refresh/reset controls and the count/size order toggle, then one wrapping breakdown per machine. */
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: "Count", value: "count" }, { title: "Size", value: "size" }]}
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<{ debugName: string }> {
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>