@tokenbuddy/tb-admin 1.0.31 → 1.0.33

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 (53) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +280 -19
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +82 -2
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +93 -0
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/provider.d.ts +120 -0
  9. package/dist/src/provider.d.ts.map +1 -0
  10. package/dist/src/provider.js +73 -0
  11. package/dist/src/provider.js.map +1 -0
  12. package/dist/src/seller.d.ts +104 -0
  13. package/dist/src/seller.d.ts.map +1 -0
  14. package/dist/src/seller.js +283 -0
  15. package/dist/src/seller.js.map +1 -0
  16. package/dist/src/ui-actions.d.ts +25 -0
  17. package/dist/src/ui-actions.d.ts.map +1 -1
  18. package/dist/src/ui-actions.js +81 -11
  19. package/dist/src/ui-actions.js.map +1 -1
  20. package/dist/src/ui-server.d.ts.map +1 -1
  21. package/dist/src/ui-server.js +15 -2
  22. package/dist/src/ui-server.js.map +1 -1
  23. package/dist/src/ui-state.d.ts +77 -2
  24. package/dist/src/ui-state.d.ts.map +1 -1
  25. package/dist/src/ui-state.js +242 -14
  26. package/dist/src/ui-state.js.map +1 -1
  27. package/dist/src/ui-static.d.ts.map +1 -1
  28. package/dist/src/ui-static.js +98 -20
  29. package/dist/src/ui-static.js.map +1 -1
  30. package/dist/src/vendor-client.d.ts +23 -0
  31. package/dist/src/vendor-client.d.ts.map +1 -0
  32. package/dist/src/vendor-client.js +2 -0
  33. package/dist/src/vendor-client.js.map +1 -0
  34. package/dist/src/vendor-commands.d.ts +35 -0
  35. package/dist/src/vendor-commands.d.ts.map +1 -0
  36. package/dist/src/vendor-commands.js +33 -0
  37. package/dist/src/vendor-commands.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/cli.ts +305 -31
  40. package/src/client.ts +118 -2
  41. package/src/provider.ts +150 -0
  42. package/src/seller.ts +362 -0
  43. package/src/ui-actions.ts +89 -11
  44. package/src/ui-server.ts +15 -1
  45. package/src/ui-state.ts +293 -15
  46. package/src/ui-static.ts +98 -20
  47. package/src/vendor-client.ts +23 -0
  48. package/src/vendor-commands.ts +65 -0
  49. package/tests/admin.test.ts +81 -3
  50. package/tests/seller.test.ts +337 -0
  51. package/tests/ui-state-fleet.test.ts +257 -0
  52. package/tests/ui-static-row.test.ts +202 -0
  53. package/tests/vendor-cli.test.ts +241 -0
package/src/ui-server.ts CHANGED
@@ -2,6 +2,7 @@ import * as http from "http";
2
2
  import { randomBytes } from "crypto";
3
3
  import { URL } from "url";
4
4
  import { ConfigManager } from "./config.js";
5
+ import { RegistryVendorClient } from "./client.js";
5
6
  import { AdminUiState } from "./ui-state.js";
6
7
  import { UiActions, type CreateSellerRequest, type UiActionProgressEvent, type UiActionResult } from "./ui-actions.js";
7
8
  import { adminUiHtml } from "./ui-static.js";
@@ -48,7 +49,7 @@ export async function startAdminUiServer(options: AdminUiServerOptions): Promise
48
49
  const jobs = new Map<string, UiJob>();
