@tokenbuddy/tb-admin 1.0.32 → 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 +29 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.js +3 -3
- package/dist/src/client.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.d.ts.map +1 -1
- package/dist/src/ui-server.js +29 -8
- 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 +32 -1
- package/src/client.ts +3 -4
- 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 +30 -8
- 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 +472 -36
- package/tests/seller.test.ts +84 -3
- package/tests/ui-state-fleet.test.ts +272 -3
- package/tests/ui-static-row.test.ts +273 -8
- package/tests/vendor-cli.test.ts +45 -1
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";
|
|
@@ -38,6 +39,7 @@ import * as vm from "vm";
|
|
|
38
39
|
|
|
39
40
|
const TEMP_CONF_PATH = path.resolve(__dirname, "../../data-test/admin-config.json");
|
|
40
41
|
const PACKAGE_JSON = path.resolve(__dirname, "../package.json");
|
|
42
|
+
const ADMIN_UI_STATE_TEST_TIMEOUT_MS = 15_000;
|
|
41
43
|
|
|
42
44
|
describe("Admin CLI Config Profile Management Tests", () => {
|
|
43
45
|
beforeEach(() => {
|
|
@@ -90,6 +92,20 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
90
92
|
expect(() => new vm.Script(scripts[0])).not.toThrow();
|
|
91
93
|
});
|
|
92
94
|
|
|
95
|
+
test("admin UI keeps release state in the seller table instead of a separate releases page", () => {
|
|
96
|
+
const html = adminUiHtml();
|
|
97
|
+
|
|
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");
|
|
105
|
+
expect(html).not.toContain('id="page-bootstrap"');
|
|
106
|
+
expect(html).not.toContain('getElementById("bootstrapGrid")');
|
|
107
|
+
});
|
|
108
|
+
|
|
93
109
|
test("Switch default profiles", () => {
|
|
94
110
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
95
111
|
mgr.setProfile("prod", { url: "http://127.0.0.1:8000", token: "secret-op" });
|
|
@@ -358,6 +374,15 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
358
374
|
expect(() => validateRegistryDocument(badDefault)).toThrow("defaultSeller `missing`");
|
|
359
375
|
});
|
|
360
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
|
+
|
|
361
386
|
test("tb-admin ui server binds loopback, rejects cross-origin APIs, and serves bootstrap data", async () => {
|
|
362
387
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
363
388
|
const started = await startAdminUiServer({
|
|
@@ -406,6 +431,216 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
406
431
|
})).rejects.toThrow("loopback");
|
|
407
432
|
});
|
|
408
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
|
+
|
|
531
|
+
test("tb-admin ui release requests endpoint proxies the active vendor profile", async () => {
|
|
532
|
+
const registry = http.createServer((req, res) => {
|
|
533
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
534
|
+
if (req.method === "GET" && url.pathname === "/platform/release-requests") {
|
|
535
|
+
expect(req.headers.authorization).toBe("Bearer vendor-token-value");
|
|
536
|
+
expect(url.searchParams.get("limit")).toBe("20");
|
|
537
|
+
sendJson(res, {
|
|
538
|
+
releaseRequests: [{
|
|
539
|
+
id: 42,
|
|
540
|
+
status: "pending",
|
|
541
|
+
submittedAt: "2026-06-11T00:00:00.000Z",
|
|
542
|
+
sellerCount: 1,
|
|
543
|
+
note: "fixture release"
|
|
544
|
+
}]
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
res.statusCode = 404;
|
|
549
|
+
sendJson(res, { error: "not found" });
|
|
550
|
+
});
|
|
551
|
+
await new Promise<void>((resolve) => registry.listen(0, "127.0.0.1", () => resolve()));
|
|
552
|
+
const address = registry.address();
|
|
553
|
+
if (!address || typeof address !== "object") {
|
|
554
|
+
throw new Error("registry fixture did not bind a TCP port");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
558
|
+
mgr.setProfile("bootstrap", { url: `http://127.0.0.1:${address.port}`, token: "vendor-token-value" });
|
|
559
|
+
const started = await startAdminUiServer({
|
|
560
|
+
host: "127.0.0.1",
|
|
561
|
+
port: 0,
|
|
562
|
+
openBrowser: false,
|
|
563
|
+
configManager: mgr,
|
|
564
|
+
profile: "bootstrap"
|
|
565
|
+
});
|
|
566
|
+
try {
|
|
567
|
+
const response = await fetch(`${started.url}api/vendor/release-requests`);
|
|
568
|
+
expect(response.status).toBe(200);
|
|
569
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
570
|
+
releaseRequests: [{ id: 42, status: "pending", note: "fixture release" }]
|
|
571
|
+
});
|
|
572
|
+
} finally {
|
|
573
|
+
await new Promise<void>((resolve) => started.server.close(() => resolve()));
|
|
574
|
+
await new Promise<void>((resolve) => registry.close(() => resolve()));
|
|
575
|
+
}
|
|
576
|
+
});
|
|
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
|
+
|
|
409
644
|
test("tb-admin ui create seller returns a progress job", async () => {
|
|
410
645
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
411
646
|
mgr.setSellerProvider("fly", { operator_secret: "operator-token-value" });
|
|
@@ -520,10 +755,20 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
520
755
|
owner: "vendor-a",
|
|
521
756
|
raw: {}
|
|
522
757
|
}],
|
|
523
|
-
|
|
524
|
-
expect(
|
|
525
|
-
|
|
526
|
-
|
|
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");
|
|
527
772
|
}
|
|
528
773
|
});
|
|
529
774
|
const sellers = await state.sellers();
|
|
@@ -542,9 +787,16 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
542
787
|
upstreamBalanceCurrency: "USD",
|
|
543
788
|
upstreamBalanceSource: "openrouter"
|
|
544
789
|
});
|
|
790
|
+
expect(sellers[0].specs).toMatchObject({
|
|
791
|
+
machines: 1,
|
|
792
|
+
runningMachines: 1,
|
|
793
|
+
cpuCores: 1,
|
|
794
|
+
memoryMb: 512,
|
|
795
|
+
volumeGb: 1
|
|
796
|
+
});
|
|
545
797
|
|
|
546
798
|
const detail = await state.sellerDetail("tbs-sin-06");
|
|
547
|
-
expect(detail.configuration.upstreamApiKeyMasked).toBe("
|
|
799
|
+
expect(detail.configuration.upstreamApiKeyMasked).toBe("configured");
|
|
548
800
|
expect(detail.configuration.upstreamBalance).toBe("USD 47.95");
|
|
549
801
|
expect(detail.configuration.upstreamBalanceSource).toBe("openrouter");
|
|
550
802
|
expect(detail.configuration.upstreamBalanceProbeTemplate).toBe("openrouter");
|
|
@@ -558,7 +810,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
558
810
|
} finally {
|
|
559
811
|
await fixture.close();
|
|
560
812
|
}
|
|
561
|
-
});
|
|
813
|
+
}, ADMIN_UI_STATE_TEST_TIMEOUT_MS);
|
|
562
814
|
|
|
563
815
|
test("AdminUiState uses Fly provider operator secret for registry sellers without local profiles", async () => {
|
|
564
816
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
@@ -642,7 +894,68 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
642
894
|
expect(detail.configuration.upstreamUrl).toBe("https://openrouter.ai/api");
|
|
643
895
|
expect(detail.configuration.upstreamApiKeyMasked).toBe("**** **** **** abcd");
|
|
644
896
|
expect(detail.models.map((model) => model.upstreamModel)).toEqual(["openai/gpt-5.4", "anthropic/claude-opus-4.7"]);
|
|
645
|
-
});
|
|
897
|
+
}, ADMIN_UI_STATE_TEST_TIMEOUT_MS);
|
|
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);
|
|
646
959
|
|
|
647
960
|
test("UiActions updates new registry seller config without a local profile", async () => {
|
|
648
961
|
const mgr = new ConfigManager(TEMP_CONF_PATH);
|
|
@@ -695,6 +1008,61 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
695
1008
|
]);
|
|
696
1009
|
});
|
|
697
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
|
+
|
|
698
1066
|
test("admin UI seller rows render missing telemetry without unknown data labels", () => {
|
|
699
1067
|
const html = adminUiHtml();
|
|
700
1068
|
expect(html).toContain("class=\"spinner\"");
|
|
@@ -722,31 +1090,62 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
722
1090
|
expect(html).toContain("pollCreateJob");
|
|
723
1091
|
expect(html).toContain("Created and added to bootstrap registry.");
|
|
724
1092
|
expect(html).toContain("upstreamUrl:\"https://openrouter.ai/api/v1\"");
|
|
725
|
-
expect(html).toContain("loadingSpinner(\"Loading
|
|
726
|
-
expect(html).toContain("
|
|
727
|
-
expect(html).toContain(
|
|
728
|
-
expect(html).toContain("
|
|
729
|
-
expect(html).toContain("
|
|
730
|
-
expect(html).toContain("
|
|
731
|
-
expect(html).toContain("
|
|
732
|
-
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");
|
|
733
1108
|
expect(html).not.toContain("sellerLastUpdated");
|
|
734
1109
|
expect(html).not.toContain("Last updated:");
|
|
735
1110
|
expect(html).not.toContain("s ago");
|
|
736
|
-
expect(html).toContain("sellerClockTimer = setInterval(() => updateSellerRefreshMeta(
|
|
1111
|
+
expect(html).toContain("sellerClockTimer = setInterval(() => { updateSellerRefreshMeta(); updateSellerRowRefreshCountdowns(); }, 1000)");
|
|
737
1112
|
expect(html).toContain("function renderSellerRows(rows)");
|
|
738
1113
|
expect(html).toContain("sellerRefreshLoaded = true");
|
|
739
1114
|
expect(html).toContain("readonly-value");
|
|
740
1115
|
expect(html).toContain("--seller-grid");
|
|
741
1116
|
expect(html).toContain("grid-template-columns:var(--seller-grid)");
|
|
742
|
-
expect(html).toContain("#sellerRows{display:grid;gap:
|
|
743
|
-
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");
|
|
744
1119
|
expect(html).toContain("detailFieldsHtml");
|
|
745
1120
|
expect(html).toContain("data-original");
|
|
746
|
-
|
|
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>");
|
|
747
1143
|
expect(html).toContain(">TTFT</span>");
|
|
748
|
-
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");
|
|
749
1147
|
expect(html).not.toContain(">Latency</span>");
|
|
1148
|
+
expect(html).not.toContain(">Discount</span>");
|
|
750
1149
|
// Spec-compliant token formats
|
|
751
1150
|
expect(html).toContain("tok/s");
|
|
752
1151
|
expect(html).toContain("AVG speed");
|
|
@@ -757,6 +1156,13 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
757
1156
|
expect(html).toContain("formatSellerStatus");
|
|
758
1157
|
expect(html).toContain("formatDiscountRatio");
|
|
759
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");
|
|
760
1166
|
// No glassmorphism per spec
|
|
761
1167
|
expect(html).not.toContain("backdrop-filter");
|
|
762
1168
|
// Detail field references
|
|
@@ -811,8 +1217,9 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
811
1217
|
// Spec uses — (em dash) for unknown, not "not reported" or "n/a"
|
|
812
1218
|
expect(html).not.toContain("not reported");
|
|
813
1219
|
expect(html).not.toContain("\"n/a\"");
|
|
814
|
-
expect(html).toContain("
|
|
815
|
-
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)');
|
|
816
1223
|
expect(html).not.toContain(">i</span>");
|
|
817
1224
|
expect(html).not.toContain(">×</button>");
|
|
818
1225
|
expect(html).not.toContain("Loading sellers...");
|
|
@@ -834,6 +1241,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
|
|
|
834
1241
|
const actions = new UiActions({
|
|
835
1242
|
configManager: mgr,
|
|
836
1243
|
profile: "bootstrap",
|
|
1244
|
+
flyApps: async () => [],
|
|
837
1245
|
commandRunner: async (args): Promise<UiActionResult> => {
|
|
838
1246
|
calls.push(args);
|
|
839
1247
|
return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
|
|
@@ -1529,24 +1937,39 @@ async function startMutableSellerConfigServer(initialConfig: Record<string, any>
|
|
|
1529
1937
|
async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
|
1530
1938
|
const server = http.createServer((req, res) => {
|
|
1531
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
|
+
}
|
|
1532
1959
|
if (pathName === "/registry/sellers") {
|
|
1533
1960
|
sendJson(res, {
|
|
1534
1961
|
version: 7,
|
|
1535
1962
|
updatedAt: "2026-06-05T00:00:00.000Z",
|
|
1536
1963
|
defaultSeller: "tbs-sin-06",
|
|
1537
|
-
sellers:
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
sampleModels: ["openai/gpt-5.4"],
|
|
1547
|
-
supportedProtocols: ["responses"],
|
|
1548
|
-
paymentMethods: ["clawtip"]
|
|
1549
|
-
}]
|
|
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" }]
|
|
1550
1973
|
});
|
|
1551
1974
|
return;
|
|
1552
1975
|
}
|
|
@@ -1587,7 +2010,7 @@ async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () =
|
|
|
1587
2010
|
sendJson(res, {
|
|
1588
2011
|
config: {
|
|
1589
2012
|
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
1590
|
-
upstreamApiKey: "
|
|
2013
|
+
upstreamApiKey: "[redacted]",
|
|
1591
2014
|
upstreamBalanceUrl: "https://openrouter.ai/api/v1/credits",
|
|
1592
2015
|
upstreamRechargeUrl: "https://openrouter.ai/settings/credits",
|
|
1593
2016
|
upstreamBalanceProbe: {
|
|
@@ -1605,6 +2028,19 @@ async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () =
|
|
|
1605
2028
|
});
|
|
1606
2029
|
return;
|
|
1607
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
|
+
}
|
|
1608
2044
|
res.statusCode = 404;
|
|
1609
2045
|
res.end();
|
|
1610
2046
|
});
|