@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.
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +28 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/seller.d.ts +40 -1
- package/dist/src/seller.d.ts.map +1 -1
- package/dist/src/seller.js +132 -2
- package/dist/src/seller.js.map +1 -1
- package/dist/src/ui-actions.d.ts +2 -0
- package/dist/src/ui-actions.d.ts.map +1 -1
- package/dist/src/ui-actions.js +8 -6
- package/dist/src/ui-actions.js.map +1 -1
- package/dist/src/ui-command.d.ts +1 -0
- package/dist/src/ui-command.d.ts.map +1 -1
- package/dist/src/ui-command.js +7 -2
- package/dist/src/ui-command.js.map +1 -1
- package/dist/src/ui-server.js +17 -0
- package/dist/src/ui-server.js.map +1 -1
- package/dist/src/ui-state.d.ts +29 -0
- package/dist/src/ui-state.d.ts.map +1 -1
- package/dist/src/ui-state.js +455 -111
- package/dist/src/ui-state.js.map +1 -1
- package/dist/src/ui-static.d.ts.map +1 -1
- package/dist/src/ui-static.js +262 -143
- package/dist/src/ui-static.js.map +1 -1
- package/dist/src/upstream-balance-probe.d.ts +2 -40
- package/dist/src/upstream-balance-probe.d.ts.map +1 -1
- package/dist/src/upstream-balance-probe.js +1 -378
- package/dist/src/upstream-balance-probe.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +31 -0
- package/src/seller.ts +179 -3
- package/src/ui-actions.ts +10 -6
- package/src/ui-command.ts +7 -2
- package/src/ui-server.ts +18 -1
- package/src/ui-state.ts +533 -111
- package/src/ui-static.ts +262 -143
- package/src/upstream-balance-probe.ts +13 -505
- package/tests/admin.test.ts +416 -39
- package/tests/seller.test.ts +51 -0
- package/tests/ui-state-fleet.test.ts +272 -3
- 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 = "
|
|
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
|
|
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, "
|
|
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), 绿点,
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
expect(out).
|
|
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
|
|
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 =
|
|
200
|
-
expect(out).toMatch(/datasource-chip
|
|
464
|
+
// 兜底 chip = published
|
|
465
|
+
expect(out).toMatch(/datasource-chip published/);
|
|
201
466
|
});
|
|
202
467
|
});
|