querysub 0.589.0 → 0.591.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.
@@ -1,7 +1,7 @@
1
1
  import { blue, green, magenta, red, yellow } from "socket-function/src/formatting/logColors";
2
2
  import { measureFnc } from "socket-function/src/profiling/measure";
3
3
  import { ARCHIVE_FLUSH_LIMIT, ARCHIVE_FLUSH_LIMIT_START, compareTime, debugPathValuePath, getCompressNetwork, isCoreQuiet, PathValue, PathValueSnapshot, registerGetCompressNetwork, Time } from "./pathValueCore";
4
- import { getArchives, nestArchives } from "../-a-archives/archives";
4
+ import { getArchives2 } from "../-a-archives/archives2";
5
5
  import { cache, cacheLimited, lazy } from "socket-function/src/caching";
6
6
  import { getOwnNodeId, getOwnNodeIdAssert } from "../-f-node-discovery/NodeDiscovery";
7
7
  import { pathValueSerializer } from "../-h-path-value-serialize/PathValueSerializer";
@@ -11,7 +11,7 @@ import { list } from "socket-function/src/misc";
11
11
  import { NodeIdParts, decodeNodeId, decodeNodeIdAssert, encodeNodeId } from "sliftutils/misc/https/certs";
12
12
  import { createArchiveLocker2 } from "./archiveLocks/ArchiveLocks2";
13
13
  import { devDebugbreak, isNoNetwork, getDomain } from "../config";
14
- import { wrapArchivesWithCache } from "../-a-archives/archiveCache";
14
+ import { wrapArchivesWithCache2 } from "../-a-archives/archiveCache2";
15
15
  import { AuthoritySpec, PathRouter, debugSpec } from "./PathRouter";
16
16
  import { authorityLookup } from "./AuthorityLookup";
17
17
  import { delay, retryFunctional } from "socket-function/src/batching";
@@ -21,10 +21,10 @@ import { shutdown } from "../diagnostics/periodic";
21
21
 
22
22
  // Kept separate from the cache-wrapped `archives` so we can probe the underlying archives
23
23
  // directly (without the cache layer in the way) when diagnosing missing-file failures.
24
- const archivesBase = lazy(() => getArchives("path-values/"));
25
- export const archives = lazy(() => wrapArchivesWithCache(archivesBase()));
26
- export const archivesLocks = lazy(() => getArchives("path-values-locks/"));
27
- export const archivesRecycleBin = lazy(() => wrapArchivesWithCache(getArchives("path-values-recycle-bin/")));
24
+ const archivesBase = lazy(() => getArchives2("path-values/"));
25
+ export const archives = lazy(() => wrapArchivesWithCache2(archivesBase()));
26
+ export const archivesLocks = lazy(() => getArchives2("path-values-locks/"));
27
+ export const archivesRecycleBin = lazy(() => wrapArchivesWithCache2(getArchives2("path-values-recycle-bin/")));
28
28
 
29
29
  // If getInfo reports a just-written file as missing, recheck a few times before treating it
30
30
  // as a fatal "written too slowly" condition.
