querysub 0.588.0 → 0.590.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.
@@ -9,11 +9,12 @@ import { sort } from "socket-function/src/misc";
9
9
  import { lazy } from "socket-function/src/caching";
10
10
  import { decodeCborx, encodeCborx } from "../../misc/cloneHelpers";
11
11
  import { runInParallel } from "socket-function/src/batching";
12
- import { formatNumber } from "socket-function/src/formatting/format";
12
+ import { formatNumber, formatTime } from "socket-function/src/formatting/format";
13
+ import { getArchives2 } from "../../-a-archives/archives2";
13
14
 
14
15
 
15
16
  // Snapshot is just a newline delimitted list of files. The name contains the metadata and time of it.
16
- const snapshots = lazy(() => getArchives("snapshots"));
17
+ const snapshots = lazy(() => getArchives2("snapshots"));
17
18
 
18
19
  export type ArchiveSnapshotOverview = {
19
20
  // If live, it isn't a real file that can be loaded
@@ -89,23 +90,26 @@ function fileNameToOverview(fileName: string): ArchiveSnapshotOverview {
89
90
  };
90
91
  }
91
92
 
92
- // pathValueArchives.decodeDataPath
93
- export async function getSnapshotList(): Promise<ArchiveSnapshotOverview[]> {
93
+ export type ArchiveSnapshotList = {
94
+ loadTime: number;
95
+ snapshots: ArchiveSnapshotOverview[];
96
+ };
97
+
98
+ export async function getSavedSnapshotList(): Promise<ArchiveSnapshotList> {
99
+ let startTime = Date.now();
94
100
  let snapshotFiles = await snapshots().find("");
101
+ let overview = snapshotFiles.map(file => fileNameToOverview(file));
102
+ sort(overview, x => -x.time);
103
+ return { loadTime: Date.now() - startTime, snapshots: overview };
104
+ }
95
105
 
96
- let overview: ArchiveSnapshotOverview[] = [];
106
+ export async function getLiveSnapshotList(): Promise<ArchiveSnapshotList> {
107
+ let startTime = Date.now();
97
108
  let locker = await pathValueArchives.getArchiveLocker();
98
109
  let allFiles = await locker.getAllValidFiles();
99
-
100
110
  let liveOverview = getSnapshotOverview(allFiles.map(x => x.file));
101
111
  liveOverview.file = "live";
102
- overview.push(liveOverview);
103
-
104
- overview.push(...snapshotFiles.map(file => fileNameToOverview(file)));
105
-
106
- sort(overview, x => -x.time);
107
-
108
- return overview;
112
+ return { loadTime: Date.now() - startTime, snapshots: [liveOverview] };
109
113
  }
110
114
 
111
115
  export async function getSnapshot(snapshotFile: string | "live"): Promise<ArchiveSnapshotRead> {
@@ -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,169 @@
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 { timeoutToUndefinedSilent, timeInSecond } from "socket-function/src/misc";
6
+ import { StatsValue, getStatsTop } from "socket-function/src/profiling/stats";
7
+ import type { MeasureChunk, MeasureSummary } from "../../diagnostics/watchdog";
8
+ import { NodeCapabilitiesController } from "../../-g-core-values/NodeCapabilities";
9
+ import { getSyncedController } from "../../library-components/SyncedController";
10
+ import { assertIsManagementUser } from "../../diagnostics/managementPages";
11
+
12
+ const NODE_CALL_TIMEOUT = timeInSecond * 10;
13
+ const BAR_HEIGHT_PX = 10;
14
+ const MIN_SEGMENT_WIDTH_PX = 30;
15
+
16
+ class ServiceMeasureControllerBase {
17
+ public async getMeasureSummaries(nodeIds: string[]): Promise<Record<string, MeasureSummary | undefined>> {
18
+ let result: Record<string, MeasureSummary | undefined> = {};
19
+ await Promise.all(nodeIds.map(async nodeId => {
20
+ result[nodeId] = await timeoutToUndefinedSilent(NODE_CALL_TIMEOUT, NodeCapabilitiesController.nodes[nodeId].getMeasureSummary());
21
+ }));
22
+ return result;
23
+ }
24
+ public async getMeasureBreakdown(nodeId: string, range: { startTime: number; endTime: number }): Promise<{ chunk?: MeasureChunk }> {
25
+ let chunk = await timeoutToUndefinedSilent(NODE_CALL_TIMEOUT, NodeCapabilitiesController.nodes[nodeId].getMeasureBreakdown(range));
26
+ return { chunk };
27
+ }
28
+ }
29
+ export const ServiceMeasureController = getSyncedController(SocketFunction.register(
30
+ "ServiceMeasureController-3f8c1de2-5b74-4e0f-a6c9-8d21f47b90a5",
31
+ new ServiceMeasureControllerBase(),
32
+ () => ({
33
+ getMeasureSummaries: { hooks: [assertIsManagementUser], compress: true },
34
+ getMeasureBreakdown: { hooks: [assertIsManagementUser], compress: true },
35
+ }),
36
+ () => ({
37
+ })
38
+ ));
39
+
40
+ function percent(value: number) {
41
+ return `${(value * 100).toFixed(2)}%`;
42
+ }
43
+
44
+ function nameHue(name: string) {
45
+ let hash = 0;
46
+ for (let i = 0; i < name.length; i++) {
47
+ hash = (hash * 31 + name.charCodeAt(i)) | 0;
48
+ }
49
+ return Math.abs(hash) % 360;
50
+ }
51
+
52
+ export class ServiceMeasureBars extends qreact.Component<{
53
+ serviceNodeIds: string[];
54
+ /** Every node id on the page, so all rows share one cached call. */
55
+ allNodeIds: string[];
56
+ expandedNodeId: string;
57
+ onToggleNode: (nodeId: string) => void;
58
+ }> {
59
+ render() {
60
+ let controller = ServiceMeasureController(SocketFunction.browserNodeId());
61
+ let summaries = controller.getMeasureSummaries(this.props.allNodeIds);
62
+ if (!summaries) return undefined;
63
+
64
+ return <div className={css.vbox(2)}>
65
+ {this.props.serviceNodeIds.map(nodeId => {
66
+ let summary = summaries[nodeId];
67
+ if (!summary) return undefined;
68
+ let timeRunFor = summary.endTime - summary.startTime;
69
+ if (timeRunFor <= 0) return undefined;
70
+ let isExpanded = this.props.expandedNodeId === nodeId;
71
+ return <div key={nodeId}
72
+ className={
73
+ css.hbox(1).wrap.button
74
+ + (isExpanded && css.outline("1px solid hsl(210, 60%, 50%)") || "")
75
+ }
76
+ onClick={() => this.props.onToggleNode(nodeId)}
77
+ >
78
+ {summary.entries.map(entry => {
79
+ let fraction = entry.ownTime / timeRunFor;
80
+ return <div key={entry.name}
81
+ className={css
82
+ .height(BAR_HEIGHT_PX)
83
+ .width(`${fraction * 100}%`)
84
+ .minWidth(MIN_SEGMENT_WIDTH_PX)
85
+ .background(`hsl(${nameHue(entry.name)}, 60%, 60%)`)
86
+ }
87
+ title={`${entry.name}\n${formatTime(entry.ownTime)} (${percent(fraction)} of ${formatTime(timeRunFor)} profiled)\n${nodeId}`}
88
+ />;
89
+ })}
90
+ </div>;
91
+ })}
92
+ </div>;
93
+ }
94
+ }
95
+
96
+ export class ServiceMeasureBreakdown extends qreact.Component<{
97
+ nodeId: string;
98
+ allNodeIds: string[];
99
+ }> {
100
+ render() {
101
+ let controller = ServiceMeasureController(SocketFunction.browserNodeId());
102
+ let summaries = controller.getMeasureSummaries(this.props.allNodeIds);
103
+ let summary = summaries?.[this.props.nodeId];
104
+ if (!summary) return undefined;
105
+
106
+ let result = controller.getMeasureBreakdown(this.props.nodeId, { startTime: summary.startTime, endTime: summary.endTime });
107
+ if (!result) {
108
+ return <div className={css.pad2(10).hsl(0, 0, 98).bord2(0, 0, 20)}>Loading breakdown...</div>;
109
+ }
110
+ let chunk = result.chunk;
111
+ if (!chunk) {
112
+ 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>;
113
+ }
114
+
115
+ let timeRunFor = chunk.endTime - chunk.startTime;
116
+ let cpuFraction = chunk.profiledTime / timeRunFor;
117
+ return <div className={css.vbox(6).pad2(10).hsl(0, 0, 98).bord2(0, 0, 20).fontFamily("monospace").fontSize(13)}>
118
+ <div className={css.boldStyle}>
119
+ Profiled {formatTime(chunk.profiledTime)} ({percent(cpuFraction)} CPU) (profile for {formatTime(timeRunFor)}, ending {formatVeryNiceDateTime(chunk.endTime)})
120
+ </div>
121
+ <div className={css.colorhsl(0, 0, 45).fontSize(12)}>{this.props.nodeId}</div>
122
+ <table className={css.borderCollapse("collapse")}>
123
+ {chunk.entries.map(entry => this.renderEntryRow(entry, chunk!))}
124
+ </table>
125
+ </div>;
126
+ }
127
+
128
+ renderEntryRow(entry: MeasureChunk["entries"][0], chunk: MeasureChunk) {
129
+ let fraction = entry.ownTime.sum / chunk.profiledTime;
130
+ let cellPad = css.pad2(6, 1).whiteSpace("pre");
131
+ return <tr key={entry.name}>
132
+ <td className={cellPad
133
+ .colorhsl(210, 70, 35)
134
+ .background(`linear-gradient(to right, hsla(210, 60%, 55%, 0.25) ${fraction * 100}%, transparent ${fraction * 100}%)`)
135
+ }>
136
+ {entry.name}
137
+ </td>
138
+ <td className={cellPad.textAlign("right")}>{percent(fraction)}</td>
139
+ <td className={cellPad.textAlign("right")}>{formatTime(entry.ownTime.sum)}</td>
140
+ <td className={cellPad}>=</td>
141
+ {this.renderEquationCell(entry.ownTime)}
142
+ <td className={cellPad}>
143
+ {entry.stillOpenCount > 0 && <span className={css.colorhsl(0, 70, 45)}>({entry.stillOpenCount} open)</span>}
144
+ </td>
145
+ </tr>;
146
+ }
147
+
148
+ renderEquationCell(stats: StatsValue) {
149
+ let cellPad = css.pad2(6, 1).whiteSpace("pre");
150
+ let top = getStatsTop(stats);
151
+ if (!top.topHeavy) {
152
+ return <td className={cellPad}>
153
+ {formatNumber(stats.count)} × {formatTime(stats.sum / stats.count)}
154
+ </td>;
155
+ }
156
+ let bottomCount = stats.count - top.count;
157
+ let bottomValue = stats.sum - top.value;
158
+ let splitAt = top.valueFraction * 100;
159
+ return <td className={cellPad
160
+ .background(`linear-gradient(to right, hsla(0, 70%, 55%, 0.2) ${splitAt}%, hsla(210, 60%, 55%, 0.15) ${splitAt}%)`)
161
+ }
162
+ title={`Top heavy: ${percent(top.valueFraction)} of the time is in ${percent(top.countFraction)} of the calls`}
163
+ >
164
+ <span className={css.colorhsl(0, 70, 40)}>{formatNumber(top.count)} × {formatTime(top.value / top.count)}</span>
165
+ {" + "}
166
+ <span>{formatNumber(bottomCount)} × {formatTime(bottomValue / bottomCount || 0)}</span>
167
+ </td>;
168
+ }
169
+ }
@@ -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,21 @@ 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
+ let allNodeIds = sort(Array.from(new Set(Array.from(serviceNodeIds.values()).flat())), x => x);
85
+
66
86
  return <div className={css.vbox(16)}>
67
87
  <div className={css.hbox(12).wrap}>
68
88
  <h2 className={css.flexGrow(1)}>Services</h2>
@@ -104,6 +124,14 @@ export class ServicesListPage extends qreact.Component {
104
124
  </button>
105
125
  <UpdateButtons services={services.map(x => x[1]).filter(isDefined)} />
106
126
  </div>
127
+ <div
128
+ className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 95).alignSelf("flex-start")}
129
+ onClick={() => {
130
+ this.state.measurementsEnabled = !this.state.measurementsEnabled;
131
+ }}
132
+ >
133
+ <span className={css.boldStyle}>{this.state.measurementsEnabled ? "☑" : "☐"} Measurements</span>
134
+ </div>
107
135
  <Tools />
108
136
  <div className={css.vbox(8)}>
109
137
  {services.map(([serviceId, config]) => {
@@ -138,9 +166,13 @@ export class ServicesListPage extends qreact.Component {
138
166
  }, 0);
139
167
  let unknown = enabledMachineIds.length - runningMachines.length - failingMachines.length - missingMachines.length;
140
168
  let duplicateKey = (keyCounts.get(config.parameters.key || "") || 0) > 1;
141
- return <div className={css.hbox(10)}>
169
+ let measureNodeIds = serviceNodeIds.get(serviceId) || [];
170
+ let expandedMeasureNodeId = this.state.expandedMeasureNodes[serviceId] || "";
171
+ return <div className={css.vbox(4)} key={serviceId}>
172
+ <div className={css.hbox(10)}>
142
173
  <ServiceCategoryBadge config={config} />
143
- <Anchor noStyles key={serviceId}
174
+ <div className={css.vbox(4)}>
175
+ <Anchor noStyles
144
176
  values={[currentViewParam.getOverride("service-detail"), selectedServiceIdParam.getOverride(serviceId)]}
145
177
  className={
146
178
  css.pad2(12).button.bord2(0, 0, 20)
@@ -206,8 +238,26 @@ export class ServicesListPage extends qreact.Component {
206
238
  </div>
207
239
  </div>
208
240
  </Anchor>
241
+ {this.state.measurementsEnabled && measureNodeIds.length > 0 && <ServiceMeasureBars
242
+ serviceNodeIds={measureNodeIds}
243
+ allNodeIds={allNodeIds}
244
+ expandedNodeId={expandedMeasureNodeId}
245
+ onToggleNode={nodeId => {
246
+ if (this.state.expandedMeasureNodes[serviceId] === nodeId) {
247
+ delete this.state.expandedMeasureNodes[serviceId];
248
+ } else {
249
+ this.state.expandedMeasureNodes[serviceId] = nodeId;
250
+ }
251
+ }}
252
+ />}
253
+ </div>
209
254
  <UpdateServiceButtons service={config} />
210
255
  <PendingDeployInfo service={config} />
256
+ </div>
257
+ {this.state.measurementsEnabled && expandedMeasureNodeId && <ServiceMeasureBreakdown
258
+ nodeId={expandedMeasureNodeId}
259
+ allNodeIds={allNodeIds}
260
+ />}
211
261
  </div>
212
262
  ;
213
263
  })}
@@ -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)}>