@lossless.org/objectstorage 11.0.0 → 11.2.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.
Files changed (51) hide show
  1. package/.smartconfig.json +19 -1
  2. package/dist_rust/ruststorage_linux_amd64 +0 -0
  3. package/dist_rust/ruststorage_linux_amd64.tsrust-build.json +4 -4
  4. package/dist_rust/ruststorage_linux_amd64_musl +0 -0
  5. package/dist_rust/ruststorage_linux_amd64_musl.tsrust-build.json +4 -4
  6. package/dist_rust/ruststorage_linux_arm64 +0 -0
  7. package/dist_rust/ruststorage_linux_arm64.tsrust-build.json +4 -4
  8. package/dist_rust/ruststorage_linux_arm64_musl +0 -0
  9. package/dist_rust/ruststorage_linux_arm64_musl.tsrust-build.json +4 -4
  10. package/dist_rust/ruststorage_macos_amd64 +0 -0
  11. package/dist_rust/ruststorage_macos_amd64.tsrust-build.json +4 -4
  12. package/dist_rust/ruststorage_macos_arm64 +0 -0
  13. package/dist_rust/ruststorage_macos_arm64.tsrust-build.json +4 -4
  14. package/dist_ts/00_commitinfo_data.js +1 -1
  15. package/dist_ts/classes.dashboard.d.ts +49 -0
  16. package/dist_ts/classes.dashboard.js +234 -0
  17. package/dist_ts/dashboard.bundled.d.ts +4 -0
  18. package/dist_ts/dashboard.bundled.js +20 -0
  19. package/dist_ts/dashboard.interfaces.d.ts +132 -0
  20. package/dist_ts/dashboard.interfaces.js +2 -0
  21. package/dist_ts/dashboard.plugins.d.ts +5 -0
  22. package/dist_ts/dashboard.plugins.js +10 -0
  23. package/dist_ts/index.d.ts +116 -1
  24. package/dist_ts/index.js +90 -2
  25. package/package.json +11 -7
  26. package/readme.md +400 -22
  27. package/third-party-notices.md +20 -4
  28. package/third_party_licenses/borsh-1.8.1-license.txt +23 -0
  29. package/third_party_licenses/borsh-derive-1.8.1-apache-2.0.txt +201 -0
  30. package/third_party_licenses/cargo-dependencies.html +420 -0
  31. package/third_party_licenses/dyn-clone-1.0.20-license.txt +23 -0
  32. package/third_party_licenses/equivalent-1.0.3-license.txt +25 -0
  33. package/third_party_licenses/hashbrown-0.17.1-license.txt +25 -0
  34. package/third_party_licenses/indexmap-2.14.2-license.txt +25 -0
  35. package/third_party_licenses/inventory.json +310 -8
  36. package/third_party_licenses/proc-macro-crate-3.5.0-license.txt +23 -0
  37. package/third_party_licenses/ref-cast-1.0.27-license.txt +23 -0
  38. package/third_party_licenses/ref-cast-impl-1.0.27-license.txt +23 -0
  39. package/third_party_licenses/schemars-1.2.3-license.txt +21 -0
  40. package/third_party_licenses/syn-3.0.9-license.txt +23 -0
  41. package/third_party_licenses/toml_datetime-1.1.2-license.txt +19 -0
  42. package/third_party_licenses/toml_edit-0.25.17-license.txt +19 -0
  43. package/third_party_licenses/toml_parser-1.1.5-license.txt +19 -0
  44. package/third_party_licenses/winnow-1.0.4-license.txt +18 -0
  45. package/ts/00_commitinfo_data.ts +1 -1
  46. package/ts/classes.dashboard.ts +280 -0
  47. package/ts/dashboard.bundled.ts.third-party-notices.json +1741 -0
  48. package/ts/dashboard.bundled.ts.third-party-notices.txt +5015 -0
  49. package/ts/dashboard.interfaces.ts +148 -0
  50. package/ts/dashboard.plugins.ts +13 -0
  51. package/ts/index.ts +217 -2
