@tokenbuddy/tb-admin 1.0.30 → 1.0.32
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 +280 -19
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +82 -2
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +93 -0
- package/dist/src/client.js.map +1 -1
- package/dist/src/provider.d.ts +120 -0
- package/dist/src/provider.d.ts.map +1 -0
- package/dist/src/provider.js +73 -0
- package/dist/src/provider.js.map +1 -0
- package/dist/src/seller.d.ts +104 -0
- package/dist/src/seller.d.ts.map +1 -0
- package/dist/src/seller.js +283 -0
- package/dist/src/seller.js.map +1 -0
- package/dist/src/ui-actions.d.ts +25 -0
- package/dist/src/ui-actions.d.ts.map +1 -1
- package/dist/src/ui-actions.js +81 -11
- package/dist/src/ui-actions.js.map +1 -1
- package/dist/src/ui-server.js +9 -0
- package/dist/src/ui-server.js.map +1 -1
- package/dist/src/ui-state.d.ts +77 -2
- package/dist/src/ui-state.d.ts.map +1 -1
- package/dist/src/ui-state.js +242 -14
- 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 +95 -17
- package/dist/src/ui-static.js.map +1 -1
- package/dist/src/vendor-client.d.ts +23 -0
- package/dist/src/vendor-client.d.ts.map +1 -0
- package/dist/src/vendor-client.js +2 -0
- package/dist/src/vendor-client.js.map +1 -0
- package/dist/src/vendor-commands.d.ts +35 -0
- package/dist/src/vendor-commands.d.ts.map +1 -0
- package/dist/src/vendor-commands.js +33 -0
- package/dist/src/vendor-commands.js.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +305 -31
- package/src/client.ts +119 -2
- package/src/provider.ts +150 -0
- package/src/seller.ts +362 -0
- package/src/ui-actions.ts +89 -11
- package/src/ui-server.ts +9 -0
- package/src/ui-state.ts +293 -15
- package/src/ui-static.ts +95 -17
- package/src/vendor-client.ts +23 -0
- package/src/vendor-commands.ts +65 -0
- package/tests/admin.test.ts +20 -1
- package/tests/seller.test.ts +307 -0
- package/tests/ui-state-fleet.test.ts +257 -0
- package/tests/ui-static-row.test.ts +202 -0
- package/tests/vendor-cli.test.ts +197 -0
package/src/ui-state.ts
CHANGED
|
@@ -10,6 +10,13 @@ import {
|
|
|
10
10
|
export type RegistryStatus = "active" | "draining" | "offline" | "pending" | "unknown";
|
|
11
11
|
export type NodeStatus = RegistryStatus | "busy_capacity" | "auth_unknown";
|
|
12
12
|
export type UpstreamStatus = "healthy" | "degraded" | "unhealthy" | "unknown";
|
|
13
|
+
/**
|
|
14
|
+
* Step 13 v1.1: dataSource 标记一行 seller 在 fly.io apps list vs
|
|
15
|
+
* registry /registry/sellers 两个独立查询里的出现情况. UI 据此决定
|
|
16
|
+
* 标红 / 立即下线 / Apply 按钮. 详细见
|
|
17
|
+
* docs/processes/seller-fleet-data-sources.md.
|
|
18
|
+
*/
|
|
19
|
+
export type SellerDataSource = "fly" | "registry" | "both";
|
|
13
20
|
|
|
14
21
|
export interface SellerRow {
|
|
15
22
|
id: string;
|
|
@@ -47,6 +54,32 @@ export interface SellerRow {
|
|
|
47
54
|
modelsCount?: number;
|
|
48
55
|
};
|
|
49
56
|
error?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Step 13 v1.1: 数据源标记. UI 据此决定:
|
|
59
|
+
* - "fly" → 灰点 + 「未发布」+ Apply 按钮 (走 vendor-bootstrap stage)
|
|
60
|
+
* - "registry" → 整行标红 + 立即下线按钮 (registry-only = 重大事故)
|
|
61
|
+
* - "both" → 正常色 + Activate / Drain 走 vendor path
|
|
62
|
+
*/
|
|
63
|
+
dataSource: SellerDataSource;
|
|
64
|
+
/**
|
|
65
|
+
* Step 13 v1.1: 标红告警 (registry-only). UI 整行用 .registry-alert
|
|
66
|
+
* class 标红, tooltip 提示事故 + 立即下线.
|
|
67
|
+
*/
|
|
68
|
+
registryAlert?: boolean;
|
|
69
|
+
/** Step 13 v1.1: 标红原因 (中文短句, UI tooltip 显示). */
|
|
70
|
+
alertReason?: string;
|
|
71
|
+
/** Step 13 v1.1: "未发布" 提示 (fly-only 行的 publishHint 按钮 caption). */
|
|
72
|
+
publishHint?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Step 13 v1.1: 立即下线按钮 caption (registry-only 行). 文案
|
|
75
|
+
* 必须含 "registry-only" 让用户知道这**不**删 fly app. 详见 spec.
|
|
76
|
+
*/
|
|
77
|
+
removeHint?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Step 13 v1.1: 关联的 fly app (raw, 来源 flyctl apps list --json).
|
|
80
|
+
* fly-only 行一定有值; both 行有值; registry-only 行 undefined.
|
|
81
|
+
*/
|
|
82
|
+
flyApp?: { name: string; status?: string; owner?: string; latestDeployAt?: string };
|
|
50
83
|
}
|
|
51
84
|
|
|
52
85
|
export interface SellerModelRow {
|
|
@@ -126,6 +159,12 @@ export interface AdminUiStateOptions {
|
|
|
126
159
|
token?: string;
|
|
127
160
|
fetchJson?: (url: string, init?: RequestInit) => Promise<unknown>;
|
|
128
161
|
balanceFetch?: typeof fetch;
|
|
162
|
+
/**
|
|
163
|
+
* Step 13 v1.1: flyctl apps list --json 替代. 默认调 seller.ts 的
|
|
164
|
+
* runFlyctlJson 真 spawn flyctl. 测试用 closure 注入 fake 数据.
|
|
165
|
+
* 返回的 SellerAppJson 形如 { name, status, owner, latestDeployAt }.
|
|
166
|
+
*/
|
|
167
|
+
flyApps?: () => Promise<import("./seller.js").SellerAppJson[]>;
|
|
129
168
|
}
|
|
130
169
|
|
|
131
170
|
interface ProfileMatch {
|
|
@@ -215,19 +254,146 @@ export class AdminUiState {
|
|
|
215
254
|
}
|
|
216
255
|
}
|
|
217
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Step 13 v1.1: 双源 seller list.
|
|
259
|
+
* - flyApps: flyctl apps list --json (走 fly token)
|
|
260
|
+
* - fetchRegistry: GET <registry>/registry/sellers (走 vendor key)
|
|
261
|
+
* **不**做 union / dedupe / merge. 渲染时按 (fly ∩ registry / fly /
|
|
262
|
+
* registry) 标 dataSource, registry-only 行整行标红. 详见
|
|
263
|
+
* docs/processes/seller-fleet-data-sources.md.
|
|
264
|
+
*/
|
|
218
265
|
public async sellers(): Promise<SellerRow[]> {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
266
|
+
const [flyApps, registryDoc] = await Promise.all([
|
|
267
|
+
this.fetchFlyApps().catch((err: any) => {
|
|
268
|
+
return { __error: err.message } as any;
|
|
269
|
+
}),
|
|
270
|
+
this.fetchRegistry().catch((err: any) => {
|
|
271
|
+
return { __error: err.message, sellers: [] } as any;
|
|
272
|
+
})
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
// Step 13: 算两个索引.
|
|
276
|
+
// flyByName: app.name -> SellerAppJson (UI 用, 不会重复)
|
|
277
|
+
// registryByKey: entry.id / entry.app -> entry (UI 用, 不会重复)
|
|
278
|
+
// 行数 = max(fly 数量, registry 数量); 双源都有时合并到 1 行.
|
|
279
|
+
const flyByName = new Map<string, import("./seller.js").SellerAppJson>();
|
|
280
|
+
if (Array.isArray(flyApps)) {
|
|
281
|
+
for (const app of flyApps) {
|
|
282
|
+
if (app?.name) {
|
|
283
|
+
flyByName.set(app.name, app);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const registryById = new Map<string, SellerRegistryEntry>();
|
|
288
|
+
const registryByApp = new Map<string, SellerRegistryEntry>();
|
|
289
|
+
for (const entry of registryDoc.sellers || []) {
|
|
290
|
+
if (entry.id) registryById.set(entry.id, entry);
|
|
291
|
+
if (entry.app) registryByApp.set(entry.app, entry);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Step 13: 4 类行 (dataSource 决定):
|
|
295
|
+
// - fly ∩ registry: 同一行 (both)
|
|
296
|
+
// - fly only: dataSource=fly, 灰点 + "未发布" 提示
|
|
297
|
+
// - registry only: dataSource=registry, **整行标红** + 立即下线按钮
|
|
298
|
+
// - 都出现但 key 不 match (e.g. registry 没填 app 字段): 也当 both
|
|
299
|
+
// (按 entry.url host 兜底 match)
|
|
300
|
+
const rows: SellerRow[] = [];
|
|
301
|
+
const consumedFly = new Set<string>();
|
|
302
|
+
const consumedRegistry = new Set<string>();
|
|
303
|
+
|
|
304
|
+
// Phase 1: registry first (因为有 id + url + 详细 metadata)
|
|
305
|
+
for (const entry of registryDoc.sellers || []) {
|
|
306
|
+
const flyMatch = entry.app ? flyByName.get(entry.app) : undefined;
|
|
307
|
+
const dataSource: SellerDataSource = flyMatch ? "both" : "registry";
|
|
308
|
+
if (flyMatch) {
|
|
309
|
+
consumedFly.add(flyMatch.name);
|
|
310
|
+
}
|
|
311
|
+
consumedRegistry.add(entry.id);
|
|
312
|
+
const snapshot = await this.sellerSnapshot(entry, flyMatch, dataSource);
|
|
313
|
+
rows.push(snapshot.row);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Phase 2: fly-only apps (registry 没有)
|
|
317
|
+
for (const app of flyByName.values()) {
|
|
318
|
+
if (consumedFly.has(app.name)) continue;
|
|
319
|
+
const stubEntry: SellerRegistryEntry = {
|
|
320
|
+
id: app.name,
|
|
321
|
+
name: app.name,
|
|
322
|
+
app: app.name,
|
|
323
|
+
url: `https://${app.name}.fly.dev`,
|
|
324
|
+
supportedProtocols: [],
|
|
325
|
+
paymentMethods: [],
|
|
326
|
+
models: []
|
|
327
|
+
};
|
|
328
|
+
const snapshot = await this.sellerSnapshot(stubEntry, app, "fly");
|
|
329
|
+
rows.push(snapshot.row);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return rows;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Step 13 v1.1: 拉 flyctl apps list --json. 默认走 seller.ts 真实
|
|
337
|
+
* flyctl spawn, 测试或受限环境可注入 options.flyApps closure.
|
|
338
|
+
*/
|
|
339
|
+
private async fetchFlyApps(): Promise<import("./seller.js").SellerAppJson[]> {
|
|
340
|
+
if (this.options.flyApps) {
|
|
341
|
+
return await this.options.flyApps();
|
|
342
|
+
}
|
|
343
|
+
// 默认路径: 走 SellerCommandRunner.ls(true) 真 spawn flyctl.
|
|
344
|
+
// 避免在 ui-state.ts 里 import 整个 seller module 引入循环依赖,
|
|
345
|
+
// 走 dynamic import; 失败 fallback 返空数组 (UI 不会崩, 只是
|
|
346
|
+
// 标 dataSource="registry" + 标红).
|
|
347
|
+
try {
|
|
348
|
+
const mod = await import("./seller.js");
|
|
349
|
+
const cfg = this.configManager.load();
|
|
350
|
+
const profile = cfg.profiles[this.options.profile || "default"];
|
|
351
|
+
const env = {
|
|
352
|
+
...process.env,
|
|
353
|
+
...(profile?.url ? { TB_REGISTRY_URL: profile.url } : {}),
|
|
354
|
+
...(profile?.token ? { TB_REGISTRY_TOKEN: profile.token } : {})
|
|
355
|
+
};
|
|
356
|
+
const runner = new mod.SellerCommandRunner(this.configManager);
|
|
357
|
+
const result = await runner.ls(true);
|
|
358
|
+
if (result && typeof result === "object" && "apps" in result) {
|
|
359
|
+
return (result as { apps: import("./seller.js").SellerAppJson[] }).apps;
|
|
360
|
+
}
|
|
361
|
+
return [];
|
|
362
|
+
} catch (err: any) {
|
|
363
|
+
return [];
|
|
364
|
+
}
|
|
222
365
|
}
|
|
223
366
|
|
|
224
367
|
public async sellerDetail(id: string): Promise<SellerDetail> {
|
|
368
|
+
// Step 13 v1.1: detail 页也走双源. 先看 entry 在不在 registry,
|
|
369
|
+
// 不在则查 fly list (registry-only 行, 标红 detail).
|
|
225
370
|
const document = await this.fetchRegistry();
|
|
226
|
-
|
|
371
|
+
let entry = document.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
|
|
372
|
+
let dataSource: SellerDataSource = "registry";
|
|
373
|
+
let flyApp: import("./seller.js").SellerAppJson | undefined;
|
|
227
374
|
if (!entry) {
|
|
228
|
-
|
|
375
|
+
const flyApps = await this.fetchFlyApps().catch(() => []);
|
|
376
|
+
const flyMatch = flyApps.find((app) => app.name === id || app.name === `tb-seller-${id}` || app.name === id.replace(/^tb-seller-/, ""));
|
|
377
|
+
if (!flyMatch) {
|
|
378
|
+
throw new Error(`seller \`${id}\` not found in fly.io apps or bootstrap registry`);
|
|
379
|
+
}
|
|
380
|
+
entry = {
|
|
381
|
+
id: flyMatch.name,
|
|
382
|
+
name: flyMatch.name,
|
|
383
|
+
app: flyMatch.name,
|
|
384
|
+
url: `https://${flyMatch.name}.fly.dev`,
|
|
385
|
+
supportedProtocols: [],
|
|
386
|
+
paymentMethods: [],
|
|
387
|
+
models: []
|
|
388
|
+
};
|
|
389
|
+
flyApp = flyMatch;
|
|
390
|
+
dataSource = "fly";
|
|
391
|
+
} else {
|
|
392
|
+
const flyApps = await this.fetchFlyApps().catch(() => []);
|
|
393
|
+
flyApp = entry.app ? flyApps.find((app) => app.name === entry!.app) : undefined;
|
|
394
|
+
dataSource = flyApp ? "both" : "registry";
|
|
229
395
|
}
|
|
230
|
-
const snapshot = await this.sellerSnapshot(entry);
|
|
396
|
+
const snapshot = await this.sellerSnapshot(entry, flyApp, dataSource);
|
|
231
397
|
const config = snapshot.config?.config || snapshot.config || {};
|
|
232
398
|
const upstreams = snapshot.upstreams || {};
|
|
233
399
|
return {
|
|
@@ -285,7 +451,7 @@ export class AdminUiState {
|
|
|
285
451
|
return document;
|
|
286
452
|
}
|
|
287
453
|
|
|
288
|
-
|
|
454
|
+
public activeBootstrapProfile(): ProfileMatch {
|
|
289
455
|
const envProfile = process.env.TOKENBUDDY_ADMIN_PROFILE;
|
|
290
456
|
const target = this.options.profile || envProfile;
|
|
291
457
|
if (this.options.url) {
|
|
@@ -335,20 +501,89 @@ export class AdminUiState {
|
|
|
335
501
|
return {};
|
|
336
502
|
}
|
|
337
503
|
|
|
338
|
-
|
|
504
|
+
/**
|
|
505
|
+
* Step 13 v1.1: seller snapshot.
|
|
506
|
+
* - 双源数据已经定好 (dataSource 由调用方传入)
|
|
507
|
+
* - nodeStatus = fetch <seller.url>/manifest 200 → active, 否则 unknown
|
|
508
|
+
* (**不**用 registry entry.status 决定绿点)
|
|
509
|
+
* - registry-only 行 (dataSource="registry") → 标红 (registryAlert=true)
|
|
510
|
+
*/
|
|
511
|
+
private async sellerSnapshot(
|
|
512
|
+
entry: SellerRegistryEntry,
|
|
513
|
+
flyApp: import("./seller.js").SellerAppJson | undefined,
|
|
514
|
+
dataSource: SellerDataSource
|
|
515
|
+
): Promise<SellerSnapshot> {
|
|
339
516
|
const match = this.matchSellerProfile(entry);
|
|
340
|
-
const baseRow = baseSellerRow(entry, match.name);
|
|
341
|
-
|
|
517
|
+
const baseRow = baseSellerRow(entry, match.name, dataSource, flyApp);
|
|
518
|
+
|
|
519
|
+
// Step 13: 立即下线 / Apply 按钮 hint 文案
|
|
520
|
+
if (dataSource === "registry") {
|
|
521
|
+
baseRow.registryAlert = true;
|
|
522
|
+
baseRow.alertReason = "registry 收录了但 fly app 失踪 — 严重事故, 立即下线";
|
|
523
|
+
baseRow.removeHint = "立即下线 (registry-only)";
|
|
524
|
+
} else if (dataSource === "fly") {
|
|
525
|
+
baseRow.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (dataSource === "fly") {
|
|
529
|
+
// fly-only 行没 registry entry, 也可能没 profile. 仍然跑 manifest
|
|
530
|
+
// probe (entry.url 来自 flyApp 推断 https://<name>.fly.dev), 但
|
|
531
|
+
// 没 profile 时只算 "unknown" (灰点) — 因为 vendor 没法管控一个
|
|
532
|
+
// 还没发布的 instance, 不该 throw auth_unknown 把 UI 卡死.
|
|
533
|
+
const manifestOk = await this.probeManifest(entry.url);
|
|
342
534
|
return {
|
|
343
535
|
row: {
|
|
344
536
|
...baseRow,
|
|
345
|
-
nodeStatus: "
|
|
537
|
+
nodeStatus: manifestOk ? "active" : "unknown",
|
|
538
|
+
error: manifestOk ? undefined : "未发布到 registry, /manifest 探测失败 (不视为事故)"
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
if (dataSource === "both" && !match.profile) {
|
|
543
|
+
// both 但 vendor profile 缺 — 还是 unknown (灰点), 1.0.31 老行为
|
|
544
|
+
// 会落 auth_unknown, 现在用 unknown 让 UI 不会显得像 "鉴权失败".
|
|
545
|
+
return {
|
|
546
|
+
row: {
|
|
547
|
+
...baseRow,
|
|
548
|
+
nodeStatus: "unknown",
|
|
346
549
|
error: "No matching local admin profile"
|
|
347
550
|
}
|
|
348
551
|
};
|
|
349
552
|
}
|
|
350
553
|
|
|
554
|
+
// Step 13: 绿点 = fetch <entry.url>/manifest 200 OK
|
|
555
|
+
// (registry-only 行 skip 这个 fetch, 因为 entry.url 可能指向死链)
|
|
556
|
+
if (dataSource === "registry") {
|
|
557
|
+
return {
|
|
558
|
+
row: {
|
|
559
|
+
...baseRow,
|
|
560
|
+
nodeStatus: "unknown"
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
351
565
|
try {
|
|
566
|
+
// 绿点路径: 优先 <entry.url>/manifest (无 auth 公共 endpoint).
|
|
567
|
+
// fallback: 走 vendor token 调 /operator/status (老 1.0.31 行为).
|
|
568
|
+
const manifestOk = await this.probeManifest(entry.url);
|
|
569
|
+
if (manifestOk) {
|
|
570
|
+
return {
|
|
571
|
+
row: {
|
|
572
|
+
...baseRow,
|
|
573
|
+
nodeStatus: "active"
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
// /manifest 失败: 试 /operator/status (老路径, 需要 vendor profile)
|
|
578
|
+
if (!match.profile) {
|
|
579
|
+
return {
|
|
580
|
+
row: {
|
|
581
|
+
...baseRow,
|
|
582
|
+
nodeStatus: "auth_unknown",
|
|
583
|
+
error: "No matching local admin profile (post /manifest fallback)"
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
}
|
|
352
587
|
const [status, service, upstreams, config] = await Promise.all([
|
|
353
588
|
this.fetchSellerAdminJson(match.profile, "/operator/status").catch((err: any) => ({ error: err.message })),
|
|
354
589
|
this.fetchSellerAdminJson(match.profile, "/operator/admin/service").catch((err: any) => ({ error: err.message })),
|
|
@@ -376,6 +611,27 @@ export class AdminUiState {
|
|
|
376
611
|
}
|
|
377
612
|
}
|
|
378
613
|
|
|
614
|
+
/**
|
|
615
|
+
* Step 13 v1.1: 探 `<entry.url>/manifest` 拿绿点. 200/204 → true;
|
|
616
|
+
* 任何非 2xx / 网络错 → false. 3s timeout 避免列表卡死.
|
|
617
|
+
*/
|
|
618
|
+
private async probeManifest(url: string): Promise<boolean> {
|
|
619
|
+
try {
|
|
620
|
+
const controller = new AbortController();
|
|
621
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
622
|
+
const target = url.replace(/\/+$/, "") + "/manifest";
|
|
623
|
+
const res = await fetch(target, {
|
|
624
|
+
method: "GET",
|
|
625
|
+
signal: controller.signal,
|
|
626
|
+
headers: { "Content-Type": "application/json" }
|
|
627
|
+
});
|
|
628
|
+
clearTimeout(timer);
|
|
629
|
+
return res.status >= 200 && res.status < 300;
|
|
630
|
+
} catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
379
635
|
private async fetchSellerAdminJson(profile: AdminProfile, pathName: string): Promise<any> {
|
|
380
636
|
return this.fetchJson(`${trimSlash(profile.url)}${pathName}`, {
|
|
381
637
|
headers: {
|
|
@@ -406,7 +662,15 @@ export class AdminUiState {
|
|
|
406
662
|
}
|
|
407
663
|
|
|
408
664
|
async function defaultFetchJson(url: string, init?: RequestInit): Promise<unknown> {
|
|
409
|
-
|
|
665
|
+
// Step 13 v1.1: 3s timeout 避免坏 host / DNS 卡死整个 admin web.
|
|
666
|
+
const controller = new AbortController();
|
|
667
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
668
|
+
let response: Response;
|
|
669
|
+
try {
|
|
670
|
+
response = await fetch(url, { ...init, signal: controller.signal });
|
|
671
|
+
} finally {
|
|
672
|
+
clearTimeout(timer);
|
|
673
|
+
}
|
|
410
674
|
if (!response.ok) {
|
|
411
675
|
const text = await response.text();
|
|
412
676
|
throw new Error(`HTTP Error ${response.status}: ${text || response.statusText}`);
|
|
@@ -415,7 +679,12 @@ async function defaultFetchJson(url: string, init?: RequestInit): Promise<unknow
|
|
|
415
679
|
return text ? JSON.parse(text) : {};
|
|
416
680
|
}
|
|
417
681
|
|
|
418
|
-
function baseSellerRow(
|
|
682
|
+
function baseSellerRow(
|
|
683
|
+
entry: SellerRegistryEntry,
|
|
684
|
+
profile?: string,
|
|
685
|
+
dataSource: SellerDataSource = "registry",
|
|
686
|
+
flyApp?: import("./seller.js").SellerAppJson
|
|
687
|
+
): SellerRow {
|
|
419
688
|
return {
|
|
420
689
|
id: entry.id,
|
|
421
690
|
name: entry.id,
|
|
@@ -424,7 +693,9 @@ function baseSellerRow(entry: SellerRegistryEntry, profile?: string): SellerRow
|
|
|
424
693
|
profile: profile || entry.profile,
|
|
425
694
|
url: entry.url,
|
|
426
695
|
registryStatus: registryStatus(entry.status),
|
|
427
|
-
|
|
696
|
+
// Step 13 v1.1: 绿点 base 改 unknown, 真正值由 sellerSnapshot
|
|
697
|
+
// 拿 /manifest 200 决定. 老逻辑直接复用 entry.status 是错的.
|
|
698
|
+
nodeStatus: "unknown",
|
|
428
699
|
region: entry.region,
|
|
429
700
|
upstreamDomain: hostName(entry.url) || "unknown",
|
|
430
701
|
upstreamStatus: "unknown",
|
|
@@ -432,7 +703,14 @@ function baseSellerRow(entry: SellerRegistryEntry, profile?: string): SellerRow
|
|
|
432
703
|
specs: {
|
|
433
704
|
region: entry.region,
|
|
434
705
|
modelsCount: entry.modelsCount ?? entry.models?.length ?? entry.sampleModels?.length
|
|
435
|
-
}
|
|
706
|
+
},
|
|
707
|
+
dataSource,
|
|
708
|
+
flyApp: flyApp ? {
|
|
709
|
+
name: flyApp.name,
|
|
710
|
+
status: flyApp.status,
|
|
711
|
+
owner: flyApp.owner,
|
|
712
|
+
latestDeployAt: flyApp.latestDeployAt
|
|
713
|
+
} : undefined
|
|
436
714
|
};
|
|
437
715
|
}
|
|
438
716
|
|
package/src/ui-static.ts
CHANGED
|
@@ -79,6 +79,25 @@ export function adminUiHtml(): string {
|
|
|
79
79
|
.app-table-head{min-height:34px;color:var(--muted);font-size:var(--label-fs);font-weight:var(--label-weight);text-transform:uppercase;letter-spacing:var(--label-spacing)}
|
|
80
80
|
.app-row{border:1px solid var(--hairline);border-radius:8px;background:#fff;min-height:76px;text-align:left}
|
|
81
81
|
.app-row:hover{border-color:var(--hairline-strong);background:#fff}
|
|
82
|
+
/* Step 13 v1.1: 双源 (fly + registry) 4 类行视觉. dataSource 决定
|
|
83
|
+
dataSource="fly" → 灰/中性边, "未发布" 提示
|
|
84
|
+
dataSource="registry" → 整行红边 + 软红底, "立即下线 (registry-only)" 按钮
|
|
85
|
+
dataSource="both" → 正常边, 跟 1.0.31 老样式一致
|
|
86
|
+
*/
|
|
87
|
+
.app-row.app-row-fly-only{border-color:#cdd2db;background:#f8f9fc}
|
|
88
|
+
.app-row.app-row-fly-only:hover{background:#f1f3f8}
|
|
89
|
+
.app-row.app-row-registry-only{border:2px solid var(--danger);background:var(--danger-soft);box-shadow:0 0 0 3px rgba(239,91,120,.08)}
|
|
90
|
+
.app-row.app-row-registry-only:hover{background:#ffe3ea}
|
|
91
|
+
.datasource-chip{display:inline-block;padding:1px 8px;border-radius:999px;font-size:10px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;line-height:16px;margin-left:6px;vertical-align:middle}
|
|
92
|
+
.datasource-chip.both{background:#e7f6ee;color:#0a8754}
|
|
93
|
+
.datasource-chip.fly{background:#e3e6ee;color:#4a5170}
|
|
94
|
+
.datasource-chip.registry{background:var(--danger-soft);color:var(--danger);border:1px solid var(--danger)}
|
|
95
|
+
.alert-reason{color:var(--danger);font-size:11px;line-height:1.4;font-weight:700;margin-top:4px;display:block}
|
|
96
|
+
.remove-hint-btn{margin-top:6px;background:var(--danger);color:#fff;border:0;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:800;cursor:pointer;display:inline-block}
|
|
97
|
+
.remove-hint-btn:hover{background:#d63d5a}
|
|
98
|
+
.remove-hint-btn::before{content:"⚠ ";margin-right:2px}
|
|
99
|
+
.publish-hint-btn{margin-top:6px;background:#fff;color:var(--primary);border:1px solid var(--hairline-strong);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:800;cursor:pointer;display:inline-block}
|
|
100
|
+
.publish-hint-btn:hover{background:#f5f3ff}
|
|
82
101
|
/* Status dot — five spec tones (green/amber/red/blue/gray) */
|
|
83
102
|
.app-dot{width:10px;height:10px;border-radius:999px;background:#c8ced8;box-shadow:0 0 0 4px #edf1f8}
|
|
84
103
|
.app-dot.tone-green{background:var(--success);box-shadow:0 0 0 4px rgba(16,185,129,.18)}
|
|
@@ -195,7 +214,7 @@ export function adminUiHtml(): string {
|
|
|
195
214
|
</style>
|
|
196
215
|
</head>
|
|
197
216
|
<body>
|
|
198
|
-
<nav class="topnav"><div class="logo">TOKENBUDDY ADMIN</div><div class="top-links"><button class="top-link active" data-page="sellers">Sellers</button><button class="top-link" data-page="
|
|
217
|
+
<nav class="topnav"><div class="logo">TOKENBUDDY ADMIN</div><div class="top-links"><button class="top-link active" data-page="sellers">Sellers</button><button class="top-link" data-page="releases">Release Requests</button></div></nav>
|
|
199
218
|
<main class="content">
|
|
200
219
|
<section id="page-sellers" class="page active">
|
|
201
220
|
<div class="panel">
|
|
@@ -223,13 +242,17 @@ export function adminUiHtml(): string {
|
|
|
223
242
|
<section id="page-bootstrap" class="page">
|
|
224
243
|
<div class="bootstrap-card">
|
|
225
244
|
<div class="panel-head">
|
|
226
|
-
<h1 class="title">
|
|
245
|
+
<h1 class="title">Release Requests</h1>
|
|
227
246
|
<div class="modal-actions">
|
|
228
|
-
<button id="
|
|
229
|
-
<button id="refreshBootstrap" class="btn">Refresh</button>
|
|
247
|
+
<button id="refreshReleases" class="btn">Refresh</button>
|
|
230
248
|
</div>
|
|
231
249
|
</div>
|
|
232
|
-
<
|
|
250
|
+
<p class="hint" style="color:var(--muted);font-size:12px;margin:0 0 12px;">
|
|
251
|
+
Pending and historical release requests you have submitted to the wallet-bootstrap
|
|
252
|
+
registry. Force-publish is available to the platform super-admin via the registry
|
|
253
|
+
admin web; vendors do not publish their own releases.
|
|
254
|
+
</p>
|
|
255
|
+
<div id="releasesGrid" class="bootstrap-grid"></div>
|
|
233
256
|
</div>
|
|
234
257
|
</section>
|
|
235
258
|
</main>
|
|
@@ -342,6 +365,17 @@ function scheduleSellerRefresh(){ clearTimeout(sellerRefreshTimer); sellerNextRe
|
|
|
342
365
|
function updateSellerRefreshMeta(refreshing){ const state = document.getElementById("sellerRefreshState"); if (!state) return; const nextSeconds = sellerNextRefreshAt ? Math.max(0, Math.ceil((sellerNextRefreshAt.getTime() - Date.now()) / 1000)) : 0; state.classList.toggle("refreshing", Boolean(refreshing)); state.classList.toggle("error", Boolean(sellerRefreshError)); state.innerHTML = refreshing ? '<span class="spinner" aria-hidden="true"></span><span>Refreshing</span>' : esc(sellerRefreshError || (sellerRefreshLoaded ? "Next refresh: " + nextSeconds + "s" : "Starting")); }
|
|
343
366
|
function sellerRow(row){
|
|
344
367
|
const fmt = window.__tbFmt;
|
|
368
|
+
// Step 13 v1.1: dataSource 决定行 class + 标红/灰 + 按钮 + chip.
|
|
369
|
+
// row.dataSource ∈ "fly" | "registry" | "both". 老 1.0.31 没这个字段,
|
|
370
|
+
// 兜底当 "both" (老 UI 看到的所有行, 默认都按已发布处理).
|
|
371
|
+
const ds = row.dataSource || "both";
|
|
372
|
+
const rowClass = ds === "registry" ? "app-row app-row-registry-only"
|
|
373
|
+
: ds === "fly" ? "app-row app-row-fly-only"
|
|
374
|
+
: "app-row";
|
|
375
|
+
const dsChipLabel = ds === "registry" ? "Registry-only"
|
|
376
|
+
: ds === "fly" ? "未发布"
|
|
377
|
+
: "Both";
|
|
378
|
+
const dsChip = '<span class="datasource-chip '+esc(ds)+'" title="'+esc('Data source: ' + ds + '. 详见 docs/processes/seller-fleet-data-sources.md')+'">'+esc(dsChipLabel)+'</span>';
|
|
345
379
|
const tip = [row.description, row.region, row.app, row.specs?.memoryGb ? row.specs.memoryGb + "GB" : "", row.specs?.machines ? row.specs.machines + " machines" : "", row.modelsCount ? row.modelsCount + " models" : ""].filter(Boolean).join(" · ") || "No specs";
|
|
346
380
|
const ttftText = fmt.formatDuration(row.ttftMs);
|
|
347
381
|
const ttft = "TTFT: " + (ttftText === fmt.UNKNOWN_VALUE ? "—" : ttftText);
|
|
@@ -352,9 +386,11 @@ function sellerRow(row){
|
|
|
352
386
|
const balanceRaw = row.upstreamBalanceUsdMicros;
|
|
353
387
|
const balanceText = (balanceRaw === undefined || balanceRaw === null) ? dash() : '<strong>'+esc(fmt.formatBalanceAmount(balanceRaw, row.upstreamBalanceCurrency || "USD"))+'</strong>';
|
|
354
388
|
const switchText = row.lastSwitchAt ? "Switch " + esc(fmt.formatTimeCompact(row.lastSwitchAt)) : "";
|
|
389
|
+
// Step 13 v1.1: 绿点 (status + tone) 仍按 nodeStatus 决定 (probeManifest
|
|
390
|
+
// 200 → active 绿点; 失败 → unknown 灰). registryStatus 单独 tooltip.
|
|
355
391
|
const status = fmt.formatSellerStatus(row.nodeStatus);
|
|
356
392
|
const tone = fmt.sellerStatusTone(row.nodeStatus);
|
|
357
|
-
const statusTip = "registry: " + esc(fmt.formatSellerStatus(row.registryStatus)) + " · upstream: " + esc(fmt.normalizeStatusLabel(row.upstreamStatus));
|
|
393
|
+
const statusTip = "registry: " + esc(fmt.formatSellerStatus(row.registryStatus)) + " · upstream: " + esc(fmt.normalizeStatusLabel(row.upstreamStatus)) + " · source: " + esc(ds);
|
|
358
394
|
const sellerLine = [
|
|
359
395
|
disc !== fmt.UNKNOWN_VALUE ? "Disc " + esc(disc) : null,
|
|
360
396
|
capacity !== fmt.UNKNOWN_VALUE ? capacity : null,
|
|
@@ -362,19 +398,55 @@ function sellerRow(row){
|
|
|
362
398
|
balanceText.includes("<strong>") ? "Balance " + esc(balanceText.replace(/<[^>]+>/g, "")) : null,
|
|
363
399
|
switchText || null
|
|
364
400
|
].filter(Boolean).join(" · ");
|
|
365
|
-
|
|
401
|
+
// Step 13 v1.1: 4 类行的 status cell 文案不同.
|
|
402
|
+
// both → 正常 active / draining / offline
|
|
403
|
+
// fly-only → "未发布" (publishHint 提示走 vendor-bootstrap stage)
|
|
404
|
+
// registry → "**严重事故**" + alertReason 红字
|
|
405
|
+
let statusCell;
|
|
406
|
+
if (ds === "registry") {
|
|
407
|
+
statusCell = '<span class="field-cell"><strong class="registry-incident" title="'+esc(statusTip)+'">严重事故</strong>' +
|
|
408
|
+
(row.alertReason ? '<span class="alert-reason">'+esc(row.alertReason)+'</span>' : '') +
|
|
409
|
+
(row.removeHint ? '<button class="remove-hint-btn" type="button" data-action="remove" data-seller-id="'+esc(row.id)+'" title="'+esc(row.removeHint)+'">'+esc(row.removeHint)+'</button>' : '') +
|
|
410
|
+
'</span>';
|
|
411
|
+
} else if (ds === "fly") {
|
|
412
|
+
statusCell = '<span class="field-cell"><strong title="'+esc(statusTip)+'">未发布</strong>' +
|
|
413
|
+
(row.publishHint ? '<button class="publish-hint-btn" type="button" data-action="publish" data-seller-id="'+esc(row.id)+'" title="'+esc(row.publishHint)+'">'+esc(row.publishHint)+'</button>' : '') +
|
|
414
|
+
'</span>';
|
|
415
|
+
} else {
|
|
416
|
+
statusCell = '<span class="field-cell"><strong title="'+esc(statusTip)+'">'+esc(status)+'</strong></span>';
|
|
417
|
+
}
|
|
418
|
+
return '<button class="'+esc(rowClass)+'" type="button" data-detail="'+esc(row.id)+'"><span class="app-dot tone-'+esc(tone)+'" aria-label="'+esc(status)+'"></span><span class="app-name"><span class="seller-title"><strong>'+esc(row.name)+'</strong>'+dsChip+'<span class="spec-tip" title="'+esc(tip)+'" aria-label="Seller specs">'+infoIcon+'</span></span><span class="muted-value" style="font-size:12px;font-family:var(--font-mono)">'+esc(sellerLine || row.app || row.id)+'</span></span><span class="field-cell"><strong>'+esc(row.upstreamDomain)+'</strong></span><span class="field-cell"><strong>'+esc(disc === fmt.UNKNOWN_VALUE ? "—" : disc)+'</strong></span><span class="field-cell"><strong>'+esc(capacity)+'</strong></span><span class="speed-cell"><strong>'+esc(ttftText === fmt.UNKNOWN_VALUE ? "—" : ttftText)+'</strong><span>'+avgSpeed+'</span></span><span class="field-cell"><span class="balance-line">'+balanceText+(row.upstreamRechargeUrl ? '<a class="recharge-btn" href="'+esc(row.upstreamRechargeUrl)+'" target="_blank" rel="noreferrer">↗</a>' : '')+'</span></span>'+statusCell+'<span class="row-actions"><span class="detail-btn">›</span></span></button>';
|
|
366
419
|
}
|
|
367
420
|
async function loadBootstrap(){
|
|
421
|
+
// Step 6 of the registry redesign: the legacy Bootstrap tab now
|
|
422
|
+
// surfaces vendor release requests. We keep the function name
|
|
423
|
+
// (loadBootstrap) so the existing click wiring continues to work,
|
|
424
|
+
// but the rendered content comes from the new
|
|
425
|
+
// /api/vendor/release-requests endpoint (added in ui-server.ts).
|
|
368
426
|
const fmt = window.__tbFmt;
|
|
369
427
|
try {
|
|
370
|
-
const data = await api("/api/
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
428
|
+
const data = await api("/api/vendor/release-requests");
|
|
429
|
+
const rows = (data.releaseRequests || []);
|
|
430
|
+
if (rows.length === 0) {
|
|
431
|
+
document.getElementById("bootstrapGrid").innerHTML = '<div class="status-line">No release requests yet. Submit one with <code>tb-admin vendor-bootstrap stage</code> + <code>release submit</code>.</div>';
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const table = '<table style="width:100%;border-collapse:collapse;font-size:13px;">' +
|
|
435
|
+
'<thead><tr><th style="text-align:left;padding:6px 8px;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:0.04em;">ID</th><th style="text-align:left;padding:6px 8px;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:0.04em;">Status</th><th style="text-align:left;padding:6px 8px;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:0.04em;">Sellers</th><th style="text-align:left;padding:6px 8px;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:0.04em;">Submitted</th><th style="text-align:left;padding:6px 8px;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:0.04em;">Version</th><th style="text-align:left;padding:6px 8px;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:0.04em;">Error</th></tr></thead>' +
|
|
436
|
+
'<tbody>' + rows.map((r) => {
|
|
437
|
+
const summary = r.payloadSummary || { count: 0, sellerIds: [] };
|
|
438
|
+
const sellerList = (summary.sellerIds || []).join(", ") || "—";
|
|
439
|
+
const version = r.publishedVersion !== null && r.publishedVersion !== undefined ? "v" + esc(String(r.publishedVersion)) : "—";
|
|
440
|
+
return '<tr>' +
|
|
441
|
+
'<td style="padding:6px 8px;font-family:ui-monospace,monospace;">#' + esc(String(r.id)) + '</td>' +
|
|
442
|
+
'<td style="padding:6px 8px;">' + esc(r.status) + '</td>' +
|
|
443
|
+
'<td style="padding:6px 8px;">' + esc(String(summary.count)) + ' <span style="color:var(--muted);font-size:11px;">(' + esc(sellerList) + ')</span></td>' +
|
|
444
|
+
'<td style="padding:6px 8px;font-family:ui-monospace,monospace;">' + esc(fmt.formatTimeCompact(r.submittedAt)) + '</td>' +
|
|
445
|
+
'<td style="padding:6px 8px;">' + version + '</td>' +
|
|
446
|
+
'<td style="padding:6px 8px;color:var(--danger);">' + (r.errorMessage ? esc(r.errorMessage) : "—") + '</td>' +
|
|
447
|
+
'</tr>';
|
|
448
|
+
}).join("") + '</tbody></table>';
|
|
449
|
+
document.getElementById("bootstrapGrid").innerHTML = table;
|
|
378
450
|
} catch (err) {
|
|
379
451
|
document.getElementById("bootstrapGrid").innerHTML = '<div class="status-line">'+esc(uiErrorMessage(err))+'</div>';
|
|
380
452
|
}
|
|
@@ -462,8 +534,14 @@ function renderCreateJob(job){ if (!job) return; currentCreateJob = job; const d
|
|
|
462
534
|
function progressStep(event){ const result = event.result || {}; const log = [result.command ? "$ " + result.command.join(" ") : "", result.stdout || "", result.stderr || ""].filter(Boolean).join("\\n").slice(0, 1600); const expanded = expandedProgressSteps.has(event.stepId); const spinner = event.status === "running" ? '<span class="spinner" aria-hidden="true"></span>' : ""; return '<button type="button" class="progress-step '+esc(event.status)+'" data-progress-step="'+esc(event.stepId)+'" aria-expanded="'+String(expanded)+'"><div class="progress-title">'+spinner+'<strong>'+esc(event.title)+'</strong></div><div class="progress-meta"><span>'+esc(event.message || event.status)+'</span>'+(log ? '<span class="progress-toggle">'+(expanded ? "Hide details" : "Show details")+'</span>' : '')+'</div>'+(log && expanded ? '<pre class="progress-log">'+esc(log)+'</pre>' : '')+'</button>'; }
|
|
463
535
|
function setCreateFormDisabled(disabled){ document.querySelectorAll("#createFields [data-field], #createFields [data-payment-tab], #createFields [data-payment-toggle]").forEach(input => { input.disabled = Boolean(disabled); }); if (!disabled) updatePaymentPanels(); }
|
|
464
536
|
document.getElementById("createProgress").onclick = event => { const step = event.target.closest("[data-progress-step]"); if (!step) return; const id = step.dataset.progressStep; if (expandedProgressSteps.has(id)) expandedProgressSteps.delete(id); else expandedProgressSteps.add(id); if (currentCreateJob) renderCreateJob(currentCreateJob); };
|
|
465
|
-
document.getElementById("refreshBootstrap")
|
|
466
|
-
|
|
537
|
+
const refreshBootstrapEl = document.getElementById("refreshBootstrap");
|
|
538
|
+
if (refreshBootstrapEl) {
|
|
539
|
+
refreshBootstrapEl.onclick = loadBootstrap;
|
|
540
|
+
}
|
|
541
|
+
const refreshReleasesEl = document.getElementById("refreshReleases");
|
|
542
|
+
if (refreshReleasesEl) {
|
|
543
|
+
refreshReleasesEl.onclick = loadBootstrap;
|
|
544
|
+
}
|
|
467
545
|
document.getElementById("closeBootstrapConfig").onclick = () => document.getElementById("bootstrapModal").classList.remove("open");
|
|
468
546
|
function fieldValue(input){ return numeric(input.value); }
|
|
469
547
|
function numeric(value){ const n = Number(value); return value !== "" && Number.isFinite(n) ? n : value; }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type alias for the seller entry shape vendor commands work with.
|
|
3
|
+
* Mirrors the server-side `SellerRegistryEntry` but stays decoupled
|
|
4
|
+
* from `wallet-bootstrap`'s internal types so this package does not
|
|
5
|
+
* become a build-time dependency of the registry service.
|
|
6
|
+
*/
|
|
7
|
+
export interface SellerRegistryEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
profile?: string;
|
|
11
|
+
app?: string;
|
|
12
|
+
url: string;
|
|
13
|
+
status?: string;
|
|
14
|
+
region?: string;
|
|
15
|
+
modelsCount?: number;
|
|
16
|
+
sampleModels?: string[];
|
|
17
|
+
models?: string[];
|
|
18
|
+
supportedProtocols: string[];
|
|
19
|
+
paymentMethods: string[];
|
|
20
|
+
recommendedFor?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { RegistryVendorClient, RegistryAdminClient } from "./client.js";
|