49
50
  const server = http.createServer(async (req, res) => {
50
51
  try {
51
- await routeRequest(req, res, state, actions, jobs);
52
+ await routeRequest(req, res, options, state, actions, jobs);
52
53
  } catch (err: any) {
53
54
  sendJson(res, 500, { error: err.message || "internal error" });
54
55
  }
@@ -74,6 +75,7 @@ export async function startAdminUiServer(options: AdminUiServerOptions): Promise
74
75
  async function routeRequest(
75
76
  req: http.IncomingMessage,
76
77
  res: http.ServerResponse,
78
+ options: AdminUiServerOptions,
77
79
  state: AdminUiState,
78
80
  actions: UiActions,
79
81
  jobs: Map<string, UiJob>
@@ -101,6 +103,18 @@ async function routeRequest(
101
103
  sendJson(res, 200, await state.bootstrapConfig());
102
104
  return;
103
105
  }
106
+ if (req.method === "GET" && parsed.pathname === "/api/vendor/release-requests") {
107
+ const profile = state.activeBootstrapProfile();
108
+ const baseUrl = options.url || profile.profile?.url;
109
+ const token = options.token || profile.profile?.token;
110
+ if (!baseUrl || !token) {
111
+ throw new Error("No bootstrap profile found. Configure an admin profile or pass --url and --token.");
112
+ }
113
+ const limit = parsed.searchParams.get("limit") || undefined;
114
+ const client = new RegistryVendorClient(baseUrl, token);
115
+ sendJson(res, 200, await client.listReleaseRequests(limit ? Number(limit) : 20));
116
+ return;
117
+ }
104
118
  if (req.method === "GET" && parsed.pathname === "/api/sellers") {
105
119
  sendJson(res, 200, await state.sellers());
106
120
  return;
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 document = await this.fetchRegistry();
220
- const snapshots = await Promise.all(document.sellers.map((seller) => this.sellerSnapshot(seller)));
221
- return snapshots.map((snapshot) => snapshot.row);
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
- const entry = document.sellers.find((seller) => seller.id === id || seller.name === id || seller.app === id);
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
- throw new Error(`seller \`${id}\` not found in bootstrap registry`);
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
- private activeBootstrapProfile(): ProfileMatch {
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
- private async sellerSnapshot(entry: SellerRegistryEntry): Promise<SellerSnapshot> {
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
- if (!match.profile) {
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: "auth_unknown",
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
- const response = await fetch(url, init);
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(entry: SellerRegistryEntry, profile?: string): SellerRow {
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
- nodeStatus: registryStatus(entry.status),
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="bootstrap">Bootstrap</button></div></nav>
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">
@@ -220,16 +239,20 @@ export function adminUiHtml(): string {
220
239
  </div>
221
240
  </div>
222
241
  </section>
223
- <section id="page-bootstrap" class="page">
242
+ <section id="page-releases" class="page">
224
243
  <div class="bootstrap-card">
225
244
  <div class="panel-head">
226
- <h1 class="title">Bootstrap</h1>
245
+ <h1 class="title">Release Requests</h1>
227
246
  <div class="modal-actions">
228
- <button id="openBootstrapConfig" class="btn primary">Edit Bootstrap Config</button>
229
- <button id="refreshBootstrap" class="btn">Refresh</button>
247
+ <button id="refreshReleases" class="btn">Refresh</button>
230
248
  </div>
231
249
  </div>
232
- <div id="bootstrapGrid" class="bootstrap-grid"></div>
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>
@@ -334,7 +357,7 @@ const infoIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h7"><
334
357
  const balanceProbeTemplates = ["openrouter","deepseek","stepfun","siliconflow","novita","newapi_generic","usage_generic","none","auto"];
335
358
  const paymentMethods = ["clawtip","mock"];
336
359
  const loadingSpinner = label => '<div class="loading-row" role="status" aria-label="'+esc(label)+'"><span class="spinner" aria-hidden="true"></span></div>';
337
- document.querySelectorAll(".top-link").forEach(btn => btn.onclick = () => { document.querySelectorAll(".top-link").forEach(b => b.classList.remove("active")); btn.classList.add("active"); document.querySelectorAll(".page").forEach(p => p.classList.remove("active")); document.getElementById("page-" + btn.dataset.page).classList.add("active"); if (btn.dataset.page === "bootstrap") loadBootstrap(); });
360
+ document.querySelectorAll(".top-link").forEach(btn => btn.onclick = () => { document.querySelectorAll(".top-link").forEach(b => b.classList.remove("active")); btn.classList.add("active"); document.querySelectorAll(".page").forEach(p => p.classList.remove("active")); document.getElementById("page-" + btn.dataset.page).classList.add("active"); if (btn.dataset.page === "releases") loadBootstrap(); });
338
361
  async function loadSellers(options={}){ if (sellerRefreshInFlight) return; clearTimeout(sellerRefreshTimer); sellerRefreshTimer = null; sellerNextRefreshAt = null; const initial = Boolean(options.initial); sellerRefreshInFlight = true; sellerRefreshError = ""; updateSellerRefreshMeta(true); if (initial) document.getElementById("sellerRows").innerHTML = loadingSpinner("Loading sellers"); try { const rows = await api("/api/sellers"); renderSellerRows(rows); sellerRefreshLoaded = true; sellerRefreshError = ""; } catch (err) { sellerRefreshError = err.message || "Refresh failed"; if (initial) document.getElementById("sellerRows").innerHTML = '<div class="status-line">'+esc(sellerRefreshError)+'</div>'; } finally { sellerRefreshInFlight = false; scheduleSellerRefresh(); updateSellerRefreshMeta(false); } }
339
362
  function renderSellerRows(rows){ sellerRowsCache = rows; document.getElementById("sellerRows").innerHTML = rows.map(row => sellerRow(row)).join(""); document.querySelectorAll("[data-detail]").forEach(btn => btn.onclick = () => openDetail(btn.dataset.detail)); }
340
363
  function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => updateSellerRefreshMeta(sellerRefreshInFlight), 1000); window.addEventListener("beforeunload", () => { clearTimeout(sellerRefreshTimer); clearInterval(sellerClockTimer); }); }
@@ -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,21 +398,57 @@ function sellerRow(row){
362
398
  balanceText.includes("<strong>") ? "Balance " + esc(balanceText.replace(/<[^>]+>/g, "")) : null,
363
399
  switchText || null
364
400
  ].filter(Boolean).join(" · ");
365
- return '<button class="app-row" 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><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><span class="field-cell"><strong title="'+esc(statusTip)+'">'+esc(status)+'</strong></span><span class="row-actions"><span class="detail-btn">›</span></span></button>';
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/bootstrap");
371
- const items = [
372
- { tone: "router", label: "Status", value: data.status || "unknown", secondary: data.url ? esc(fmt.formatSellerId(data.url)) : null },
373
- { tone: "spend", label: "Registry", value: data.registryVersion === undefined ? "" : "#" + esc(String(data.registryVersion)), secondary: data.registryUpdatedAt ? "Updated " + esc(fmt.formatTimeCompact(data.registryUpdatedAt)) : null },
374
- { tone: "tokens", label: "Sellers", value: data.sellerEntries === undefined ? "—" : esc(String(data.sellerEntries)), secondary: data.regions && data.regions.length ? esc(data.regions.join(", ")) : null },
375
- { tone: "inventory", label: "Default", value: data.defaultSeller || "—", secondary: data.profile ? "Profile " + esc(data.profile) : null }
376
- ];
377
- document.getElementById("bootstrapGrid").innerHTML = items.map(item => entryCardHtml(item)).join("");
428
+ const data = await api("/api/vendor/release-requests");
429
+ const rows = (data.releaseRequests || []);
430
+ if (rows.length === 0) {
431
+ document.getElementById("releasesGrid").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("releasesGrid").innerHTML = table;
378
450
  } catch (err) {
379
- document.getElementById("bootstrapGrid").innerHTML = '<div class="status-line">'+esc(uiErrorMessage(err))+'</div>';
451
+ document.getElementById("releasesGrid").innerHTML = '<div class="status-line">'+esc(uiErrorMessage(err))+'</div>';
380
452
  }
381
453
  }
382
454
  function entryCardHtml(item){
@@ -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").onclick = loadBootstrap;
466
- document.getElementById("openBootstrapConfig").onclick = openBootstrapConfig;
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; }