@@ -0,0 +1,148 @@
1
+ import type { ITypedRequest, implementsTR } from '@api.global/typedrequest-interfaces';
2
+ import type { ITypedSocketEvent } from '@api.global/typedsocket';
3
+
4
+ /**
5
+ * Engine snapshot shapes, as `ObjectStorage.getClusterSnapshot()` returns
6
+ * them. The engine builds them from memory only: the drive states the last
7
+ * heartbeat probe left, runtime stats, healing state, S3 listener counters
8
+ * and a bounded ring of recent events. Times are milliseconds since the Unix
9
+ * epoch; absent values are `null`.
10
+ */
11
+ export interface IObjectStorageTelemetryEvent {
12
+ /** Numbered per node from 1; a gap means older events left the ring. */
13
+ seq: number;
14
+ at: number;
15
+ kind: 'engine' | 'peer' | 'drive' | 'healing';
16
+ severity: 'info' | 'warning' | 'error';
17
+ message: string;
18
+ }
19
+
20
+ /** S3 listener counters since the engine started; bytes include HTTP headers. */
21
+ export interface IObjectStorageTrafficCounters {
22
+ requestsTotal: number;
23
+ errorResponses: number;
24
+ bytesIn: number;
25
+ bytesOut: number;
26
+ }
27
+
28
+ export interface IObjectStorageStorageTotals {
29
+ bucketCount: number;
30
+ objectCount: number;
31
+ totalBytes: number;
32
+ }
33
+
34
+ export interface IObjectStorageDriveSnapshot {
35
+ index: number;
36
+ path: string;
37
+ status: 'online' | 'degraded' | 'offline' | 'healing';
38
+ totalBytes: number;
39
+ usedBytes: number;
40
+ availableBytes: number;
41
+ errorCount: number;
42
+ lastError: string | null;
43
+ lastCheck: number | null;
44
+ erasureSetId: number | null;
45
+ }
46
+
47
+ export interface IObjectStorageHealingSnapshot {
48
+ active: boolean;
49
+ scanIntervalMs: number;
50
+ lastRunStartedAt: number | null;
51
+ lastRunCompletedAt: number | null;
52
+ lastDurationMs: number | null;
53
+ shardsChecked: number | null;
54
+ shardsHealed: number | null;
55
+ shardsPending: number | null;
56
+ failed: number | null;
57
+ lastError: string | null;
58
+ }
59
+
60
+ /** What one node reports about itself; local and remote nodes share this shape. */
61
+ export interface IObjectStorageNodeSnapshot {
62
+ /** Absent in standalone mode. */
63
+ nodeId: string | null;
64
+ mode: 'standalone' | 'cluster';
65
+ startedAt: number;
66
+ capturedAt: number;
67
+ traffic: IObjectStorageTrafficCounters;
68
+ storage: IObjectStorageStorageTotals;
69
+ /** Local drives (cluster mode); empty in standalone mode. */
70
+ drives: IObjectStorageDriveSnapshot[];
71
+ healing: IObjectStorageHealingSnapshot | null;
72
+ /** Recent events, oldest first. */
73
+ events: IObjectStorageTelemetryEvent[];
74
+ }
75
+
76
+ export interface IObjectStoragePeerSnapshot {
77
+ nodeId: string;
78
+ status: 'online' | 'suspect' | 'offline';
79
+ quicAddress: string;
80
+ s3Address: string;
81
+ driveCount: number;
82
+ lastHeartbeat: number;
83
+ missedHeartbeats: number;
84
+ /** The peer's own snapshot, when it answered within a second. */
85
+ snapshot: IObjectStorageNodeSnapshot | null;
86
+ unavailableReason: string | null;
87
+ }
88
+
89
+ export interface IObjectStorageErasureSnapshot {
90
+ dataShards: number;
91
+ parityShards: number;
92
+ chunkSizeBytes: number;
93
+ totalShards: number;
94
+ readQuorum: number;
95
+ writeQuorum: number;
96
+ erasureSetCount: number;
97
+ }
98
+
99
+ export interface IObjectStorageClusterViewSnapshot {
100
+ nodeId: string;
101
+ quorumHealthy: boolean;
102
+ majorityHealthy: boolean;
103
+ erasure: IObjectStorageErasureSnapshot;
104
+ peers: IObjectStoragePeerSnapshot[];
105
+ }
106
+
107
+ export interface IObjectStorageClusterSnapshot {
108
+ capturedAt: number;
109
+ clusterEnabled: boolean;
110
+ local: IObjectStorageNodeSnapshot;
111
+ /** Absent in standalone mode. */
112
+ cluster: IObjectStorageClusterViewSnapshot | null;
113
+ }
114
+
115
+ /**
116
+ * Dashboard configuration. The dashboard is off unless `enabled` is true.
117
+ */
118
+ export interface IObjectStorageDashboardConfig {
119
+ enabled: boolean;
120
+ /** Interface the dashboard listens on. Default `127.0.0.1`. */
121
+ address?: string;
122
+ /** TCP port; `0` asks the OS for one. Default 3001; must differ from the S3 port. */
123
+ port?: number;
124
+ }
125
+
126
+ /** One dashboard sample, as pushed to every connected dashboard. */
127
+ export interface IObjectStorageDashboardSample {
128
+ sampledAt: number;
129
+ /** The snapshot, or `null` when the engine could not answer. */
130
+ snapshot: IObjectStorageClusterSnapshot | null;
131
+ error: string | null;
132
+ /** Events were left out of peer snapshots to keep the sample within the push limit. */
133
+ eventsTruncated: boolean;
134
+ }
135
+
136
+ /** Answers with the latest sample, taking one when none exists yet. */
137
+ export interface IReq_ObjectStorageDashboardGetSample
138
+ extends implementsTR<ITypedRequest, IReq_ObjectStorageDashboardGetSample> {
139
+ method: 'objectStorageDashboardGetSample';
140
+ request: Record<string, never>;
141
+ response: IObjectStorageDashboardSample;
142
+ }
143
+
144
+ /** Pushed every two seconds while at least one dashboard is connected. */
145
+ export interface IEvent_ObjectStorageDashboardSample
146
+ extends ITypedSocketEvent<IObjectStorageDashboardSample> {
147
+ name: 'objectStorageDashboardSample';
148
+ }
@@ -0,0 +1,13 @@
1
+ // Loaded only by the dashboard module, which `ObjectStorage` imports
2
+ // dynamically when the dashboard is enabled.
3
+
4
+ // @api.global scope
5
+ import * as typedrequest from '@api.global/typedrequest';
6
+ import * as typedsocket from '@api.global/typedsocket';
7
+
8
+ export { typedrequest, typedsocket };
9
+
10
+ // @push.rocks scope
11
+ import * as smartserve from '@push.rocks/smartserve';
12
+
13
+ export { smartserve };
package/ts/index.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import * as paths from './paths.js';
3
+ import type { ObjectStorageDashboard } from './classes.dashboard.js';
4
+ import type {
5
+ IObjectStorageClusterSnapshot,
6
+ IObjectStorageDashboardConfig,
7
+ } from './dashboard.interfaces.js';
8
+
9
+ export * from './dashboard.interfaces.js';
3
10
 
