@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
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,6 +1008,61 @@ 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\"");
|
|
@@ -781,31 +1090,62 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
781
1090
|
expect(html).toContain("pollCreateJob");
|
|
782
1091
|
expect(html).toContain("Created and added to bootstrap registry.");
|
|
783
1092
|
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("
|
|
1093
|
+
expect(html).toContain("loadingSpinner(\"Loading registry\")");
|
|
1094
|
+
expect(html).toContain('id="detailGrid" class="detail-grid hidden"');
|
|
1095
|
+
expect(html).toContain('showDetailStatus("Loading seller data", true)');
|
|
1096
|
+
expect(html).toContain('document.getElementById("detailGrid").classList.add("hidden")');
|
|
1097
|
+
expect(html).toContain('document.getElementById("detailGrid").classList.remove("hidden")');
|
|
1098
|
+
expect(html).not.toContain("loadingSpinner(\"Loading configuration\")");
|
|
1099
|
+
expect(html).not.toContain("loadingSpinner(\"Loading models\")");
|
|
1100
|
+
expect(html).toContain("sellerStatusRefreshIntervalMs = 30000");
|
|
1101
|
+
expect(html).toContain("/api/sellers/status");
|
|
1102
|
+
expect(html).toContain("function pumpSellerDetailQueue()");
|
|
1103
|
+
expect(html).toContain("function statusRefreshRow(row)");
|
|
1104
|
+
expect(html).toContain("function scheduleSellerDetailRefresh(row)");
|
|
1105
|
+
expect(html).toContain("const nextRefreshAt = new Date(Date.now() + sellerStatusRefreshIntervalMs).toISOString()");
|
|
1106
|
+
expect(html).toContain("sellerDetailTimers.set(key, timer)");
|
|
1107
|
+
expect(html).toContain("Refreshing details");
|
|
792
1108
|
expect(html).not.toContain("sellerLastUpdated");
|
|
793
1109
|
expect(html).not.toContain("Last updated:");
|
|
794
1110
|
expect(html).not.toContain("s ago");
|
|
795
|
-
expect(html).toContain("sellerClockTimer = setInterval(() => updateSellerRefreshMeta(
|
|
1111
|
+
expect(html).toContain("sellerClockTimer = setInterval(() => { updateSellerRefreshMeta(); updateSellerRowRefreshCountdowns(); }, 1000)");
|
|
796
1112
|
expect(html).toContain("function renderSellerRows(rows)");
|
|
797
1113
|
expect(html).toContain("sellerRefreshLoaded = true");
|
|
798
1114
|
expect(html).toContain("readonly-value");
|
|
799
1115
|
expect(html).toContain("--seller-grid");
|
|
800
1116
|
expect(html).toContain("grid-template-columns:var(--seller-grid)");
|
|
801
|
-
expect(html).toContain("#sellerRows{display:grid;gap:
|
|
802
|
-
expect(html).toContain("gap:
|
|
1117
|
+
expect(html).toContain("#sellerRows{display:grid;gap:8px;width:100%;min-width:0}");
|
|
1118
|
+
expect(html).toContain("gap:8px;width:100%;min-width:0");
|
|
803
1119
|
expect(html).toContain("detailFieldsHtml");
|
|
804
1120
|
expect(html).toContain("data-original");
|
|
805
|
-
|
|
1121
|
+
expect(html).toContain('id="closeDetail" class="modal-close"');
|
|
1122
|
+
expect(html).toContain('aria-label="Close detail"');
|
|
1123
|
+
expect(html).not.toContain('id="closeDetail" class="btn">Close</button>');
|
|
1124
|
+
expect(html).toContain("function registryStatusForAction(action)");
|
|
1125
|
+
expect(html).toContain("function patchSellerRegistryStatus(id, status)");
|
|
1126
|
+
expect(html).toContain('showDetailStatus("Updating registry status", true)');
|
|
1127
|
+
expect(html).toContain("patchSellerRegistryStatus(id, status)");
|
|
1128
|
+
expect(html).toContain('document.getElementById("detailModal").classList.remove("open")');
|
|
1129
|
+
expect(html).not.toContain("Set \"+currentDetail.row.name+\" registry status via");
|
|
1130
|
+
expect(html).toContain("function registryStatusDisplay(status)");
|
|
1131
|
+
expect(html).toContain('["registryStatus", registryStatusDisplay(c.registryStatus || d.row.registryStatus)]');
|
|
1132
|
+
expect(html).toContain("function setDetailSavingBusy(busy)");
|
|
1133
|
+
expect(html).toContain('edit.textContent = busy ? "Saving" : (editing ? "Save changes" : "Edit config")');
|
|
1134
|
+
expect(html).toContain('showDetailStatus("Saving seller config", true)');
|
|
1135
|
+
expect(html).toContain('if (!result.ok) throw new Error(result.stderr || "Save failed")');
|
|
1136
|
+
expect(html).toContain("editing = false; renderDetail(); loadSellers();");
|
|
1137
|
+
expect(html).toContain('catch (err) { showDetailStatus(err.message || "Save failed", false); }');
|
|
1138
|
+
expect(html).not.toContain('showDetailStatus(result.ok ? "Saved"');
|
|
1139
|
+
// Design-spec header labels keep monitoring fields visible.
|
|
1140
|
+
expect(html).toContain(">Connection</span>");
|
|
1141
|
+
expect(html).toContain(">Resources</span>");
|
|
1142
|
+
expect(html).toContain(">Balance</span>");
|
|
806
1143
|
expect(html).toContain(">TTFT</span>");
|
|
807
|
-
expect(html).toContain(">
|
|
1144
|
+
expect(html).toContain(">Status</span>");
|
|
1145
|
+
expect(html).toContain("function upstreamStatusTone(status)");
|
|
1146
|
+
expect(html).toContain("Upstream status from seller /operator/status");
|
|
808
1147
|
expect(html).not.toContain(">Latency</span>");
|
|
1148
|
+
expect(html).not.toContain(">Discount</span>");
|
|
809
1149
|
// Spec-compliant token formats
|
|
810
1150
|
expect(html).toContain("tok/s");
|
|
811
1151
|
expect(html).toContain("AVG speed");
|
|
@@ -816,6 +1156,13 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
816
1156
|
expect(html).toContain("formatSellerStatus");
|
|
817
1157
|
expect(html).toContain("formatDiscountRatio");
|
|
818
1158
|
expect(html).toContain("formatBalanceAmount");
|
|
1159
|
+
expect(html).not.toContain("status-badge");
|
|
1160
|
+
expect(html).not.toContain("balance-badge");
|
|
1161
|
+
expect(html).not.toContain("probe blocked");
|
|
1162
|
+
expect(html).toContain("runningMachines");
|
|
1163
|
+
expect(html).toContain("memoryMb");
|
|
1164
|
+
expect(html).toContain("resourceCpuPercent");
|
|
1165
|
+
expect(html).toContain("resourceMemoryPercent");
|
|
819
1166
|
// No glassmorphism per spec
|
|
820
1167
|
expect(html).not.toContain("backdrop-filter");
|
|
821
1168
|
// Detail field references
|
|
@@ -870,8 +1217,9 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
870
1217
|
// Spec uses — (em dash) for unknown, not "not reported" or "n/a"
|
|
871
1218
|
expect(html).not.toContain("not reported");
|
|
872
1219
|
expect(html).not.toContain("\"n/a\"");
|
|
873
|
-
expect(html).toContain("
|
|
874
|
-
expect(html).toContain("
|
|
1220
|
+
expect(html).toContain("function usagePercent(value)");
|
|
1221
|
+
expect(html).toContain("usage CPU ");
|
|
1222
|
+
expect(html).toContain('const resourcePrimary = (cpuUsageText === "—" ? "-" : cpuUsageText) + "/" + (memoryUsageText === "—" ? "-" : memoryUsageText)');
|
|
875
1223
|
expect(html).not.toContain(">i</span>");
|
|
876
1224
|
expect(html).not.toContain(">×</button>");
|
|
877
1225
|
expect(html).not.toContain("Loading sellers...");
|
|
@@ -893,6 +1241,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
893
1241
|
const actions = new UiActions({
|
|
894
1242
|
configManager: mgr,
|
|
895
1243
|
profile: "bootstrap",
|
|
1244
|
+
flyApps: async () => [],
|
|
896
1245
|
commandRunner: async (args): Promise<UiActionResult> => {
|
|
897
1246
|
calls.push(args);
|
|
898
1247
|
return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
|
|
@@ -1588,24 +1937,39 @@ async function startMutableSellerConfigServer(initialConfig: Record<string, any>
|
|
|
1588
1937
|
async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
|
1589
1938
|
const server = http.createServer((req, res) => {
|
|
1590
1939
|
const pathName = new URL(req.url || "/", "http://127.0.0.1").pathname;
|
|
1940
|
+
const baseUrl = `http://${req.headers.host}`;
|
|
1941
|
+
const registrySellers = [{
|
|
1942
|
+
id: "tbs-sin-06",
|
|
1943
|
+
name: "tbs-sin-06",
|
|
1944
|
+
profile: "seller-sin",
|
|
1945
|
+
app: "tbs-sin-06",
|
|
1946
|
+
url: baseUrl,
|
|
1947
|
+
status: "active",
|
|
1948
|
+
region: "sin",
|
|
1949
|
+
modelsCount: 1,
|
|
1950
|
+
sampleModels: ["openai/gpt-5.4"],
|
|
1951
|
+
supportedProtocols: ["responses"],
|
|
1952
|
+
paymentMethods: ["clawtip"]
|
|
1953
|
+
}];
|
|
1954
|
+
if (pathName === "/platform/sellers") {
|
|
1955
|
+
expect(req.headers.authorization).toBe("Bearer bootstrap-token");
|
|
1956
|
+
sendJson(res, { sellers: registrySellers });
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1591
1959
|
if (pathName === "/registry/sellers") {
|
|
1592
1960
|
sendJson(res, {
|
|
1593
1961
|
version: 7,
|
|
1594
1962
|
updatedAt: "2026-06-05T00:00:00.000Z",
|
|
1595
1963
|
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
|
-
}]
|
|
1964
|
+
sellers: registrySellers
|
|
1965
|
+
});
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
if (pathName === "/manifest") {
|
|
1969
|
+
sendJson(res, {
|
|
1970
|
+
sellerId: "tbs-sin-06",
|
|
1971
|
+
supportedProtocols: ["responses"],
|
|
1972
|
+
models: [{ id: "openai/gpt-5.4" }]
|
|
1609
1973
|
});
|
|
1610
1974
|
return;
|
|
1611
1975
|
}
|
|
@@ -1646,7 +2010,7 @@ async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () =
|
|
|
1646
2010
|
sendJson(res, {
|
|
1647
2011
|
config: {
|
|
1648
2012
|
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
1649
|
-
upstreamApiKey: "
|
|
2013
|
+
upstreamApiKey: "[redacted]",
|
|
1650
2014
|
upstreamBalanceUrl: "https://openrouter.ai/api/v1/credits",
|
|
1651
2015
|
upstreamRechargeUrl: "https://openrouter.ai/settings/credits",
|
|
1652
2016
|
upstreamBalanceProbe: {
|
|
@@ -1664,6 +2028,19 @@ async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () =
|
|
|
1664
2028
|
});
|
|
1665
2029
|
return;
|
|
1666
2030
|
}
|
|
2031
|
+
if (pathName === "/operator/admin/upstream-balance") {
|
|
2032
|
+
sendJson(res, {
|
|
2033
|
+
status: "ok",
|
|
2034
|
+
balance: {
|
|
2035
|
+
source: "openrouter",
|
|
2036
|
+
rawAmount: 47.95,
|
|
2037
|
+
amountUsdMicros: 47_950_000,
|
|
2038
|
+
currency: "USD",
|
|
2039
|
+
fetchedAt: 1781220000000
|
|
2040
|
+
}
|
|
2041
|
+
});
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
1667
2044
|
res.statusCode = 404;
|
|
1668
2045
|
res.end();
|
|
1669
2046
|
});
|
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)", () => {
|