@truenas/api-client 1.0.13 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -10,76 +10,6 @@ var __export = (target, all) => {
10
10
  __defProp(target, name, { get: all[name], enumerable: true });
11
11
  };
12
12
 
13
- // src/enums/truenas-endpoint.enum.ts
14
- var TrueNasEndpoint = /* @__PURE__ */ ((TrueNasEndpoint2) => {
15
- TrueNasEndpoint2["UpdateStatus"] = "update.status";
16
- TrueNasEndpoint2["AppQuery"] = "app.query";
17
- TrueNasEndpoint2["AppStart"] = "app.start";
18
- TrueNasEndpoint2["AppStop"] = "app.stop";
19
- TrueNasEndpoint2["AppRedeploy"] = "app.redeploy";
20
- TrueNasEndpoint2["AppUpgrade"] = "app.upgrade";
21
- TrueNasEndpoint2["CoreSubscribe"] = "core.subscribe";
22
- TrueNasEndpoint2["ReplicationQuery"] = "replication.query";
23
- TrueNasEndpoint2["ReplicationRun"] = "replication.run";
24
- TrueNasEndpoint2["InterfaceQuery"] = "interface.query";
25
- TrueNasEndpoint2["CloudBackupQuery"] = "cloud_backup.query";
26
- TrueNasEndpoint2["CloudSyncQuery"] = "cloudsync.query";
27
- TrueNasEndpoint2["VirtualInstanceQuery"] = "virt.instance.query";
28
- TrueNasEndpoint2["VirtualInstanceStop"] = "virt.instance.stop";
29
- TrueNasEndpoint2["VirtualInstanceStart"] = "virt.instance.start";
30
- TrueNasEndpoint2["VirtualInstanceRestart"] = "virt.instance.restart";
31
- TrueNasEndpoint2["ContainerQuery"] = "container.query";
32
- TrueNasEndpoint2["ContainerStart"] = "container.start";
33
- TrueNasEndpoint2["ContainerStop"] = "container.stop";
34
- TrueNasEndpoint2["VmQuery"] = "vm.query";
35
- TrueNasEndpoint2["VmStart"] = "vm.start";
36
- TrueNasEndpoint2["VmStop"] = "vm.stop";
37
- TrueNasEndpoint2["VmRestart"] = "vm.restart";
38
- TrueNasEndpoint2["CoreDownload"] = "core.download";
39
- TrueNasEndpoint2["CoreGetJobs"] = "core.get_jobs";
40
- TrueNasEndpoint2["SystemInfo"] = "system.info";
41
- TrueNasEndpoint2["SystemProductType"] = "system.product_type";
42
- TrueNasEndpoint2["ExtendedSystemInfo"] = "webui.main.dashboard.sys_info";
43
- TrueNasEndpoint2["DiskQuery"] = "disk.query";
44
- TrueNasEndpoint2["DeviceGetInfo"] = "device.get_info";
45
- TrueNasEndpoint2["CorePing"] = "core.ping";
46
- TrueNasEndpoint2["GenerateToken"] = "auth.generate_token";
47
- TrueNasEndpoint2["AlertList"] = "alert.list";
48
- TrueNasEndpoint2["AuthLogin"] = "auth.login_ex";
49
- TrueNasEndpoint2["AuthLogout"] = "auth.logout";
50
- TrueNasEndpoint2["ApiKeyQuery"] = "api_key.query";
51
- TrueNasEndpoint2["ApiKeyCreate"] = "api_key.create";
52
- TrueNasEndpoint2["ApiKeyDelete"] = "api_key.delete";
53
- TrueNasEndpoint2["FailOverStatus"] = "failover.status";
54
- TrueNasEndpoint2["FailOverNode"] = "failover.node";
55
- TrueNasEndpoint2["FailOverDisabledReasons"] = "failover.disabled.reasons";
56
- TrueNasEndpoint2["PoolQuery"] = "pool.query";
57
- TrueNasEndpoint2["DatasetQuery"] = "pool.dataset.query";
58
- TrueNasEndpoint2["DatasetCreate"] = "pool.dataset.create";
59
- TrueNasEndpoint2["DatasetDelete"] = "pool.dataset.delete";
60
- TrueNasEndpoint2["SnapshotTaskCreate"] = "pool.snapshottask.create";
61
- TrueNasEndpoint2["SnapshotTaskQuery"] = "pool.snapshottask.query";
62
- TrueNasEndpoint2["SnapshotTaskDelete"] = "pool.snapshottask.delete";
63
- TrueNasEndpoint2["SnapshotCreate"] = "pool.snapshot.create";
64
- TrueNasEndpoint2["SnapshotDelete"] = "pool.snapshot.delete";
65
- TrueNasEndpoint2["ReplicationCountEligibleSnapshots"] = "replication.count_eligible_manual_snapshots";
66
- TrueNasEndpoint2["BootGetState"] = "boot.get_state";
67
- TrueNasEndpoint2["TnConnectUpdate"] = "tn_connect.update";
68
- TrueNasEndpoint2["HardwareVirtualizationVariant"] = "hardware.virtualization.variant";
69
- TrueNasEndpoint2["KeychainCredentialGenerateSshKeyPair"] = "keychaincredential.generate_ssh_key_pair";
70
- TrueNasEndpoint2["KeychainCredentialCreate"] = "keychaincredential.create";
71
- TrueNasEndpoint2["KeychainCredentialRemoteSshHostKeyScan"] = "keychaincredential.remote_ssh_host_key_scan";
72
- TrueNasEndpoint2["KeychainCredentialDelete"] = "keychaincredential.delete";
73
- TrueNasEndpoint2["ReplicationCreate"] = "replication.create";
74
- TrueNasEndpoint2["UserQuery"] = "user.query";
75
- TrueNasEndpoint2["UserUpdate"] = "user.update";
76
- TrueNasEndpoint2["NetworkConfiguration"] = "network.configuration.config";
77
- TrueNasEndpoint2["ServiceQuery"] = "service.query";
78
- TrueNasEndpoint2["ServiceUpdate"] = "service.update";
79
- TrueNasEndpoint2["ServiceControl"] = "service.control";
80
- return TrueNasEndpoint2;
81
- })(TrueNasEndpoint || {});
82
-
83
13
  // src/types/api-error.type.ts
