@tokenbuddy/tb-admin 1.0.32 → 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 (46) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +29 -1
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.js +3 -3
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/seller.d.ts +40 -1
  7. package/dist/src/seller.d.ts.map +1 -1
  8. package/dist/src/seller.js +132 -2
  9. package/dist/src/seller.js.map +1 -1
  10. package/dist/src/ui-actions.d.ts +2 -0
  11. package/dist/src/ui-actions.d.ts.map +1 -1
  12. package/dist/src/ui-actions.js +8 -6
  13. package/dist/src/ui-actions.js.map +1 -1
  14. package/dist/src/ui-command.d.ts +1 -0
  15. package/dist/src/ui-command.d.ts.map +1 -1
  16. package/dist/src/ui-command.js +7 -2
  17. package/dist/src/ui-command.js.map +1 -1
  18. package/dist/src/ui-server.d.ts.map +1 -1
  19. package/dist/src/ui-server.js +29 -8
  20. package/dist/src/ui-server.js.map +1 -1
  21. package/dist/src/ui-state.d.ts +29 -0
  22. package/dist/src/ui-state.d.ts.map +1 -1
  23. package/dist/src/ui-state.js +455 -111
  24. package/dist/src/ui-state.js.map +1 -1
  25. package/dist/src/ui-static.d.ts.map +1 -1
  26. package/dist/src/ui-static.js +262 -143
  27. package/dist/src/ui-static.js.map +1 -1
  28. package/dist/src/upstream-balance-probe.d.ts +2 -40
  29. package/dist/src/upstream-balance-probe.d.ts.map +1 -1
  30. package/dist/src/upstream-balance-probe.js +1 -378
  31. package/dist/src/upstream-balance-probe.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cli.ts +32 -1
  34. package/src/client.ts +3 -4
  35. package/src/seller.ts +179 -3
  36. package/src/ui-actions.ts +10 -6
  37. package/src/ui-command.ts +7 -2
  38. package/src/ui-server.ts +30 -8
  39. package/src/ui-state.ts +533 -111
  40. package/src/ui-static.ts +262 -143
  41. package/src/upstream-balance-probe.ts +13 -505
  42. package/tests/admin.test.ts +472 -36
  43. package/tests/seller.test.ts +84 -3
  44. package/tests/ui-state-fleet.test.ts +272 -3
  45. package/tests/ui-static-row.test.ts +273 -8
  46. package/tests/vendor-cli.test.ts +45 -1
@@ -7,7 +7,7 @@
7
7
  * registry / 老 1.0.31 缺字段) 渲染出正确的 CSS class + 文案 + 按钮.
8
8
  *
9
9
  * 覆盖 (跟 docs/processes/seller-fleet-data-sources.md 一致):
10
- * 1. dataSource="both" → "app-row" (无红/灰 class), 绿点 active, "Both" chip
10
+ * 1. dataSource="both" → "app-row" (无红/灰 class), 绿点 active, "已发布" chip
11
11
  * 2. dataSource="fly" → "app-row app-row-fly-only" (灰), "未发布" chip
12
12
  * + publish-hint-btn 文案
13
13
  * 3. dataSource="registry" → "app-row app-row-registry-only" (整行红边)
@@ -98,7 +98,7 @@ function renderRow(row: Record<string, unknown>): string {
98
98
  }
99
99
 