4
11
  /**
5
12
  * Authentication configuration
@@ -145,11 +152,34 @@ export interface IDriveConfig {
145
152
  allowSharedDevice?: boolean;
146
153
  }
147
154
 
155
+ /**
156
+ * Mutual TLS for the cluster transport. Every file is read once at startup and
157
+ * must lie outside the control and drive roots, at an absolute path without
158
+ * symbolic links; a restart reloads them.
159
+ */
160
+ export interface IClusterTlsConfig {
161
+ /** PEM file with one or two CA certificates (two only during a CA rotation). */
162
+ caFile: string;
163
+ /**
164
+ * PEM file with this node's certificate, optionally followed by intermediates. It has one DNS SAN,
165
+ * equal to nodeId, and is valid for server and client authentication: an extended key usage
166
+ * extension listing serverAuth and clientAuth, or no extended key usage extension.
167
+ */
168
+ certFile: string;
169
+ /** PEM private key of certFile; owned by the engine's user, mode 0600 or 0400. */
170
+ keyFile: string;
171
+ }
172
+
148
173
  /**
149
174
  * Cluster configuration for distributed mode
150
175
  */
151
176
  export interface IClusterConfig {
152
177
  enabled: boolean;
178
+ /**
179
+ * This node's id. Generated and persisted when omitted, except with `tls`:
180
+ * then it is required and must be a lowercase DNS name equal to the node
181
+ * certificate's DNS subject alternative name.
182
+ */
153
183
  nodeId?: string;
154
184
  quicPort?: number;
155
185
  seedNodes?: string[];
@@ -167,12 +197,20 @@ export interface IClusterConfig {
167
197
  * 60,000 (1 minute) to 604,800,000 (7 days).
168
198
  */
169
199
  getDeadlineMs?: number;
200
+ /**
201
+ * Mutual TLS for the cluster transport. Without it the transport is
202
+ * encrypted but authenticates no peer. Every node of a cluster sets it, or
203
+ * none does: enabling it is a full stop of every node, not a rolling change.
204
+ */
205
+ tls?: IClusterTlsConfig;
170
206
  }
171
207
 
172
208
  /**
173
209
  * Configuration as the engine receives it: every section resolved.
174
210
  */
175
- export type TObjectStorageResolvedConfig = Required<Omit<IObjectStorageConfig, 'binaryPath'>>;
211
+ export type TObjectStorageResolvedConfig = Required<
212
+ Omit<IObjectStorageConfig, 'binaryPath' | 'dashboard'>
213
+ >;
176
214
 
177
215
  /**
178
216
  * Complete objectstorage configuration
@@ -193,17 +231,34 @@ export interface IObjectStorageConfig {
193
231
  limits?: ILimitsConfig;
194
232
  multipart?: IMultipartConfig;
195
233
  cluster?: IClusterConfig;
234
+ /**
235
+ * The read-only visibility dashboard, served by this process on its own
236
+ * port. Off unless `enabled` is true; never sent to the engine.
237
+ */
238
+ dashboard?: IObjectStorageDashboardConfig;
196
239
  }
