@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
package/src/seller.ts CHANGED
@@ -35,6 +35,16 @@ export interface SellerAppJson {
35
35
  raw: Record<string, unknown>;
36
36
  }
37
37
 
38
+ export interface SellerMachineSpecs {
39
+ machines: number;
40
+ runningMachines?: number;
41
+ cpuKind?: string;
42
+ cpuCores?: number;
43
+ memoryMb?: number;
44
+ volumeGb?: number;
45
+ regions?: string[];
46
+ }
47
+
38
48
  /** ls --json 输出 */
39
49
  export interface SellerListResult {
40
50
  ok: true;
@@ -68,12 +78,39 @@ export interface SellerActionResult<T extends "create" | "deploy" | "remove"> {
68
78
  machines?: string[];
69
79
  }
70
80
 
81
+ /** roll --json 输出: 按顺序对一组 seller 跑 image-only redeploy */
82
+ export interface SellerRollResult {
83
+ ok: true;
84
+ provider: "fly";
85
+ action: "roll";
86
+ image: string;
87
+ dryRun: boolean;
88
+ /** 实际进入 roll 的 seller app 名 (去 exclude 之后) */
89
+ candidates: string[];
90
+ /** 被 --exclude 排除的 seller */
91
+ excluded: string[];
92
+ /** 每一台 deploy 的结果, 按执行顺序. 失败那一台后续不再执行 */
93
+ attempts: SellerRollAttempt[];
94
+ /** true = 全部 success; false = 至少一台失败且后续没跑 */
95
+ completed: boolean;
96
+ }
97
+
98
+ export interface SellerRollAttempt {
99
+ app: string;
100
+ ok: boolean;
101
+ /** 失败时填 stderr / 异常 message */
102
+ error?: string;
103
+ /** 实际 deploy 后 flyctl 摘录 */
104
+ summary?: string;
105
+ }
106
+
71
107
  export type SellerCommandResult =
72
108
  | SellerListResult
73
109
  | SellerStatusResult
74
110
  | SellerActionResult<"create">
75
111
  | SellerActionResult<"deploy">
76
- | SellerActionResult<"remove">;
112
+ | SellerActionResult<"remove">
113
+ | SellerRollResult;
77
114
 
78
115
  export class FlyCliMissingError extends Error {
79
116
  constructor(public readonly flyctl: string) {
@@ -162,9 +199,10 @@ function parseFlyStatusJson(jsonText: string): Record<string, unknown> {
162
199
  function runFlyctlJson(
163
200
  flyctl: string,
164
201
  args: string[],
165
- env: NodeJS.ProcessEnv
202
+ env: NodeJS.ProcessEnv,
203
+ timeoutMs?: number
166
204
  ): unknown {
167
- const result = spawnSync(flyctl, args, { encoding: "utf8", env });
205
+ const result = spawnSync(flyctl, args, { encoding: "utf8", env, timeout: timeoutMs });
168
206
  if (result.error) {
169
207
  throw result.error;
170
208
  }
@@ -178,6 +216,36 @@ function runFlyctlJson(
178
216
  return result.stdout;
179
217
  }
180
218
 
219
+ function parseFlyMachinesJson(jsonText: string): SellerMachineSpecs {
220
+ let parsed: unknown;
221
+ try {
222
+ parsed = JSON.parse(jsonText || "[]");
223
+ } catch (err) {
224
+ throw new Error(`flyctl machines list --json returned invalid JSON: ${(err as Error).message}`);
225
+ }
226
+ if (!Array.isArray(parsed)) {
227
+ throw new Error("flyctl machines list --json did not return an array");
228
+ }
229
+ const machines = parsed.filter((item) => item && typeof item === "object") as Array<Record<string, unknown>>;
230
+ const first = machines[0];
231
+ const guest = first && typeof first.config === "object" && first.config
232
+ ? (first.config as Record<string, unknown>).guest as Record<string, unknown> | undefined
233
+ : undefined;
234
+ const mount = first && typeof first.config === "object" && first.config && Array.isArray((first.config as Record<string, unknown>).mounts)
235
+ ? ((first.config as Record<string, unknown>).mounts as unknown[]).find((item) => item && typeof item === "object") as Record<string, unknown> | undefined
236
+ : undefined;
237
+ const regions = Array.from(new Set(machines.map((machine) => typeof machine.region === "string" ? machine.region : undefined).filter((region): region is string => Boolean(region)))).sort();
238
+ return {
239
+ machines: machines.length,
240
+ runningMachines: machines.filter((machine) => machine.state === "started").length,
241
+ cpuKind: typeof guest?.cpu_kind === "string" ? guest.cpu_kind : undefined,
242
+ cpuCores: typeof guest?.cpus === "number" ? guest.cpus : undefined,
243
+ memoryMb: typeof guest?.memory_mb === "number" ? guest.memory_mb : undefined,
244
+ volumeGb: typeof mount?.size_gb === "number" ? mount.size_gb : undefined,
245
+ regions
246
+ };
247
+ }
248
+
181
249
  /**
182
250
  * 顶层 seller 命令入口. 5 个 op 都走这里.
183
251
  * - `json=false` (默认): 走 FlyProvider 现有方法, 返回人类可读 string, 调用方 console.log
@@ -233,6 +301,16 @@ export class SellerCommandRunner {
233
301
  return { ok: true, provider: "fly", action: "status", app: resolvedName, status };
234
302
  }
235
303
 
304
+ public machineSpecs(appName: string): SellerMachineSpecs | undefined {
305
+ const flyctl = this.getFlyctl();
306
+ if (!providerCheck(flyctl)) {
307
+ throw new FlyCliMissingError(flyctl);
308
+ }
309
+ const env = flyEnv(this.getProviderConfig());
310
+ const stdout = runFlyctlJson(flyctl, ["machines", "list", "--app", appName, "--json"], env, 10000) as string;
311
+ return parseFlyMachinesJson(stdout);
312
+ }
313
+
236
314
  // -- create --
237
315
 
238
316
  public create(options: SellerCreateOptions, json: boolean): SellerActionResult<"create"> | string {
@@ -321,6 +399,104 @@ export class SellerCommandRunner {
321
399
  summary: String(summary)
322
400
  };
323
401
  }
402
+
403
+ // -- roll --
404
+ //
405
+ // Step 13 之后 (v1.1 增量): 不写死 5 台 fixed-fleet, 从 `fly apps list` 拉全部
406
+ // `tbs-*` 候选, 按顺序 image-only redeploy 到指定镜像. 失败停、报告.
407
+ //
408
+ // 设计约束 (跟 1.0.31 deploy 子命令保持一致):
409
+ // - 复用 FlyProvider.deploySeller (image-only, 不动 volume / config / secrets)
410
+ // - 走 --json 路径 (runFlyctlJson), 失败可解析, 不靠 stdio inherit
411
+ // - 候选列表 = fly apps list --json 中 name 以 `tbs-` 开头的, 排除 --exclude
412
+ // - 真跑: 失败那台后续不再执行, 报告 attempts 全部 + completed=false
413
+ // - dry-run: 跑 fly apps list 拿候选, 不真 deploy, attempts 全部 ok=true, summary=计划
414
+
415
+ public roll(
416
+ args: { image: string; exclude?: string[]; dryRun?: boolean },
417
+ json: boolean
418
+ ): SellerRollResult | string {
419
+ const provider = this.getProvider();
420
+ const flyctl = this.getFlyctl();
421
+ const targetImage = args.image;
422
+ const excludeSet = new Set<string>((args.exclude || []).filter((s) => s.trim().length > 0));
423
+
424
+ if (!targetImage) {
425
+ throw new Error("seller roll requires --image registry.fly.io/tb-seller:<v>");
426
+ }
427
+
428
+ // 拿候选: 必须走 --json 路径 (需要结构化 Name 字段做 filter)
429
+ if (!providerCheck(flyctl)) {
430
+ throw new FlyCliMissingError(flyctl);
431
+ }
432
+ const env = flyEnv(this.getProviderConfig());
433
+ const stdout = runFlyctlJson(flyctl, ["apps", "list", "--json"], env) as string;
434
+ const allApps = parseFlyListJson(stdout);
435
+ const tbsOnly = allApps.filter((a) => a.name.startsWith("tbs-"));
436
+ const excluded = tbsOnly.filter((a) => excludeSet.has(a.name)).map((a) => a.name);
437
+ const candidates = tbsOnly.filter((a) => !excludeSet.has(a.name)).map((a) => a.name);
438
+
439
+ if (!json) {
440
+ // 文本路径: 先列计划, 然后按顺序跑
441
+ const lines: string[] = [];
442
+ lines.push(`[Fly.io] roll candidates (tbs-*, excludes applied): ${candidates.length}`);
443
+ for (const app of candidates) lines.push(` - ${app}`);
444
+ if (excluded.length > 0) {
445
+ lines.push(`[Fly.io] excluded by --exclude: ${excluded.join(", ")}`);
446
+ }
447
+ lines.push(`[Fly.io] image: ${targetImage}`);
448
+ lines.push(`[Fly.io] mode: ${args.dryRun ? "dry-run (no actual deploy)" : "live sequential, fail-fast"}`);
449
+ lines.push("");
450
+
451
+ let completed = true;
452
+ for (const app of candidates) {
453
+ if (args.dryRun) {
454
+ lines.push(`[Fly.io] [DRY-RUN] would update ${app} image to ${targetImage}`);
455
+ continue;
456
+ }
457
+ try {
458
+ const summary = provider.deploySeller({ app, image: targetImage });
459
+ lines.push(`[Fly.io] ${app}: ${summary}`);
460
+ } catch (err: any) {
461
+ lines.push(`[Fly.io] ${app}: FAILED — ${err.message || String(err)}`);
462
+ completed = false;
463
+ break;
464
+ }
465
+ }
466
+ lines.push("");
467
+ lines.push(completed ? "[Fly.io] roll completed." : "[Fly.io] roll stopped at failure.");
468
+ return lines.join("\n");
469
+ }
470
+
471
+ // --json 路径: 同样顺序执行, 但输出结构化
472
+ const attempts: SellerRollAttempt[] = [];
473
+ let completed = true;
474
+ for (const app of candidates) {
475
+ if (args.dryRun) {
476
+ attempts.push({ app, ok: true, summary: `[dry-run] would update ${app} image to ${targetImage}` });
477
+ continue;
478
+ }
479
+ try {
480
+ const summary = provider.deploySeller({ app, image: targetImage });
481
+ attempts.push({ app, ok: true, summary: String(summary) });
482
+ } catch (err: any) {
483
+ attempts.push({ app, ok: false, error: err.message || String(err) });
484
+ completed = false;
485
+ break;
486
+ }
487
+ }
488
+ return {
489
+ ok: true,
490
+ provider: "fly",
491
+ action: "roll",
492
+ image: targetImage,
493
+ dryRun: Boolean(args.dryRun),
494
+ candidates,
495
+ excluded,
496
+ attempts,
497
+ completed
498
+ };
499
+ }
324
500
  }
325
501
 
326
502
  // ===== helpers =====
package/src/ui-actions.ts CHANGED
@@ -61,6 +61,8 @@ export interface UiActionsOptions {
61
61
  url?: string;
62
62
  token?: string;
63
63
  fetchJson?: (url: string, init?: RequestInit) => Promise<unknown>;
64
+ balanceFetch?: typeof fetch;
65
+ flyApps?: () => Promise<import("./seller.js").SellerAppJson[]>;
64
66
  commandRunner?: (args: string[], timeoutMs: number) => Promise<UiActionResult>;
65
67
  }
66
68
 
@@ -264,7 +266,7 @@ export class UiActions {
264
266
  // Step 14 (v1.1): tb-admin ui 现在走 vendor-scoped /platform/sellers/{id}/status.
265
267
  // 老路径 `tb-admin bootstrap sellers status` (operator-level) 保留给 platform operator 显式调用.
266
268
  // Vendor client 用当前 active profile 的 token, 跟 admin web 鉴权一致.
267
- const registry = await this.state.fetchRegistry();
269
+ const registry = await this.state.fetchManagedRegistry();
268
270
  const target = registry.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
269
271
  if (!target) {
270
272
  throw new Error(`seller \`${id}\` not found in bootstrap registry`);
@@ -277,17 +279,19 @@ export class UiActions {
277
279
  throw new Error("No bootstrap profile found. Configure an admin profile or pass --url and --token.");
278
280
  }
279
281
  const client = new RegistryVendorClient(baseUrl, token);
280
- const idempotencyKey = `tb-admin-ui-${target.id}-${status}-${registry.version}-${Date.now()}`;
282
+ const expectedVersion = Number.isFinite(registry.version) && registry.version > 0 ? registry.version : undefined;
283
+ const versionPart = expectedVersion === undefined ? "unversioned" : String(expectedVersion);
284
+ const idempotencyKey = `tb-admin-ui-${target.id}-${status}-${versionPart}-${Date.now()}`;
281
285
  const response = await client.setSellerStatus(target.id, status, {
282
- expectedVersion: registry.version,
286
+ expectedVersion,
283
287
  idempotencyKey
284
288
  });
285
- const newVersion = (response as { registry?: { version?: number } })?.registry?.version ?? registry.version + 1;
289
+ const newVersion = (response as { registry?: { version?: number } })?.registry?.version ?? (expectedVersion === undefined ? undefined : expectedVersion + 1);
286
290
  return {
287
291
  ok: true,
288
- stdout: `Set seller ${target.id} status=${status}: version=${newVersion} (vendor path)`,
292
+ stdout: `Set seller ${target.id} status=${status}${newVersion === undefined ? "" : `: version=${newVersion}`} (vendor path)`,
289
293
  stderr: "",
290
- command: ["PUT", `/platform/sellers/${target.id}/status`, JSON.stringify({ status, expectedVersion: registry.version })],
294
+ command: ["PUT", `/platform/sellers/${target.id}/status`, JSON.stringify({ status, ...(expectedVersion === undefined ? {} : { expectedVersion }) })],
291
295
  json: response
292
296
  };
293
297
  }
package/src/ui-command.ts CHANGED
@@ -12,13 +12,14 @@ export function bindAdminUiCommand(program: Command, configManager: ConfigManage
12
12
  .action(async (options) => {
13
13
  try {
14
14
  const rootOptions = program.opts();
15
+ const mgr = rootOptions.config ? new ConfigManager(rootOptions.config) : configManager;
15
16
  const started = await startAdminUiServer({
16
17
  host: options.host,
17
18
  port: options.port,
18
19
  openBrowser: Boolean(options.open),
19
- configManager,
20
+ configManager: mgr,
20
21
  configPath: rootOptions.config,
21
- profile: rootOptions.profile || process.env.TOKENBUDDY_ADMIN_PROFILE || "bootstrap",
22
+ profile: rootOptions.profile || process.env.TOKENBUDDY_ADMIN_PROFILE || defaultUiProfile(mgr),
22
23
  url: rootOptions.url || process.env.TOKENBUDDY_ADMIN_URL,
23
24
  token: rootOptions.token || process.env.TOKENBUDDY_ADMIN_TOKEN
24
25
  });
@@ -37,3 +38,7 @@ function parsePort(value: string): number {
37
38
  }
38
39
  return parsed;
39
40
  }
41
+
42
+ export function defaultUiProfile(configManager: ConfigManager): string {
43
+ return configManager.getProfile("bootstrap-vendor") ? "bootstrap-vendor" : "bootstrap";
44
+ }
package/src/ui-server.ts CHANGED
@@ -3,7 +3,7 @@ import { randomBytes } from "crypto";
3
3
  import { URL } from "url";
4
4
  import { ConfigManager } from "./config.js";
5
5
  import { RegistryVendorClient } from "./client.js";
6
- import { AdminUiState } from "./ui-state.js";
6
+ import { AdminUiState, type SellerRow } from "./ui-state.js";
7
7
  import { UiActions, type CreateSellerRequest, type UiActionProgressEvent, type UiActionResult } from "./ui-actions.js";
8
8
  import { adminUiHtml } from "./ui-static.js";
9
9
 
@@ -115,10 +115,27 @@ async function routeRequest(
115
115
  sendJson(res, 200, await client.listReleaseRequests(limit ? Number(limit) : 20));
116
116
  return;
117
117
  }
118
+ if (req.method === "GET" && parsed.pathname === "/api/sellers/registry") {
119
+ sendJson(res, 200, await state.sellerRegistryRows());
120
+ return;
121
+ }
122
+ if (req.method === "GET" && parsed.pathname === "/api/sellers/inventory") {
123
+ sendJson(res, 200, await state.sellerInventory());
124
+ return;
125
+ }
118
126
  if (req.method === "GET" && parsed.pathname === "/api/sellers") {
119
127
  sendJson(res, 200, await state.sellers());
120
128
  return;
121
129
  }
130
+ if (parsed.pathname === "/api/sellers/status" && req.method !== "POST") {
131
+ sendJson(res, 405, { error: "method not allowed" });
132
+ return;
133
+ }
134
+ if (req.method === "POST" && parsed.pathname === "/api/sellers/status") {
135
+ const body = await readJson(req) as { rows?: SellerRow[] };
136
+ sendJson(res, 200, await state.refreshSellerRows(Array.isArray(body.rows) ? body.rows : []));
137
+ return;
138
+ }
122
139
  if (req.method === "POST" && parsed.pathname === "/api/sellers") {
123
140
  const body = await readJson(req) as CreateSellerRequest;
124
141
  const job = startCreateSellerJob(jobs, actions, body);