@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.
- 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 +31 -0
- package/dist/src/ui-state.d.ts.map +1 -1
- package/dist/src/ui-state.js +461 -115
- 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 +267 -144
- 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 +541 -115
- package/src/ui-static.ts +267 -144
- package/src/upstream-balance-probe.ts +13 -505
- package/tests/admin.test.ts +418 -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
package/tests/admin.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { ConfigManager } from "../src/config.js";
|
|
|
2
2
|
import { buildAdminCli } from "../src/cli.js";
|
|
3
3
|
import { FlyProvider, parseFlyMachineIds, requirePublishedDockerImage } from "../src/server-cmd.js";
|
|
4
4
|
import { startAdminUiServer } from "../src/ui-server.js";
|
|
5
|
+
import { defaultUiProfile } from "../src/ui-command.js";
|
|
5
6
|
import { AdminUiState } from "../src/ui-state.js";
|
|
6
7
|
import { UiActions, type CreateSellerRequest, type UiActionResult } from "../src/ui-actions.js";
|
|
7
8
|
import { adminUiHtml } from "../src/ui-static.js";
|
|
@@ -91,13 +92,16 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
91
92
|
expect(() => new vm.Script(scripts[0])).not.toThrow();
|
|
92
93
|
});
|
|
93
94
|
|
|
94
|
-
test("admin UI release
|
|
95
|
+
test("admin UI keeps release state in the seller table instead of a separate releases page", () => {
|
|
95
96
|
const html = adminUiHtml();
|
|
96
97
|
|
|
97
|
-
expect(html).toContain(
|
|
98
|
-
expect(html).toContain(
|
|
99
|
-
expect(html).toContain('
|
|
100
|
-
expect(html).toContain('
|
|
98
|
+
expect(html).toContain("<span>Pub</span>");
|
|
99
|
+
expect(html).not.toContain("Release Requests");
|
|
100
|
+
expect(html).not.toContain('data-page="releases"');
|
|
101
|
+
expect(html).not.toContain('id="page-releases"');
|
|
102
|
+
expect(html).not.toContain('id="releasesGrid"');
|
|
103
|
+
expect(html).not.toContain('btn.dataset.page === "releases"');
|
|
104
|
+
expect(html).not.toContain("/api/vendor/release-requests");
|
|
101
105
|
expect(html).not.toContain('id="page-bootstrap"');
|
|
102
106
|
expect(html).not.toContain('getElementById("bootstrapGrid")');
|
|
103
107
|
});
|
|
@@ -370,6 +374,15 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
370
374
|
expect(() => validateRegistryDocument(badDefault)).toThrow("defaultSeller `missing`");
|
|
371
375
|
});
|
|
372
376
|
|
|
377
|
+
test("tb-admin ui prefers vendor bootstrap profile when available", () => {
|
|
378
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
379
|
+
mgr.setProfile("bootstrap", { url: "https://tb-registry.fly.dev", token: "operator-token" });
|
|
380
|
+
expect(defaultUiProfile(mgr)).toBe("bootstrap");
|
|
381
|
+
|
|
382
|
+
mgr.setProfile("bootstrap-vendor", { url: "https://tb-registry.fly.dev", token: "vendor-token" });
|
|
383
|
+
expect(defaultUiProfile(mgr)).toBe("bootstrap-vendor");
|
|
384
|
+
});
|
|
385
|
+
|
|
373
386
|
test("tb-admin ui server binds loopback, rejects cross-origin APIs, and serves bootstrap data", async () => {
|
|
374
387
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
375
388
|
const started = await startAdminUiServer({
|
|
@@ -418,6 +431,103 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
418
431
|
})).rejects.toThrow("loopback");
|
|
419
432
|
});
|
|
420
433
|
|
|
434
|
+
test("tb-admin ui status refresh updates existing rows without inventory reload", async () => {
|
|
435
|
+
const seller = http.createServer((req, res) => {
|
|
436
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
437
|
+
if (req.method === "GET" && url.pathname === "/manifest") {
|
|
438
|
+
sendJson(res, { sellerId: "tbs-status-only" });
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (req.method === "GET" && url.pathname === "/operator/status") {
|
|
442
|
+
expect(req.headers.authorization).toBe("Bearer operator-secret");
|
|
443
|
+
sendJson(res, {
|
|
444
|
+
status: "healthy",
|
|
445
|
+
upstream: { status: "healthy" },
|
|
446
|
+
capacity: { activeConnections: 2, maxConnections: 9 },
|
|
447
|
+
runtime: { cpuPercent: 8.4, memoryPercent: 41.2, memoryRssMb: 211, memoryLimitMb: 512 },
|
|
448
|
+
latency: { ttftMs: 123, avgTokensPerSecond: 42.5, sampleCount: 7 }
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (req.method === "GET" && url.pathname === "/operator/admin/upstream-balance") {
|
|
453
|
+
expect(req.headers.authorization).toBe("Bearer operator-secret");
|
|
454
|
+
sendJson(res, {
|
|
455
|
+
status: "ok",
|
|
456
|
+
balance: {
|
|
457
|
+
rawAmount: 12.75,
|
|
458
|
+
amountUsdMicros: 12750000,
|
|
459
|
+
currency: "USD",
|
|
460
|
+
source: "openrouter",
|
|
461
|
+
fetchedAt: 1800000000000
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
res.statusCode = 404;
|
|
467
|
+
sendJson(res, { error: "not found" });
|
|
468
|
+
});
|
|
469
|
+
await new Promise<void>((resolve) => seller.listen(0, "127.0.0.1", () => resolve()));
|
|
470
|
+
const address = seller.address();
|
|
471
|
+
if (!address || typeof address !== "object") {
|
|
472
|
+
throw new Error("seller fixture did not bind a TCP port");
|
|
473
|
+
}
|
|
474
|
+
const sellerUrl = `http://127.0.0.1:${address.port}`;
|
|
475
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
476
|
+
mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
|
|
477
|
+
const started = await startAdminUiServer({
|
|
478
|
+
host: "127.0.0.1",
|
|
479
|
+
port: 0,
|
|
480
|
+
openBrowser: false,
|
|
481
|
+
configManager: mgr
|
|
482
|
+
});
|
|
483
|
+
try {
|
|
484
|
+
const response = await fetch(`${started.url}api/sellers/status`, {
|
|
485
|
+
method: "POST",
|
|
486
|
+
headers: { "Content-Type": "application/json" },
|
|
487
|
+
body: JSON.stringify({
|
|
488
|
+
rows: [{
|
|
489
|
+
id: "tbs-status-only",
|
|
490
|
+
name: "tbs-status-only",
|
|
491
|
+
app: "tbs-status-only",
|
|
492
|
+
url: sellerUrl,
|
|
493
|
+
registryStatus: "unknown",
|
|
494
|
+
nodeStatus: "unknown",
|
|
495
|
+
upstreamDomain: "seller.example",
|
|
496
|
+
upstreamStatus: "unknown",
|
|
497
|
+
upstreamBalanceError: "unauthorized: check upstreamApiKey",
|
|
498
|
+
dataSource: "fly",
|
|
499
|
+
flyApp: { name: "tbs-status-only", status: "deployed" }
|
|
500
|
+
}]
|
|
501
|
+
})
|
|
502
|
+
});
|
|
503
|
+
expect(response.status).toBe(200);
|
|
504
|
+
const wrongMethodResponse = await fetch(`${started.url}api/sellers/status`);
|
|
505
|
+
expect(wrongMethodResponse.status).toBe(405);
|
|
506
|
+
await expect(wrongMethodResponse.json()).resolves.toMatchObject({ error: "method not allowed" });
|
|
507
|
+
const rows = await response.json() as any[];
|
|
508
|
+
expect(rows).toHaveLength(1);
|
|
509
|
+
expect(rows[0]).toMatchObject({
|
|
510
|
+
id: "tbs-status-only",
|
|
511
|
+
nodeStatus: "active",
|
|
512
|
+
upstreamStatus: "healthy",
|
|
513
|
+
capacityUsed: 2,
|
|
514
|
+
capacityLimit: 9,
|
|
515
|
+
resourceCpuPercent: 8.4,
|
|
516
|
+
resourceMemoryPercent: 41.2,
|
|
517
|
+
ttftMs: 123,
|
|
518
|
+
avgTokensPerSecond: 42.5,
|
|
519
|
+
latencySamples: 7,
|
|
520
|
+
upstreamBalanceUsdMicros: 12750000,
|
|
521
|
+
upstreamBalanceCurrency: "USD",
|
|
522
|
+
upstreamBalanceSource: "openrouter"
|
|
523
|
+
});
|
|
524
|
+
expect(rows[0].upstreamBalanceError).toBeUndefined();
|
|
525
|
+
} finally {
|
|
526
|
+
await new Promise<void>((resolve) => started.server.close(() => resolve()));
|
|
527
|
+
await new Promise<void>((resolve) => seller.close(() => resolve()));
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
421
531
|
test("tb-admin ui release requests endpoint proxies the active vendor profile", async () => {
|
|
422
532
|
const registry = http.createServer((req, res) => {
|
|
423
533
|
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
@@ -465,6 +575,72 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
465
575
|
}
|
|
466
576
|
});
|
|
467
577
|
|
|
578
|
+
test("UiActions setRegistryStatus uses vendor-scoped seller list and mutation auth", async () => {
|
|
579
|
+
const calls: Array<{ method?: string; path: string; authorization?: string; body?: any }> = [];
|
|
580
|
+
const registry = http.createServer((req, res) => {
|
|
581
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
582
|
+
let body = "";
|
|
583
|
+
req.on("data", (chunk) => {
|
|
584
|
+
body += chunk;
|
|
585
|
+
});
|
|
586
|
+
req.on("end", () => {
|
|
587
|
+
const parsed = body ? JSON.parse(body) : undefined;
|
|
588
|
+
calls.push({ method: req.method, path: url.pathname, authorization: req.headers.authorization, body: parsed });
|
|
589
|
+
if (req.method === "GET" && url.pathname === "/platform/sellers") {
|
|
590
|
+
expect(req.headers.authorization).toBe("Bearer vendor-token-value");
|
|
591
|
+
sendJson(res, {
|
|
592
|
+
sellers: [{
|
|
593
|
+
id: "vendor-live-1",
|
|
594
|
+
name: "Vendor Live 1",
|
|
595
|
+
app: "tbs-vendor-live-1",
|
|
596
|
+
url: "https://tbs-vendor-live-1.fly.dev",
|
|
597
|
+
status: "active",
|
|
598
|
+
supportedProtocols: ["chat_completions"],
|
|
599
|
+
paymentMethods: ["clawtip"]
|
|
600
|
+
}]
|
|
601
|
+
});
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (req.method === "GET" && url.pathname === "/registry/sellers") {
|
|
605
|
+
expect(req.headers.authorization).toBeUndefined();
|
|
606
|
+
sendJson(res, { version: 12, sellers: [] });
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (req.method === "PUT" && url.pathname === "/platform/sellers/vendor-live-1/status") {
|
|
610
|
+
expect(req.headers.authorization).toBe("Bearer vendor-token-value");
|
|
611
|
+
expect(parsed).toMatchObject({ status: "draining", expectedVersion: 12 });
|
|
612
|
+
expect(req.headers["idempotency-key"]).toMatch(/^tb-admin-ui-vendor-live-1-draining-12-/);
|
|
613
|
+
sendJson(res, { registry: { version: 13, sellers: [] } });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
res.statusCode = 404;
|
|
617
|
+
sendJson(res, { error: "not found" });
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
await new Promise<void>((resolve) => registry.listen(0, "127.0.0.1", () => resolve()));
|
|
621
|
+
const address = registry.address();
|
|
622
|
+
if (!address || typeof address !== "object") {
|
|
623
|
+
throw new Error("registry fixture did not bind a TCP port");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
627
|
+
mgr.setProfile("bootstrap", { url: `http://127.0.0.1:${address.port}`, token: "vendor-token-value" });
|
|
628
|
+
const actions = new UiActions({ configManager: mgr, profile: "bootstrap" });
|
|
629
|
+
try {
|
|
630
|
+
const result = await actions.setRegistryStatus("vendor-live-1", "draining");
|
|
631
|
+
|
|
632
|
+
expect(result.ok).toBe(true);
|
|
633
|
+
expect(result.stdout).toContain("version=13");
|
|
634
|
+
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([
|
|
635
|
+
"GET /platform/sellers",
|
|
636
|
+
"GET /registry/sellers",
|
|
637
|
+
"PUT /platform/sellers/vendor-live-1/status"
|
|
638
|
+
]);
|
|
639
|
+
} finally {
|
|
640
|
+
await new Promise<void>((resolve) => registry.close(() => resolve()));
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
468
644
|
test("tb-admin ui create seller returns a progress job", async () => {
|
|
469
645
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
470
646
|
mgr.setSellerProvider("fly", { operator_secret: "operator-token-value" });
|
|
@@ -579,10 +755,20 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
579
755
|
owner: "vendor-a",
|
|
580
756
|
raw: {}
|
|
581
757
|
}],
|
|
582
|
-
|
|
583
|
-
expect(
|
|
584
|
-
|
|
585
|
-
|
|
758
|
+
flyMachineSpecs: async (appName) => {
|
|
759
|
+
expect(appName).toBe("tbs-sin-06");
|
|
760
|
+
return {
|
|
761
|
+
machines: 1,
|
|
762
|
+
runningMachines: 1,
|
|
763
|
+
cpuKind: "shared",
|
|
764
|
+
cpuCores: 1,
|
|
765
|
+
memoryMb: 512,
|
|
766
|
+
volumeGb: 1,
|
|
767
|
+
regions: ["sin"]
|
|
768
|
+
};
|
|
769
|
+
},
|
|
770
|
+
balanceFetch: async () => {
|
|
771
|
+
throw new Error("admin UI must use seller operator balance endpoint before local probing");
|
|
586
772
|
}
|
|
587
773
|
});
|
|
588
774
|
const sellers = await state.sellers();
|
|
@@ -601,9 +787,16 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
601
787
|
upstreamBalanceCurrency: "USD",
|
|
602
788
|
upstreamBalanceSource: "openrouter"
|
|
603
789
|
});
|
|
790
|
+
expect(sellers[0].specs).toMatchObject({
|
|
791
|
+
machines: 1,
|
|
792
|
+
runningMachines: 1,
|
|
793
|
+
cpuCores: 1,
|
|
794
|
+
memoryMb: 512,
|
|
795
|
+
volumeGb: 1
|
|
796
|
+
});
|
|
604
797
|
|
|
605
798
|
const detail = await state.sellerDetail("tbs-sin-06");
|
|
606
|
-
expect(detail.configuration.upstreamApiKeyMasked).toBe("
|
|
799
|
+
expect(detail.configuration.upstreamApiKeyMasked).toBe("configured");
|
|
607
800
|
expect(detail.configuration.upstreamBalance).toBe("USD 47.95");
|
|
608
801
|
expect(detail.configuration.upstreamBalanceSource).toBe("openrouter");
|
|
609
802
|
expect(detail.configuration.upstreamBalanceProbeTemplate).toBe("openrouter");
|
|
@@ -703,6 +896,67 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
703
896
|
expect(detail.models.map((model) => model.upstreamModel)).toEqual(["openai/gpt-5.4", "anthropic/claude-opus-4.7"]);
|
|
704
897
|
}, ADMIN_UI_STATE_TEST_TIMEOUT_MS);
|
|
705
898
|
|
|
899
|
+
test("AdminUiState loads Fly seller detail when vendor registry auth fails", async () => {
|
|
900
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
901
|
+
mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
|
|
902
|
+
const state = new AdminUiState({
|
|
903
|
+
configManager: mgr,
|
|
904
|
+
url: "https://bootstrap.example.test",
|
|
905
|
+
flyApps: async () => [{
|
|
906
|
+
name: "tbs-flyonly-node",
|
|
907
|
+
status: "running",
|
|
908
|
+
owner: "vendor-a",
|
|
909
|
+
raw: {}
|
|
910
|
+
}],
|
|
911
|
+
fetchJson: async (url, init) => {
|
|
912
|
+
const pathName = new URL(url).pathname;
|
|
913
|
+
if (pathName === "/platform/sellers") {
|
|
914
|
+
throw new Error('HTTP Error 401: {"error":"vendor_auth_failed"}');
|
|
915
|
+
}
|
|
916
|
+
expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer operator-secret");
|
|
917
|
+
if (pathName === "/operator/status") {
|
|
918
|
+
return { status: "healthy", upstream: { status: "healthy" }, capacity: { activeConnections: 2, maxConnections: 6 } };
|
|
919
|
+
}
|
|
920
|
+
if (pathName === "/operator/admin/service") {
|
|
921
|
+
return { sellerId: "tbs-flyonly-node", modelsCount: 1, capacity: { maxConnections: 6, maxQueueDepth: 3 } };
|
|
922
|
+
}
|
|
923
|
+
if (pathName === "/operator/admin/upstreams") {
|
|
924
|
+
return {
|
|
925
|
+
upstreams: [{
|
|
926
|
+
upstreamUrl: "https://api.moonshot.cn/v1",
|
|
927
|
+
models: [{ id: "moonshot-v1-8k" }]
|
|
928
|
+
}]
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
if (pathName === "/operator/admin/config") {
|
|
932
|
+
return {
|
|
933
|
+
config: {
|
|
934
|
+
upstreamUrl: "https://api.moonshot.cn/v1",
|
|
935
|
+
upstreamApiKey: "moonshot-live-secret",
|
|
936
|
+
upstreamBalanceProbe: { template: "none" },
|
|
937
|
+
maxConnections: 6,
|
|
938
|
+
maxQueueDepth: 3
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
throw new Error(`unexpected fetch url ${url}`);
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
const detail = await state.sellerDetail("tbs-flyonly-node");
|
|
947
|
+
|
|
948
|
+
expect(detail.row).toMatchObject({
|
|
949
|
+
id: "tbs-flyonly-node",
|
|
950
|
+
dataSource: "fly",
|
|
951
|
+
nodeStatus: "active",
|
|
952
|
+
upstreamDomain: "api.moonshot.cn",
|
|
953
|
+
capacityUsed: 2,
|
|
954
|
+
capacityLimit: 6
|
|
955
|
+
});
|
|
956
|
+
expect(detail.configuration.upstreamApiKeyMasked).toBe("**** **** **** cret");
|
|
957
|
+
expect(detail.models.map((model) => model.upstreamModel)).toEqual(["moonshot-v1-8k"]);
|
|
958
|
+
}, ADMIN_UI_STATE_TEST_TIMEOUT_MS);
|
|
959
|
+
|
|
706
960
|
test("UiActions updates new registry seller config without a local profile", async () => {
|
|
707
961
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
708
962
|
mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
|
|
@@ -754,11 +1008,68 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
754
1008
|
]);
|
|
755
1009
|
});
|
|
756
1010
|
|
|
1011
|
+
test("UiActions updates Fly seller config when vendor registry auth fails", async () => {
|
|
1012
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
1013
|
+
mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
|
|
1014
|
+
const calls: string[][] = [];
|
|
1015
|
+
const fetchPaths: string[] = [];
|
|
1016
|
+
const actions = new UiActions({
|
|
1017
|
+
configManager: mgr,
|
|
1018
|
+
url: "https://bootstrap.example.test",
|
|
1019
|
+
token: "vendor-token",
|
|
1020
|
+
flyApps: async () => [{
|
|
1021
|
+
name: "tbs-flyonly-node",
|
|
1022
|
+
status: "running",
|
|
1023
|
+
owner: "vendor-a",
|
|
1024
|
+
raw: {}
|
|
1025
|
+
}],
|
|
1026
|
+
fetchJson: async (url, init) => {
|
|
1027
|
+
const pathName = new URL(url).pathname;
|
|
1028
|
+
fetchPaths.push(pathName);
|
|
1029
|
+
if (pathName === "/platform/sellers") {
|
|
1030
|
+
expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer vendor-token");
|
|
1031
|
+
throw new Error('HTTP Error 401: {"error":"vendor_auth_failed"}');
|
|
1032
|
+
}
|
|
1033
|
+
expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer operator-secret");
|
|
1034
|
+
if (pathName === "/operator/admin/config") {
|
|
1035
|
+
return {
|
|
1036
|
+
config: {
|
|
1037
|
+
upstreamUrl: "https://api.moonshot.cn/v1",
|
|
1038
|
+
upstreamApiKey: "live-secret-abcd",
|
|
1039
|
+
maxConnections: 6,
|
|
1040
|
+
maxQueueDepth: 3
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
throw new Error(`unexpected fetch url ${url}`);
|
|
1045
|
+
},
|
|
1046
|
+
commandRunner: async (args): Promise<UiActionResult> => {
|
|
1047
|
+
calls.push(args);
|
|
1048
|
+
return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
const result = await actions.updateSellerConfig("tbs-flyonly-node", { maxConnections: 9 });
|
|
1053
|
+
|
|
1054
|
+
expect(result.ok).toBe(true);
|
|
1055
|
+
expect(fetchPaths).toEqual([
|
|
1056
|
+
"/platform/sellers",
|
|
1057
|
+
"/registry/sellers",
|
|
1058
|
+
"/operator/admin/config"
|
|
1059
|
+
]);
|
|
1060
|
+
expect(calls.map((args) => args.join(" "))).toEqual([
|
|
1061
|
+
expect.stringContaining("--url https://tbs-flyonly-node.fly.dev --token operator-secret seller-config validate --file"),
|
|
1062
|
+
expect.stringContaining("--url https://tbs-flyonly-node.fly.dev --token operator-secret seller-config put --file")
|
|
1063
|
+
]);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
757
1066
|
test("admin UI seller rows render missing telemetry without unknown data labels", () => {
|
|
758
1067
|
const html = adminUiHtml();
|
|
759
1068
|
expect(html).toContain("class=\"spinner\"");
|
|
760
1069
|
expect(html).toContain("@keyframes spin");
|
|
761
1070
|
expect(html).toContain("role=\"status\" aria-label=\"Loading sellers\"");
|
|
1071
|
+
expect(html).toContain("<th>Enable</th>");
|
|
1072
|
+
expect(html).toContain("data-model-enabled");
|
|
762
1073
|
expect(html).toContain("id=\"createStatus\" class=\"status-line hidden\"");
|
|
763
1074
|
expect(html).toContain("id=\"createProgress\"");
|
|
764
1075
|
expect(html).toContain("create-progress");
|
|
@@ -781,31 +1092,62 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
781
1092
|
expect(html).toContain("pollCreateJob");
|
|
782
1093
|
expect(html).toContain("Created and added to bootstrap registry.");
|
|
783
1094
|
expect(html).toContain("upstreamUrl:\"https://openrouter.ai/api/v1\"");
|
|
784
|
-
expect(html).toContain("loadingSpinner(\"Loading
|
|
785
|
-
expect(html).toContain("
|
|
786
|
-
expect(html).toContain(
|
|
787
|
-
expect(html).toContain("
|
|
788
|
-
expect(html).toContain("
|
|
789
|
-
expect(html).toContain("
|
|
790
|
-
expect(html).toContain("
|
|
791
|
-
expect(html).toContain("
|
|
1095
|
+
expect(html).toContain("loadingSpinner(\"Loading registry\")");
|
|
1096
|
+
expect(html).toContain('id="detailGrid" class="detail-grid hidden"');
|
|
1097
|
+
expect(html).toContain('showDetailStatus("Loading seller data", true)');
|
|
1098
|
+
expect(html).toContain('document.getElementById("detailGrid").classList.add("hidden")');
|
|
1099
|
+
expect(html).toContain('document.getElementById("detailGrid").classList.remove("hidden")');
|
|
1100
|
+
expect(html).not.toContain("loadingSpinner(\"Loading configuration\")");
|
|
1101
|
+
expect(html).not.toContain("loadingSpinner(\"Loading models\")");
|
|
1102
|
+
expect(html).toContain("sellerStatusRefreshIntervalMs = 30000");
|
|
1103
|
+
expect(html).toContain("/api/sellers/status");
|
|
1104
|
+
expect(html).toContain("function pumpSellerDetailQueue()");
|
|
1105
|
+
expect(html).toContain("function statusRefreshRow(row)");
|
|
1106
|
+
expect(html).toContain("function scheduleSellerDetailRefresh(row)");
|
|
1107
|
+
expect(html).toContain("const nextRefreshAt = new Date(Date.now() + sellerStatusRefreshIntervalMs).toISOString()");
|
|
1108
|
+
expect(html).toContain("sellerDetailTimers.set(key, timer)");
|
|
1109
|
+
expect(html).toContain("Refreshing details");
|
|
792
1110
|
expect(html).not.toContain("sellerLastUpdated");
|
|
793
1111
|
expect(html).not.toContain("Last updated:");
|
|
794
1112
|
expect(html).not.toContain("s ago");
|
|
795
|
-
expect(html).toContain("sellerClockTimer = setInterval(() => updateSellerRefreshMeta(
|
|
1113
|
+
expect(html).toContain("sellerClockTimer = setInterval(() => { updateSellerRefreshMeta(); updateSellerRowRefreshCountdowns(); }, 1000)");
|
|
796
1114
|
expect(html).toContain("function renderSellerRows(rows)");
|
|
797
1115
|
expect(html).toContain("sellerRefreshLoaded = true");
|
|
798
1116
|
expect(html).toContain("readonly-value");
|
|
799
1117
|
expect(html).toContain("--seller-grid");
|
|
800
1118
|
expect(html).toContain("grid-template-columns:var(--seller-grid)");
|
|
801
|
-
expect(html).toContain("#sellerRows{display:grid;gap:
|
|
802
|
-
expect(html).toContain("gap:
|
|
1119
|
+
expect(html).toContain("#sellerRows{display:grid;gap:8px;width:100%;min-width:0}");
|
|
1120
|
+
expect(html).toContain("gap:8px;width:100%;min-width:0");
|
|
803
1121
|
expect(html).toContain("detailFieldsHtml");
|
|
804
1122
|
expect(html).toContain("data-original");
|
|
805
|
-
|
|
1123
|
+
expect(html).toContain('id="closeDetail" class="modal-close"');
|
|
1124
|
+
expect(html).toContain('aria-label="Close detail"');
|
|
1125
|
+
expect(html).not.toContain('id="closeDetail" class="btn">Close</button>');
|
|
1126
|
+
expect(html).toContain("function registryStatusForAction(action)");
|
|
1127
|
+
expect(html).toContain("function patchSellerRegistryStatus(id, status)");
|
|
1128
|
+
expect(html).toContain('showDetailStatus("Updating registry status", true)');
|
|
1129
|
+
expect(html).toContain("patchSellerRegistryStatus(id, status)");
|
|
1130
|
+
expect(html).toContain('document.getElementById("detailModal").classList.remove("open")');
|
|
1131
|
+
expect(html).not.toContain("Set \"+currentDetail.row.name+\" registry status via");
|
|
1132
|
+
expect(html).toContain("function registryStatusDisplay(status)");
|
|
1133
|
+
expect(html).toContain('["registryStatus", registryStatusDisplay(c.registryStatus || d.row.registryStatus)]');
|
|
1134
|
+
expect(html).toContain("function setDetailSavingBusy(busy)");
|
|
1135
|
+
expect(html).toContain('edit.textContent = busy ? "Saving" : (editing ? "Save changes" : "Edit config")');
|
|
1136
|
+
expect(html).toContain('showDetailStatus("Saving seller config", true)');
|
|
1137
|
+
expect(html).toContain('if (!result.ok) throw new Error(result.stderr || "Save failed")');
|
|
1138
|
+
expect(html).toContain("editing = false; renderDetail(); loadSellers();");
|
|
1139
|
+
expect(html).toContain('catch (err) { showDetailStatus(err.message || "Save failed", false); }');
|
|
1140
|
+
expect(html).not.toContain('showDetailStatus(result.ok ? "Saved"');
|
|
1141
|
+
// Design-spec header labels keep monitoring fields visible.
|
|
1142
|
+
expect(html).toContain(">Connection</span>");
|
|
1143
|
+
expect(html).toContain(">Resources</span>");
|
|
1144
|
+
expect(html).toContain(">Balance</span>");
|
|
806
1145
|
expect(html).toContain(">TTFT</span>");
|
|
807
|
-
expect(html).toContain(">
|
|
1146
|
+
expect(html).toContain(">Status</span>");
|
|
1147
|
+
expect(html).toContain("function upstreamStatusTone(status)");
|
|
1148
|
+
expect(html).toContain("Upstream status from seller /operator/status");
|
|
808
1149
|
expect(html).not.toContain(">Latency</span>");
|
|
1150
|
+
expect(html).not.toContain(">Discount</span>");
|
|
809
1151
|
// Spec-compliant token formats
|
|
810
1152
|
expect(html).toContain("tok/s");
|
|
811
1153
|
expect(html).toContain("AVG speed");
|
|
@@ -816,6 +1158,13 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
816
1158
|
expect(html).toContain("formatSellerStatus");
|
|
817
1159
|
expect(html).toContain("formatDiscountRatio");
|
|
818
1160
|
expect(html).toContain("formatBalanceAmount");
|
|
1161
|
+
expect(html).not.toContain("status-badge");
|
|
1162
|
+
expect(html).not.toContain("balance-badge");
|
|
1163
|
+
expect(html).not.toContain("probe blocked");
|
|
1164
|
+
expect(html).toContain("runningMachines");
|
|
1165
|
+
expect(html).toContain("memoryMb");
|
|
1166
|
+
expect(html).toContain("resourceCpuPercent");
|
|
1167
|
+
expect(html).toContain("resourceMemoryPercent");
|
|
819
1168
|
// No glassmorphism per spec
|
|
820
1169
|
expect(html).not.toContain("backdrop-filter");
|
|
821
1170
|
// Detail field references
|
|
@@ -870,8 +1219,9 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
870
1219
|
// Spec uses — (em dash) for unknown, not "not reported" or "n/a"
|
|
871
1220
|
expect(html).not.toContain("not reported");
|
|
872
1221
|
expect(html).not.toContain("\"n/a\"");
|
|
873
|
-
expect(html).toContain("
|
|
874
|
-
expect(html).toContain("
|
|
1222
|
+
expect(html).toContain("function usagePercent(value)");
|
|
1223
|
+
expect(html).toContain("usage CPU ");
|
|
1224
|
+
expect(html).toContain('const resourcePrimary = (cpuUsageText === "—" ? "-" : cpuUsageText) + "/" + (memoryUsageText === "—" ? "-" : memoryUsageText)');
|
|
875
1225
|
expect(html).not.toContain(">i</span>");
|
|
876
1226
|
expect(html).not.toContain(">×</button>");
|
|
877
1227
|
expect(html).not.toContain("Loading sellers...");
|
|
@@ -893,6 +1243,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
893
1243
|
const actions = new UiActions({
|
|
894
1244
|
configManager: mgr,
|
|
895
1245
|
profile: "bootstrap",
|
|
1246
|
+
flyApps: async () => [],
|
|
896
1247
|
commandRunner: async (args): Promise<UiActionResult> => {
|
|
897
1248
|
calls.push(args);
|
|
898
1249
|
return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
|
|
@@ -1588,24 +1939,39 @@ async function startMutableSellerConfigServer(initialConfig: Record<string, any>
|
|
|
1588
1939
|
async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
|
1589
1940
|
const server = http.createServer((req, res) => {
|
|
1590
1941
|
const pathName = new URL(req.url || "/", "http://127.0.0.1").pathname;
|
|
1942
|
+
const baseUrl = `http://${req.headers.host}`;
|
|
1943
|
+
const registrySellers = [{
|
|
1944
|
+
id: "tbs-sin-06",
|
|
1945
|
+
name: "tbs-sin-06",
|
|
1946
|
+
profile: "seller-sin",
|
|
1947
|
+
app: "tbs-sin-06",
|
|
1948
|
+
url: baseUrl,
|
|
1949
|
+
status: "active",
|
|
1950
|
+
region: "sin",
|
|
1951
|
+
modelsCount: 1,
|
|
1952
|
+
sampleModels: ["openai/gpt-5.4"],
|
|
1953
|
+
supportedProtocols: ["responses"],
|
|
1954
|
+
paymentMethods: ["clawtip"]
|
|
1955
|
+
}];
|
|
1956
|
+
if (pathName === "/platform/sellers") {
|
|
1957
|
+
expect(req.headers.authorization).toBe("Bearer bootstrap-token");
|
|
1958
|
+
sendJson(res, { sellers: registrySellers });
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1591
1961
|
if (pathName === "/registry/sellers") {
|
|
1592
1962
|
sendJson(res, {
|
|
1593
1963
|
version: 7,
|
|
1594
1964
|
updatedAt: "2026-06-05T00:00:00.000Z",
|
|
1595
1965
|
defaultSeller: "tbs-sin-06",
|
|
1596
|
-
sellers:
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
sampleModels: ["openai/gpt-5.4"],
|
|
1606
|
-
supportedProtocols: ["responses"],
|
|
1607
|
-
paymentMethods: ["clawtip"]
|
|
1608
|
-
}]
|
|
1966
|
+
sellers: registrySellers
|
|
1967
|
+
});
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (pathName === "/manifest") {
|
|
1971
|
+
sendJson(res, {
|
|
1972
|
+
sellerId: "tbs-sin-06",
|
|
1973
|
+
supportedProtocols: ["responses"],
|
|
1974
|
+
models: [{ id: "openai/gpt-5.4" }]
|
|
1609
1975
|
});
|
|
1610
1976
|
return;
|
|
1611
1977
|
}
|
|
@@ -1646,7 +2012,7 @@ async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () =
|
|
|
1646
2012
|
sendJson(res, {
|
|
1647
2013
|
config: {
|
|
1648
2014
|
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
1649
|
-
upstreamApiKey: "
|
|
2015
|
+
upstreamApiKey: "[redacted]",
|
|
1650
2016
|
upstreamBalanceUrl: "https://openrouter.ai/api/v1/credits",
|
|
1651
2017
|
upstreamRechargeUrl: "https://openrouter.ai/settings/credits",
|
|
1652
2018
|
upstreamBalanceProbe: {
|
|
@@ -1664,6 +2030,19 @@ async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () =
|
|
|
1664
2030
|
});
|
|
1665
2031
|
return;
|
|
1666
2032
|
}
|
|
2033
|
+
if (pathName === "/operator/admin/upstream-balance") {
|
|
2034
|
+
sendJson(res, {
|
|
2035
|
+
status: "ok",
|
|
2036
|
+
balance: {
|
|
2037
|
+
source: "openrouter",
|
|
2038
|
+
rawAmount: 47.95,
|
|
2039
|
+
amountUsdMicros: 47_950_000,
|
|
2040
|
+
currency: "USD",
|
|
2041
|
+
fetchedAt: 1781220000000
|
|
2042
|
+
}
|
|
2043
|
+
});
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
1667
2046
|
res.statusCode = 404;
|
|
1668
2047
|
res.end();
|
|
1669
2048
|
});
|
package/tests/seller.test.ts
CHANGED
|
@@ -222,6 +222,57 @@ describe("tb-admin seller CLI real spawn (no mock)", () => {
|
|
|
222
222
|
});
|
|
223
223
|
expect(parsed.commands[0]).toContain("apps destroy tbs-86d81e");
|
|
224
224
|
});
|
|
225
|
+
|
|
226
|
+
itRequires("roll --dry-run text path lists all live tbs-* candidates from fly apps list", () => {
|
|
227
|
+
const result = runTbAdminReal([
|
|
228
|
+
"seller", "roll",
|
|
229
|
+
"--image", "registry.fly.io/tb-seller:1.0.33",
|
|
230
|
+
"--dry-run"
|
|
231
|
+
]);
|
|
232
|
+
expect(result.ok).toBe(true);
|
|
233
|
+
expect(result.stdout).toMatch(/\[Fly\.io\] roll candidates \(tbs-\*, excludes applied\): \d+/);
|
|
234
|
+
// 至少要有 tbs-86d81e (live 1.0.31 已知)
|
|
235
|
+
expect(result.stdout).toMatch(/tbs-86d81e/);
|
|
236
|
+
// dry-run 模式: 每台都是 [DRY-RUN] 而非真 flyctl
|
|
237
|
+
expect(result.stdout).toMatch(/\[DRY-RUN\] would update tbs-86d81e image to/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
itRequires("roll --dry-run --json returns structured candidates/excluded/attempts", () => {
|
|
241
|
+
const result = runTbAdminReal([
|
|
242
|
+
"seller", "roll",
|
|
243
|
+
"--image", "registry.fly.io/tb-seller:1.0.33",
|
|
244
|
+
"--exclude", "tbs-anpin-ai-0d7517,tbs-openrouter-ai-06vry",
|
|
245
|
+
"--dry-run", "--json"
|
|
246
|
+
]);
|
|
247
|
+
expect(result.ok).toBe(true);
|
|
248
|
+
const parsed = JSON.parse(result.stdout);
|
|
249
|
+
expect(parsed).toMatchObject({
|
|
250
|
+
ok: true,
|
|
251
|
+
provider: "fly",
|
|
252
|
+
action: "roll",
|
|
253
|
+
image: "registry.fly.io/tb-seller:1.0.33",
|
|
254
|
+
dryRun: true,
|
|
255
|
+
completed: true
|
|
256
|
+
});
|
|
257
|
+
expect(Array.isArray(parsed.candidates)).toBe(true);
|
|
258
|
+
expect(parsed.candidates).toContain("tbs-86d81e");
|
|
259
|
+
expect(parsed.candidates).not.toContain("tbs-anpin-ai-0d7517");
|
|
260
|
+
expect(parsed.excluded).toEqual(expect.arrayContaining(["tbs-anpin-ai-0d7517", "tbs-openrouter-ai-06vry"]));
|
|
261
|
+
// attempts 顺序应该跟 candidates 顺序一致
|
|
262
|
+
expect(parsed.attempts.length).toBe(parsed.candidates.length);
|
|
263
|
+
for (let i = 0; i < parsed.attempts.length; i++) {
|
|
264
|
+
expect(parsed.attempts[i].app).toBe(parsed.candidates[i]);
|
|
265
|
+
expect(parsed.attempts[i].ok).toBe(true);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
itRequires("roll rejects empty image and returns 1 (with usage error)", () => {
|
|
270
|
+
const result = runTbAdminReal([
|
|
271
|
+
"seller", "roll", "--dry-run", "--json"
|
|
272
|
+
]);
|
|
273
|
+
// commander requiredOption 失败时, 进程退出非 0
|
|
274
|
+
expect(result.ok).toBe(false);
|
|
275
|
+
});
|
|
225
276
|
});
|
|
226
277
|
|
|
227
278
|
describe("SellerCommandRunner integration with real ConfigManager (no mock)", () => {
|