197
240
 
198
241
  /**
199
242
  * Logical bucket stats maintained by the Rust runtime.
200
243
  * Values are initialized from native storage on startup and updated on objectstorage mutations.
201
244
  */
245
+ /** A versioned bucket's S3 versioning status; a bucket that was never versioned has none. */
246
+ export type TBucketVersioningStatus = 'Enabled' | 'Suspended';
247
+
202
248
  export interface IBucketSummary {
203
249
  name: string;
250
+ /** Current objects; noncurrent versions and delete markers are counted below. */
204
251
  objectCount: number;
205
252
  totalSizeBytes: number;
206
253
  creationDate?: number;
254
+ /** Present only for a versioned bucket (standalone), and so are the counters below. */
255
+ versioning?: TBucketVersioningStatus;
256
+ /** Noncurrent data versions. */
257
+ noncurrentVersionCount?: number;
258
+ /** Payload bytes of the noncurrent data versions. */
259
+ noncurrentBytes?: number;
260
+ /** Delete markers, current and noncurrent. */
261
+ deleteMarkerCount?: number;
207
262
  }
208
263
 
209
264
  /**
@@ -332,6 +387,15 @@ export interface IStorageStats {
332
387
  buckets: IBucketSummary[];
333
388
  storageDirectory: string;
334
389
  storageLocations?: IStorageLocationSummary[];
390
+ /** Totals over the versioned buckets; absent without one. */
391
+ totalNoncurrentVersionCount?: number;
392
+ totalNoncurrentBytes?: number;
393
+ totalDeleteMarkerCount?: number;
394
+ /**
395
+ * How far version stamps run ahead of the wall clock, after the wall clock
396
+ * went backwards (or a forward jump was corrected); absent when they do not.
397
+ */
398
+ stampClockAheadOfWallMs?: number;
335
399
  }
