@tokenbuddy/tb-admin 1.0.33 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +28 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/seller.d.ts +40 -1
- package/dist/src/seller.d.ts.map +1 -1
- package/dist/src/seller.js +132 -2
- package/dist/src/seller.js.map +1 -1
- package/dist/src/ui-actions.d.ts +2 -0
- package/dist/src/ui-actions.d.ts.map +1 -1
- package/dist/src/ui-actions.js +8 -6
- package/dist/src/ui-actions.js.map +1 -1
- package/dist/src/ui-command.d.ts +1 -0
- package/dist/src/ui-command.d.ts.map +1 -1
- package/dist/src/ui-command.js +7 -2
- package/dist/src/ui-command.js.map +1 -1
- package/dist/src/ui-server.js +17 -0
- package/dist/src/ui-server.js.map +1 -1
- package/dist/src/ui-state.d.ts +29 -0
- package/dist/src/ui-state.d.ts.map +1 -1
- package/dist/src/ui-state.js +455 -111
- package/dist/src/ui-state.js.map +1 -1
- package/dist/src/ui-static.d.ts.map +1 -1
- package/dist/src/ui-static.js +262 -143
- package/dist/src/ui-static.js.map +1 -1
- package/dist/src/upstream-balance-probe.d.ts +2 -40
- package/dist/src/upstream-balance-probe.d.ts.map +1 -1
- package/dist/src/upstream-balance-probe.js +1 -378
- package/dist/src/upstream-balance-probe.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +31 -0
- package/src/seller.ts +179 -3
- package/src/ui-actions.ts +10 -6
- package/src/ui-command.ts +7 -2
- package/src/ui-server.ts +18 -1
- package/src/ui-state.ts +533 -111
- package/src/ui-static.ts +262 -143
- package/src/upstream-balance-probe.ts +13 -505
- package/tests/admin.test.ts +416 -39
- package/tests/seller.test.ts +51 -0
- package/tests/ui-state-fleet.test.ts +272 -3
- package/tests/ui-static-row.test.ts +273 -8
package/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.
|
|
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
|
|
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
|
|
286
|
+
expectedVersion,
|
|
283
287
|
idempotencyKey
|
|
284
288
|
});
|
|
285
|
-
const newVersion = (response as { registry?: { version?: number } })?.registry?.version ??
|
|
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:
|
|
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 ||
|
|
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);
|