@tokenbuddy/tb-admin 1.0.33 → 1.0.35

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 +31 -0
  19. package/dist/src/ui-state.d.ts.map +1 -1
  20. package/dist/src/ui-state.js +461 -115
  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 +267 -144
  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 +541 -115
  36. package/src/ui-static.ts +267 -144
  37. package/src/upstream-balance-probe.ts +13 -505
  38. package/tests/admin.test.ts +418 -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
@@ -40,22 +40,44 @@ interface FakeServerHandle {
40
40
  url: string;
41
41
  setManifest: (sellerId: string, resp: FakeManifestResponse) => void;
42
42
  setManifestPath: (path: string, resp: FakeManifestResponse) => void;
43
+ setOperatorJson: (path: string, resp: unknown) => void;
43
44
  setRegistry: (resp: { sellers: any[]; version: number }) => void;
45
+ setManagedSellers: (sellers: any[]) => void;
46
+ requests: () => Array<{ path: string; authorization?: string }>;
44
47
  close: () => Promise<void>;
45
48
  }
46
49
 
47
50
  function startFakeRegistry(): Promise<FakeServerHandle> {
48
51
  let registryDoc: { sellers: any[]; version: number } = { sellers: [], version: 0 };
52
+ let managedSellers: any[] | undefined;
49
53
  const manifests = new Map<string, FakeManifestResponse>();
54
+ const operatorJson = new Map<string, unknown>();
55
+ const requests: Array<{ path: string; authorization?: string }> = [];
50
56
 
51
57
  return new Promise((resolve) => {
52
58
  const server = createServer((req, res) => {
53
59
  const url = new URL(req.url || "/", "http://localhost");
60
+ requests.push({ path: url.pathname, authorization: req.headers.authorization });
61
+ if (url.pathname === "/platform/sellers") {
62
+ if (req.headers.authorization !== "Bearer fake-vendor-token") {
63
+ res.writeHead(401, { "Content-Type": "application/json" });
64
+ res.end(JSON.stringify({ error: "vendor_auth_required" }));
65
+ return;
66
+ }
67
+ res.writeHead(200, { "Content-Type": "application/json" });
68
+ res.end(JSON.stringify({ sellers: managedSellers ?? registryDoc.sellers }));
69
+ return;
70
+ }
54
71
  if (url.pathname === "/registry/sellers") {
55
72
  res.writeHead(200, { "Content-Type": "application/json" });
56
73
  res.end(JSON.stringify(registryDoc));
57
74
  return;
58
75
  }
76
+ if (operatorJson.has(url.pathname)) {
77
+ res.writeHead(200, { "Content-Type": "application/json" });
78
+ res.end(JSON.stringify(operatorJson.get(url.pathname)));
79
+ return;
80
+ }
59
81
  // 1) 按 path 查 (setManifestPath 注册的)
60
82
  if (manifests.has(url.pathname)) {
61
83
  const resp = manifests.get(url.pathname)!;
@@ -85,9 +107,16 @@ function startFakeRegistry(): Promise<FakeServerHandle> {
85
107
  setManifestPath: (path, resp) => {
86
108
  manifests.set(path, resp);
87
109
  },
110
+ setOperatorJson: (path, resp) => {
111
+ operatorJson.set(path, resp);
112
+ },
88
113
  setRegistry: (doc) => {
89
114
  registryDoc = doc;
90
115
  },
116
+ setManagedSellers: (sellers) => {
117
+ managedSellers = sellers;
118
+ },
119
+ requests: () => requests.slice(),
91
120
  close: () => new Promise<void>((r) => server.close(() => r()))
92
121
  };
93
122
  resolve(handle);
@@ -100,7 +129,8 @@ let tmpConfigPath: string;
100
129
 
101
130
  function makeState(
102
131
  registryUrl: string,
103
- flyApps: Omit<SellerAppJson, "raw">[]
132
+ flyApps: Omit<SellerAppJson, "raw">[],
133
+ token = "fake-vendor-token"
104
134
  ): AdminUiState {
105
135
  if (!tmpDir) {
106
136
  tmpDir = mkdtempSync(join(tmpdir(), "ui-fleet-test-"));
@@ -110,7 +140,7 @@ function makeState(
110
140
  tmpConfigPath,
111
141
  `[profiles.default]
112
142
  url = "${registryUrl}"
113
- token = "fake-vendor-token"
143
+ token = "${token}"
114
144
  `,
115
145
  "utf8"
116
146
  );
@@ -119,10 +149,13 @@ token = "fake-vendor-token"
119
149
  configManager,
120
150
  profile: "default",
121
151
  url: registryUrl,
122
- token: "fake-vendor-token",
152
+ token,
123
153
  flyApps: async () => flyApps.map((a) => ({ ...a, raw: {} })),
124
154
  fetchJson: (async (target: string, init?: RequestInit) => {
125
155
  const res = await fetch(target, init);
156
+ if (!res.ok) {
157
+ throw new Error(`HTTP Error ${res.status}: ${await res.text()}`);
158
+ }
126
159
  return await res.json();
127
160
  }) as any
128
161
  });
@@ -174,6 +207,242 @@ describe("AdminUiState 双源 seller list (v1.1 spec)", () => {
174
207
  expect(rows[0].registryAlert).toBeFalsy();
175
208
  // 绿点: /manifest 200 → nodeStatus=active (manifest ok 映射 active)
176
209
  expect(rows[0].nodeStatus).toBe("active");
210
+ expect(fake.requests()).toContainEqual({
211
+ path: "/platform/sellers",
212
+ authorization: "Bearer fake-vendor-token"
213
+ });
214
+ });
215
+
216
+ test("sellerRegistryRows returns registry publish-checking rows before Fly inventory", async () => {
217
+ fake.setRegistry({
218
+ version: 7,
219
+ sellers: [
220
+ {
221
+ id: "alpha",
222
+ name: "Alpha Seller",
223
+ app: "tb-seller-alpha",
224
+ url: `${fake.url}/alpha-base`,
225
+ status: "active",
226
+ supportedProtocols: ["chat_completions"],
227
+ paymentMethods: ["clawtip"],
228
+ models: ["gpt-5.4"]
229
+ }
230
+ ]
231
+ });
232
+ const state = makeState(fake.url, [
233
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
234
+ ]);
235
+
236
+ const rows = await state.sellerRegistryRows();
237
+ expect(rows).toHaveLength(1);
238
+ expect(rows[0]).toMatchObject({
239
+ id: "alpha",
240
+ publishStatus: "checking",
241
+ detailStatus: "pending",
242
+ nodeStatus: "unknown"
243
+ });
244
+ expect(fake.requests().some((request) => request.path.includes("/manifest"))).toBe(false);
245
+ });
246
+
247
+ test("sellerInventory merges registry + Fly without probing seller details", async () => {
248
+ fake.setRegistry({
249
+ version: 7,
250
+ sellers: [
251
+ {
252
+ id: "alpha",
253
+ name: "Alpha Seller",
254
+ app: "tb-seller-alpha",
255
+ url: `${fake.url}/alpha-base`,
256
+ status: "active",
257
+ supportedProtocols: ["chat_completions"],
258
+ paymentMethods: ["clawtip"],
259
+ models: ["gpt-5.4"]
260
+ }
261
+ ]
262
+ });
263
+ fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
264
+ const state = makeState(fake.url, [
265
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" },
266
+ { name: "tb-seller-flyonly", status: "running", owner: "vendor-a" }
267
+ ]);
268
+
269
+ const rows = await state.sellerInventory();
270
+ expect(rows).toHaveLength(2);
271
+ expect(rows.find((row) => row.id === "alpha")).toMatchObject({
272
+ dataSource: "both",
273
+ publishStatus: "published",
274
+ detailStatus: "pending",
275
+ nodeStatus: "unknown"
276
+ });
277
+ expect(rows.find((row) => row.id === "tb-seller-flyonly")).toMatchObject({
278
+ dataSource: "fly",
279
+ publishStatus: "unpublished",
280
+ detailStatus: "pending"
281
+ });
282
+ expect(fake.requests().some((request) => request.path.includes("/manifest"))).toBe(false);
283
+ });
284
+
285
+ test("sellerInventory filters non-seller Fly apps", async () => {
286
+ fake.setRegistry({ version: 7, sellers: [] });
287
+ const state = makeState(fake.url, [
288
+ { name: "tb-registry", status: "running", owner: "vendor-a" },
289
+ { name: "tb-seller", status: "running", owner: "vendor-a" },
290
+ { name: "tbs-valid", status: "running", owner: "vendor-a" }
291
+ ]);
292
+
293
+ const rows = await state.sellerInventory();
294
+ expect(rows.map((row) => row.app)).toEqual(["tbs-valid"]);
295
+ });
296
+
297
+ test("refreshSellerRows hydrates upstream domain from seller config", async () => {
298
+ fake.setManifestPath("/manifest", { status: 200, body: { ok: true } });
299
+ fake.setOperatorJson("/operator/status", {
300
+ status: "healthy",
301
+ upstream: { status: "healthy" },
302
+ capacity: { activeConnections: 2, maxConnections: 8 },
303
+ runtime: { cpuPercent: 12.5, memoryPercent: 48, memoryRssMb: 246, memoryLimitMb: 512 }
304
+ });
305
+ fake.setOperatorJson("/operator/admin/upstreams", {
306
+ upstreams: [{
307
+ upstreamUrl: "https://openrouter.ai/api/v1",
308
+ discountRatio: 1,
309
+ models: [{ id: "openai/gpt-5.4" }]
310
+ }]
311
+ });
312
+ fake.setOperatorJson("/operator/admin/config", {
313
+ config: {
314
+ upstreamUrl: "https://openrouter.ai/api/v1",
315
+ discountRatio: 1
316
+ }
317
+ });
318
+ const state = makeState(fake.url, []);
319
+
320
+ const rows = await state.refreshSellerRows([{
321
+ id: "tbs-openrouter",
322
+ name: "tbs-openrouter",
323
+ app: "tbs-openrouter",
324
+ url: fake.url,
325
+ registryStatus: "active",
326
+ nodeStatus: "unknown",
327
+ upstreamDomain: "tbs-openrouter.fly.dev",
328
+ upstreamStatus: "unknown",
329
+ dataSource: "both",
330
+ publishStatus: "published",
331
+ detailStatus: "queued"
332
+ }]);
333
+
334
+ expect(rows[0]).toMatchObject({
335
+ upstreamDomain: "openrouter.ai",
336
+ upstreamStatus: "healthy",
337
+ capacityUsed: 2,
338
+ capacityLimit: 8,
339
+ resourceCpuPercent: 12.5,
340
+ resourceMemoryPercent: 48,
341
+ modelsCount: 1,
342
+ detailStatus: "fresh"
343
+ });
344
+ });
345
+
346
+ test("case 1b: empty vendor-managed list keeps public registry publish state", async () => {
347
+ fake.setRegistry({
348
+ version: 9,
349
+ sellers: [
350
+ {
351
+ id: "public-alpha",
352
+ name: "Public Alpha",
353
+ app: "tb-seller-alpha",
354
+ url: `${fake.url}/alpha-base`,
355
+ status: "active",
356
+ supportedProtocols: ["chat_completions"],
357
+ paymentMethods: ["clawtip"],
358
+ models: ["gpt-5.4"]
359
+ }
360
+ ]
361
+ });
362
+ fake.setManagedSellers([]);
363
+ fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
364
+ const state = makeState(fake.url, [
365
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
366
+ ]);
367
+
368
+ const rows = await state.sellers();
369
+ expect(rows).toHaveLength(1);
370
+ expect(rows[0]).toMatchObject({
371
+ id: "public-alpha",
372
+ dataSource: "both",
373
+ registryStatus: "active",
374
+ nodeStatus: "active"
375
+ });
376
+ expect(rows[0].publishHint).toBeUndefined();
377
+ });
378
+
379
+ test("case 1c: vendor-managed auth failure falls back to public registry", async () => {
380
+ fake.setRegistry({
381
+ version: 9,
382
+ sellers: [
383
+ {
384
+ id: "public-alpha",
385
+ name: "Public Alpha",
386
+ app: "tb-seller-alpha",
387
+ url: `${fake.url}/alpha-base`,
388
+ status: "active",
389
+ supportedProtocols: ["chat_completions"],
390
+ paymentMethods: ["clawtip"],
391
+ models: ["gpt-5.4"]
392
+ }
393
+ ]
394
+ });
395
+ fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
396
+ const state = makeState(fake.url, [
397
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
398
+ ], "wrong-vendor-token");
399
+
400
+ const rows = await state.sellers();
401
+ expect(rows).toHaveLength(1);
402
+ expect(rows[0]).toMatchObject({
403
+ id: "public-alpha",
404
+ dataSource: "both",
405
+ registryStatus: "active",
406
+ nodeStatus: "active"
407
+ });
408
+ expect(fake.requests()).toContainEqual({
409
+ path: "/platform/sellers",
410
+ authorization: "Bearer wrong-vendor-token"
411
+ });
412
+ expect(fake.requests()).toContainEqual({
413
+ path: "/registry/sellers",
414
+ authorization: undefined
415
+ });
416
+ });
417
+
418
+ test("case 1d: registry entry without app matches Fly app by id", async () => {
419
+ fake.setRegistry({
420
+ version: 9,
421
+ sellers: [
422
+ {
423
+ id: "tb-seller-alpha",
424
+ name: "Alpha Seller",
425
+ url: `${fake.url}/alpha-base`,
426
+ status: "active",
427
+ supportedProtocols: ["chat_completions"],
428
+ paymentMethods: ["clawtip"],
429
+ models: ["gpt-5.4"]
430
+ }
431
+ ]
432
+ });
433
+ fake.setManifestPath("/alpha-base/manifest", { status: 200, body: { ok: true } });
434
+ const state = makeState(fake.url, [
435
+ { name: "tb-seller-alpha", status: "running", owner: "vendor-a" }
436
+ ]);
437
+
438
+ const rows = await state.sellers();
439
+ expect(rows).toHaveLength(1);
440
+ expect(rows[0]).toMatchObject({
441
+ id: "tb-seller-alpha",
442
+ dataSource: "both",
443
+ registryStatus: "active",
444
+ nodeStatus: "active"
445
+ });
177
446
  });
178
447
 
179
448
  test("case 2: fly only → dataSource=fly, 灰点, registryAlert=undefined", async () => {
@@ -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
  });