@tokenbuddy/tb-admin 1.0.33 → 1.0.34

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 (41) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +28 -0
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/seller.d.ts +40 -1
  5. package/dist/src/seller.d.ts.map +1 -1
  6. package/dist/src/seller.js +132 -2
  7. package/dist/src/seller.js.map +1 -1
  8. package/dist/src/ui-actions.d.ts +2 -0
  9. package/dist/src/ui-actions.d.ts.map +1 -1
  10. package/dist/src/ui-actions.js +8 -6
  11. package/dist/src/ui-actions.js.map +1 -1
  12. package/dist/src/ui-command.d.ts +1 -0
  13. package/dist/src/ui-command.d.ts.map +1 -1
  14. package/dist/src/ui-command.js +7 -2
  15. package/dist/src/ui-command.js.map +1 -1
  16. package/dist/src/ui-server.js +17 -0
  17. package/dist/src/ui-server.js.map +1 -1
  18. package/dist/src/ui-state.d.ts +29 -0
  19. package/dist/src/ui-state.d.ts.map +1 -1
  20. package/dist/src/ui-state.js +455 -111
  21. package/dist/src/ui-state.js.map +1 -1
  22. package/dist/src/ui-static.d.ts.map +1 -1
  23. package/dist/src/ui-static.js +262 -143
  24. package/dist/src/ui-static.js.map +1 -1
  25. package/dist/src/upstream-balance-probe.d.ts +2 -40
  26. package/dist/src/upstream-balance-probe.d.ts.map +1 -1
  27. package/dist/src/upstream-balance-probe.js +1 -378
  28. package/dist/src/upstream-balance-probe.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli.ts +31 -0
  31. package/src/seller.ts +179 -3
  32. package/src/ui-actions.ts +10 -6
  33. package/src/ui-command.ts +7 -2
  34. package/src/ui-server.ts +18 -1
  35. package/src/ui-state.ts +533 -111
  36. package/src/ui-static.ts +262 -143
  37. package/src/upstream-balance-probe.ts +13 -505
  38. package/tests/admin.test.ts +416 -39
  39. package/tests/seller.test.ts +51 -0
  40. package/tests/ui-state-fleet.test.ts +272 -3
  41. package/tests/ui-static-row.test.ts +273 -8
