@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.
Files changed (41) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +28 -0
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/seller.d.ts +40 -1
  5. package/dist/src/seller.d.ts.map +1 -1
  6. package/dist/src/seller.js +132 -2
  7. package/dist/src/seller.js.map +1 -1
  8. package/dist/src/ui-actions.d.ts +2 -0
  9. package/dist/src/ui-actions.d.ts.map +1 -1
  10. package/dist/src/ui-actions.js +8 -6
  11. package/dist/src/ui-actions.js.map +1 -1
  12. package/dist/src/ui-command.d.ts +1 -0
  13. package/dist/src/ui-command.d.ts.map +1 -1
  14. package/dist/src/ui-command.js +7 -2
  15. package/dist/src/ui-command.js.map +1 -1
  16. package/dist/src/ui-server.js +17 -0
  17. package/dist/src/ui-server.js.map +1 -1
  18. package/dist/src/ui-state.d.ts +29 -0
  19. package/dist/src/ui-state.d.ts.map +1 -1
  20. package/dist/src/ui-state.js +455 -111
  21. package/dist/src/ui-state.js.map +1 -1
  22. package/dist/src/ui-static.d.ts.map +1 -1
  23. package/dist/src/ui-static.js +262 -143
  24. package/dist/src/ui-static.js.map +1 -1
  25. package/dist/src/upstream-balance-probe.d.ts +2 -40
  26. package/dist/src/upstream-balance-probe.d.ts.map +1 -1
  27. package/dist/src/upstream-balance-probe.js +1 -378
  28. package/dist/src/upstream-balance-probe.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli.ts +31 -0
  31. package/src/seller.ts +179 -3
  32. package/src/ui-actions.ts +10 -6
  33. package/src/ui-command.ts +7 -2
  34. package/src/ui-server.ts +18 -1
  35. package/src/ui-state.ts +533 -111
  36. package/src/ui-static.ts +262 -143
  37. package/src/upstream-balance-probe.ts +13 -505
  38. package/tests/admin.test.ts +416 -39
  39. package/tests/seller.test.ts +51 -0
  40. package/tests/ui-state-fleet.test.ts +272 -3
  41. package/tests/ui-static-row.test.ts +273 -8
@@ -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 requests tab targets the releases page and loader", () => {
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('data-page="releases"');
98
- expect(html).toContain('id="page-releases"');
99
- expect(html).toContain('id="releasesGrid"');
100
- expect(html).toContain('btn.dataset.page === "releases"');
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
- balanceFetch: async (url, init) => {
583
- expect(String(url)).toBe("https://openrouter.ai/api/v1/credits");
584
- expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-live-key-27f9");
585
- return jsonResponse({ data: { total_credits: 50, total_usage: 2.05 } });
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("**** **** **** 27f9");
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 sellers\")");
785
- expect(html).toContain("loadingSpinner(\"Loading configuration\")");
786
- expect(html).toContain("loadingSpinner(\"Loading models\")");
787
- expect(html).toContain("sellerRefreshIntervalMs = 30000");
788
- expect(html).toContain("function scheduleSellerRefresh()");
789
- expect(html).toContain("sellerNextRefreshAt = new Date(Date.now() + sellerRefreshIntervalMs)");
790
- expect(html).toContain("sellerRefreshTimer = setTimeout(() => loadSellers(), sellerRefreshIntervalMs)");
791
- expect(html).toContain("Next refresh: ");
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(sellerRefreshInFlight), 1000)");
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:10px;width:100%;min-width:0}");
802
- expect(html).toContain("gap:12px;width:100%;min-width:0");
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
- // Design-spec header labels (TTFT not Latency, Disc not Discount)
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(">Disc</span>");
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("aria-label=\"Seller specs\"");
874
- expect(html).toContain("<svg viewBox=\"0 0 24 24\"");
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
- id: "tbs-sin-06",
1598
- name: "tbs-sin-06",
1599
- profile: "seller-sin",
1600
- app: "tbs-sin-06",
1601
- url: "https://seller.example.test",
1602
- status: "active",
1603
- region: "sin",
1604
- modelsCount: 1,
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: "fixture-live-key-27f9",
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
  });
@@ -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)", () => {