84
14
  function isJsonRpcError(error) {
85
15
  return typeof error === "object" && error !== null && "message" in error && typeof error.message === "string";
@@ -114,6 +44,16 @@ var JobState = /* @__PURE__ */ ((JobState2) => {
114
44
  JobState2["Waiting"] = "WAITING";
115
45
  return JobState2;
116
46
  })(JobState || {});
47
+ var terminalStates = [
48
+ "SUCCESS" /* Success */,
49
+ "FAILED" /* Failed */,
50
+ "ABORTED" /* Aborted */,
51
+ "ERROR" /* Error */,
52
+ "FINISHED" /* Finished */
53
+ ];
54
+ function isJobFinished(job) {
55
+ return terminalStates.includes(job.state);
56
+ }
117
57
  var withId = (id) => rxjs.filter((msg) => msg.id === id);
118
58
  function randomUUID() {
119
59
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -140,6 +80,7 @@ function createJsonRpcMessage(method, params) {
140
80
  }
141
81
 
142
82
  // src/api/truenas-api.ts
83
+ var EVENT_KINDS = ["added", "changed", "removed"];
143
84
  var TrueNasApi = class {
144
85
  constructor(authenticated, connection) {
145
86
  this.authenticated = authenticated;
@@ -156,33 +97,67 @@ var TrueNasApi = class {
156
97
  rxjs.filter((job) => !!job?.id),
157
98
  rxjs.share()
158
99
  );
100
+ /**
101
+ * One shared stream per subscribed event name — see {@link events}.
102
+ * Keyed by name rather than by caller, because the subscription it stands
103
+ * for lives on the server and is per socket, not per caller.
104
+ */
105
+ this.eventStreams = /* @__PURE__ */ new Map();
159
106
  this.initializeJobEventsSubscription();
160
107
  }
161
- call(method, params) {
162
- return this.dispatch(method, params);
108
+ /**
109
+ * Send a request to a method of the surface this instance is typed against,
110
+ * and emit its result.
111
+ *
112
+ * ```typescript
113
+ * api.call('system.info') // SystemInfo
114
+ * api.call('pool.dataset.delete', ['tank/ds', { recursive: true }])
115
+ * ```
116
+ *
117
+ * `params` is required exactly when the method takes them — the directory
118
+ * says which — so a method that needs an id cannot be called without one.
119
+ *
120
+ * The polymorphic `.query` methods are reachable here too, but their
121
+ * `response` is the five-way union the server may return, which is
122
+ * {@link query} / {@link queryOne} / {@link queryCount}'s job to resolve.
123
+ * Reach for a verb instead.
124
+ */
125
+ call(method, ...params) {
126
+ return this.dispatch(method, params[0]);
163
127
  }
164
128
  /**
165
129
  * Send a JSON-RPC request and emit its result.
166
130
  *
167
- * Shared by {@link call} and the query verbs, which type the same wire call
168
- * against different directories `call` against the hand-maintained one,
169
- * the verbs against the generated one.
131
+ * Shared by {@link call} and the query verbs, which read the same directory
132
+ * but resolve different things from it: `call` takes the entry's `response`
133
+ * verbatim, the verbs narrow its polymorphic union by the verb chosen.
134
+ *
135
+ * Also the way the class reaches methods on its own behalf — `core.get_jobs`,
136
+ * `auth.generate_token`. Inside the class body `D` is a type parameter, so
137
+ * the checker cannot know those are among its methods; they are, since both
138
+ * are in the shared base every surface extends.
170
139
  */
171
140
  dispatch(method, params) {
172
- const message = createJsonRpcMessage(method, params);
173
- this.connection.ws.next(message);
174
- const messageId = message.id ?? "";
175
- return this.connection.messages().pipe(
176
- withId(messageId),
177
- rxjs.map((msg) => {
178
- if (msg.error) {
179
- const errorMessage = getApiErrorMessage(msg.error, "API call failed");
180
- throw new Error(errorMessage);
181
- }
182
- return msg.result;
183
- }),
184
- rxjs.take(1)
185
- );
141
+ return rxjs.defer(() => {
142
+ const message = createJsonRpcMessage(method, params);
143
+ const messageId = message.id ?? "";
144
+ const reply = this.connection.messages().pipe(
145
+ withId(messageId),
146
+ rxjs.map((msg) => {
147
+ if (msg.error) {
148
+ const errorMessage = getApiErrorMessage(
149
+ msg.error,
150
+ "API call failed"
151
+ );
152
+ throw new Error(errorMessage);
153
+ }
154
+ return msg.result;
155
+ }),
156
+ rxjs.take(1)
157
+ );
158
+ const sending = this.connection.send(message);
159
+ return reply.pipe(rxjs.finalize(() => sending.unsubscribe()));
160
+ });
186
161
  }
187
162
  /**
188
163
  * Query a collection and emit the matching entries.
@@ -237,81 +212,159 @@ var TrueNasApi = class {
237
212
  return this.dispatch(method, [filters ?? [], { count: true }]);
238
213
  }
239
214
  /**
240
- * Makes an API call and returns the job ID from the websocket event.
241
- * Used for v26 where API calls return null but job events contain the job ID.
215
+ * Start a job and emit its id.
242
216
  *
243
- * The job ID is extracted from the first job event where message_ids contains
244
- * the original request ID.
217
+ * Keyed off the surface's *job* directory rather than its call directory
218
+ * a disjoint key space, so `callAndGetJobId('app.query')` is rejected and
219
+ * `call('app.start')` is too. Which one a method belongs to is a fact about
220
+ * the method, and the generated directories are where that fact lives.
221
+ *
222
+ * The id comes from the first job event whose `message_ids` carries this
223
+ * request's id, not from the response: what a job method returns on the wire
224
+ * differs by version (v25.10 answers with the id, v26 with `null`), while
225
+ * the event correlation holds for both.
245
226
  *
246
- * @param method The API method to call
247
- * @param params The parameters for the API call
248
227
  * @returns Observable that emits the job ID when received from websocket events
249
228
  */
250
- callAndGetJobId(method, params) {
251
- const message = createJsonRpcMessage(method, params);
252
- this.connection.ws.next(message);
253
- const requestId = message.id ?? "";
254
- return this.jobEvents.pipe(
255
- rxjs.filter((job) => job.message_ids?.includes(requestId) ?? false),
256
- rxjs.map((job) => job.id),
257
- rxjs.take(1)
258
- );
259
- }
260
- events(eventName) {
261
- this.authenticated.pipe(rxjs.filter(Boolean), rxjs.take(1)).subscribe(() => {
262
- const message = createJsonRpcMessage("core.subscribe", [eventName]);
263
- this.connection.ws.next(message);
229
+ callAndGetJobId(method, ...params) {
230
+ return rxjs.defer(() => {
231
+ const message = createJsonRpcMessage(method, params[0]);
232
+ const seen = this.jobEvents.pipe(
233
+ rxjs.filter((job) => job.message_ids?.includes(message.id ?? "") ?? false),
234
+ rxjs.map((job) => job.id),
235
+ rxjs.take(1)
236
+ );
237
+ const sending = this.connection.send(message);
238
+ return seen.pipe(rxjs.finalize(() => sending.unsubscribe()));
264
239
  });
265
- return this.connection.messages().pipe(
266
- rxjs.filter((res) => {
267
- const params = res.params;
268
- return res.method === "collection_update" && params?.collection === eventName && ["added", "changed", "removed"].includes(params?.msg || "") && params.fields !== void 0;
269
- })
240
+ }
241
+ /**
242
+ * Start a job and follow it to completion.
243
+ *
244
+ * Emits the job's state as it progresses and completes when the job reaches
245
+ * a terminal state, so `last()` gives the finished job and the intermediate
246
+ * emissions drive a progress indicator.
247
+ *
248
+ * ```typescript
249
+ * api.job('pool.dataset.unlock', ['tank/enc', { … }])
250
+ * .subscribe(job => bar.set(job.progress.percent ?? 0));
251
+ * ```
252
+ *
253
+ * The result is typed from the job directory, which is the whole reason to
254
+ * prefer this over {@link callAndGetJobId} plus {@link trackJob}: those two
255
+ * lose the connection between the method and its result, and the job comes
256
+ * back with `result: unknown`. It is `R | null` either way — a job that has
257
+ * not finished has no result, and neither does one that failed.
258
+ */
259
+ job(method, ...params) {
260
+ return this.callAndGetJobId(method, ...params).pipe(
261
+ rxjs.switchMap((jobId) => this.trackJob(jobId))
270
262
  );
271
263
  }
264
+ /**
265
+ * Subscribe to a collection and emit its changes.
266
+ *
267
+ * Emits the change itself rather than the transport frame, as a union
268
+ * discriminated on `msg`:
269
+ *
270
+ * ```typescript
271
+ * api.events('app.query').subscribe(event => {
272
+ * if (event.msg === 'removed') return drop(event.id);
273
+ * render(event.fields); // only reachable once narrowed
274
+ * });
275
+ * ```
276
+ *
277
+ * The narrowing is load-bearing, not decoration: a `removed` event carries
278
+ * an `id` and no `fields` in 55 of the 56 collections that declare one, so
279
+ * reaching `fields` unconditionally is wrong for almost all of them.
280
+ *
281
+ * Event *sources* — the entries taking subscribe-time arguments — are not
282
+ * reachable here; see {@link EventName}.
283
+ */
284
+ events(event) {
285
+ const existing = this.eventStreams.get(event);
286
+ if (existing) return existing;
287
+ const stream = rxjs.defer(() => {
288
+ const resubscribe = this.authenticated.pipe(rxjs.distinctUntilChanged(), rxjs.filter(Boolean)).subscribe(() => {
289
+ this.connection.send(createJsonRpcMessage("core.subscribe", [event]));
290
+ });
291
+ return this.connection.messages().pipe(
292
+ rxjs.filter((res) => res.method === "collection_update"),
293
+ rxjs.map((res) => res.params),
294
+ rxjs.filter(
295
+ (params) => params?.collection === event && EVENT_KINDS.includes(params.msg)
296
+ ),
297
+ // `collection` is the subscription the caller already named; what is
298
+ // left is exactly the payload the directory declares, plus its tag.
299
+ rxjs.map(({ collection, ...change }) => change),
300
+ rxjs.finalize(() => resubscribe.unsubscribe())
301
+ );
302
+ }).pipe(rxjs.share({ resetOnRefCountZero: false, resetOnComplete: false }));
303
+ this.eventStreams.set(event, stream);
304
+ return stream;
305
+ }
272
306
  /**
273
307
  * Convenience wrapper for auth.generate_token API call.
308
+ *
309
+ * Goes through {@link dispatch} rather than {@link call}: inside the class
310
+ * body `D` is still a type parameter, so TypeScript cannot know that
311
+ * `auth.generate_token` is one of its methods. It is — the method is in the
312
+ * shared base, so every surface has it — but proving that to the checker
313
+ * would mean constraining `D` on the class, which would push the constraint
314
+ * onto every caller. The signature below is the guarantee instead.
274
315
  */
275
316
  generateToken(ttl = 600, matchOrigin = false, singleUse = true) {
276
- return this.call("auth.generate_token" /* GenerateToken */, [
317
+ return this.dispatch("auth.generate_token", [
277
318
  ttl,
278
319
  {},
279
320
  matchOrigin,
280
321
  singleUse
281
322
  ]);
282
323
  }
324
+ /**
325
+ * Follow an already-started job to completion.
326
+ *
327
+ * @typeParam R - what the job resolves to. An id carries no evidence of
328
+ * which method produced it, so nothing here can infer this and the default
329
+ * is `unknown`; naming it is the caller's assertion. {@link job} knows the
330
+ * method and fills it in from the directory, which is the reason to prefer
331
+ * it whenever you are the one starting the job.
332
+ */
283
333
  trackJob(jobId) {
284
- const completedStates = [
285
- "SUCCESS" /* Success */,
286
- "FAILED" /* Failed */,
287
- "ABORTED" /* Aborted */,
288
- "ERROR" /* Error */,
289
- "FINISHED" /* Finished */
290
- ];
291
- const currentJobState$ = this.call("core.get_jobs", [
334
+ const updates$ = this.jobEvents.pipe(
335
+ rxjs.filter((job) => job.id === jobId),
336
+ rxjs.map((job) => job)
337
+ );
338
+ const read$ = this.dispatch("core.get_jobs", [
292
339
  [["id", "=", jobId]]
293
340
  ]).pipe(
294
341
  rxjs.map((jobs) => jobs[0]),
295
- rxjs.filter((job) => job !== void 0)
342
+ rxjs.share()
296
343
  );
297
- const jobUpdates$ = this.jobEvents.pipe(
298
- rxjs.filter((job) => job.id === jobId),
299
- rxjs.takeWhile((job) => !completedStates.includes(job.state), true)
300
- // Include the final state
344
+ const missing$ = read$.pipe(
345
+ rxjs.filter((job) => job === void 0),
346
+ rxjs.takeUntil(updates$)
301
347
  );
302
- return currentJobState$.pipe(
303
- rxjs.switchMap((currentJob) => {
304
- if (completedStates.includes(currentJob.state)) {
305
- return rxjs.of(currentJob);
306
- }
307
- return rxjs.merge(rxjs.of(currentJob), jobUpdates$);
308
- })
348
+ const snapshot$ = read$.pipe(
349
+ rxjs.filter((job) => job !== void 0),
350
+ rxjs.takeUntil(updates$)
351
+ );
352
+ return rxjs.merge(snapshot$, updates$).pipe(
353
+ rxjs.takeWhile((job) => !isJobFinished(job), true),
354
+ // Include the final state
355
+ rxjs.takeUntil(missing$)
309
356
  );
310
357
  }
358
+ /**
359
+ * Ask the server for job events, and keep asking after every reconnect —
360
+ * `authenticated$` returns to `false` when the socket drops, and a `take(1)`
361
+ * here left job tracking permanently deaf once that happened.
362
+ */
311
363
  initializeJobEventsSubscription() {
312
- this.authenticated.pipe(rxjs.filter(Boolean), rxjs.take(1)).subscribe(() => {
313
- const message = createJsonRpcMessage("core.subscribe", ["core.get_jobs"]);
314
- this.connection.ws.next(message);
364
+ this.authenticated.pipe(rxjs.distinctUntilChanged(), rxjs.filter(Boolean)).subscribe(() => {
365
+ this.connection.send(
366
+ createJsonRpcMessage("core.subscribe", ["core.get_jobs"])
367
+ );
315
368
  });
316
369
  }
317
370
  };
@@ -3007,6 +3060,36 @@ var TrueNasApiClient = class {
3007
3060
  }
3008
3061
  };
3009
3062
 
3063
+ // src/types/app-query.type.ts
3064
+ var AppState = /* @__PURE__ */ ((AppState2) => {
3065
+ AppState2["Running"] = "RUNNING";
3066
+ AppState2["Stopped"] = "STOPPED";
3067
+ AppState2["Stopping"] = "STOPPING";
3068
+ AppState2["Deploying"] = "DEPLOYING";
3069
+ return AppState2;
3070
+ })(AppState || {});
3071
+
3072
+ // src/utils/app-state.utils.ts
3073
+ function toAppState(state) {
3074
+ switch (state.toUpperCase()) {
3075
+ case "RUNNING":
3076
+ return "RUNNING" /* Running */;
3077
+ case "STOPPED":
3078
+ return "STOPPED" /* Stopped */;
3079
+ case "STOPPING":
3080
+ return "STOPPING" /* Stopping */;
3081
+ // `STARTING` is v25.10 `virt.instance` only, and folding it into `Stopped`
3082
+ // told callers a container that is coming up is at rest — enough for a UI
3083
+ // to offer a Start button for it, or for a poll loop to give up. `Deploying`
3084
+ // is the only in-progress state `AppState` has.
3085
+ case "STARTING":
3086
+ case "DEPLOYING":
3087
+ return "DEPLOYING" /* Deploying */;
3088
+ default:
3089
+ return "STOPPED" /* Stopped */;
3090
+ }
3091
+ }
3092
+
3010
3093
  // src/client/truenas-api-client-v25-10.ts
3011
3094
  var TrueNasApiClientV2510 = class extends TrueNasApiClient {
3012
3095
  /**
@@ -3018,48 +3101,34 @@ var TrueNasApiClientV2510 = class extends TrueNasApiClient {
3018
3101
  */
3019
3102
  createOperations() {
3020
3103
  return {
3021
- containerQuery: () => this.api.call("virt.instance.query" /* VirtualInstanceQuery */, [
3022
- [["type", "=", "CONTAINER" /* Container */]]
3023
- ]).pipe(rxjs.map((instances) => instances.map(this.toContainer))),
3024
- containerStart: (id) => this.api.callAndGetJobId("virt.instance.start" /* VirtualInstanceStart */, [id]).pipe(rxjs.switchMap((jobId) => this.api.trackJob(jobId))),
3025
- containerStop: (id, options) => this.api.callAndGetJobId("virt.instance.stop" /* VirtualInstanceStop */, [id, options]).pipe(rxjs.switchMap((jobId) => this.api.trackJob(jobId))),
3026
- containerRestart: (id, options) => this.api.callAndGetJobId("virt.instance.restart" /* VirtualInstanceRestart */, [
3027
- id,
3028
- options
3029
- ]).pipe(rxjs.switchMap((jobId) => this.api.trackJob(jobId)))
3030
- };
3031
- }
3032
- /**
3033
- * Transform VirtualInstanceQuery to unified Container type
3034
- */
3035
- toContainer(instance) {
3036
- return {
3037
- id: instance.id,
3038
- name: instance.name,
3039
- status: instance.status,
3040
- autostart: instance.autostart,
3041
- cpu: instance.cpu,
3042
- memory: instance.memory,
3043
- image: instance.image
3104
+ // A polymorphic `.query`, so it goes through the verb rather than
3105
+ // `call`: the directory types the raw method's response as the five-way
3106
+ // union the server may return, and the verb is what fixes it to a list.
3107
+ containerQuery: () => this.api.query("virt.instance.query", [["type", "=", "CONTAINER"]]).pipe(rxjs.map((instances) => instances.map(toContainer))),
3108
+ containerStart: (id) => this.api.job("virt.instance.start", [id]),
3109
+ containerStop: (id, options) => this.api.job("virt.instance.stop", [id, options]),
3110
+ containerRestart: (id, options) => this.api.job("virt.instance.restart", [id, options])
3044
3111
  };
3045
3112
  }
3046
3113
  };
3047
- var TrueNasApiClientV26 = class _TrueNasApiClientV26 extends TrueNasApiClient {
3048
- /**
3049
- * Map v26 status state string to AppState enum
3050
- */
3051
- static mapStatus(state) {
3052
- switch (state.toUpperCase()) {
3053
- case "RUNNING":
3054
- return "RUNNING" /* Running */;
3055
- case "STOPPED":
3056
- return "STOPPED" /* Stopped */;
3057
- case "STOPPING":
3058
- return "STOPPING" /* Stopping */;
3059
- default:
3060
- return "STOPPED" /* Stopped */;
3061
- }
3062
- }
3114
+ function toContainer(instance) {
3115
+ return {
3116
+ id: instance.id,
3117
+ name: instance.name,
3118
+ status: toAppState(instance.status),
3119
+ autostart: instance.autostart,
3120
+ cpu: instance.cpu ?? void 0,
3121
+ memory: instance.memory ?? void 0,
3122
+ // The whole image object, not just its description: `Container.image`
3123
+ // declares only `description`, but the API returns `architecture`, `os`,
3124
+ // `release` and more, and callers were already receiving them. Narrowing
3125
+ // to the declared field would take data away to match a type that was
3126
+ // always an under-declaration — the same call as the v26 `description`
3127
+ // widening, decided the same way.
3128
+ image: instance.image.description === null ? void 0 : { ...instance.image, description: instance.image.description }
3129
+ };
3130
+ }
3131
+ var TrueNasApiClientV26 = class extends TrueNasApiClient {
3063
3132
  /**
3064
3133
  * Create v26-specific operation mappings
3065
3134
  *
@@ -3069,58 +3138,58 @@ var TrueNasApiClientV26 = class _TrueNasApiClientV26 extends TrueNasApiClient {
3069
3138
  */
3070
3139
  createOperations() {
3071
3140
  return {
3072
- containerQuery: () => this.api.call("container.query" /* ContainerQuery */, [[]]).pipe(rxjs.map((containers) => containers.map(this.toContainer))),
3141
+ // A polymorphic `.query`, so it goes through the verb rather than
3142
+ // `call`: the directory types the raw method's response as the five-way
3143
+ // union the server may return, and the verb is what fixes it to a list.
3144
+ containerQuery: () => this.api.query("container.query").pipe(
3145
+ rxjs.map((containers) => containers.map(toContainer2))
3146
+ ),
3073
3147
  // container.start is synchronous in v26.0.0 - emit null
3074
- containerStart: (id) => this.api.call("container.start" /* ContainerStart */, [parseInt(id, 10)]).pipe(rxjs.map(() => null)),
3148
+ containerStart: (id) => this.api.call("container.start", [parseInt(id, 10)]).pipe(rxjs.map(() => null)),
3075
3149
  // container.stop emits job updates
3076
- containerStop: (id, options) => this.api.callAndGetJobId("container.stop" /* ContainerStop */, [
3150
+ containerStop: (id, options) => this.api.job("container.stop", [
3077
3151
  parseInt(id, 10),
3078
3152
  {
3079
3153
  force: options.force,
3080
3154
  force_after_timeout: options.force
3081
3155
  }
3082
- ]).pipe(rxjs.switchMap((jobId) => this.api.trackJob(jobId))),
3156
+ ]),
3083
3157
  // v26.0.0 doesn't have container.restart - chain stop + start
3084
3158
  // Emits Job updates during stop, then null when start completes
3085
3159
  containerRestart: (id, options) => {
3086
3160
  const numericId = parseInt(id, 10);
3087
- return this.api.callAndGetJobId("container.stop" /* ContainerStop */, [
3161
+ return this.api.job("container.stop", [
3088
3162
  numericId,
3089
3163
  {
3090
3164
  force: options.force,
3091
3165
  force_after_timeout: options.force
3092
3166
  }
3093
3167
  ]).pipe(
3094
- // Track stop job until it completes
3095
- rxjs.switchMap((stopJobId) => this.api.trackJob(stopJobId)),
3096
3168
  // Collect all job updates to ensure stop fully completes
3097
3169
  rxjs.toArray(),
3098
3170
  // Re-emit job updates, then call start after stop is done
3099
3171
  rxjs.switchMap(
3100
3172
  (jobUpdates) => rxjs.concat(
3101
3173
  rxjs.from(jobUpdates),
3102
- this.api.call("container.start" /* ContainerStart */, [numericId]).pipe(rxjs.map(() => null))
3174
+ this.api.call("container.start", [numericId]).pipe(rxjs.map(() => null))
3103
3175
  )
3104
3176
  )
3105
3177
  );
3106
3178
  }
3107
3179
  };
3108
3180
  }
3109
- /**
3110
- * Transform v26 ContainerQueryV26 to unified Container type
3111
- */
3112
- toContainer(container) {
3113
- return {
3114
- id: container.id.toString(),
3115
- name: container.name,
3116
- status: _TrueNasApiClientV26.mapStatus(container.status.state),
3117
- autostart: container.autostart,
3118
- description: container.description
3119
- // cpu and memory are not available in v26 container.query
3120
- // image is not available in v26 container.query
3121
- };
3122
- }
3123
3181
  };
3182
+ function toContainer2(container) {
3183
+ const { description } = container;
3184
+ return {
3185
+ id: container.id.toString(),
3186
+ name: container.name,
3187
+ status: toAppState(container.status.state),
3188
+ // Optional in the generated entry, required by `Container`.
3189
+ autostart: container.autostart ?? false,
3190
+ description
3191
+ };
3192
+ }
3124
3193
 
3125
3194
  // src/errors/version-discovery.errors.ts
3126
3195
  var VersionDiscoveryError = class extends Error {
@@ -3237,7 +3306,7 @@ var VersionDiscovery = class {
3237
3306
  operators.map((versionStrings) => this.selectVersion(hostname, versionStrings)),
3238
3307
  operators.catchError((error) => {
3239
3308
  this.versionCache.delete(hostname);
3240
- this.logger.error("Version discovery failed", { hostname, error });
3309
+ this.logger.warn("Version discovery failed", { hostname, error });
3241
3310
  return rxjs.throwError(() => this.classify(error, hostname));
3242
3311
  }),
3243
3312
  operators.shareReplay(1)
@@ -3308,7 +3377,7 @@ var VersionDiscovery = class {
3308
3377
  });
3309
3378
  const parsedVersions = versionStrings.map(parseApiVersion).filter((v) => v !== null);
3310
3379
  if (parsedVersions.length === 0) {
3311
- this.logger.error("No valid versions in response", {
3380
+ this.logger.warn("No valid versions in response", {
3312
3381
  hostname,
3313
3382
  versionStrings
3314
3383
  });
@@ -3410,29 +3479,34 @@ async function createTrueNasClient(opts) {
3410
3479
  `Cannot create client for system ${uuid}: hostnames array is empty`
3411
3480
  );
3412
3481
  }
3413
- const primaryHostname = hostnames[0];
3414
3482
  const versionDiscovery = new VersionDiscovery(logger);
3415
3483
  logger.info("Creating versioned API client", {
3416
3484
  uuid: uuid.slice(0, 8),
3417
- hostname: primaryHostname,
3485
+ hostnames: hostnames.join(", "),
3418
3486
  systemName
3419
3487
  });
3420
3488
  let version;
3421
3489
  try {
3422
- version = await rxjs.firstValueFrom(
3423
- versionDiscovery.discoverVersion(primaryHostname)
3490
+ const winner = await discoverVersionFromAnyHostname(
3491
+ hostnames,
3492
+ versionDiscovery
3424
3493
  );
3494
+ version = winner.version;
3425
3495
  logger.info("API version discovered, instantiating client", {
3426
3496
  uuid: uuid.slice(0, 8),
3497
+ // Which hostname answered is log context only. The client is built with
3498
+ // the full hostname list regardless — the websocket connection races all
3499
+ // of them anyway.
3500
+ hostname: winner.hostname,
3427
3501
  version: version.version,
3428
3502
  websocketPath: version.websocketPath
3429
3503
  });
3430
3504
  } catch (error) {
3431
3505
  const errorMessage = errorMessageOrDefault(error, "Unknown error");
3432
3506
  if (!(error instanceof VersionDiscoveryNetworkError)) {
3433
- logger.error("Version discovery failed", {
3507
+ logger.error("Version discovery failed on every hostname", {
3434
3508
  uuid: uuid.slice(0, 8),
3435
- hostname: primaryHostname,
3509
+ hostnames: hostnames.join(", "),
3436
3510
  error: errorMessage,
3437
3511
  errorType: error instanceof Error ? error.constructor.name : typeof error
3438
3512
  });
@@ -3443,7 +3517,7 @@ async function createTrueNasClient(opts) {
3443
3517
  if (!fallbackVersion) {
3444
3518
  logger.error("Invalid fallback version configuration", {
3445
3519
  uuid: uuid.slice(0, 8),
3446
- hostname: primaryHostname,
3520
+ hostnames: hostnames.join(", "),
3447
3521
  fallbackVersion: fallbackVersionString
3448
3522
  });
3449
3523
  throw error;
@@ -3452,7 +3526,7 @@ async function createTrueNasClient(opts) {
3452
3526
  "Version discovery failed with a network error (possible CORS or network issue), falling back to assumed version",
3453
3527
  {
3454
3528
  uuid: uuid.slice(0, 8),
3455
- hostname: primaryHostname,
3529
+ hostnames: hostnames.join(", "),
3456
3530
  fallbackVersion: fallbackVersionString,
3457
3531
  originalError: errorMessage,
3458
3532
  warning: "A network error has multiple causes (CORS, network down, DNS failure). The connection may still fail during the WebSocket handshake."
@@ -3462,6 +3536,27 @@ async function createTrueNasClient(opts) {
3462
3536
  }
3463
3537
  return instantiateClientForVersion(version, opts, logger);
3464
3538
  }
3539
+ async function discoverVersionFromAnyHostname(hostnames, versionDiscovery) {
3540
+ const attempts = hostnames.map(
3541
+ (hostname) => rxjs.firstValueFrom(versionDiscovery.discoverVersion(hostname)).then(
3542
+ (version) => ({ hostname, version })
3543
+ )
3544
+ );
3545
+ try {
3546
+ return await Promise.any(attempts);
3547
+ } catch (error) {
3548
+ const failures = error instanceof AggregateError ? error.errors : [error];
3549
+ throw selectRepresentativeFailure(failures);
3550
+ }
3551
+ }
3552
+ function selectRepresentativeFailure(failures) {
3553
+ const isVersionError = (error) => (
3554
+ // cases: valid response, but the given versions won't work for us
3555
+ error instanceof VersionTooOldError || error instanceof VersionTooNewError || error instanceof NoCompatibleVersionsError || error instanceof VersionEndpointNotFoundError
3556
+ );
3557
+ const isNetworkError = (error) => error instanceof VersionDiscoveryNetworkError;
3558
+ return failures.find(isVersionError) ?? failures.find(isNetworkError) ?? failures[0];
3559
+ }
3465
3560
  var CLIENT_BY_VERSION_KEY = {
3466
3561
  "25.10": TrueNasApiClientV2510,
3467
3562
  "26": TrueNasApiClientV26
@@ -3510,6 +3605,7 @@ function errorMessageOrDefault(error, fallback) {
3510
3605
  return fallback;
3511
3606
  }
3512
3607
 
3608
+ exports.AppState = AppState;
3513
3609
  exports.AuthError = AuthError;
3514
3610
  exports.AuthErrorCode = AuthErrorCode;
3515
3611
  exports.InvalidVersionResponseError = InvalidVersionResponseError;
@@ -3520,7 +3616,6 @@ exports.TrueNasApiClient = TrueNasApiClient;
3520
3616
  exports.TrueNasApiClientV2510 = TrueNasApiClientV2510;
3521
3617
  exports.TrueNasApiClientV26 = TrueNasApiClientV26;
3522
3618
  exports.TrueNasAuthMechanism = TrueNasAuthMechanism;
3523
- exports.TrueNasEndpoint = TrueNasEndpoint;
3524
3619
  exports.VersionCompatibility = VersionCompatibility;
3525
3620
  exports.VersionDiscovery = VersionDiscovery;
3526
3621
  exports.VersionDiscoveryError = VersionDiscoveryError;
@@ -3532,6 +3627,7 @@ exports.VersionTooOldError = VersionTooOldError;
3532
3627
  exports.consoleLogger = consoleLogger;
3533
3628
  exports.createTrueNasClient = createTrueNasClient;
3534
3629
  exports.getApiErrorMessage = getApiErrorMessage;
3630
+ exports.isJobFinished = isJobFinished;
3535
3631
  exports.noopLogger = noopLogger;
3536
3632
  exports.v25_10_0 = v25_10_0_exports;
3537
3633
  exports.v25_10_1 = v25_10_1_exports;