@@ -6,6 +6,7 @@ export class AdminUiState {
6
6
  options;
7
7
  fetchJson;
8
8
  balanceCache = new BalanceProbeCache();
9
+ machineSpecsCache = new Map();
9
10
  constructor(options) {
10
11
  this.options = options;
11
12
  this.configManager = options.configPath ? new ConfigManager(options.configPath) : options.configManager;
@@ -84,7 +85,7 @@ export class AdminUiState {
84
85
  this.fetchFlyApps().catch((err) => {
85
86
  return { __error: err.message };
86
87
  }),
87
- this.fetchRegistry().catch((err) => {
88
+ this.fetchManagedRegistry().catch((err) => {
88
89
  return { __error: err.message, sellers: [] };
89
90
  })
90
91
  ]);
@@ -117,21 +118,25 @@ export class AdminUiState {
117
118
  const rows = [];
118
119
  const consumedFly = new Set();
119
120
  const consumedRegistry = new Set();
121
+ const specsByApp = await this.fetchFlyMachineSpecsForApps(Array.from(flyByName.keys()));
122
+ const registryTargets = [];
120
123
  // Phase 1: registry first (因为有 id + url + 详细 metadata)
121
124
  for (const entry of registryDoc.sellers || []) {
122
- const flyMatch = entry.app ? flyByName.get(entry.app) : undefined;
125
+ const flyMatch = findFlyAppForEntry(flyByName, entry);
123
126
  const dataSource = flyMatch ? "both" : "registry";
124
127
  if (flyMatch) {
125
128
  consumedFly.add(flyMatch.name);
126
129
  }
127
130
  consumedRegistry.add(entry.id);
128
- const snapshot = await this.sellerSnapshot(entry, flyMatch, dataSource);
129
- rows.push(snapshot.row);
131
+ registryTargets.push({ entry, flyMatch, dataSource });
130
132
  }
133
+ rows.push(...await Promise.all(registryTargets.map(async ({ entry, flyMatch, dataSource }) => {
134
+ const snapshot = await this.sellerSnapshot(entry, flyMatch, dataSource, { balanceTimeoutMs: 8000, machineSpecs: flyMatch ? specsByApp.get(flyMatch.name) : undefined });
135
+ return snapshot.row;
136
+ })));
131
137
  // Phase 2: fly-only apps (registry 没有)
132
- for (const app of flyByName.values()) {
133
- if (consumedFly.has(app.name))
134
- continue;
138
+ const flyOnlyApps = Array.from(flyByName.values()).filter((app) => !consumedFly.has(app.name));
139
+ rows.push(...await Promise.all(flyOnlyApps.map(async (app) => {
135
140
  const stubEntry = {
136
141
  id: app.name,
137
142
  name: app.name,
@@ -141,18 +146,72 @@ export class AdminUiState {
141
146
  paymentMethods: [],
142
147
  models: []
143
148
  };
144
- const snapshot = await this.sellerSnapshot(stubEntry, app, "fly");
145
- rows.push(snapshot.row);
149
+ const snapshot = await this.sellerSnapshot(stubEntry, app, "fly", { includeOperator: true, balanceTimeoutMs: 8000, machineSpecs: specsByApp.get(app.name) });
150
+ return snapshot.row;
151
+ })));
152
+ return rows;
153
+ }
154
+ async sellerRegistryRows() {
155
+ const registryDoc = await this.fetchManagedRegistry();
156
+ return (registryDoc.sellers || []).map((entry) => {
157
+ const match = this.matchSellerProfile(entry);
158
+ return {
159
+ ...baseSellerRow(entry, match.name, "registry"),
160
+ publishStatus: "checking",
161
+ detailStatus: "pending"
162
+ };
163
+ });
164
+ }
165
+ async sellerInventory() {
166
+ const registryDoc = await this.fetchManagedRegistry();
167
+ const flyApps = await this.fetchFlyApps().catch(() => []);
168
+ const flyByName = new Map();
169
+ for (const app of flyApps) {
170
+ if (app?.name) {
171
+ flyByName.set(app.name, app);
172
+ }
173
+ }
174
+ const specsByApp = await this.fetchFlyMachineSpecsForApps(Array.from(flyByName.keys()));
175
+ const rows = [];
176
+ const consumedFly = new Set();
177
+ for (const entry of registryDoc.sellers || []) {
178
+ const flyMatch = findFlyAppForEntry(flyByName, entry);
179
+ const dataSource = flyMatch ? "both" : "registry";
180
+ if (flyMatch) {
181
+ consumedFly.add(flyMatch.name);
182
+ }
183
+ const match = this.matchSellerProfile(entry);
184
+ const row = baseSellerRow(entry, match.name, dataSource, flyMatch, flyMatch ? specsByApp.get(flyMatch.name) : undefined);
185
+ row.publishStatus = flyMatch ? "published" : "registry_only";
186
+ row.detailStatus = dataSource === "registry" ? "skipped" : "pending";
187
+ if (dataSource === "registry") {
188
+ row.registryAlert = true;
189
+ row.alertReason = "registry 收录了但 fly app 失踪 — 严重事故, 立即下线";
190
+ row.removeHint = "立即下线 (registry-only)";
191
+ }
192
+ rows.push(row);
193
+ }
194
+ const flyOnlyApps = Array.from(flyByName.values()).filter((app) => !consumedFly.has(app.name));
195
+ for (const app of flyOnlyApps) {
196
+ const stubEntry = sellerEntryFromFlyApp(app);
197
+ const row = baseSellerRow(stubEntry, undefined, "fly", app, specsByApp.get(app.name));
198
+ row.publishStatus = "unpublished";
199
+ row.detailStatus = "pending";
200
+ row.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
201
+ rows.push(row);
146
202
  }
147
203
  return rows;
148
204
  }
205
+ async refreshSellerRows(rows) {
206
+ return await Promise.all(rows.map((row) => this.refreshSellerRow(row)));
207
+ }
149
208
  /**
150
209
  * Step 13 v1.1: 拉 flyctl apps list --json. 默认走 seller.ts 真实
151
210
  * flyctl spawn, 测试或受限环境可注入 options.flyApps closure.
152
211
  */
153
212
  async fetchFlyApps() {
154
213
  if (this.options.flyApps) {
155
- return await this.options.flyApps();
214
+ return (await this.options.flyApps()).filter((app) => isSellerFlyAppName(app.name));
156
215
  }
157
216
  // 默认路径: 走 SellerCommandRunner.ls(true) 真 spawn flyctl.
158
217
  // 避免在 ui-state.ts 里 import 整个 seller module 引入循环依赖,
@@ -170,7 +229,7 @@ export class AdminUiState {
170
229
  const runner = new mod.SellerCommandRunner(this.configManager);
171
230
  const result = await runner.ls(true);
172
231
  if (result && typeof result === "object" && "apps" in result) {
173
- return result.apps;
232
+ return result.apps.filter((app) => isSellerFlyAppName(app.name));
174
233
  }
175
234
  return [];
176
235
  }
@@ -178,37 +237,40 @@ export class AdminUiState {
178
237
  return [];
179
238
  }
180
239
  }
181
- async sellerDetail(id) {
182
- // Step 13 v1.1: detail 页也走双源. 先看 entry 在不在 registry,
183
- // 不在则查 fly list (registry-only 行, 标红 detail).
184
- const document = await this.fetchRegistry();
185
- let entry = document.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
186
- let dataSource = "registry";
187
- let flyApp;
188
- if (!entry) {
189
- const flyApps = await this.fetchFlyApps().catch(() => []);
190
- const flyMatch = flyApps.find((app) => app.name === id || app.name === `tb-seller-${id}` || app.name === id.replace(/^tb-seller-/, ""));
191
- if (!flyMatch) {
192
- throw new Error(`seller \`${id}\` not found in fly.io apps or bootstrap registry`);
193
- }
194
- entry = {
195
- id: flyMatch.name,
196
- name: flyMatch.name,
197
- app: flyMatch.name,
198
- url: `https://${flyMatch.name}.fly.dev`,
199
- supportedProtocols: [],
200
- paymentMethods: [],
201
- models: []
202
- };
203
- flyApp = flyMatch;
204
- dataSource = "fly";
240
+ async fetchFlyMachineSpecsForApps(appNames) {
241
+ if (!this.options.flyMachineSpecs && this.options.flyApps) {
242
+ return new Map(appNames.map((appName) => [appName, undefined]));
205
243
  }
206
- else {
207
- const flyApps = await this.fetchFlyApps().catch(() => []);
208
- flyApp = entry.app ? flyApps.find((app) => app.name === entry.app) : undefined;
209
- dataSource = flyApp ? "both" : "registry";
244
+ const entries = [];
245
+ for (const appName of appNames) {
246
+ entries.push([appName, await this.fetchFlyMachineSpecs(appName)]);
247
+ }
248
+ return new Map(entries);
249
+ }
250
+ async fetchFlyMachineSpecs(appName) {
251
+ if (this.machineSpecsCache.has(appName)) {
252
+ return this.machineSpecsCache.get(appName);
253
+ }
254
+ try {
255
+ const specs = this.options.flyMachineSpecs
256
+ ? await this.options.flyMachineSpecs(appName)
257
+ : await this.defaultFlyMachineSpecs(appName);
258
+ this.machineSpecsCache.set(appName, specs);
259
+ return specs;
210
260
  }
211
- const snapshot = await this.sellerSnapshot(entry, flyApp, dataSource);
261
+ catch {
262
+ this.machineSpecsCache.set(appName, undefined);
263
+ return undefined;
264
+ }
265
+ }
266
+ async defaultFlyMachineSpecs(appName) {
267
+ const mod = await import("./seller.js");
268
+ const runner = new mod.SellerCommandRunner(this.configManager);
269
+ return runner.machineSpecs(appName);
270
+ }
271
+ async sellerDetail(id) {
272
+ const { entry, flyApp, dataSource } = await this.resolveSellerTarget(id);
273
+ const snapshot = await this.sellerSnapshot(entry, flyApp, dataSource, { includeOperator: true, balanceTimeoutMs: 8000 });
212
274
  const config = snapshot.config?.config || snapshot.config || {};
213
275
  const upstreams = snapshot.upstreams || {};
214
276
  return {
@@ -238,12 +300,73 @@ export class AdminUiState {
238
300
  models: modelRows(upstreams, config, snapshot.status)
239
301
  };
240
302
  }
241
- async rawSellerConfig(id) {
242
- const document = await this.fetchRegistry();
243
- const entry = document.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
244
- if (!entry) {
245
- throw new Error(`seller \`${id}\` not found in bootstrap registry`);
303
+ async refreshSellerRow(row) {
304
+ const dataSource = row.dataSource || "both";
305
+ if (dataSource === "registry") {
306
+ return { ...row, nodeStatus: "unknown", detailStatus: "skipped", detailUpdatedAt: new Date().toISOString() };
246
307
  }
308
+ const entry = sellerEntryFromRow(row);
309
+ const match = this.matchSellerProfile(entry);
310
+ const manifestPromise = this.probeManifest(entry.url);
311
+ if (!match.profile) {
312
+ const manifestOk = await manifestPromise;
313
+ return {
314
+ ...row,
315
+ nodeStatus: manifestOk ? "active" : "unknown",
316
+ detailStatus: manifestOk ? "fresh" : "error",
317
+ detailUpdatedAt: new Date().toISOString(),
318
+ error: manifestOk ? row.error : (row.error || "No matching local admin profile; /manifest probe failed")
319
+ };
320
+ }
321
+ const [manifestOk, status, upstreams, config] = await Promise.all([
322
+ manifestPromise,
323
+ this.fetchSellerAdminJson(match.profile, "/operator/status").catch((err) => ({ error: err.message })),
324
+ this.fetchSellerAdminJson(match.profile, "/operator/admin/upstreams").catch((err) => ({ error: err.message })),
325
+ this.fetchSellerAdminJson(match.profile, "/operator/admin/config").catch((err) => ({ error: err.message }))
326
+ ]);
327
+ const balance = await this.operatorBalanceSnapshot(match.profile, 8000).catch(() => unavailableBalanceSnapshot("seller balance endpoint unavailable"));
328
+ const configDocument = config?.config || config || {};
329
+ const normalizedUpstreams = upstreamDocument(upstreams);
330
+ const upstreamUrl = stringValue(configDocument.upstreamUrl || normalizedUpstreams?.upstreamUrl || status?.upstream?.url || status?.upstreamUrl);
331
+ if (status?.error) {
332
+ return {
333
+ ...row,
334
+ nodeStatus: manifestOk ? "active" : "unknown",
335
+ upstreamDomain: upstreamUrl ? (hostName(upstreamUrl) || row.upstreamDomain) : row.upstreamDomain,
336
+ upstreamStatus: upstreamStatus(normalizedUpstreams?.status || row.upstreamStatus),
337
+ discountRatio: numberValue(configDocument.discountRatio ?? normalizedUpstreams?.discountRatio) ?? row.discountRatio,
338
+ modelsCount: numberValue(normalizedUpstreams?.models?.length ?? row.modelsCount) ?? row.modelsCount,
339
+ ...balanceFields(balance, row),
340
+ detailStatus: "error",
341
+ detailUpdatedAt: new Date().toISOString(),
342
+ error: status.error
343
+ };
344
+ }
345
+ const capacity = status?.capacity || {};
346
+ return {
347
+ ...row,
348
+ nodeStatus: manifestOk ? "active" : nodeStatus(status?.status || row.nodeStatus),
349
+ upstreamDomain: upstreamUrl ? (hostName(upstreamUrl) || row.upstreamDomain) : row.upstreamDomain,
350
+ upstreamStatus: upstreamStatus(status?.upstream?.status || normalizedUpstreams?.status || row.upstreamStatus),
351
+ discountRatio: numberValue(configDocument.discountRatio ?? normalizedUpstreams?.discountRatio) ?? row.discountRatio,
352
+ capacityUsed: numberValue(capacity.activeConnections) ?? row.capacityUsed,
353
+ capacityLimit: numberValue(capacity.maxConnections) ?? row.capacityLimit,
354
+ ...runtimeUsageFields(status?.runtime, row),
355
+ ttftMs: numberValue(status?.latency?.ttftMs) ?? row.ttftMs,
356
+ avgInferenceMs: numberValue(status?.latency?.avgInferenceMs) ?? row.avgInferenceMs,
357
+ lastInferenceMs: numberValue(status?.latency?.lastInferenceMs) ?? row.lastInferenceMs,
358
+ avgTokensPerSecond: numberValue(status?.latency?.avgTokensPerSecond) ?? row.avgTokensPerSecond,
359
+ lastTokensPerSecond: numberValue(status?.latency?.lastTokensPerSecond) ?? row.lastTokensPerSecond,
360
+ latencySamples: numberValue(status?.latency?.sampleCount) ?? row.latencySamples,
361
+ modelsCount: numberValue(normalizedUpstreams?.models?.length ?? row.modelsCount) ?? row.modelsCount,
362
+ ...balanceFields(balance, row),
363
+ detailStatus: "fresh",
364
+ detailUpdatedAt: new Date().toISOString(),
365
+ error: undefined
366
+ };
367
+ }
368
+ async rawSellerConfig(id) {
369
+ const { entry } = await this.resolveSellerTarget(id);
247
370
  const match = this.matchSellerProfile(entry);
248
371
  if (!match.profile) {
249
372
  throw new Error(`seller \`${entry.id}\` has no matching local admin profile`);
@@ -251,17 +374,66 @@ export class AdminUiState {
251
374
  const response = await this.fetchSellerAdminJson(match.profile, "/operator/admin/config");
252
375
  return { entry, profileName: match.localProfile ? match.name : undefined, config: response.config || response };
253
376
  }
377
+ async resolveSellerTarget(id) {
378
+ let document;
379
+ let registryError;
380
+ try {
381
+ document = await this.fetchManagedRegistry();
382
+ }
383
+ catch (err) {
384
+ registryError = err;
385
+ document = { version: 0, sellers: [] };
386
+ }
387
+ const flyApps = await this.fetchFlyApps().catch(() => []);
388
+ const flyByName = new Map(flyApps.map((app) => [app.name, app]));
389
+ const entry = document.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
390
+ if (entry) {
391
+ const flyApp = findFlyAppForEntry(flyByName, entry);
392
+ return { entry, flyApp, dataSource: flyApp ? "both" : "registry" };
393
+ }
394
+ const flyApp = flyApps.find((app) => app.name === id || app.name === `tb-seller-${id}` || app.name === id.replace(/^tb-seller-/, ""));
395
+ if (flyApp) {
396
+ return { entry: sellerEntryFromFlyApp(flyApp), flyApp, dataSource: "fly" };
397
+ }
398
+ if (registryError) {
399
+ throw registryError;
400
+ }
401
+ throw new Error(`seller \`${id}\` not found in fly.io apps or bootstrap registry`);
402
+ }
254
403
  async fetchRegistry() {
255
404
  const profile = this.activeBootstrapProfile();
256
405
  const baseUrl = this.options.url || profile.profile?.url;
257
406
  if (!baseUrl) {
258
407
  throw new Error("No bootstrap profile found. Configure an admin profile or pass --url.");
259
408
  }
260
- const document = await this.fetchJson(`${trimSlash(baseUrl)}/registry/sellers`);
261
- if (!document || !Array.isArray(document.sellers)) {
262
- throw new Error("bootstrap registry response did not include sellers");
409
+ return normalizeRegistryDocument(await this.fetchBootstrapJson(`${trimSlash(baseUrl)}/registry/sellers`), "bootstrap registry response");
410
+ }
411
+ async fetchManagedRegistry() {
412
+ const profile = this.activeBootstrapProfile();
413
+ const baseUrl = this.options.url || profile.profile?.url;
414
+ const token = this.options.token || profile.profile?.token;
415
+ if (!baseUrl) {
416
+ throw new Error("No bootstrap profile found. Configure an admin profile or pass --url.");
417
+ }
418
+ if (!token) {
419
+ return this.fetchRegistry();
420
+ }
421
+ try {
422
+ const managed = normalizeRegistryDocument(await this.fetchBootstrapJson(`${trimSlash(baseUrl)}/platform/sellers`, {
423
+ headers: { Authorization: `Bearer ${token}` }
424
+ }), "platform sellers response");
425
+ const publicDoc = await this.fetchRegistry().catch(() => undefined);
426
+ if (!publicDoc) {
427
+ return managed;
428
+ }
429
+ return mergeRegistryDocuments(publicDoc, managed);
430
+ }
431
+ catch (err) {
432
+ if (isUnavailablePlatformSellersEndpoint(err)) {
433
+ return this.fetchRegistry();
434
+ }
435
+ throw err;
263
436
  }
264
- return document;
265
437
  }
266
438
  activeBootstrapProfile() {
267
439
  const envProfile = process.env.TOKENBUDDY_ADMIN_PROFILE;
@@ -318,9 +490,9 @@ export class AdminUiState {
318
490
  * (**不**用 registry entry.status 决定绿点)
319
491
  * - registry-only 行 (dataSource="registry") → 标红 (registryAlert=true)
320
492
  */
321
- async sellerSnapshot(entry, flyApp, dataSource) {
493
+ async sellerSnapshot(entry, flyApp, dataSource, options = {}) {
322
494
  const match = this.matchSellerProfile(entry);
323
- const baseRow = baseSellerRow(entry, match.name, dataSource, flyApp);
495
+ const baseRow = baseSellerRow(entry, match.name, dataSource, flyApp, options.machineSpecs);
324
496
  // Step 13: 立即下线 / Apply 按钮 hint 文案
325
497
  if (dataSource === "registry") {
326
498
  baseRow.registryAlert = true;
@@ -330,85 +502,71 @@ export class AdminUiState {
330
502
  else if (dataSource === "fly") {
331
503
  baseRow.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
332
504
  }
333
- if (dataSource === "fly") {
334
- // fly-only 行没 registry entry, 也可能没 profile. 仍然跑 manifest
335
- // probe (entry.url 来自 flyApp 推断 https://<name>.fly.dev), 但
336
- // 没 profile 时只算 "unknown" (灰点) — 因为 vendor 没法管控一个
337
- // 还没发布的 instance, 不该 throw auth_unknown 把 UI 卡死.
338
- const manifestOk = await this.probeManifest(entry.url);
505
+ if (dataSource === "registry") {
339
506
  return {
340
507
  row: {
341
508
  ...baseRow,
342
- nodeStatus: manifestOk ? "active" : "unknown",
343
- error: manifestOk ? undefined : "未发布到 registry, /manifest 探测失败 (不视为事故)"
509
+ nodeStatus: "unknown"
344
510
  }
345
511
  };
346
512
  }
347
- if (dataSource === "both" && !match.profile) {
348
- // both vendor profile 缺 — 还是 unknown (灰点), 1.0.31 老行为
349
- // 会落 auth_unknown, 现在用 unknown 让 UI 不会显得像 "鉴权失败".
513
+ let manifestOk = false;
514
+ const manifestPromise = this.probeManifest(entry.url).then((ok) => {
515
+ manifestOk = ok;
516
+ return ok;
517
+ });
518
+ if (dataSource === "fly" && (!options.includeOperator || !match.profile)) {
519
+ // fly-only 行没 registry entry, 也可能没 profile. 仍然跑 manifest
520
+ // probe (entry.url 来自 flyApp 推断 https://<name>.fly.dev), 但
521
+ // 没 profile 时只算 "unknown" (灰点) — 因为 vendor 没法管控一个
522
+ // 还没发布的 instance, 不该 throw auth_unknown 把 UI 卡死.
523
+ manifestOk = await manifestPromise;
350
524
  return {
351
525
  row: {
352
526
  ...baseRow,
353
- nodeStatus: "unknown",
354
- error: "No matching local admin profile"
527
+ nodeStatus: manifestOk ? "active" : "unknown",
528
+ error: manifestOk ? undefined : "未发布到 registry, /manifest 探测失败 (不视为事故)"
355
529
  }
356
530
  };
357
531
  }
358
- // Step 13: 绿点 = fetch <entry.url>/manifest 200 OK
359
- // (registry-only skip 这个 fetch, 因为 entry.url 可能指向死链)
360
- if (dataSource === "registry") {
532
+ if (!match.profile) {
533
+ manifestOk = await manifestPromise;
361
534
  return {
362
535
  row: {
363
536
  ...baseRow,
364
- nodeStatus: "unknown"
537
+ nodeStatus: manifestOk ? "active" : "unknown",
538
+ error: manifestOk ? "No matching local admin profile" : "No matching local admin profile; /manifest probe failed"
365
539
  }
366
540
  };
367
541
  }
368
542
  try {
369
- // 绿点路径: 优先 <entry.url>/manifest (无 auth 公共 endpoint).
370
- // fallback: 走 vendor token 调 /operator/status (老 1.0.31 行为).
371
- const manifestOk = await this.probeManifest(entry.url);
372
- if (manifestOk) {
373
- return {
374
- row: {
375
- ...baseRow,
376
- nodeStatus: "active"
377
- }
378
- };
379
- }
380
- // /manifest 失败: 试 /operator/status (老路径, 需要 vendor profile)
381
- if (!match.profile) {
382
- return {
383
- row: {
384
- ...baseRow,
385
- nodeStatus: "auth_unknown",
386
- error: "No matching local admin profile (post /manifest fallback)"
387
- }
388
- };
389
- }
390
- const [status, service, upstreams, config] = await Promise.all([
543
+ const [, status, service, upstreams, config] = await Promise.all([
544
+ manifestPromise,
391
545
  this.fetchSellerAdminJson(match.profile, "/operator/status").catch((err) => ({ error: err.message })),
392
546
  this.fetchSellerAdminJson(match.profile, "/operator/admin/service").catch((err) => ({ error: err.message })),
393
547
  this.fetchSellerAdminJson(match.profile, "/operator/admin/upstreams").catch((err) => ({ error: err.message })),
394
548
  this.fetchSellerAdminJson(match.profile, "/operator/admin/config").catch((err) => ({ error: err.message }))
395
549
  ]);
396
550
  const configDocument = config?.config || config || {};
397
- const balance = await this.balanceSnapshot(configDocument, upstreams);
551
+ const balance = await this.balanceSnapshot(match.profile, configDocument, upstreams, options.balanceTimeoutMs);
398
552
  return {
399
553
  status,
400
554
  service,
401
555
  upstreams,
402
556
  config,
403
557
  balance,
404
- row: mergeSellerRow(baseRow, entry, status, service, upstreams, configDocument, balance)
558
+ row: mergeSellerRow({
559
+ ...baseRow,
560
+ nodeStatus: manifestOk ? "active" : baseRow.nodeStatus
561
+ }, entry, status, service, upstreams, configDocument, balance, manifestOk)
405
562
  };
406
563
  }
407
564
  catch (err) {
565
+ await manifestPromise.catch(() => false);
408
566
  return {
409
567
  row: {
410
568
  ...baseRow,
411
- nodeStatus: "unknown",
569
+ nodeStatus: manifestOk ? "active" : "unknown",
412
570
  error: err.message
413
571
  }
414
572
  };
@@ -435,21 +593,58 @@ export class AdminUiState {
435
593
  return false;
436
594
  }
437
595
  }
438
- async fetchSellerAdminJson(profile, pathName) {
439
- return this.fetchJson(`${trimSlash(profile.url)}${pathName}`, {
440
- headers: {
441
- "Content-Type": "application/json",
442
- Authorization: `Bearer ${profile.token}`
596
+ async fetchSellerAdminJson(profile, pathName, options = {}) {
597
+ const controller = options.timeoutMs ? new AbortController() : undefined;
598
+ const timer = controller ? setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
599
+ try {
600
+ return await this.fetchJson(`${trimSlash(profile.url)}${pathName}`, {
601
+ headers: {
602
+ "Content-Type": "application/json",
603
+ Authorization: `Bearer ${profile.token}`
604
+ },
605
+ ...(controller ? { signal: controller.signal } : {})
606
+ });
607
+ }
608
+ finally {
609
+ if (timer) {
610
+ clearTimeout(timer);
443
611
  }
444
- });
612
+ }
613
+ }
614
+ async fetchBootstrapJson(url, init) {
615
+ const controller = new AbortController();
616
+ const timer = setTimeout(() => controller.abort(), 10000);
617
+ try {
618
+ return await this.fetchJson(url, { ...init, signal: controller.signal });
619
+ }
620
+ finally {
621
+ clearTimeout(timer);
622
+ }
445
623
  }
446
- async balanceSnapshot(config, upstreams) {
624
+ async balanceSnapshot(profile, config, upstreams, timeoutMs) {
447
625
  if (config?.error) {
448
626
  return undefined;
449
627
  }
450
628
  if (stringValue(config.upstreamBalanceProbe?.template) === "none") {
451
629
  return undefined;
452
630
  }
631
+ const operatorBalance = await this.operatorBalanceSnapshot(profile, timeoutMs).catch(() => undefined);
632
+ if (operatorBalance) {
633
+ return operatorBalance;
634
+ }
635
+ if (isRedactedConfigSecret(config.upstreamApiKey)) {
636
+ return {
637
+ rawAmount: null,
638
+ amountUsdMicros: null,
639
+ currency: null,
640
+ source: "unknown",
641
+ fetchedAt: Date.now(),
642
+ error: {
643
+ httpStatus: 0,
644
+ message: "seller balance endpoint unavailable"
645
+ }
646
+ };
647
+ }
453
648
  return probeUpstreamBalance({
454
649
  upstreamUrl: stringValue(config.upstreamUrl || upstreams?.upstreamUrl),
455
650
  upstreamBalanceUrl: stringValue(config.upstreamBalanceUrl || upstreams?.upstreamBalanceUrl),
@@ -458,20 +653,31 @@ export class AdminUiState {
458
653
  upstreamBalanceProbe: objectValue(config.upstreamBalanceProbe)
459
654
  }, {
460
655
  fetch: this.options.balanceFetch,
461
- cache: this.balanceCache
656
+ cache: this.balanceCache,
657
+ timeoutMs
658
+ });
659
+ }
660
+ async operatorBalanceSnapshot(profile, timeoutMs) {
661
+ return await this.fetchSellerAdminJson(profile, "/operator/admin/upstream-balance", { timeoutMs })
662
+ .then((response) => {
663
+ const responseObject = objectValue(response);
664
+ const balance = objectValue(responseObject?.balance) || responseObject;
665
+ return balance;
462
666
  });
463
667
  }
464
668
  }
465
669
  async function defaultFetchJson(url, init) {
466
670
  // Step 13 v1.1: 3s timeout 避免坏 host / DNS 卡死整个 admin web.
467
- const controller = new AbortController();
468
- const timer = setTimeout(() => controller.abort(), 3000);
671
+ const controller = init?.signal ? undefined : new AbortController();
672
+ const timer = controller ? setTimeout(() => controller.abort(), 3000) : undefined;
469
673
  let response;
470
674
  try {
471
- response = await fetch(url, { ...init, signal: controller.signal });
675
+ response = await fetch(url, controller ? { ...init, signal: controller.signal } : init);
472
676
  }
473
677
  finally {
474
- clearTimeout(timer);
678
+ if (timer) {
679
+ clearTimeout(timer);
680
+ }
475
681
  }
476
682
  if (!response.ok) {
477
683
  const text = await response.text();
@@ -480,7 +686,8 @@ async function defaultFetchJson(url, init) {
480
686
  const text = await response.text();
481
687
  return text ? JSON.parse(text) : {};
482
688
  }
483
- function baseSellerRow(entry, profile, dataSource = "registry", flyApp) {
689
+ function baseSellerRow(entry, profile, dataSource = "registry", flyApp, machineSpecs) {
690
+ const primaryRegion = entry.region || machineSpecs?.regions?.[0];
484
691
  return {
485
692
  id: entry.id,
486
693
  name: entry.id,
@@ -492,15 +699,25 @@ function baseSellerRow(entry, profile, dataSource = "registry", flyApp) {
492
699
  // Step 13 v1.1: 绿点 base 改 unknown, 真正值由 sellerSnapshot
493
700
  // 拿 /manifest 200 决定. 老逻辑直接复用 entry.status 是错的.
494
701
  nodeStatus: "unknown",
495
- region: entry.region,
702
+ region: primaryRegion,
496
703
  upstreamDomain: hostName(entry.url) || "unknown",
497
704
  upstreamStatus: "unknown",
498
705
  modelsCount: entry.modelsCount ?? entry.models?.length ?? entry.sampleModels?.length,
499
706
  specs: {
500
- region: entry.region,
707
+ cpuKind: machineSpecs?.cpuKind,
708
+ cpuCores: machineSpecs?.cpuCores,
709
+ memoryMb: machineSpecs?.memoryMb,
710
+ memoryGb: machineSpecs?.memoryMb ? Number((machineSpecs.memoryMb / 1024).toFixed(2)) : undefined,
711
+ machines: machineSpecs?.machines,
712
+ runningMachines: machineSpecs?.runningMachines,
713
+ volumeGb: machineSpecs?.volumeGb,
714
+ region: primaryRegion,
715
+ regions: machineSpecs?.regions,
501
716
  modelsCount: entry.modelsCount ?? entry.models?.length ?? entry.sampleModels?.length
502
717
  },
503
718
  dataSource,
719
+ publishStatus: dataSource === "both" ? "published" : dataSource === "fly" ? "unpublished" : "unknown",
720
+ detailStatus: "pending",
504
721
  flyApp: flyApp ? {
505
722
  name: flyApp.name,
506
723
  status: flyApp.status,
@@ -509,19 +726,61 @@ function baseSellerRow(entry, profile, dataSource = "registry", flyApp) {
509
726
  } : undefined
510
727
  };
511
728
  }
512
- function mergeSellerRow(base, entry, status, service, upstreams, config, balance) {
729
+ function isSellerFlyAppName(name) {
730
+ return Boolean(name && (name.startsWith("tbs-") || name.startsWith("tb-seller-")) && name !== "tb-seller");
731
+ }
732
+ function sellerEntryFromFlyApp(app) {
733
+ return {
734
+ id: app.name,
735
+ name: app.name,
736
+ app: app.name,
737
+ url: `https://${app.name}.fly.dev`,
738
+ supportedProtocols: [],
739
+ paymentMethods: [],
740
+ models: []
741
+ };
742
+ }
743
+ function findFlyAppForEntry(flyByName, entry) {
744
+ for (const key of [entry.app, entry.id, entry.name]) {
745
+ const normalized = stringValue(key);
746
+ if (!normalized) {
747
+ continue;
748
+ }
749
+ const match = flyByName.get(normalized);
750
+ if (match) {
751
+ return match;
752
+ }
753
+ }
754
+ return undefined;
755
+ }
756
+ function sellerEntryFromRow(row) {
757
+ return {
758
+ id: row.id,
759
+ name: row.name,
760
+ app: row.app || row.flyApp?.name || row.id,
761
+ url: row.url,
762
+ status: row.registryStatus,
763
+ region: row.region,
764
+ modelsCount: row.modelsCount,
765
+ supportedProtocols: [],
766
+ paymentMethods: [],
767
+ models: []
768
+ };
769
+ }
770
+ function mergeSellerRow(base, entry, status, service, upstreams, config, balance, manifestOk = false) {
513
771
  const normalizedUpstreams = upstreamDocument(upstreams);
514
772
  const capacity = status?.capacity || service?.capacity || {};
515
773
  const upstreamUrl = stringValue(config?.upstreamUrl || normalizedUpstreams?.upstreamUrl || service?.upstreamUrl) || entry.url;
516
774
  const error = firstError(status, service, upstreams);
517
775
  return {
518
776
  ...base,
519
- nodeStatus: error ? "unknown" : nodeStatus(status?.status || entry.status),
777
+ nodeStatus: error ? (manifestOk ? "active" : "unknown") : (manifestOk ? "active" : nodeStatus(status?.status || entry.status)),
520
778
  upstreamDomain: hostName(upstreamUrl) || base.upstreamDomain,
521
779
  upstreamStatus: upstreamStatus(status?.upstream?.status || normalizedUpstreams?.status),
522
780
  discountRatio: numberValue(config?.discountRatio ?? normalizedUpstreams?.discountRatio),
523
781
  capacityUsed: numberValue(capacity.activeConnections),
524
782
  capacityLimit: numberValue(capacity.maxConnections),
783
+ ...runtimeUsageFields(status?.runtime, base),
525
784
  ttftMs: numberValue(status?.latency?.ttftMs),
526
785
  avgInferenceMs: numberValue(status?.latency?.avgInferenceMs),
527
786
  lastInferenceMs: numberValue(status?.latency?.lastInferenceMs),
@@ -537,12 +796,46 @@ function mergeSellerRow(base, entry, status, service, upstreams, config, balance
537
796
  modelsCount: numberValue(service?.modelsCount ?? normalizedUpstreams?.models?.length ?? base.modelsCount),
538
797
  specs: {
539
798
  ...base.specs,
540
- region: entry.region,
799
+ region: entry.region || base.specs?.region,
541
800
  modelsCount: numberValue(service?.modelsCount ?? normalizedUpstreams?.models?.length ?? base.modelsCount)
542
801
  },
543
802
  error
544
803
  };
545
804
  }
805
+ function runtimeUsageFields(runtime, fallback) {
806
+ return {
807
+ resourceCpuPercent: numberValue(runtime?.cpuPercent) ?? fallback.resourceCpuPercent,
808
+ resourceMemoryPercent: numberValue(runtime?.memoryPercent) ?? fallback.resourceMemoryPercent,
809
+ resourceMemoryRssMb: numberValue(runtime?.memoryRssMb) ?? fallback.resourceMemoryRssMb,
810
+ resourceMemoryLimitMb: numberValue(runtime?.memoryLimitMb) ?? fallback.resourceMemoryLimitMb
811
+ };
812
+ }
813
+ function balanceFields(balance, fallback) {
814
+ if (!balance) {
815
+ return {};
816
+ }
817
+ return {
818
+ upstreamBalanceUsdMicros: Number.isFinite(balance.amountUsdMicros ?? NaN) ? balance.amountUsdMicros : undefined,
819
+ upstreamBalanceCurrency: typeof balance.currency === "string" ? balance.currency : undefined,
820
+ upstreamBalanceSource: balance.source,
821
+ upstreamBalanceFetchedAt: new Date(balance.fetchedAt).toISOString(),
822
+ upstreamBalanceError: balance.error?.message,
823
+ upstreamRechargeUrl: fallback.upstreamRechargeUrl
824
+ };
825
+ }
826
+ function unavailableBalanceSnapshot(message) {
827
+ return {
828
+ rawAmount: null,
829
+ amountUsdMicros: null,
830
+ currency: null,
831
+ source: "unknown",
832
+ fetchedAt: Date.now(),
833
+ error: {
834
+ httpStatus: 0,
835
+ message
836
+ }
837
+ };
838
+ }
546
839
  function modelRows(upstreams, config, status) {
547
840
  const normalizedUpstreams = upstreamDocument(upstreams);
548
841
  const aliases = config.modelAliases || normalizedUpstreams.modelAliases || {};
@@ -600,6 +893,9 @@ export function maskApiKey(value) {
600
893
  if (!normalized) {
601
894
  return undefined;
602
895
  }
896
+ if (isRedactedConfigSecret(normalized)) {
897
+ return "configured";
898
+ }
603
899
  const tail = normalized.replace(/\s+/g, "").slice(-4);
604
900
  return tail ? `**** **** **** ${tail}` : "****";
605
901
  }
@@ -640,6 +936,9 @@ function objectValue(value) {
640
936
  ? value
641
937
  : undefined;
642
938
  }
939
+ function isRedactedConfigSecret(value) {
940
+ return value === "[redacted]";
941
+ }
643
942
  function priceString(value) {
644
943
  const parsed = numberValue(value);
645
944
  if (parsed === undefined) {
@@ -663,4 +962,49 @@ function firstError(...values) {
663
962
  const hit = values.find((value) => value?.error);
664
963
  return hit?.error;
665
964
  }
965
+ function normalizeRegistryDocument(value, label) {
966
+ if (!value || typeof value !== "object" || !Array.isArray(value.sellers)) {
967
+ throw new Error(`${label} did not include sellers`);
968
+ }
969
+ const document = value;
970
+ return {
971
+ version: numberValue(document.version) ?? 0,
972
+ updatedAt: stringValue(document.updatedAt),
973
+ purpose: stringValue(document.purpose),
974
+ defaultSeller: stringValue(document.defaultSeller),
975
+ notes: Array.isArray(document.notes) ? document.notes.map((note) => stringValue(note)).filter((note) => Boolean(note)) : undefined,
976
+ sellers: document.sellers
977
+ };
978
+ }
979
+ function mergeRegistryDocuments(publicDoc, managedDoc) {
980
+ const sellers = publicDoc.sellers.map((seller) => ({ ...seller }));
981
+ for (const managed of managedDoc.sellers) {
982
+ const index = sellers.findIndex((seller) => sameSellerEntry(seller, managed));
983
+ if (index >= 0) {
984
+ sellers[index] = { ...sellers[index], ...managed };
985
+ }
986
+ else {
987
+ sellers.push(managed);
988
+ }
989
+ }
990
+ return {
991
+ version: publicDoc.version || managedDoc.version,
992
+ updatedAt: publicDoc.updatedAt || managedDoc.updatedAt,
993
+ purpose: publicDoc.purpose || managedDoc.purpose,
994
+ defaultSeller: publicDoc.defaultSeller || managedDoc.defaultSeller,
995
+ notes: publicDoc.notes || managedDoc.notes,
996
+ sellers
997
+ };
998
+ }
999
+ function sameSellerEntry(a, b) {
1000
+ const aKeys = new Set([a.id, a.app, a.name].map((value) => stringValue(value)).filter((value) => Boolean(value)));
1001
+ return [b.id, b.app, b.name].some((value) => {
1002
+ const normalized = stringValue(value);
1003
+ return normalized ? aKeys.has(normalized) : false;
1004
+ });
1005
+ }
1006
+ function isUnavailablePlatformSellersEndpoint(err) {
1007
+ const message = err instanceof Error ? err.message : String(err || "");
1008
+ return /HTTP Error (401|403|404)|Cannot GET \/platform\/sellers|vendor_auth|not found/i.test(message);
1009
+ }
666
1010
  //# sourceMappingURL=ui-state.js.map