querysub 0.605.0 → 0.607.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.
@@ -0,0 +1,170 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { qreact } from "../../../4-dom/qreact";
3
+ import { css } from "typesafecss";
4
+ import { formatNumber, formatTime } from "socket-function/src/formatting/format";
5
+ import { sort, timeInSecond } from "socket-function/src/misc";
6
+ import { Querysub } from "../../../4-querysub/Querysub";
7
+ import type { ArchivesConfig, SyncActivity } from "sliftutils/storage/IArchives";
8
+ import { StorageSynced } from "./storageController";
9
+ import { INACTIVE_STATE } from "./bucketRows";
10
+ import { parseSourceDebugName, SourceNameParts, SOURCE_PART_FONT_SIZE } from "./sourceNames";
11
+
12
+ module.hotreload = true;
13
+
14
+ const ACTIVATE_ERROR_COLOR = { h: 0, s: 60, l: 40 };
15
+
16
+ /** An inactive bucket exists on the server's disk but isn't loaded, so it isn't synchronizing. Activating loads it, which starts synchronization. */
17
+ export class ActivateBucketButton extends qreact.Component<{ serverUrl: string; bucketName: string }> {
18
+ state = {
19
+ activating: false,
20
+ error: "",
21
+ };
22
+ render() {
23
+ return <div className={css.hbox(6).wrap}>
24
+ <div className={css.whiteSpace("nowrap")}>{INACTIVE_STATE}</div>
25
+ <button
26
+ className={css.pad2(6, 1).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap")}
27
+ disabled={this.state.activating}
28
+ onClick={() => {
29
+ // Props and state are synchronized, so everything the async work needs is copied into plain locals HERE, in the tracked part
30
+ let serverUrl = this.props.serverUrl;
31
+ let bucketName = this.props.bucketName;
32
+ this.state.activating = true;
33
+ this.state.error = "";
34
+ Querysub.onCommitFinished(async () => {
35
+ try {
36
+ let result = await StorageSynced(SocketFunction.browserNodeId()).activateBucket.promise(serverUrl, bucketName);
37
+ // A refusal comes back as a string - it's a normal result, not an error
38
+ let refusal = typeof result === "string" && result || "";
39
+ if (refusal) {
40
+ console.log(`Activating bucket ${bucketName} on ${serverUrl} was refused: ${refusal}`);
41
+ } else {
42
+ console.log(`Activated bucket ${bucketName} on ${serverUrl}`);
43
+ }
44
+ Querysub.commit(() => {
45
+ this.state.error = refusal;
46
+ // Only this server's buckets changed
47
+ if (!refusal) {
48
+ StorageSynced(SocketFunction.browserNodeId()).getServerBuckets.reset(serverUrl);
49
+ }
50
+ });
51
+ } finally {
52
+ // Without this a throw would leave the button disabled and stuck on "Activating..."
53
+ Querysub.commit(() => {
54
+ this.state.activating = false;
55
+ });
56
+ }
57
+ });
58
+ }}
59
+ >
60
+ {this.state.activating && "Activating..." || "Activate"}
61
+ </button>
62
+ {this.state.error && <div
63
+ className={css.colorhsl(ACTIVATE_ERROR_COLOR.h, ACTIVATE_ERROR_COLOR.s, ACTIVATE_ERROR_COLOR.l).ellipsis}
64
+ title={this.state.error}
65
+ >
66
+ {this.state.error}
67
+ </div>}
68
+ </div>;
69
+ }
70
+ }
71
+
72
+ const SOURCE_BAR_COLOR = { h: 210, s: 65, l: 55 };
73
+ const REMOTE_SOURCE_BAR_COLOR = { h: 0, s: 75, l: 55 };
74
+ const SOURCE_BAR_OPACITY = 0.3;
75
+ const SOURCE_ROW_PADDING = { horizontal: 4, vertical: 3 };
76
+ const DISK_SOURCE_TYPE = "disk";
77
+ const REMOTE_SOURCE_TOOLTIP = "Values stored on another server. If this other server goes down, we'll lose access to the data.";
78
+
79
+ export class IndexSourcesCell extends qreact.Component<{ sources: ArchivesConfig["indexSources"] }> {
80
+ render() {
81
+ let sources = [...this.props.sources || []];
82
+ if (!sources.length) return undefined;
83
+ sort(sources, x => -x.byteCount);
84
+ let totalBytes = sources.reduce((sum, x) => sum + x.byteCount, 0);
85
+ return <div className={css.vbox(2).fillWidth}>
86
+ {sources.map(source => {
87
+ let fraction = totalBytes && source.byteCount / totalBytes || 0;
88
+ // Only the server's own disk holds bytes we can't lose to another machine going down
89
+ let remote = parseSourceDebugName(source.debugName).type !== DISK_SOURCE_TYPE;
90
+ let barColor = remote && REMOTE_SOURCE_BAR_COLOR || SOURCE_BAR_COLOR;
91
+ return <div
92
+ key={source.debugName}
93
+ className={css.relative.fillWidth.pad2(SOURCE_ROW_PADDING.horizontal, SOURCE_ROW_PADDING.vertical)}
94
+ title={remote && REMOTE_SOURCE_TOOLTIP || undefined}
95
+ >
96
+ <div className={
97
+ css.absolute.top(0).left(0).size(`${fraction * 100}%`, "100%")
98
+ .hsla(barColor.h, barColor.s, barColor.l, SOURCE_BAR_OPACITY)
99
+ } />
100
+ <div className={css.relative.hbox(6).wrap}>
101
+ <div className={css.whiteSpace("nowrap")}>
102
+ {formatNumber(source.byteCount)}B, {formatNumber(source.fileCount)} files
103
+ </div>
104
+ <SourceNameParts debugName={source.debugName} />
105
+ </div>
106
+ </div>;
107
+ })}
108
+ </div>;
109
+ }
110
+ }
111
+
112
+ const SYNC_PROGRESS_COLOR = { h: 130, s: 60, l: 50 };
113
+ const SYNC_PROGRESS_OPACITY = 0.25;
114
+ const SYNC_TYPE_COLORS: { [type in SyncActivity["type"]]: { h: number; s: number; l: number } } = {
115
+ metadataScan: { h: 265, s: 40, l: 88 },
116
+ fullSync: { h: 205, s: 50, l: 85 },
117
+ };
118
+ const SYNC_DETAIL_FONT_SIZE = 11;
119
+ const PERCENT_DECIMALS = 0;
120
+
121
+ /** How far along a scan is, by files when it knows the file count and by bytes otherwise. Undefined until the total is known - a scan that hasn't finished listing has no denominator yet. */
122
+ function getSyncFraction(activity: SyncActivity): number | undefined {
123
+ if (activity.totalFiles) return (activity.doneFiles || 0) / activity.totalFiles;
124
+ if (activity.totalBytes) return (activity.doneBytes || 0) / activity.totalBytes;
125
+ return undefined;
126
+ }
127
+
128
+ export class SyncingCell extends qreact.Component<{ syncing: ArchivesConfig["syncing"] }> {
129
+ render() {
130
+ let syncing = this.props.syncing || [];
131
+ if (!syncing.length) return undefined;
132
+ let now = Querysub.nowDelayed(timeInSecond);
133
+ return <div className={css.vbox(3).fillWidth}>
134
+ {syncing.map((activity, activityIndex) => {
135
+ let fraction = getSyncFraction(activity);
136
+ let elapsed = now - activity.startTime;
137
+ let typeColor = SYNC_TYPE_COLORS[activity.type];
138
+ // Only meaningful once something has actually been done, otherwise the rate is a divide by zero
139
+ let remaining = fraction && fraction > 0 && elapsed * (1 - fraction) / fraction || undefined;
140
+ return <div key={activityIndex} className={css.relative.fillWidth.pad2(4, 3)}>
141
+ {fraction !== undefined && <div className={
142
+ css.absolute.top(0).left(0).size(`${fraction * 100}%`, "100%")
143
+ .hsla(SYNC_PROGRESS_COLOR.h, SYNC_PROGRESS_COLOR.s, SYNC_PROGRESS_COLOR.l, SYNC_PROGRESS_OPACITY)
144
+ } />}
145
+ <div className={css.relative.vbox(2)}>
146
+ <div className={css.hbox(4).wrap}>
147
+ <div className={css.pad2(5, 0).fontSize(SOURCE_PART_FONT_SIZE).whiteSpace("nowrap").hsl(typeColor.h, typeColor.s, typeColor.l).bord2(typeColor.h, typeColor.s, typeColor.l - 20)}>
148
+ {activity.type}
149
+ </div>
150
+ <SourceNameParts debugName={activity.sourceDebugName} />
151
+ </div>
152
+ <div className={css.hbox(6).wrap.fontSize(SYNC_DETAIL_FONT_SIZE).colorhsl(0, 0, 35)}>
153
+ <div className={css.whiteSpace("nowrap")}>running {formatTime(elapsed)}</div>
154
+ {fraction !== undefined && <div className={css.whiteSpace("nowrap")}>
155
+ {(fraction * 100).toFixed(PERCENT_DECIMALS)}%
156
+ </div>}
157
+ {remaining !== undefined && <div className={css.whiteSpace("nowrap")}>~{formatTime(remaining)} left</div>}
158
+ {activity.totalFiles !== undefined && <div className={css.whiteSpace("nowrap")}>
159
+ {formatNumber(activity.doneFiles || 0)}/{formatNumber(activity.totalFiles)} files
160
+ </div>}
161
+ {activity.totalBytes !== undefined && <div className={css.whiteSpace("nowrap")}>
162
+ {formatNumber(activity.doneBytes || 0)}B/{formatNumber(activity.totalBytes)}B
163
+ </div>}
164
+ </div>
165
+ </div>
166
+ </div>;
167
+ })}
168
+ </div>;
169
+ }
170
+ }
@@ -0,0 +1,145 @@
1
+ import { formatNumber } from "socket-function/src/formatting/format";
2
+ import { sort } from "socket-function/src/misc";
3
+ import type { ArchivesConfig, RemoteConfig, HostedConfig } from "sliftutils/storage/IArchives";
4
+ import type { BucketWriteStats } from "sliftutils/storage/remoteStorage/storageServerState";
5
+ import type { BucketDiskInfo } from "sliftutils/storage/remoteStorage/bucketDisk";
6
+ import { parseHostedUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
7
+ import { SourceTag, getSourceTags } from "./Tag";
8
+ import type { StorageServerBuckets } from "./storageServers";
9
+
10
+ module.hotreload = true;
11
+
12
+ export const ACTIVE_STATE = "active";
13
+ export const INACTIVE_STATE = "inactive";
14
+
15
+ const GAIN_DECIMALS = 1;
16
+ const DEFAULT_HTTPS_PORT = 443;
17
+
18
+ export type BucketRow = {
19
+ server: string;
20
+ /** Kept even on the rows that blank out the server column, so the row's buttons know which server to call */
21
+ serverUrl: string;
22
+ bucket: string;
23
+ state: string;
24
+ files: string;
25
+ bytes: string;
26
+ flags: SourceTag[];
27
+ indexSources: ArchivesConfig["indexSources"];
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
+ writeGain: string;
32
+ disk?: BucketDiskInfo;
33
+ diskError: string;
34
+ syncing: ArchivesConfig["syncing"];
35
+ error: string;
36
+ };
37
+
38
+ /** The flags this server's own entries in the bucket's routing config set - how it holds the bucket, which is not something the bucket's own state reports. */
39
+ function getBucketFlags(serverUrl: string, bucketName: string, remoteConfig: RemoteConfig | undefined): SourceTag[] {
40
+ let host: URL;
41
+ try {
42
+ host = new URL(serverUrl);
43
+ } catch {
44
+ return [];
45
+ }
46
+ let selfEntries: HostedConfig[] = [];
47
+ for (let source of remoteConfig?.sources || []) {
48
+ if (typeof source === "string" || source.type !== "remote") continue;
49
+ try {
50
+ let parsed = parseHostedUrl(source.url);
51
+ if (parsed.address !== host.hostname) continue;
52
+ if (String(parsed.port) !== (host.port || String(DEFAULT_HTTPS_PORT))) continue;
53
+ if (parsed.bucketName !== bucketName) continue;
54
+ selfEntries.push(source);
55
+ } catch {
56
+ // A malformed url in the config is not this column's problem
57
+ }
58
+ }
59
+ // A server can hold several entries for one bucket (different windows or route shards), so the flags are the union of theirs, deduped by what they say
60
+ let flags = new Map<string, SourceTag>();
61
+ for (let entry of selfEntries) {
62
+ for (let tag of getSourceTags(entry)) {
63
+ if (flags.has(tag.text)) continue;
64
+ flags.set(tag.text, tag);
65
+ }
66
+ }
67
+ return [...flags.values()];
68
+ }
69
+
70
+ /** How much fast-mode coalescing saved: every accepted write over the ones that actually reached a source. */
71
+ function getWriteGain(stats: BucketWriteStats | undefined): string {
72
+ if (!stats) return "";
73
+ if (!stats.flushedWrites || !stats.flushedBytes) return "";
74
+ let writeGain = stats.originalWrites / stats.flushedWrites;
75
+ let byteGain = stats.originalBytes / stats.flushedBytes;
76
+ return `${writeGain.toFixed(GAIN_DECIMALS)}X / ${byteGain.toFixed(GAIN_DECIMALS)}X B`;
77
+ }
78
+
79
+ export function getBucketRows(servers: StorageServerBuckets[]): BucketRow[] {
80
+ let rows: BucketRow[] = [];
81
+ for (let server of servers) {
82
+ let baseRow: BucketRow = {
83
+ server: server.url,
84
+ serverUrl: server.url,
85
+ bucket: "",
86
+ state: "",
87
+ files: "",
88
+ bytes: "",
89
+ flags: [],
90
+ indexSources: undefined,
91
+ readerDiskLimit: "",
92
+ operationsServerUrl: "",
93
+ writeGain: "",
94
+ disk: undefined,
95
+ diskError: "",
96
+ syncing: undefined,
97
+ error: "",
98
+ };
99
+ if (server.error) {
100
+ rows.push({ ...baseRow, error: server.error });
101
+ continue;
102
+ }
103
+ if (server.loading) {
104
+ rows.push({ ...baseRow, bucket: "Loading..." });
105
+ continue;
106
+ }
107
+ let buckets = [...server.buckets || []];
108
+ sort(buckets, x => x.bucketName);
109
+ if (!buckets.length) {
110
+ rows.push({ ...baseRow, bucket: "No buckets", operationsServerUrl: server.url });
111
+ continue;
112
+ }
113
+ for (let [index, bucket] of buckets.entries()) {
114
+ let config = bucket.config;
115
+ // Per bucket for the same reason it is per server: a single unparseable config costs its own row, not the table
116
+ try {
117
+ rows.push({
118
+ ...baseRow,
119
+ server: index === 0 && server.url || "",
120
+ bucket: bucket.bucketName,
121
+ state: bucket.active && ACTIVE_STATE || INACTIVE_STATE,
122
+ files: config?.index && formatNumber(config.index.fileCount) || "",
123
+ bytes: config?.index && formatNumber(config.index.byteCount) + "B" || "",
124
+ flags: getBucketFlags(server.url, bucket.bucketName, config?.remoteConfig),
125
+ indexSources: config?.indexSources,
126
+ readerDiskLimit: config?.readerDiskLimit && formatNumber(config.readerDiskLimit) + "B" || "",
127
+ operationsServerUrl: index === 0 && server.url || "",
128
+ writeGain: getWriteGain(bucket.writeStats),
129
+ disk: bucket.disk,
130
+ diskError: bucket.diskError || "",
131
+ syncing: config?.syncing,
132
+ error: bucket.error || "",
133
+ });
134
+ } catch (e: any) {
135
+ rows.push({
136
+ ...baseRow,
137
+ server: index === 0 && server.url || "",
138
+ bucket: bucket.bucketName,
139
+ error: e.stack ?? String(e),
140
+ });
141
+ }
142
+ }
143
+ }
144
+ return rows;
145
+ }
@@ -0,0 +1,81 @@
1
+ import { qreact } from "../../../4-dom/qreact";
2
+ import { css } from "typesafecss";
3
+ import preact from "preact";
4
+ import { Querysub } from "../../../4-querysub/Querysub";
5
+ import { formatTime, formatDateTimeDetailed } from "socket-function/src/formatting/format";
6
+ import { timeInSecond } from "socket-function/src/misc";
7
+ import type { StorageService } from "./storageServers";
8
+
9
+ module.hotreload = true;
10
+
11
+ const DEPLOY_PENDING_HUE = { h: 35, s: 80 };
12
+ const DEPLOY_OVERLAP_HUE = { h: 280, s: 60 };
13
+ const DEPLOY_NOTICE_FONT_SIZE = 14;
14
+ const DEPLOY_NOTICE_TEXT_LIGHTNESS = 30;
15
+ const DEPLOY_NOTICE_BORDER_LIGHTNESS = 55;
16
+ const DEPLOY_NOTICE_BACKGROUND_LIGHTNESS = 94;
17
+
18
+ class DeployNotice extends qreact.Component<{
19
+ hue: { h: number; s: number };
20
+ icon: string;
21
+ title: string;
22
+ detail: string;
23
+ tooltip: string;
24
+ }> {
25
+ render() {
26
+ let { hue, icon, title, detail, tooltip } = this.props;
27
+ return <div
28
+ className={
29
+ css.hbox(8).pad2(12, 8).fontSize(DEPLOY_NOTICE_FONT_SIZE).alignItems("center")
30
+ .hsl(hue.h, hue.s, DEPLOY_NOTICE_BACKGROUND_LIGHTNESS)
31
+ .bord2(hue.h, hue.s, DEPLOY_NOTICE_BORDER_LIGHTNESS)
32
+ .colorhsl(hue.h, hue.s, DEPLOY_NOTICE_TEXT_LIGHTNESS)
33
+ }
34
+ title={tooltip}
35
+ >
36
+ <div className={css.fontSize(DEPLOY_NOTICE_FONT_SIZE + 4)}>{icon}</div>
37
+ <div className={css.vbox(2)}>
38
+ <div className={css.boldStyle}>{title}</div>
39
+ <div className={css.fontSize(DEPLOY_NOTICE_FONT_SIZE - 2)}>{detail}</div>
40
+ </div>
41
+ </div>;
42
+ }
43
+ }
44
+
45
+ /** The services behind these storage servers may be mid-release, which moves (or duplicates) the servers the page just read from. */
46
+ export class DeployNotices extends qreact.Component<{ services: StorageService[] }> {
47
+ render() {
48
+ let now = Querysub.nowDelayed(timeInSecond);
49
+ let notices: preact.ComponentChild[] = [];
50
+ for (let service of this.props.services) {
51
+ let serviceKey = service.serviceKey;
52
+ let releaseTime = service.releaseTime || 0;
53
+ // The old instances run out their overlap purely on time - we don't verify they're actually still up
54
+ if (!releaseTime || !service.hasOldParameters) continue;
55
+ let killTime = releaseTime + service.overlapTime;
56
+ if (now < releaseTime) {
57
+ notices.push(<DeployNotice
58
+ key={serviceKey + "pending"}
59
+ hue={DEPLOY_PENDING_HUE}
60
+ icon="🕐"
61
+ title={`Deploys in ${formatTime(releaseTime - now)}`}
62
+ detail={`${service.serviceTitle} (${serviceKey})`}
63
+ tooltip={`Deploys at ${formatDateTimeDetailed(releaseTime)}`}
64
+ />);
65
+ continue;
66
+ }
67
+ if (now < killTime) {
68
+ notices.push(<DeployNotice
69
+ key={serviceKey + "overlap"}
70
+ hue={DEPLOY_OVERLAP_HUE}
71
+ icon="🔀"
72
+ title={`Old version still running for ${formatTime(killTime - now)}`}
73
+ detail={`${service.serviceTitle} (${serviceKey})`}
74
+ tooltip={`Old instances shut down at ${formatDateTimeDetailed(killTime)}`}
75
+ />);
76
+ }
77
+ }
78
+ if (!notices.length) return undefined;
79
+ return <div className={css.hbox(8).wrap}>{notices}</div>;
80
+ }
81
+ }
@@ -0,0 +1,217 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { qreact } from "../../../4-dom/qreact";
3
+ import { css } from "typesafecss";
4
+ import { formatNumber } from "socket-function/src/formatting/format";
5
+ import { sort } from "socket-function/src/misc";
6
+ import { Querysub } from "../../../4-querysub/Querysub";
7
+ import { t } from "../../../2-proxy/schema2";
8
+ import { Table } from "../../../5-diagnostics/Table";
9
+ import { ButtonSelector } from "../../../library-components/ButtonSelector";
10
+ import type { AccessSummaryState } from "sliftutils/storage/remoteStorage/accessStats";
11
+ import type { SummaryEntry } from "sliftutils/treeSummary";
12
+ import { StorageSynced } from "./storageController";
13
+
14
+ module.hotreload = true;
15
+
16
+ const OPERATION_SUMMARY_LIMIT = 30;
17
+ type AccessMode = "count" | "size";
18
+
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
+ const opsDrilled = new Set<string>();
21
+ let opsMode: AccessMode = "count";
22
+ const opsStateVersion = Querysub.createLocalSchema("storageOpsStateVersion", {
23
+ version: t.number,
24
+ });
25
+ /** Reading this in a render subscribes it to every operations-view change. */
26
+ function opsStateChanged(): void {
27
+ Querysub.localCommit(() => opsStateVersion().version++);
28
+ }
29
+ function subscribeOpsState(): void {
30
+ opsStateVersion().version;
31
+ }
32
+ function getOpsMode(): AccessMode {
33
+ subscribeOpsState();
34
+ return opsMode;
35
+ }
36
+ function setOpsMode(mode: AccessMode): void {
37
+ opsMode = mode;
38
+ opsStateChanged();
39
+ }
40
+
41
+ /** Drops the cached operation totals and summaries so they refetch, without touching the main bucket data. */
42
+ export function refreshOperationsData(): void {
43
+ let controller = StorageSynced(SocketFunction.browserNodeId());
44
+ controller.getAccessStats.resetAll();
45
+ controller.getAccessSummaries.resetAll();
46
+ }
47
+ function getDrillKey(serverUrl: string, operation: string): string {
48
+ return `${serverUrl}|${operation}`;
49
+ }
50
+ function isOperationDrilled(serverUrl: string, operation: string): boolean {
51
+ subscribeOpsState();
52
+ return opsDrilled.has(getDrillKey(serverUrl, operation));
53
+ }
54
+ function toggleOperationDrilled(serverUrl: string, operation: string): void {
55
+ let key = getDrillKey(serverUrl, operation);
56
+ if (opsDrilled.has(key)) {
57
+ opsDrilled.delete(key);
58
+ } else {
59
+ opsDrilled.add(key);
60
+ }
61
+ opsStateChanged();
62
+ }
63
+
64
+ function formatAccessMetric(value: number, asBytes: boolean): string {
65
+ return asBytes && formatNumber(value) + "B" || formatNumber(value);
66
+ }
67
+
68
+ type OperationEntry = [string, { count: number; size: number }];
69
+
70
+ /** The operations of one server, heaviest first for the current mode. Reads the synced stats, so it returns undefined while they load and throws if the read failed. */
71
+ function readSortedOperations(serverUrl: string, mode: AccessMode): OperationEntry[] | undefined {
72
+ let totals = StorageSynced(SocketFunction.browserNodeId()).getAccessStats(serverUrl);
73
+ if (!totals) return undefined;
74
+ let operations = Object.entries(totals);
75
+ sort(operations, ([, op]) => -op[mode]);
76
+ return operations;
77
+ }
78
+
79
+ const OP_CHIP_COLOR = { h: 210, s: 45, l: 90 };
80
+ const OP_CHIP_ACTIVE_COLOR = { h: 210, s: 60, l: 78 };
81
+ const OP_METRIC_COLOR = { h: 0, s: 0, l: 35 };
82
+ const OP_METRIC_FONT_SIZE = 12;
83
+ const ACCESS_ERROR_COLOR = { h: 0, s: 60, l: 40 };
84
+
85
+ const OPS_SECTION_BG = { h: 210, s: 20, l: 96 };
86
+ const OPS_SECTION_BORDER = { h: 210, s: 20, l: 82 };
87
+ const OPS_MACHINE_BG = { h: 0, s: 0, l: 100 };
88
+ const OPS_MACHINE_BORDER = { h: 0, s: 0, l: 88 };
89
+ const OPS_DESCRIPTION_COLOR = { h: 0, s: 0, l: 40 };
90
+
91
+ function chipClass(color: { h: number; s: number; l: number }) {
92
+ return css.hbox(6).alignItems("center").pad2(8, 4).whiteSpace("nowrap")
93
+ .hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 20);
94
+ }
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
+ /** One operation's top-N path breakdown, ordered by the current mode. */
120
+ class OperationSummaryTable extends qreact.Component<{ serverUrl: string; operation: string; weightBySize: boolean; asBytes: boolean }> {
121
+ render() {
122
+ let { serverUrl, operation, weightBySize, asBytes } = this.props;
123
+ let summaries: SummaryEntry<AccessSummaryState>[] | undefined;
124
+ try {
125
+ summaries = StorageSynced(SocketFunction.browserNodeId()).getAccessSummaries(serverUrl, operation, OPERATION_SUMMARY_LIMIT, weightBySize);
126
+ } catch (e: any) {
127
+ return <div className={css.colorhsl(ACCESS_ERROR_COLOR.h, ACCESS_ERROR_COLOR.s, ACCESS_ERROR_COLOR.l)}>{e.stack ?? String(e)}</div>;
128
+ }
129
+ if (!summaries) return <div>Loading...</div>;
130
+ if (!summaries.length) return <div className={css.colorhsl(OP_METRIC_COLOR.h, OP_METRIC_COLOR.s, OP_METRIC_COLOR.l)}>No accesses recorded.</div>;
131
+ let rows = summaries.map(entry => ({
132
+ path: entry.path,
133
+ value: formatAccessMetric(entry.weight, asBytes),
134
+ }));
135
+ return <Table
136
+ rows={rows}
137
+ columns={{
138
+ path: { title: "Path" },
139
+ value: { title: asBytes && "Size" || "Count" },
140
+ }}
141
+ />;
142
+ }
143
+ }
144
+
145
+ /** One server's operations as wrapping chips; expanding a chip grows it into that operation's summary table, and the wrap reflows around it. */
146
+ class OperationsMachineBreakdown extends qreact.Component<{ serverUrl: string }> {
147
+ render() {
148
+ let serverUrl = this.props.serverUrl;
149
+ let mode = getOpsMode();
150
+ let operations: OperationEntry[] | undefined;
151
+ try {
152
+ operations = readSortedOperations(serverUrl, mode);
153
+ } catch (e: any) {
154
+ return <div className={css.vbox(8).fillWidth.pad(12).hsl(OPS_MACHINE_BG.h, OPS_MACHINE_BG.s, OPS_MACHINE_BG.l).bord2(OPS_MACHINE_BORDER.h, OPS_MACHINE_BORDER.s, OPS_MACHINE_BORDER.l)}>
155
+ <h4>{serverUrl}</h4>
156
+ <div className={css.colorhsl(ACCESS_ERROR_COLOR.h, ACCESS_ERROR_COLOR.s, ACCESS_ERROR_COLOR.l)}>{e.stack ?? String(e)}</div>
157
+ </div>;
158
+ }
159
+ return <div className={css.vbox(8).fillWidth.pad(12).hsl(OPS_MACHINE_BG.h, OPS_MACHINE_BG.s, OPS_MACHINE_BG.l).bord2(OPS_MACHINE_BORDER.h, OPS_MACHINE_BORDER.s, OPS_MACHINE_BORDER.l)}>
160
+ <h4>{serverUrl}</h4>
161
+ {!operations && <div>Loading operations...</div>}
162
+ {operations && !operations.length && <div className={css.colorhsl(OP_METRIC_COLOR.h, OP_METRIC_COLOR.s, OP_METRIC_COLOR.l)}>No operations recorded.</div>}
163
+ {operations && <div className={css.hbox(6).wrap.fillWidth}>
164
+ {operations.map(([operation, op]) => {
165
+ let drilled = isOperationDrilled(serverUrl, operation);
166
+ // A count-only operation has no size tree, so its breakdown is by count even in size mode - format its weights to match.
167
+ let asBytes = mode === "size" && op.size > 0;
168
+ // Expanding forces the chip's container to a full row, so its summary table takes its own line and the remaining chips wrap past it.
169
+ return <div key={operation} className={css.vbox(6) + (drilled && css.fillWidth)}>
170
+ <div
171
+ className={chipClass(drilled && OP_CHIP_ACTIVE_COLOR || OP_CHIP_COLOR) + css.button}
172
+ onClick={() => toggleOperationDrilled(serverUrl, operation)}
173
+ >
174
+ <span>{drilled && "▾" || "▸"}</span>
175
+ <span className={css.boldStyle}>{operation}</span>
176
+ <span className={css.fontSize(OP_METRIC_FONT_SIZE).colorhsl(OP_METRIC_COLOR.h, OP_METRIC_COLOR.s, OP_METRIC_COLOR.l)}>
177
+ {formatNumber(op.count)} · {formatNumber(op.size)}B
178
+ </span>
179
+ </div>
180
+ {drilled && <OperationSummaryTable serverUrl={serverUrl} operation={operation} weightBySize={mode === "size"} asBytes={asBytes} />}
181
+ </div>;
182
+ })}
183
+ </div>}
184
+ </div>;
185
+ }
186
+ }
187
+
188
+ /** 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. */
189
+ export class OperationsSection extends qreact.Component<{ serverUrls: string[]; onResetWriteStats: () => void }> {
190
+ render() {
191
+ let mode = getOpsMode();
192
+ let buttonClass = css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap");
193
+ return <div className={css.vbox(16).fillWidth.pad(16).hsl(OPS_SECTION_BG.h, OPS_SECTION_BG.s, OPS_SECTION_BG.l).bord2(OPS_SECTION_BORDER.h, OPS_SECTION_BORDER.s, OPS_SECTION_BORDER.l)}>
194
+ <div className={css.hbox(12).wrap.alignItems("center")}>
195
+ <div className={css.vbox(2).flexGrow(1)}>
196
+ <h2>Access operations</h2>
197
+ <div className={css.fontSize(13).colorhsl(OPS_DESCRIPTION_COLOR.h, OPS_DESCRIPTION_COLOR.s, OPS_DESCRIPTION_COLOR.l)}>
198
+ The reads and writes each storage server has served since the last reset, per operation. Expand an operation for its top {OPERATION_SUMMARY_LIMIT} paths.
199
+ </div>
200
+ </div>
201
+ <ButtonSelector<AccessMode>
202
+ title="Order by"
203
+ options={[{ title: "Count", value: "count" }, { title: "Size", value: "size" }]}
204
+ value={mode}
205
+ onChange={setOpsMode}
206
+ />
207
+ <button className={buttonClass} onClick={() => refreshOperationsData()}>
208
+ Refresh
209
+ </button>
210
+ <button className={buttonClass} onClick={this.props.onResetWriteStats}>
211
+ Reset write stats
212
+ </button>
213
+ </div>
214
+ {this.props.serverUrls.map(serverUrl => <OperationsMachineBreakdown key={serverUrl} serverUrl={serverUrl} />)}
215
+ </div>;
216
+ }
217
+ }
@@ -0,0 +1,43 @@
1
+ import { qreact } from "../../../4-dom/qreact";
2
+ import { css } from "typesafecss";
3
+
4
+ module.hotreload = true;
5
+
6
+ const SOURCE_TYPE_COLOR = { h: 265, s: 40, l: 88 };
7
+ const SOURCE_PART_COLOR = { h: 0, s: 0, l: 96 };
8
+ export const SOURCE_PART_FONT_SIZE = 11;
9
+
10
+ const DEBUG_NAME_PATTERNS: { regex: RegExp; parts: string[] }[] = [
11
+ { regex: /^remoteStorage (\S+) account (\S+) bucket (.+)$/, parts: ["remoteStorage"] },
12
+ { regex: /^localBucket account (\S+) bucket (.+)$/, parts: ["localBucket"] },
13
+ { regex: /^backblaze (.+)$/, parts: ["backblaze"] },
14
+ { regex: /^disk (.+)$/, parts: ["disk"] },
15
+ ];
16
+
17
+ /** Splits an IArchives debug name into its type and the identifying pieces after it, so each piece can be boxed separately. */
18
+ export function parseSourceDebugName(debugName: string): { type: string; parts: string[] } {
19
+ for (let pattern of DEBUG_NAME_PATTERNS) {
20
+ let match = pattern.regex.exec(debugName);
21
+ if (!match) continue;
22
+ return { type: pattern.parts[0], parts: match.slice(1) };
23
+ }
24
+ return { type: debugName, parts: [] };
25
+ }
26
+
27
+ export class SourceNameParts extends qreact.Component<{ debugName: string }> {
28
+ render() {
29
+ let { type, parts } = parseSourceDebugName(this.props.debugName);
30
+ let partStyle = css.pad2(5, 0).fontSize(SOURCE_PART_FONT_SIZE).whiteSpace("nowrap");
31
+ return <div className={css.hbox(3).wrap}>
32
+ <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
+ {type}
34
+ </div>
35
+ {parts.map(part => <div
36
+ key={part}
37
+ className={partStyle.hsl(SOURCE_PART_COLOR.h, SOURCE_PART_COLOR.s, SOURCE_PART_COLOR.l).bord2(SOURCE_PART_COLOR.h, SOURCE_PART_COLOR.s, SOURCE_PART_COLOR.l - 20)}
38
+ >
39
+ {part}
40
+ </div>)}
41
+ </div>;
42
+ }
43
+ }