336
400
 
337
401
  export interface IStorageStopResult {
@@ -1070,6 +1134,8 @@ export interface IObjectStorageHealth {
1070
1134
  resourcePublicationHoldSupported: boolean;
1071
1135
  resourcePublicationHoldRequiresExternalDrain: false;
1072
1136
  resourceFencingReason?: string;
1137
+ /** See `IStorageStats.stampClockAheadOfWallMs`. */
1138
+ stampClockAheadOfWallMs?: number;
1073
1139
  }
1074
1140
 
1075
1141
  export const objectStorageResourceFenceErrorCodes = [
@@ -1194,6 +1260,35 @@ export interface IClusterPeerHealth {
1194
1260
  missedHeartbeats?: number;
1195
1261
  }
1196
1262
 
1263
+ /**
1264
+ * A drive's medium: `'rotational'` and `'flash'` (network volumes included),
1265
+ * `'smr'` for drive-managed SMR, which reports itself as rotational and is
1266
+ * never detected, and `'zoned'` for host-managed or host-aware SMR and ZNS.
1267
+ */
1268
+ export type TDriveMedia = 'rotational' | 'smr' | 'flash' | 'zoned';
1269
+
1270
+ /**
1271
+ * A drive's media.
1272
+ */
1273
+ export interface IClusterDriveMedia {
1274
+ /**
1275
+ * The media the configuration declares for the drive. Not reported yet:
1276
+ * drives cannot declare their media.
1277
+ */
1278
+ declared?: TDriveMedia;
1279
+ /**
1280
+ * What the engine detected once at startup, from the whole disk behind the
1281
+ * drive root's filesystem (Linux sysfs `queue/zoned` and `queue/rotational`;
1282
+ * a partition counts as its disk). `'unknown'` for filesystems without a
1283
+ * block device of their own (tmpfs, overlay, network filesystems),
1284
+ * device-mapper, md, loop and other virtual block devices, and on every
1285
+ * platform but Linux. Virtual and network block devices report the
1286
+ * rotational flag their driver or hypervisor sets (QEMU disks show
1287
+ * `'rotational'` unless configured with `rotation_rate=1`).
1288
+ */
1289
+ detected: TDriveMedia | 'unknown';
1290
+ }
1291
+
1197
1292
  /**
1198
1293
  * Local drive health as measured by objectstorage's runtime probes.
1199
1294
  */
@@ -1208,6 +1303,7 @@ export interface IClusterDriveHealth {
1208
1303
  lastError?: string;
1209
1304
  lastCheck?: number;
1210
1305
  erasureSetId?: number;
1306
+ media: IClusterDriveMedia;
1211
1307
  }
1212
1308
 
1213
1309
  export interface IClusterErasureHealth {
@@ -1234,6 +1330,25 @@ export interface IClusterRepairHealth {
1234
1330
  lastError?: string;
1235
1331
  }
1236
1332
 
1333
+ export interface IClusterTransportCertificate {
1334
+ /** SHA-256 of the DER certificate, lowercase hex. */
1335
+ sha256: string;
1336
+ /** Milliseconds since the Unix epoch. */
1337
+ notAfter: number;
1338
+ }
1339
+
1340
+ /**
1341
+ * How the cluster transport authenticates peers, and the certificates it
1342
+ * loaded at startup.
1343
+ */
1344
+ export interface IClusterTransportHealth {
1345
+ mode: 'mutualTls' | 'unauthenticated';
1346
+ /** This node's certificate; only in mode 'mutualTls'. */
1347
+ certificate?: IClusterTransportCertificate;
1348
+ /** The loaded CA bundle in file order; only in mode 'mutualTls'. */
1349
+ caCertificates?: IClusterTransportCertificate[];
1350
+ }
1351
+
1237
1352
  /**
1238
1353
  * Cluster runtime health from the Rust core.
1239
1354
  * When clustering is disabled, the response is `{ enabled: false }`.
@@ -1249,6 +1364,7 @@ export interface IClusterHealth {
1249
1364
  drives?: IClusterDriveHealth[];
1250
1365
  erasure?: IClusterErasureHealth;
1251
1366
  repairs?: IClusterRepairHealth;
1367
+ transport?: IClusterTransportHealth;
1252
1368
  }
1253
1369
 
1254
1370
  /**
@@ -1333,6 +1449,14 @@ const DEFAULT_CONFIG: IObjectStorageConfig = {
1333
1449
  // Keep this aligned with the bounded Rust management-line reader.
1334
1450
  const MAXIMUM_MANAGEMENT_PAYLOAD_BYTES = 144 * 1024 * 1024;
1335
1451
 
1452
+ // The `start` command's timeout: the largest delay Node's setTimeout honours
1453
+ // (2^31 - 1 ms, about 24.8 days; Node clamps any larger delay, so also
1454
+ // Number.MAX_SAFE_INTEGER, to 1 ms). Startup recovers every version chain and
1455
+ // scans every current object, so it grows with the stored data and has no
1456
+ // fixed bound; an engine that dies during startup still rejects `start` at
1457
+ // once, through the bridge's exit detection.
1458
+ const START_COMMAND_TIMEOUT_MS = 2_147_483_647;
1459
+
1336
1460
  function assertExactMountedDirectory(directoryArg: string, fieldArg: string): void {
1337
1461
  if (
1338
1462
  directoryArg.length === 0 ||
@@ -1450,6 +1574,33 @@ function mergeConfig(userConfig: IObjectStorageConfig): TObjectStorageResolvedCo
1450
1574
  } as TObjectStorageResolvedConfig;
1451
1575
  }
1452
1576
 
1577
+ const DEFAULT_DASHBOARD_ADDRESS = '127.0.0.1';
1578
+ const DEFAULT_DASHBOARD_PORT = 3001;
1579
+
1580
+ function resolveDashboardConfig(
1581
+ dashboardArg: IObjectStorageDashboardConfig | undefined,
1582
+ s3PortArg: number,
1583
+ ): Required<IObjectStorageDashboardConfig> | undefined {
1584
+ if (dashboardArg === undefined) {
1585
+ return undefined;
1586
+ }
1587
+ if (typeof dashboardArg.enabled !== 'boolean') {
1588
+ throw new Error('dashboard.enabled must be a boolean.');
1589
+ }
1590
+ const address = dashboardArg.address ?? DEFAULT_DASHBOARD_ADDRESS;
1591
+ if (typeof address !== 'string' || address.length === 0) {
1592
+ throw new Error('dashboard.address must be a non-empty string.');
1593
+ }
1594
+ const port = dashboardArg.port ?? DEFAULT_DASHBOARD_PORT;
1595
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
1596
+ throw new Error('dashboard.port must be an integer from 0 to 65535.');
1597
+ }
1598
+ if (dashboardArg.enabled && port !== 0 && port === s3PortArg) {
1599
+ throw new Error(`dashboard.port ${port} is the S3 port; the dashboard needs its own port.`);
1600
+ }
1601
+ return { enabled: dashboardArg.enabled, address, port };
1602
+ }
1603
+
1453
1604
  function createAccessKeyId(): string {
1454
1605
  return `SS${plugins.crypto.randomBytes(10).toString('hex').toUpperCase()}`;
1455
1606
  }
@@ -1608,6 +1759,7 @@ type TRustStorageCommands = {
1608
1759
  result: { available: boolean; credentialCount: number; tombstoneCount: number };
1609
1760
  };
1610
1761
  getClusterHealth: { params: {}; result: IClusterHealth };
1762
+ getClusterSnapshot: { params: {}; result: IObjectStorageClusterSnapshot };
1611
1763
  runHealingScan: { params: {}; result: IClusterHealingScanResult };
1612
1764
  };
1613
1765
 
@@ -1692,6 +1844,8 @@ export class ObjectStorage {
1692
1844
  private bridgeOwned = false;
1693
1845
  private stopRequested = false;
1694
1846
  private stopCompleted = false;
1847
+ private readonly dashboardConfig: Required<IObjectStorageDashboardConfig> | undefined;
1848
+ private dashboard: ObjectStorageDashboard | undefined;
1695
1849
 
1696
1850
  constructor(configArg: IObjectStorageConfig = {}) {
1697
1851
  const binaryPath = configArg.binaryPath;
@@ -1702,6 +1856,7 @@ export class ObjectStorage {
1702
1856
  throw new Error('binaryPath must be a non-empty executable path.');
1703
1857
  }
1704
1858
  this.config = mergeConfig(configArg);
1859
+ this.dashboardConfig = resolveDashboardConfig(configArg.dashboard, this.config.server.port!);
1705
1860
  this.bridge = new plugins.RustBridge<TRustStorageCommands>({
1706
1861
  binaryName: 'ruststorage',
1707
1862
  ...(binaryPath !== undefined
@@ -1724,6 +1879,12 @@ export class ObjectStorage {
1724
1879
  console.error(`ruststorage engine exited unexpectedly (code: ${code}, signal: ${signal}).`);
1725
1880
  }
1726
1881
  });
1882
+ // The engine logs (warnings, errors, startup recovery progress) to its
1883
+ // stderr; forward every line verbatim to the host's stderr, as an
1884
+ // inherited stderr would.
1885
+ this.bridge.on('stderr', (line: string) => {
1886
+ console.error(line);
1887
+ });
1727
1888
  }
1728
1889
 
1729
1890
  public start(): Promise<void> {
@@ -1762,11 +1923,32 @@ export class ObjectStorage {
1762
1923
  if (!spawned) {
1763
1924
  throw new Error('Failed to spawn ruststorage binary. Make sure it is compiled (pnpm build).');
1764
1925
  }
1765
- const result = await this.bridge.sendCommand('start', { config: this.config });
1926
+ const result = await this.bridge.sendCommand(
1927
+ 'start',
1928
+ { config: this.config },
1929
+ { timeoutMs: START_COMMAND_TIMEOUT_MS },
1930
+ );
1766
1931
  if (!Number.isInteger(result.port) || result.port < 1 || result.port > 65535) {
1767
1932
  throw new Error('Storage engine did not report a valid bound TCP port.');
1768
1933
  }
1769
1934
  this.boundPort = result.port;
1935
+ if (this.dashboardConfig?.enabled) {
1936
+ // Enabled but unable to start (a port in use, say) fails startup:
1937
+ // the catch below stops the engine.
1938
+ const { ObjectStorageDashboard } = await import('./classes.dashboard.js');
1939
+ const dashboard = new ObjectStorageDashboard({
1940
+ address: this.dashboardConfig.address,
1941
+ port: this.dashboardConfig.port,
1942
+ readSnapshot: () => this.getClusterSnapshot(),
1943
+ });
1944
+ try {
1945
+ await dashboard.start();
1946
+ } catch (dashboardError) {
1947
+ await dashboard.stop();
1948
+ throw dashboardError;
1949
+ }
1950
+ this.dashboard = dashboard;
1951
+ }
1770
1952
  } catch (error) {
1771
1953
  try {
1772
1954
  if (this.bridgeOwned) {
@@ -1794,6 +1976,11 @@ export class ObjectStorage {
1794
1976
  return this.running ? this.boundPort : this.config.server.port!;
1795
1977
  }
1796
1978
 
1979
+ /** The dashboard's bound TCP port while it runs; `undefined` otherwise. */
1980
+ public get dashboardPort(): number | undefined {
1981
+ return this.dashboard?.port;
1982
+ }
1983
+
1797
1984
  public async getStorageDescriptor(
1798
1985
  optionsArg?: Partial<plugins.tsclass.storage.IS3Descriptor>,
1799
1986
  ): Promise<plugins.tsclass.storage.IS3Descriptor> {
@@ -2182,6 +2369,17 @@ export class ObjectStorage {
2182
2369
  return this.bridge.sendCommand('getClusterHealth', {});
2183
2370
  }
2184
2371
 
2372
+ /**
2373
+ * The dashboard's snapshot, built by the engine from memory only: it never
2374
+ * probes a drive, syncs or walks storage. A cluster node also asks each
2375
+ * online peer for its own snapshot, waiting up to a second per peer, in
2376
+ * parallel; a peer that does not answer is reported unavailable. One
2377
+ * snapshot runs at a time: a call while another runs rejects.
2378
+ */
2379
+ public async getClusterSnapshot(): Promise<IObjectStorageClusterSnapshot> {
2380
+ return this.bridge.sendCommand('getClusterSnapshot', {});
2381
+ }
2382
+
2185
2383
  /**
2186
2384
  * Runs a healing scan on this cluster node and resolves with its result.
2187
2385
  * The scan covers the manifests this node coordinated. The request joins
@@ -2265,11 +2463,15 @@ export class ObjectStorage {
2265
2463
  resourcePublicationHoldRequiresExternalDrain:
2266
2464
  resourceFencing.publicationHoldRequiresExternalDrain,
2267
2465
  resourceFencingReason: resourceFencing.reason,
2466
+ ...(stats.stampClockAheadOfWallMs === undefined
2467
+ ? {}
2468
+ : { stampClockAheadOfWallMs: stats.stampClockAheadOfWallMs }),
2268
2469
  };
2269
2470
  }
2270
2471
 
2271
2472
  public async getMetrics(): Promise<IObjectStorageMetrics> {
2272
2473
  const health = await this.getHealth();
2474
+ const stats = health.running ? await this.getStorageStats() : undefined;
2273
2475
  const clusterEnabled = health.cluster.enabled;
2274
2476
  return {
2275
2477
  bucketCount: health.bucketCount,
@@ -2303,6 +2505,15 @@ export class ObjectStorage {
2303
2505
  '# HELP objectstorage_cluster_enabled Cluster mode enabled.',
2304
2506
  '# TYPE objectstorage_cluster_enabled gauge',
2305
2507
  `objectstorage_cluster_enabled ${clusterEnabled ? 1 : 0}`,
2508
+ '# HELP objectstorage_noncurrent_versions_total Noncurrent object versions.',
2509
+ '# TYPE objectstorage_noncurrent_versions_total gauge',
2510
+ `objectstorage_noncurrent_versions_total ${stats?.totalNoncurrentVersionCount ?? 0}`,
2511
+ '# HELP objectstorage_noncurrent_bytes_total Payload bytes of noncurrent object versions.',
2512
+ '# TYPE objectstorage_noncurrent_bytes_total gauge',
2513
+ `objectstorage_noncurrent_bytes_total ${stats?.totalNoncurrentBytes ?? 0}`,
2514
+ '# HELP objectstorage_delete_markers_total Delete markers, current and noncurrent.',
2515
+ '# TYPE objectstorage_delete_markers_total gauge',
2516
+ `objectstorage_delete_markers_total ${stats?.totalDeleteMarkerCount ?? 0}`,
2306
2517
  ].join('\n'),
2307
2518
  };
2308
2519
  }
@@ -2332,6 +2543,10 @@ export class ObjectStorage {
2332
2543
  // Startup keeps its own rejection; stop joins only its resource ownership.
2333
2544
  await Promise.allSettled([this.startPromise]);
2334
2545
  }
2546
+ if (this.dashboard) {
2547
+ await this.dashboard.stop();
2548
+ this.dashboard = undefined;
2549
+ }
2335
2550
  if (this.bridgeOwned) {
2336
2551
  if (this.running) {
2337
2552
  this.completedStopResult ??= await this.bridge.sendCommand('stop', {});