@@ -314,7 +314,7 @@ export class PathValueArchives {
314
314
  while (pendingDataPaths.length > 0) {
315
315
  let dataPath = pendingDataPaths.pop()!;
316
316
  if (readCache.has(dataPath)) continue;
317
- let data = await archives().get(dataPath, { fastRead: true });
317
+ let data = await archives().get(dataPath);
318
318
  if (!data) continue;
319
319
  readCache.set(dataPath, data);
320
320
  }
@@ -469,7 +469,7 @@ export class PathValueArchives {
469
469
  const BATCH_SIZE = 16;
470
470
  for (let i = 0; i < dataPath.length; i += BATCH_SIZE) {
471
471
  let cur = dataPath.slice(i, i + BATCH_SIZE);
472
- let curResults = await Promise.all(cur.map(x => archives().get(x, { fastRead: true })));
472
+ let curResults = await Promise.all(cur.map(x => archives().get(x)));
473
473
  results.push(...curResults);
474
474
  }
475
475
  if (config?.includeRecycleBin) {
@@ -483,7 +483,7 @@ export class PathValueArchives {
483
483
  for (let i = 0; i < notFoundList.length; i += BATCH_SIZE) {
484
484
  let curIndexes = notFoundList.slice(i, i + BATCH_SIZE);
485
485
  let cur = curIndexes.map(x => dataPath[x]);
486
- let curResults = await Promise.all(cur.map(x => archivesRecycleBin().get(x, { fastRead: true })));
486
+ let curResults = await Promise.all(cur.map(x => archivesRecycleBin().get(x)));
487
487
  for (let j = 0; j < curResults.length; j++) {
488
488
  results[curIndexes[j]] = curResults[j];
489
489
  }
@@ -664,7 +664,7 @@ export class PathValueProxyWatcher {
664
664
  return pathValue;
665
665
  };
666
666
  public getCallback = (pathStr: string, syncParentKeys?: "parentKeys", readTransparent?: "readTransparent"): { value: unknown } | undefined => {
667
- if (PathValueProxyWatcher.BREAK_ON_READS.size > 0 && (proxyWatcher.isAllSynced() || this)) {
667
+ if (PathValueProxyWatcher.BREAK_ON_READS.size > 0 && (proxyWatcher.isAllSynced({ checkCommitAllRunsFlag: true }) || this)) {
668
668
  // NOTE: We can't do a recursive match, as the parent paths include the
669
669
  // root, which is constantly read, but not relevant.
670
670
  if (PathValueProxyWatcher.BREAK_ON_READS.has(pathStr)) {
@@ -750,7 +750,7 @@ export class PathValueProxyWatcher {
750
750
  };
751
751
 
752
752
  public setCallback = (pathStr: string, value: unknown, inRecursion = false, allowSpecial = false): void => {
753
- if (PathValueProxyWatcher.BREAK_ON_WRITES.size > 0 && proxyWatcher.isAllSynced()) {
753
+ if (PathValueProxyWatcher.BREAK_ON_WRITES.size > 0 && proxyWatcher.isAllSynced({ checkCommitAllRunsFlag: true })) {
754
754
  if (isRecursiveMatch(PathValueProxyWatcher.BREAK_ON_WRITES, pathStr)) {
755
755
  let unwatch = () => removeMatches(PathValueProxyWatcher.BREAK_ON_WRITES, pathStr);
756
756
  debugger;
@@ -758,7 +758,7 @@ export class PathValueProxyWatcher {
758
758
  }
759
759
  }
760
760
 
761
- if (PathValueProxyWatcher.SET_FUNCTION_WATCH_ON_WRITES.size > 0 && proxyWatcher.isAllSynced()) {
761
+ if (PathValueProxyWatcher.SET_FUNCTION_WATCH_ON_WRITES.size > 0 && proxyWatcher.isAllSynced({ checkCommitAllRunsFlag: true })) {
762
762
  if (isRecursiveMatch(PathValueProxyWatcher.SET_FUNCTION_WATCH_ON_WRITES, pathStr)) {
763
763
  let unwatch = () => removeMatches(PathValueProxyWatcher.SET_FUNCTION_WATCH_ON_WRITES, pathStr);
764
764
  PathValueProxyWatcher.BREAK_ON_CALL.add(
@@ -767,7 +767,7 @@ export class PathValueProxyWatcher {
767
767
  }
768
768
  }
769
769
 
770
- if (PathValueProxyWatcher.LOG_WRITES_INCLUDES.size > 0 && proxyWatcher.isAllSynced()) {
770
+ if (PathValueProxyWatcher.LOG_WRITES_INCLUDES.size > 0 && proxyWatcher.isAllSynced({ checkCommitAllRunsFlag: true })) {
771
771
  if (isRecursiveMatch(PathValueProxyWatcher.LOG_WRITES_INCLUDES, pathStr)) {
772
772
  let unwatch = () => removeMatches(PathValueProxyWatcher.LOG_WRITES_INCLUDES, pathStr);
773
773
  console.log(`Write path "${pathStr}" = ${value}`);
@@ -2138,9 +2138,9 @@ export class PathValueProxyWatcher {
2138
2138
  }
2139
2139
 
2140
2140
  public isAllSynced(config?: {
2141
- ignoreAlwaysCommitAllRunsFlag?: boolean;
2141
+ checkCommitAllRunsFlag?: boolean;
2142
2142
  }) {
2143
- if (!config?.ignoreAlwaysCommitAllRunsFlag && this.runningWatcher?.options.commitAllRuns) {
2143
+ if (config?.checkCommitAllRunsFlag && this.runningWatcher?.options.commitAllRuns) {
2144
2144
  return true;
2145
2145
  }
2146
2146
  return !this.getTriggeredWatcherMaybeUndefined()?.hasAnyUnsyncedAccesses();
@@ -744,7 +744,7 @@ class QRenderClass {
744
744
  } else {
745
745
  // We check here, and not in the caller, so the props are always up to date,
746
746
  // as the objects might be mutated or special (or have callback functions).
747
- if (statics.jsonComparePropUpdates && Querysub.isAllSynced()) {
747
+ if (statics.jsonComparePropUpdates && Querysub.isAllSynced({ checkCommitAllRunsFlag: true })) {
748
748
 
749
749
  let props = self.data().props;
750
750
 
@@ -809,7 +809,7 @@ class QRenderClass {
809
809
  // ALSO, this stops infinite loops caused by self triggering, which is really useful.
810
810
  if (!QRenderClass.areVNodesEqual(comparePrevVNode, vNode)) {
811
811
  self.data().vNodeForRender = frozen;
812
- if (Querysub.isAllSynced()) {
812
+ if (Querysub.isAllSynced({ checkCommitAllRunsFlag: true })) {
813
813
  comparePrevVNode = frozen.value;
814
814
  }
815
815
  } else {
@@ -1886,7 +1886,7 @@ class QRenderClass {
1886
1886
  export const __INTERNAL__QRenderClass = QRenderClass;
1887
1887
 
1888
1888
  let defaultErrorHandler: ErrorHandler = ({ error, debugName }) => {
1889
- if (Querysub.isAllSynced()) {
1889
+ if (Querysub.isAllSynced({ checkCommitAllRunsFlag: true })) {
1890
1890
  console.error(`Render error in ${debugName}`, error);
1891
1891
  // Throw, so we get a good callstack
1892
1892
  setImmediate(() => { throw error; });
@@ -2673,7 +2673,7 @@ function watchUnsyncedComponents(): Set<ExternalRenderClass> {
2673
2673
  return unsyncedComponents;
2674
2674
  }
2675
2675
  function componentRendered(component: QRenderClass) {
2676
- if (component.disposed || Querysub.isAllSynced({ ignoreAlwaysCommitAllRunsFlag: true })) {
2676
+ if (component.disposed || Querysub.isAllSynced()) {
2677
2677
  if (unsyncedComponents.has(component)) {
2678
2678
  unsyncedComponents.delete(component);
2679
2679
  triggerUnsyncedNow();
@@ -422,7 +422,7 @@ export class Querysub {
422
422
  public static anyUnsynced() {
423
423
  return !Querysub.allSynced();
424
424
  }
425
- public static allSynced(config?: { ignoreAlwaysCommitAllRunsFlag?: boolean }) {
425
+ public static allSynced(config?: { checkCommitAllRunsFlag?: boolean }) {
426
426
  return proxyWatcher.isAllSynced(config);
427
427
  }
428
428
  public static fullySynced = Querysub.allSynced;
@@ -528,6 +528,13 @@ export class Querysub {
528
528
  });
529
529
  }
530
530
 
531
+ public static triggerOnPromiseFinish(promise: MaybePromise<unknown>, config: {
532
+ waitReason: string;
533
+ noWait?: boolean;
534
+ }) {
535
+ proxyWatcher.triggerOnPromiseFinish(promise, config);
536
+ }
537
+
531
538
  /** Returns true if any predictions are running. In which case, we will correctly rerun the function and consider it non-synced until the predictions finish. This allows you to check for predictions of the in your function and return if you have any. This is useful for functions that need a stable state before they run. However, if many functions use this and you trigger them at once, it might result in an n-squared situation, so this should be used with caution.
532
539
  * - The best use case is if something is triggering maybe on focus and on blur, and you want to make sure the on blur changes happen before your on focus changes happen.
533
540
  */
@@ -183,9 +183,18 @@ export function getRouteConfigGroups(servers: StorageServerBuckets[]): RouteConf
183
183
  let byBucket = new Map<string, Map<string, ConfigVariant>>();
184
184
  for (let server of servers) {
185
185
  for (let bucket of server.buckets || []) {
186
+ // An inactive bucket is not serving anything, so whatever routing it happens to hold says nothing about where values actually come from - and letting it vote would put a stale config up against the live ones as a conflict
187
+ if (!bucket.active) continue;
186
188
  let remoteConfig: RemoteConfig | undefined = bucket.config?.remoteConfig;
187
189
  if (!remoteConfig) continue;
188
- let sources = remoteConfig.sources.map(normalizeSource);
190
+ let sources: Source[];
191
+ try {
192
+ sources = remoteConfig.sources.map(normalizeSource);
193
+ } catch (e: any) {
194
+ // One server's unreadable config must not cost us the grouping for every other bucket - it simply does not get a say in the consensus
195
+ console.error(`Ignoring the routing config ${server.url} reports for ${bucket.bucketName}, which could not be read:`, e.stack ?? e);
196
+ continue;
197
+ }
189
198
  let variants = byBucket.get(bucket.bucketName);
190
199
  if (!variants) {
191
200
  variants = new Map();
@@ -0,0 +1,179 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { qreact } from "../../4-dom/qreact";
3
+ import { css } from "typesafecss";
4
+ import { formatNumber, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
5
+ import { timeInSecond } from "socket-function/src/misc";
6
+ import { timeoutToError } from "../../errors";
7
+ import { StatsValue, getStatsTop } from "socket-function/src/profiling/stats";
8
+ import type { MeasureChunk, MeasureSummary } from "../../diagnostics/watchdog";
9
+ import { NodeCapabilitiesController } from "../../-g-core-values/NodeCapabilities";
10
+ import { getSyncedController } from "../../library-components/SyncedController";
11
+ import { assertIsManagementUser } from "../../diagnostics/managementPages";
12
+
13
+ const NODE_CALL_TIMEOUT = timeInSecond * 10;
14
+ const BAR_HEIGHT_PX = 6;
15
+ const MIN_BAR_WIDTH_PX = 30;
16
+ // Green at idle, red at fully busy.
17
+ const BAR_IDLE_HUE = 120;
18
+ const BAR_FLASH_THRESHOLD = 0.6;
19
+ const FLASH_CLASS_NAME = "ServiceMeasureBars-flash";
20
+
21
+ class ServiceMeasureControllerBase {
22
+ public async getMeasureSummary(nodeId: string): Promise<MeasureSummary | undefined> {
23
+ return await timeoutToError(NODE_CALL_TIMEOUT, NodeCapabilitiesController.nodes[nodeId].getMeasureSummary(), () => new Error(`Timed out asking ${nodeId} for its measure summary`));
24
+ }
25
+ public async getMeasureBreakdown(nodeId: string, range: { startTime: number; endTime: number }): Promise<{ chunk?: MeasureChunk }> {
26
+ let chunk = await timeoutToError(NODE_CALL_TIMEOUT, NodeCapabilitiesController.nodes[nodeId].getMeasureBreakdown(range), () => new Error(`Timed out asking ${nodeId} for its measure breakdown`));
27
+ return { chunk };
28
+ }
29
+ }
30
+ export const ServiceMeasureController = getSyncedController(SocketFunction.register(
31
+ "ServiceMeasureController-3f8c1de2-5b74-4e0f-a6c9-8d21f47b90a5",
32
+ new ServiceMeasureControllerBase(),
33
+ () => ({
34
+ getMeasureSummary: { hooks: [assertIsManagementUser], compress: true },
35
+ getMeasureBreakdown: { hooks: [assertIsManagementUser], compress: true },
36
+ }),
37
+ () => ({
38
+ })
39
+ ));
40
+
41
+ function percent(value: number) {
42
+ return `${(value * 100).toFixed(2)}%`;
43
+ }
44
+
45
+ export class ServiceMeasureBars extends qreact.Component<{
46
+ serviceNodeIds: string[];
47
+ expandedNodeId: string;
48
+ onToggleNode: (nodeId: string) => void;
49
+ }> {
50
+ render() {
51
+ let controller = ServiceMeasureController(SocketFunction.browserNodeId());
52
+
53
+ return <div className={css.vbox(1).alignItems("stretch")}>
54
+ {this.props.serviceNodeIds.map(nodeId => {
55
+ let isExpanded = this.props.expandedNodeId === nodeId;
56
+ let summary: MeasureSummary | undefined;
57
+ try {
58
+ summary = controller.getMeasureSummary(nodeId);
59
+ } catch (err) {
60
+ return <div key={nodeId}
61
+ className={css.height(BAR_HEIGHT_PX).hsl(0, 70, 55)}
62
+ title={`Node did not respond (it likely doesn't have the measure API yet)\n${nodeId}\n${(err as Error).stack ?? err}`}
63
+ />;
64
+ }
65
+ if (!summary) return undefined;
66
+ let timeRunFor = summary.endTime - summary.startTime;
67
+ if (timeRunFor <= 0) return undefined;
68
+ let fraction = summary.profiledTime / timeRunFor;
69
+ let clampedFraction = Math.min(1, Math.max(0, fraction));
70
+ let hue = BAR_IDLE_HUE * (1 - clampedFraction);
71
+ let isFlashing = fraction > BAR_FLASH_THRESHOLD;
72
+ return <div key={nodeId} className={css.hbox(0)}>
73
+ <div
74
+ className={
75
+ css.height(BAR_HEIGHT_PX)
76
+ .width(`${fraction * 100}%`)
77
+ .minWidth(MIN_BAR_WIDTH_PX)
78
+ .button
79
+ .hsl(hue, 70, isExpanded ? 40 : 55)
80
+ + (isFlashing && (" " + FLASH_CLASS_NAME) || "")
81
+ }
82
+ title={`Profiled ${formatTime(summary.profiledTime)} of ${formatTime(timeRunFor)} (${percent(fraction)} CPU)\n${nodeId}`}
83
+ onClick={() => this.props.onToggleNode(nodeId)}
84
+ />
85
+ {isFlashing && <style>{`
86
+ @keyframes ${FLASH_CLASS_NAME}-anim {
87
+ 0%, 100% { opacity: 1; }
88
+ 50% { opacity: 0.3; }
89
+ }
90
+ .${FLASH_CLASS_NAME} {
91
+ animation: ${FLASH_CLASS_NAME}-anim 0.8s infinite;
92
+ }
93
+ `}</style>}
94
+ </div>;
95
+ })}
96
+ </div>;
97
+ }
98
+ }
99
+
100
+ export class ServiceMeasureBreakdown extends qreact.Component<{
101
+ nodeId: string;
102
+ }> {
103
+ render() {
104
+ let controller = ServiceMeasureController(SocketFunction.browserNodeId());
105
+ let summary: MeasureSummary | undefined;
106
+ let result: { chunk?: MeasureChunk } | undefined;
107
+ try {
108
+ summary = controller.getMeasureSummary(this.props.nodeId);
109
+ if (!summary) return undefined;
110
+ result = controller.getMeasureBreakdown(this.props.nodeId, { startTime: summary.startTime, endTime: summary.endTime });
111
+ } catch (err) {
112
+ return <div className={css.pad2(10).hsl(0, 70, 92).bord2(0, 0, 20)}>
113
+ <div className={css.boldStyle.colorhsl(0, 70, 35)}>Node did not respond (it likely doesn't have the measure API yet)</div>
114
+ <pre className={css.whiteSpace("pre-wrap").fontSize(12)}>{(err as Error).stack ?? String(err)}</pre>
115
+ </div>;
116
+ }
117
+ if (!result) {
118
+ return <div className={css.pad2(10).hsl(0, 0, 98).bord2(0, 0, 20)}>Loading breakdown...</div>;
119
+ }
120
+ let chunk = result.chunk;
121
+ if (!chunk) {
122
+ return <div className={css.pad2(10).hsl(0, 0, 98).bord2(0, 0, 20)}>Breakdown is no longer available on the node (it only keeps the last hour, and restarts clear it).</div>;
123
+ }
124
+
125
+ let timeRunFor = chunk.endTime - chunk.startTime;
126
+ let cpuFraction = chunk.profiledTime / timeRunFor;
127
+ return <div className={css.vbox(6).pad2(10).hsl(0, 0, 98).bord2(0, 0, 20).fontFamily("monospace").fontSize(13)}>
128
+ <div className={css.boldStyle}>
129
+ Profiled {formatTime(chunk.profiledTime)} ({percent(cpuFraction)} CPU) (profile for {formatTime(timeRunFor)}, ending {formatVeryNiceDateTime(chunk.endTime)})
130
+ </div>
131
+ <div className={css.colorhsl(0, 0, 45).fontSize(12)}>{this.props.nodeId}</div>
132
+ <table className={css.borderCollapse("collapse")}>
133
+ {chunk.entries.map(entry => this.renderEntryRow(entry, chunk!))}
134
+ </table>
135
+ </div>;
136
+ }
137
+
138
+ renderEntryRow(entry: MeasureChunk["entries"][0], chunk: MeasureChunk) {
139
+ let fraction = entry.ownTime.sum / chunk.profiledTime;
140
+ let cellPad = css.pad2(6, 1).whiteSpace("pre");
141
+ return <tr key={entry.name}>
142
+ <td className={cellPad
143
+ .colorhsl(210, 70, 35)
144
+ .background(`linear-gradient(to right, hsla(210, 60%, 55%, 0.25) ${fraction * 100}%, transparent ${fraction * 100}%)`)
145
+ }>
146
+ {entry.name}
147
+ </td>
148
+ <td className={cellPad.textAlign("right")}>{percent(fraction)}</td>
149
+ <td className={cellPad.textAlign("right")}>{formatTime(entry.ownTime.sum)}</td>
150
+ <td className={cellPad}>=</td>
151
+ {this.renderEquationCell(entry.ownTime)}
152
+ <td className={cellPad}>
153
+ {entry.stillOpenCount > 0 && <span className={css.colorhsl(0, 70, 45)}>({entry.stillOpenCount} open)</span>}
154
+ </td>
155
+ </tr>;
156
+ }
157
+
158
+ renderEquationCell(stats: StatsValue) {
159
+ let cellPad = css.pad2(6, 1).whiteSpace("pre");
160
+ let top = getStatsTop(stats);
161
+ if (!top.topHeavy) {
162
+ return <td className={cellPad}>
163
+ {formatNumber(stats.count)} × {formatTime(stats.sum / stats.count)}
164
+ </td>;
165
+ }
166
+ let bottomCount = stats.count - top.count;
167
+ let bottomValue = stats.sum - top.value;
168
+ let splitAt = top.valueFraction * 100;
169
+ return <td className={cellPad
170
+ .background(`linear-gradient(to right, hsla(0, 70%, 55%, 0.2) ${splitAt}%, hsla(210, 60%, 55%, 0.15) ${splitAt}%)`)
171
+ }
172
+ title={`Top heavy: ${percent(top.valueFraction)} of the time is in ${percent(top.countFraction)} of the calls`}
173
+ >
174
+ <span className={css.colorhsl(0, 70, 40)}>{formatNumber(top.count)} × {formatTime(top.value / top.count)}</span>
175
+ {" + "}
176
+ <span>{formatNumber(bottomCount)} × {formatTime(bottomValue / bottomCount || 0)}</span>
177
+ </td>;
178
+ }
179
+ }
@@ -17,6 +17,7 @@ import { PendingDeployInfo, UpdateButtons, UpdateServiceButtons } from "./deploy
17
17
  import { isDefined } from "../../misc";
18
18
  import { formatDateJSX } from "../../misc/formatJSX";
19
19
  import { Tools } from "./Tools";
20
+ import { ServiceMeasureBars, ServiceMeasureBreakdown } from "./ServiceMeasureBars";
20
21
 
21
22
  module.hotreload = true;
22
23
 
@@ -41,6 +42,10 @@ class ServiceCategoryBadge extends qreact.Component<{ config: ServiceConfig }> {
41
42
  }
42
43
 
43
44
  export class ServicesListPage extends qreact.Component {
45
+ state = t.state({
46
+ measurementsEnabled: t.atomic<boolean>(true),
47
+ expandedMeasureNodes: t.lookup(t.string),
48
+ });
44
49
 
45
50
  render() {
46
51
  let controller = MachineServiceController(SocketFunction.browserNodeId());
@@ -63,6 +68,20 @@ export class ServicesListPage extends qreact.Component {
63
68
  keyCounts.set(key, (keyCounts.get(key) || 0) + 1);
64
69
  }
65
70
 
71
+ let serviceNodeIds = new Map<string, string[]>();
72
+ if (this.state.measurementsEnabled) {
73
+ for (let [serviceId, config] of services) {
74
+ if (!config) continue;
75
+ let nodeIds: string[] = [];
76
+ for (let machineId of getMachineIdList(config.parameters)) {
77
+ if (getMachineConfig(machineId)?.disabled) continue;
78
+ let serviceInfo = getMachineInfo(machineId)?.services[serviceId];
79
+ nodeIds.push(...Object.values(serviceInfo?.nodeIds || {}));
80
+ }
81
+ serviceNodeIds.set(serviceId, nodeIds);
82
+ }
83
+ }
84
+
66
85
  return <div className={css.vbox(16)}>
67
86
  <div className={css.hbox(12).wrap}>
68
87
  <h2 className={css.flexGrow(1)}>Services</h2>
@@ -104,6 +123,14 @@ export class ServicesListPage extends qreact.Component {
104
123
  </button>
105
124
  <UpdateButtons services={services.map(x => x[1]).filter(isDefined)} />
106
125
  </div>
126
+ <div
127
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 95).alignSelf("flex-start")}
128
+ onClick={() => {
129
+ this.state.measurementsEnabled = !this.state.measurementsEnabled;
130
+ }}
131
+ >
132
+ <span className={css.boldStyle}>{this.state.measurementsEnabled ? "☑" : "☐"} Measurements</span>
133
+ </div>
107
134
  <Tools />
108
135
  <div className={css.vbox(8)}>
109
136
  {services.map(([serviceId, config]) => {
@@ -138,9 +165,13 @@ export class ServicesListPage extends qreact.Component {
138
165
  }, 0);
139
166
  let unknown = enabledMachineIds.length - runningMachines.length - failingMachines.length - missingMachines.length;
140
167
  let duplicateKey = (keyCounts.get(config.parameters.key || "") || 0) > 1;
141
- return <div className={css.hbox(10)}>
168
+ let measureNodeIds = serviceNodeIds.get(serviceId) || [];
169
+ let expandedMeasureNodeId = this.state.expandedMeasureNodes[serviceId] || "";
170
+ return <div className={css.vbox(4).alignItems("stretch")} key={serviceId}>
171
+ <div className={css.hbox(10)}>
142
172
  <ServiceCategoryBadge config={config} />
143
- <Anchor noStyles key={serviceId}
173
+ <div className={css.vbox(4).alignItems("stretch")}>
174
+ <Anchor noStyles
144
175
  values={[currentViewParam.getOverride("service-detail"), selectedServiceIdParam.getOverride(serviceId)]}
145
176
  className={
146
177
  css.pad2(12).button.bord2(0, 0, 20)
@@ -206,8 +237,24 @@ export class ServicesListPage extends qreact.Component {
206
237
  </div>
207
238
  </div>
208
239
  </Anchor>
240
+ {this.state.measurementsEnabled && measureNodeIds.length > 0 && <ServiceMeasureBars
241
+ serviceNodeIds={measureNodeIds}
242
+ expandedNodeId={expandedMeasureNodeId}
243
+ onToggleNode={nodeId => {
244
+ if (this.state.expandedMeasureNodes[serviceId] === nodeId) {
245
+ delete this.state.expandedMeasureNodes[serviceId];
246
+ } else {
247
+ this.state.expandedMeasureNodes[serviceId] = nodeId;
248
+ }
249
+ }}
250
+ />}
251
+ </div>
209
252
  <UpdateServiceButtons service={config} />
210
253
  <PendingDeployInfo service={config} />
254
+ </div>
255
+ {this.state.measurementsEnabled && expandedMeasureNodeId && <ServiceMeasureBreakdown
256
+ nodeId={expandedMeasureNodeId}
257
+ />}
211
258
  </div>
212
259
  ;
213
260
  })}
@@ -41,6 +41,8 @@ export type StorageServerBuckets = StorageServer & {
41
41
  /** Still waiting on this server's own endpoint - each server loads independently */
42
42
  loading?: boolean;
43
43
  buckets?: ServerBucketInfo[];
44
+ /** This server could not be read at all. Kept per server rather than thrown, so one bad server costs its own row and nothing else. */
45
+ error?: string;
44
46
  };
45
47
 
46
48
  function getStorageUrls(parameters: ServiceParameters): string[] {
@@ -59,7 +61,7 @@ function getStorageUrls(parameters: ServiceParameters): string[] {
59
61
  export function getStorageServers(configs: ServiceConfig[]): StorageServer[] {
60
62
  let serviceByUrl = new Map<string, StorageServer>();
61
63
  for (let config of configs) {
62
- let allParameters = [getLiveServiceParameters(config), config.parameters, config.oldParameters];
64
+ let allParameters = [getLiveServiceParameters(config)];
63
65
  for (let parameters of allParameters) {
64
66
  if (!parameters) continue;
65
67
  for (let url of getStorageUrls(parameters)) {
@@ -214,6 +216,10 @@ function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
214
216
  syncing: undefined,
215
217
  error: "",
216
218
  };
219
+ if (server.error) {
220
+ rows.push({ ...baseRow, error: server.error });
221
+ continue;
222
+ }
217
223
  if (server.loading) {
218
224
  rows.push({ ...baseRow, bucket: "Loading..." });
219
225
  continue;
@@ -226,24 +232,34 @@ function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
226
232
  }
227
233
  for (let [index, bucket] of buckets.entries()) {
228
234
  let config = bucket.config;
229
- rows.push({
230
- ...baseRow,
231
- server: index === 0 && server.url || "",
232
- bucket: bucket.bucketName,
233
- state: bucket.active && ACTIVE_STATE || INACTIVE_STATE,
234
- files: config?.index && formatNumber(config.index.fileCount) || "",
235
- bytes: config?.index && formatNumber(config.index.byteCount) + "B" || "",
236
- flags: getBucketFlags(server.url, bucket.bucketName, config?.remoteConfig),
237
- indexSources: config?.indexSources,
238
- readerDiskLimit: config?.readerDiskLimit && formatNumber(config.readerDiskLimit) + "B" || "",
239
- writes: bucket.writeStats && formatNumber(bucket.writeStats.originalWrites) || "",
240
- written: bucket.writeStats && formatNumber(bucket.writeStats.originalBytes) + "B" || "",
241
- writeGain: getWriteGain(bucket.writeStats),
242
- disk: bucket.disk,
243
- diskError: bucket.diskError || "",
244
- syncing: config?.syncing,
245
- error: bucket.error || "",
246
- });
235
+ // Per bucket for the same reason it is per server: a single unparseable config costs its own row, not the table
236
+ try {
237
+ rows.push({
238
+ ...baseRow,
239
+ server: index === 0 && server.url || "",
240
+ bucket: bucket.bucketName,
241
+ state: bucket.active && ACTIVE_STATE || INACTIVE_STATE,
242
+ files: config?.index && formatNumber(config.index.fileCount) || "",
243
+ bytes: config?.index && formatNumber(config.index.byteCount) + "B" || "",
244
+ flags: getBucketFlags(server.url, bucket.bucketName, config?.remoteConfig),
245
+ indexSources: config?.indexSources,
246
+ readerDiskLimit: config?.readerDiskLimit && formatNumber(config.readerDiskLimit) + "B" || "",
247
+ writes: bucket.writeStats && formatNumber(bucket.writeStats.originalWrites) || "",
248
+ written: bucket.writeStats && formatNumber(bucket.writeStats.originalBytes) + "B" || "",
249
+ writeGain: getWriteGain(bucket.writeStats),
250
+ disk: bucket.disk,
251
+ diskError: bucket.diskError || "",
252
+ syncing: config?.syncing,
253
+ error: bucket.error || "",
254
+ });
255
+ } catch (e: any) {
256
+ rows.push({
257
+ ...baseRow,
258
+ server: index === 0 && server.url || "",
259
+ bucket: bucket.bucketName,
260
+ error: e.stack ?? String(e),
261
+ });
262
+ }
247
263
  }
248
264
  }
249
265
  return rows;
@@ -537,9 +553,14 @@ export class StoragePage extends qreact.Component {
537
553
  const configs = (serviceList || []).map(serviceId => machineController.getServiceConfig(serviceId)).filter(isDefined);
538
554
  const storageServers = getStorageServers(configs);
539
555
  const servers: StorageServerBuckets[] = storageServers.map(server => {
540
- let buckets = controller.getServerBuckets(server.url);
541
- if (!buckets) return { ...server, loading: true };
542
- return { ...server, buckets };
556
+ // Every other server still has something worth showing, so one that cannot be read becomes a row saying so instead of an empty page
557
+ try {
558
+ let buckets = controller.getServerBuckets(server.url);
559
+ if (!buckets) return { ...server, loading: true };
560
+ return { ...server, buckets };
561
+ } catch (e: any) {
562
+ return { ...server, error: e.stack ?? String(e) };
563
+ }
543
564
  });
544
565
  const header = <>
545
566
  <div className={css.hbox(12)}>