@tokenbuddy/tb-admin 1.0.33 → 1.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +28 -0
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/seller.d.ts +40 -1
  5. package/dist/src/seller.d.ts.map +1 -1
  6. package/dist/src/seller.js +132 -2
  7. package/dist/src/seller.js.map +1 -1
  8. package/dist/src/ui-actions.d.ts +2 -0
  9. package/dist/src/ui-actions.d.ts.map +1 -1
  10. package/dist/src/ui-actions.js +8 -6
  11. package/dist/src/ui-actions.js.map +1 -1
  12. package/dist/src/ui-command.d.ts +1 -0
  13. package/dist/src/ui-command.d.ts.map +1 -1
  14. package/dist/src/ui-command.js +7 -2
  15. package/dist/src/ui-command.js.map +1 -1
  16. package/dist/src/ui-server.js +17 -0
  17. package/dist/src/ui-server.js.map +1 -1
  18. package/dist/src/ui-state.d.ts +31 -0
  19. package/dist/src/ui-state.d.ts.map +1 -1
  20. package/dist/src/ui-state.js +461 -115
  21. package/dist/src/ui-state.js.map +1 -1
  22. package/dist/src/ui-static.d.ts.map +1 -1
  23. package/dist/src/ui-static.js +267 -144
  24. package/dist/src/ui-static.js.map +1 -1
  25. package/dist/src/upstream-balance-probe.d.ts +2 -40
  26. package/dist/src/upstream-balance-probe.d.ts.map +1 -1
  27. package/dist/src/upstream-balance-probe.js +1 -378
  28. package/dist/src/upstream-balance-probe.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli.ts +31 -0
  31. package/src/seller.ts +179 -3
  32. package/src/ui-actions.ts +10 -6
  33. package/src/ui-command.ts +7 -2
  34. package/src/ui-server.ts +18 -1
  35. package/src/ui-state.ts +541 -115
  36. package/src/ui-static.ts +267 -144
  37. package/src/upstream-balance-probe.ts +13 -505
  38. package/tests/admin.test.ts +418 -39
  39. package/tests/seller.test.ts +51 -0
  40. package/tests/ui-state-fleet.test.ts +272 -3
  41. package/tests/ui-static-row.test.ts +273 -8
@@ -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,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 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: ");
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(sellerRefreshInFlight), 1000)");
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:10px;width:100%;min-width:0}");
802
- expect(html).toContain("gap:12px;width:100%;min-width:0");
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
- // Design-spec header labels (TTFT not Latency, Disc not Discount)
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(">Disc</span>");
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("aria-label=\"Seller specs\"");
874
- expect(html).toContain("<svg viewBox=\"0 0 24 24\"");
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
- 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
- }]
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: "fixture-live-key-27f9",
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
  });
@@ -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)", () => {