100
100
  describe("admin web seller row 渲染 (Step 13 v1.1 双源 spec)", () => {
101
- test("dataSource=both → 正常行 (无红/灰 class), 绿点, Both chip", () => {
101
+ test("dataSource=both → 正常行 (无红/灰 class), 绿点, 已发布 chip", () => {
102
102
  const out = renderRow({
103
103
  id: "tbs-alpha",
104
104
  name: "tbs-alpha",
@@ -118,9 +118,11 @@ describe("admin web seller row 渲染 (Step 13 v1.1 双源 spec)", () => {
118
118
  expect(out).not.toContain("app-row-fly-only");
119
119
  // 绿点 active → tone-green
120
120
  expect(out).toMatch(/app-dot tone-green/);
121
- // Both chip
122
- expect(out).toMatch(/datasource-chip both/);
123
- expect(out).toContain("Both");
121
+ expect(out).toContain('title="registry: active · upstream: healthy · publish: published · detail: pending"');
122
+ // 已发布 chip
123
+ expect(out).toMatch(/datasource-chip published/);
124
+ expect(out).toContain("已发布");
125
+ expect(out).not.toContain("status-badge");
124
126
  // 无 alert-reason / remove-hint-btn / publish-hint-btn
125
127
  expect(out).not.toContain("alert-reason");
126
128
  expect(out).not.toContain("remove-hint-btn");
@@ -139,18 +141,280 @@ describe("admin web seller row 渲染 (Step 13 v1.1 双源 spec)", () => {
139
141
  upstreamStatus: "unknown",
140
142
  modelsCount: 0,
141
143
  dataSource: "fly",
144
+ publishStatus: "unpublished",
142
145
  publishHint: "未发布 — 可申请发布 (走 vendor-bootstrap stage)",
143
146
  flyApp: { name: "tbs-flyonly", status: "running" }
144
147
  });
145
148
  expect(out).toContain("app-row-fly-only");
146
149
  expect(out).not.toContain("app-row-registry-only");
147
- expect(out).toMatch(/datasource-chip fly/);
150
+ expect(out).toMatch(/datasource-chip unpublished/);
148
151
  expect(out).toContain("未发布");
149
152
  // publish-hint-btn 出现 + 文案含 spec 关键词
150
153
  expect(out).toContain("publish-hint-btn");
151
154
  expect(out).toContain("未发布 — 可申请发布");
152
155
  });
153
156
 
157
+ test("registryStatus=offline overrides Pub chip even when Fly app still exists", () => {
158
+ const out = renderRow({
159
+ id: "tbs-offline",
160
+ name: "tbs-offline",
161
+ app: "tbs-offline",
162
+ url: "https://tbs-offline.fly.dev",
163
+ registryStatus: "offline",
164
+ nodeStatus: "unknown",
165
+ upstreamDomain: "api.example.com",
166
+ upstreamStatus: "unknown",
167
+ modelsCount: 1,
168
+ dataSource: "both",
169
+ publishStatus: "published",
170
+ flyApp: { name: "tbs-offline", status: "running" }
171
+ });
172
+
173
+ expect(out).toMatch(/datasource-chip offline/);
174
+ expect(out).toContain("已下线");
175
+ expect(out).toContain("Publish: offline · registry: offline · source: both");
176
+ expect(out).toContain('title="registry: offline · upstream: unknown · publish: offline · detail: pending"');
177
+ expect(out).not.toContain(">已发布</span>");
178
+ expect(out).not.toContain("registry: error");
179
+ });
180
+
181
+ test("registryStatus=draining overrides Pub chip even when publish relation is published", () => {
182
+ const out = renderRow({
183
+ id: "tbs-draining",
184
+ name: "tbs-draining",
185
+ app: "tbs-draining",
186
+ url: "https://tbs-draining.fly.dev",
187
+ registryStatus: "draining",
188
+ nodeStatus: "active",
189
+ upstreamDomain: "api.example.com",
190
+ upstreamStatus: "healthy",
191
+ modelsCount: 1,
192
+ dataSource: "both",
193
+ publishStatus: "published",
194
+ flyApp: { name: "tbs-draining", status: "running" }
195
+ });
196
+
197
+ expect(out).toMatch(/datasource-chip draining/);
198
+ expect(out).toContain("下线中");
199
+ expect(out).toContain("Publish: draining · registry: draining · source: both");
200
+ });
201
+
202
+ test("publishStatus=checking → 不把未知发布状态显示成未发布", () => {
203
+ const out = renderRow({
204
+ id: "tbs-checking",
205
+ name: "tbs-checking",
206
+ app: "tbs-checking",
207
+ url: "https://tbs-checking.fly.dev",
208
+ registryStatus: "unknown",
209
+ nodeStatus: "unknown",
210
+ upstreamDomain: "tbs-checking.fly.dev",
211
+ upstreamStatus: "unknown",
212
+ modelsCount: 0,
213
+ dataSource: "fly",
214
+ publishStatus: "checking",
215
+ detailStatus: "pending",
216
+ flyApp: { name: "tbs-checking", status: "running" }
217
+ });
218
+ expect(out).not.toContain("app-row-fly-only");
219
+ expect(out).toMatch(/datasource-chip checking/);
220
+ expect(out).toContain("发布待确认");
221
+ expect(out).not.toContain("publish-hint-btn");
222
+ expect(out).not.toContain(">未发布<");
223
+ });
224
+
225
+ test("列表列语义: connection 显示连接数, resources 无第二排, upstream 不显示 seller host / models", () => {
226
+ const out = renderRow({
227
+ id: "tbs-hide-host",
228
+ name: "tbs-hide-host",
229
+ app: "tbs-hide-host",
230
+ url: "https://tbs-hide-host.fly.dev",
231
+ registryStatus: "active",
232
+ nodeStatus: "active",
233
+ upstreamDomain: "tbs-hide-host.fly.dev",
234
+ upstreamStatus: "healthy",
235
+ upstreamBalanceUsdMicros: 1020000,
236
+ upstreamBalanceCurrency: "USD",
237
+ upstreamBalanceSource: "usage_generic",
238
+ capacityUsed: 3,
239
+ capacityLimit: 8,
240
+ resourceCpuPercent: 12.5,
241
+ resourceMemoryPercent: 48,
242
+ resourceMemoryRssMb: 246,
243
+ resourceMemoryLimitMb: 512,
244
+ discountRatio: 1,
245
+ modelsCount: 7,
246
+ specs: { cpuCores: 1, memoryMb: 512, machines: 1, runningMachines: 1, volumeGb: 1 },
247
+ dataSource: "both",
248
+ publishStatus: "published",
249
+ detailStatus: "fresh",
250
+ detailUpdatedAt: "2026-06-12T13:06:00.000Z",
251
+ detailNextRefreshAt: "2999-01-01T00:00:00.000Z",
252
+ flyApp: { name: "tbs-hide-host", status: "running" }
253
+ });
254
+
255
+ expect(out).toContain("op-value");
256
+ expect(out).toContain(">3 / 8</strong>");
257
+ expect(out).toContain("12.5%/48%");
258
+ expect(out).toContain("spec 1 vCPU / 512 MB");
259
+ expect(out).toContain("balance-line tone-red");
260
+ expect(out).toContain("op-chip tone-red");
261
+ expect(out).not.toMatch(/Resources<\/span><strong[^>]*>1 vCPU \/ 512 MB<\/strong><span class="metric-sub"/);
262
+ expect(out).not.toContain("cap 3 / 8");
263
+ expect(out).not.toContain("<strong>tbs-hide-host.fly.dev</strong>");
264
+ expect(out).not.toContain("7 models · disc");
265
+ expect(out).not.toContain("usage_generic");
266
+ expect(out).not.toContain("balance-badge tone-green");
267
+ expect(out).toContain("op-chip tone-green");
268
+ expect(out).toContain(">healthy</span>");
269
+ });
270
+
271
+ test("CPU/RAM 列只显示占比, 无运行数据时显示 -/-", () => {
272
+ const out = renderRow({
273
+ id: "tbs-no-runtime",
274
+ name: "tbs-no-runtime",
275
+ app: "tbs-no-runtime",
276
+ url: "https://tbs-no-runtime.fly.dev",
277
+ registryStatus: "active",
278
+ nodeStatus: "active",
279
+ upstreamDomain: "api.example.com",
280
+ upstreamStatus: "healthy",
281
+ capacityUsed: 0,
282
+ capacityLimit: 8,
283
+ dataSource: "both",
284
+ publishStatus: "published",
285
+ flyApp: { name: "tbs-no-runtime", status: "running" }
286
+ });
287
+
288
+ expect(out).toContain("-/-");
289
+ expect(out).not.toContain("CPU — / MEM —");
290
+ });
291
+
292
+ test("上游 status 从 seller /operator/status 单列显示, unhealthy 显示红色告警", () => {
293
+ const out = renderRow({
294
+ id: "tbs-upstream-down",
295
+ name: "tbs-upstream-down",
296
+ app: "tbs-upstream-down",
297
+ url: "https://tbs-upstream-down.fly.dev",
298
+ registryStatus: "active",
299
+ nodeStatus: "active",
300
+ upstreamDomain: "api.downstream.example",
301
+ upstreamStatus: "unhealthy",
302
+ capacityUsed: 0,
303
+ capacityLimit: 8,
304
+ dataSource: "both",
305
+ publishStatus: "published",
306
+ flyApp: { name: "tbs-upstream-down", status: "running" }
307
+ });
308
+
309
+ expect(out).toContain(">Status</span>");
310
+ expect(out).toContain("Upstream status from seller /operator/status");
311
+ expect(out).toContain("op-chip tone-red");
312
+ expect(out).toContain(">unhealthy</span>");
313
+ expect(out).toContain("row-alert");
314
+ });
315
+
316
+ test("上游 status 未知时显示中性 -, 不显示 unknown 文案", () => {
317
+ const out = renderRow({
318
+ id: "tbs-upstream-unknown",
319
+ name: "tbs-upstream-unknown",
320
+ app: "tbs-upstream-unknown",
321
+ url: "https://tbs-upstream-unknown.fly.dev",
322
+ registryStatus: "active",
323
+ nodeStatus: "active",
324
+ upstreamDomain: "api.example.com",
325
+ upstreamStatus: "unknown",
326
+ capacityUsed: 0,
327
+ capacityLimit: 8,
328
+ dataSource: "both",
329
+ publishStatus: "published",
330
+ flyApp: { name: "tbs-upstream-unknown", status: "running" }
331
+ });
332
+
333
+ expect(out).toContain(">Status</span>");
334
+ expect(out).toContain("op-chip tone-gray");
335
+ expect(out).toContain(">-\u003c/span>");
336
+ expect(out).not.toContain(">unknown</span>");
337
+ expect(out).not.toContain(">unkown</span>");
338
+ });
339
+
340
+ test("余额探测失败只显示 -, 不显示额外状态文案", () => {
341
+ const out = renderRow({
342
+ id: "tbs-balance-blocked",
343
+ name: "tbs-balance-blocked",
344
+ app: "tbs-balance-blocked",
345
+ url: "https://tbs-balance-blocked.fly.dev",
346
+ registryStatus: "active",
347
+ nodeStatus: "active",
348
+ upstreamDomain: "api.example.com",
349
+ upstreamStatus: "healthy",
350
+ upstreamBalanceError: "unauthorized: check upstreamApiKey",
351
+ capacityUsed: 0,
352
+ capacityLimit: 8,
353
+ dataSource: "both",
354
+ publishStatus: "published",
355
+ flyApp: { name: "tbs-balance-blocked", status: "running" }
356
+ });
357
+
358
+ expect(out).toContain('title="unauthorized: check upstreamApiKey">-</span>');
359
+ expect(out).not.toContain("probe blocked");
360
+ expect(out).not.toContain("balance-badge tone-gray");
361
+ expect(out).not.toContain("balance-badge tone-red");
362
+ expect(out).not.toContain("balance-line tone-red");
363
+ });
364
+
365
+ test("详情刷新 queued/loading 时 Next 列显示 spinner, 不显示状态文本", () => {
366
+ const out = renderRow({
367
+ id: "tbs-refreshing",
368
+ name: "tbs-refreshing",
369
+ app: "tbs-refreshing",
370
+ url: "https://tbs-refreshing.fly.dev",
371
+ registryStatus: "active",
372
+ nodeStatus: "active",
373
+ upstreamDomain: "api.example.com",
374
+ upstreamStatus: "healthy",
375
+ capacityUsed: 0,
376
+ capacityLimit: 8,
377
+ detailStatus: "loading",
378
+ dataSource: "both",
379
+ publishStatus: "published",
380
+ flyApp: { name: "tbs-refreshing", status: "running" }
381
+ });
382
+
383
+ expect(out).toContain("inline-spinner");
384
+ expect(out).toContain("aria-label=\"detail loading\"");
385
+ expect(out).not.toContain(">loading</strong>");
386
+ expect(out).not.toContain(">queued</strong>");
387
+ });
388
+
389
+ test("运维关键指标按风险突出: 满连接/高资源/慢 TTFT 显示红黄 chip", () => {
390
+ const out = renderRow({
391
+ id: "tbs-hot",
392
+ name: "tbs-hot",
393
+ app: "tbs-hot",
394
+ url: "https://tbs-hot.fly.dev",
395
+ registryStatus: "active",
396
+ nodeStatus: "active",
397
+ upstreamDomain: "openrouter.ai",
398
+ upstreamStatus: "healthy",
399
+ capacityUsed: 8,
400
+ capacityLimit: 8,
401
+ resourceCpuPercent: 81,
402
+ resourceMemoryPercent: 93,
403
+ ttftMs: 6500,
404
+ upstreamBalanceUsdMicros: 50000000,
405
+ upstreamBalanceCurrency: "USD",
406
+ dataSource: "both",
407
+ publishStatus: "published",
408
+ flyApp: { name: "tbs-hot", status: "running" }
409
+ });
410
+
411
+ expect(out).toContain("row-alert");
412
+ expect(out).toContain("op-chip tone-red");
413
+ expect(out).toContain(">8 / 8</span>");
414
+ expect(out).toContain("81%/93%");
415
+ expect(out).toContain(">6.50s</span>");
416
+ });
417
+
154
418
  test("dataSource=registry → 整行红边 + 严重事故 + alert-reason + 立即下线 (registry-only) 按钮", () => {
155
419
  const out = renderRow({
156
420
  id: "tbs-ghost",
@@ -163,6 +427,7 @@ describe("admin web seller row 渲染 (Step 13 v1.1 双源 spec)", () => {
163
427
  upstreamStatus: "unknown",
164
428
  modelsCount: 1,
165
429
  dataSource: "registry",
430
+ publishStatus: "registry_only",
166
431
  registryAlert: true,
167
432
  alertReason: "registry 收录了但 fly app 失踪 — 严重事故, 立即下线",
168
433
  removeHint: "立即下线 (registry-only)"
@@ -196,7 +461,7 @@ describe("admin web seller row 渲染 (Step 13 v1.1 双源 spec)", () => {
196
461
  expect(out).toContain('class="app-row"');
197
462
  expect(out).not.toContain("app-row-registry-only");
198
463
  expect(out).not.toContain("app-row-fly-only");
199
- // 兜底 chip = both
200
- expect(out).toMatch(/datasource-chip both/);
464
+ // 兜底 chip = published
465
+ expect(out).toMatch(/datasource-chip published/);
201
466
  });
202
467
  });
@@ -7,7 +7,9 @@ import { execSync } from "child_process";
7
7
 
8
8
  // Use the workspace-linked package. ts-jest will resolve the
9
9
  // `.js` to the compiled `.ts` via the root moduleNameMapper.
10
- import { buildApp, BootstrapConfig } from "@tokenbuddy/wallet-bootstrap";
10
+ import { buildApp } from "@tokenbuddy/wallet-bootstrap";
11
+ import type { BootstrapConfig } from "@tokenbuddy/wallet-bootstrap";
12
+ import { RegistryAdminClient } from "../src/client.js";
11
13
 
12
14
  const WALLET_TEST_DIR = path.resolve(__dirname, "../../data-vendor-cli-test");
13
15
  const TEMP_REGISTRY_PATH = path.join(WALLET_TEST_DIR, "sellers.json");
@@ -194,4 +196,46 @@ describe("Vendor CLI integration (Step 5)", () => {
194
196
  expect(list.body.releaseRequests).toHaveLength(1);
195
197
  expect(list.body.releaseRequests[0].id).toBe(submit.body.releaseRequest.id);
196
198
  });
199
+
200
+ test("RegistryAdminClient uses canonical admin API routes", async () => {
201
+ const app = buildServer();
202
+ const server = app.listen(0);
203
+ const address = server.address();
204
+ if (!address || typeof address !== "object") {
205
+ throw new Error("test server did not bind a TCP port");
206
+ }
207
+ const client = new RegistryAdminClient(`http://127.0.0.1:${address.port}`, SUPER_ADMIN_KEY);
208
+ try {
209
+ const vendor = await client.createVendor("AdminClientVendor");
210
+ expect(vendor.token).toBeTruthy();
211
+
212
+ const vendors = await client.listVendors();
213
+ expect(vendors.vendors.length).toBeGreaterThan(0);
214
+
215
+ const versions = await client.listVersions();
216
+ expect(versions.versions.length).toBeGreaterThan(0);
217
+
218
+ const token = vendor.token;
219
+ await request(app)
220
+ .post("/platform/sellers/stage")
221
+ .set("Authorization", `Bearer ${token}`)
222
+ .send({ seller: {
223
+ id: "admin-client-force-1",
224
+ name: "Admin Client Force",
225
+ url: "https://admin-client-force.example.com",
226
+ status: "active",
227
+ models: ["m1"],
228
+ supportedProtocols: ["chat_completions"],
229
+ paymentMethods: ["clawtip"]
230
+ } });
231
+ const submit = await request(app)
232
+ .post("/platform/release-requests")
233
+ .set("Authorization", `Bearer ${token}`)
234
+ .send({ stagedSellerIds: ["admin-client-force-1"] });
235
+ const forced = await client.forcePublish(submit.body.releaseRequest.id);
236
+ expect(forced.releaseRequest).toMatchObject({ status: "published" });
237
+ } finally {
238
+ await new Promise<void>((resolve) => server.close(() => resolve()));
239
+ }
240
+ });
197
241
  });