@tokenbuddy/tb-admin 1.0.36 → 1.0.38

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 (93) hide show
  1. package/dist/src/cli.js +98 -25
  2. package/dist/src/config.d.ts +8 -2
  3. package/dist/src/config.js +17 -5
  4. package/dist/src/display-format.js +6 -14
  5. package/dist/src/init-command.d.ts +50 -0
  6. package/dist/src/init-command.js +347 -0
  7. package/dist/src/providers/fly-io.d.ts +3 -0
  8. package/dist/src/providers/fly-io.js +137 -0
  9. package/dist/src/providers/provider-definition.d.ts +38 -0
  10. package/dist/src/providers/provider-definition.js +2 -0
  11. package/dist/src/seller.d.ts +2 -0
  12. package/dist/src/seller.js +30 -13
  13. package/dist/src/server-cmd.d.ts +1 -0
  14. package/dist/src/server-cmd.js +9 -2
  15. package/dist/src/ui-actions.d.ts +3 -0
  16. package/dist/src/ui-actions.js +199 -27
  17. package/dist/src/ui-command.js +3 -2
  18. package/dist/src/ui-state.d.ts +1 -3
  19. package/dist/src/ui-state.js +4 -8
  20. package/dist/src/ui-static.js +43 -15
  21. package/dist/src/workdir.d.ts +21 -0
  22. package/dist/src/workdir.js +50 -0
  23. package/package.json +9 -3
  24. package/templates/providers/fly.io/admin.toml.example +18 -0
  25. package/templates/providers/fly.io/deploy-secrets/bootstrap/README.md +18 -0
  26. package/templates/providers/fly.io/deploy-secrets/bootstrap/admin-web.example.env +3 -0
  27. package/templates/providers/fly.io/deploy-secrets/bootstrap/cloudflare-r2.example.env +6 -0
  28. package/templates/providers/fly.io/deploy-secrets/bootstrap/registry-signing-key.example.json +6 -0
  29. package/templates/providers/fly.io/deploy-secrets/bootstrap/registry.example.json +14 -0
  30. package/templates/providers/fly.io/deploy-secrets/bootstrap/tb-registry.example.yaml +14 -0
  31. package/templates/providers/fly.io/deploy-secrets/seller-configs/README.md +13 -0
  32. package/templates/providers/fly.io/deploy-secrets/seller-configs/seller.example.yaml +35 -0
  33. package/templates/providers/fly.io/env/deploy.env.example +12 -0
  34. package/templates/providers/fly.io/fly/fly.tb-registry.toml +31 -0
  35. package/templates/providers/fly.io/fly/fly.tb-seller.toml +25 -0
  36. package/templates/providers/fly.io/provider.toml.example +10 -0
  37. package/dist/src/bootstrap-registry.d.ts.map +0 -1
  38. package/dist/src/bootstrap-registry.js.map +0 -1
  39. package/dist/src/cli.d.ts.map +0 -1
  40. package/dist/src/cli.js.map +0 -1
  41. package/dist/src/client.d.ts.map +0 -1
  42. package/dist/src/client.js.map +0 -1
  43. package/dist/src/config.d.ts.map +0 -1
  44. package/dist/src/config.js.map +0 -1
  45. package/dist/src/display-format.d.ts.map +0 -1
  46. package/dist/src/display-format.js.map +0 -1
  47. package/dist/src/index.d.ts.map +0 -1
  48. package/dist/src/index.js.map +0 -1
  49. package/dist/src/provider.d.ts.map +0 -1
  50. package/dist/src/provider.js.map +0 -1
  51. package/dist/src/seller.d.ts.map +0 -1
  52. package/dist/src/seller.js.map +0 -1
  53. package/dist/src/server-cmd.d.ts.map +0 -1
  54. package/dist/src/server-cmd.js.map +0 -1
  55. package/dist/src/ui-actions.d.ts.map +0 -1
  56. package/dist/src/ui-actions.js.map +0 -1
  57. package/dist/src/ui-command.d.ts.map +0 -1
  58. package/dist/src/ui-command.js.map +0 -1
  59. package/dist/src/ui-server.d.ts.map +0 -1
  60. package/dist/src/ui-server.js.map +0 -1
  61. package/dist/src/ui-state.d.ts.map +0 -1
  62. package/dist/src/ui-state.js.map +0 -1
  63. package/dist/src/ui-static.d.ts.map +0 -1
  64. package/dist/src/ui-static.js.map +0 -1
  65. package/dist/src/upstream-balance-probe.d.ts.map +0 -1
  66. package/dist/src/upstream-balance-probe.js.map +0 -1
  67. package/dist/src/vendor-client.d.ts.map +0 -1
  68. package/dist/src/vendor-client.js.map +0 -1
  69. package/dist/src/vendor-commands.d.ts.map +0 -1
  70. package/dist/src/vendor-commands.js.map +0 -1
  71. package/src/bootstrap-registry.ts +0 -90
  72. package/src/cli.ts +0 -1614
  73. package/src/client.ts +0 -179
  74. package/src/config.ts +0 -194
  75. package/src/display-format.ts +0 -411
  76. package/src/index.ts +0 -11
  77. package/src/provider.ts +0 -150
  78. package/src/seller.ts +0 -538
  79. package/src/server-cmd.ts +0 -362
  80. package/src/ui-actions.ts +0 -1040
  81. package/src/ui-command.ts +0 -44
  82. package/src/ui-server.ts +0 -353
  83. package/src/ui-state.ts +0 -1318
  84. package/src/ui-static.ts +0 -673
  85. package/src/upstream-balance-probe.ts +0 -13
  86. package/src/vendor-client.ts +0 -23
  87. package/src/vendor-commands.ts +0 -65
  88. package/tests/admin.test.ts +0 -2162
  89. package/tests/seller.test.ts +0 -388
  90. package/tests/ui-state-fleet.test.ts +0 -526
  91. package/tests/ui-static-row.test.ts +0 -467
  92. package/tests/vendor-cli.test.ts +0 -241
  93. package/tsconfig.json +0 -8
@@ -50,9 +50,6 @@ export class UiActions {
50
50
  if (!operatorSecret) {
51
51
  throw new Error("operatorSecret is required in local admin config seller_providers.fly.operator_secret or request body");
52
52
  }
53
- if (!flyConfig) {
54
- throw new Error("flyConfig is required before creating a seller deployment");
55
- }
56
53
  const configRequest = {
57
54
  ...normalizedRequest,
58
55
  operatorSecret
@@ -86,13 +83,14 @@ export class UiActions {
86
83
  normalizedRequest.region,
87
84
  "--image",
88
85
  normalizedRequest.image,
89
- "--fly-config",
90
- flyConfig,
91
86
  "--initial-config",
92
87
  filePath,
93
88
  "--operator-secret",
94
89
  operatorSecret
95
90
  ];
91
+ if (flyConfig) {
92
+ args.push("--fly-config", flyConfig);
93
+ }
96
94
  if (normalizedRequest.app) {
97
95
  args.push("--app", normalizedRequest.app);
98
96
  }
@@ -388,25 +386,91 @@ export class UiActions {
388
386
  });
389
387
  return failed;
390
388
  }
391
- return this.withTempJson(entry, async (filePath) => {
392
- const put = await this.runAdmin(globalArgs(this.options, [
393
- "bootstrap",
394
- "sellers",
395
- "add",
396
- "--file",
397
- filePath,
398
- "--expect-version",
399
- String(registry.version)
400
- ]), 30000);
401
- report({
402
- stepId: "publish_registry",
403
- status: put.ok ? "succeeded" : "failed",
404
- title: "Update bootstrap registry",
405
- message: put.ok ? "Bootstrap registry entry was added. Run registry publish to update R2." : "Bootstrap registry update failed.",
406
- result: put
407
- });
408
- return put;
389
+ const submitted = await this.submitCreatedSellerRelease(entry);
390
+ report({
391
+ stepId: "publish_registry",
392
+ status: submitted.ok ? "succeeded" : "failed",
393
+ title: "Submit registry release",
394
+ message: submitted.ok ? "Seller was staged and a registry release request was submitted." : "Registry release request failed.",
395
+ result: submitted
409
396
  });
397
+ return submitted;
398
+ }
399
+ async submitCreatedSellerRelease(entry) {
400
+ const profile = this.state.activeBootstrapProfile();
401
+ const baseUrl = this.options.url || profile.profile?.url;
402
+ const token = this.options.token || profile.profile?.token;
403
+ const command = ["POST", "/platform/sellers/stage", "POST", "/platform/release-requests"];
404
+ if (!baseUrl || !token) {
405
+ return {
406
+ ok: false,
407
+ stdout: "",
408
+ stderr: "No bootstrap vendor profile found. Configure a bootstrap-vendor profile or pass --url and --token.",
409
+ command
410
+ };
411
+ }
412
+ const fetchJson = this.options.fetchJson || defaultFetchJson;
413
+ const headers = {
414
+ "Content-Type": "application/json",
415
+ Authorization: `Bearer ${token}`
416
+ };
417
+ try {
418
+ const stage = await fetchJson(`${trimSlash(baseUrl)}/platform/sellers/stage`, {
419
+ method: "POST",
420
+ headers,
421
+ body: JSON.stringify({ seller: entry })
422
+ });
423
+ const stagedSellerId = stagedSellerIdFromResponse(stage) || entry.id;
424
+ const release = await fetchJson(`${trimSlash(baseUrl)}/platform/release-requests`, {
425
+ method: "POST",
426
+ headers,
427
+ body: JSON.stringify({
428
+ stagedSellerIds: [stagedSellerId],
429
+ note: `tb-admin ui create seller ${entry.id}`
430
+ })
431
+ });
432
+ const releaseId = releaseRequestIdFromResponse(release);
433
+ if (releaseId === undefined) {
434
+ return {
435
+ ok: false,
436
+ stdout: "",
437
+ stderr: "Registry release request did not return an id.",
438
+ command
439
+ };
440
+ }
441
+ const published = await waitForSubmittedRelease({
442
+ baseUrl,
443
+ headers,
444
+ fetchJson,
445
+ releaseId,
446
+ sellerId: stagedSellerId
447
+ });
448
+ if (!published.ok) {
449
+ return {
450
+ ok: false,
451
+ stdout: "",
452
+ stderr: published.error,
453
+ command: [...command, "GET", "/platform/release-requests/:id", "GET", "/platform/sellers"],
454
+ json: { stage, release, stagedSellerId, publishStatus: published }
455
+ };
456
+ }
457
+ const json = { stage, release, stagedSellerId, published };
458
+ return {
459
+ ok: true,
460
+ stdout: JSON.stringify(releaseSummary(json), null, 2),
461
+ stderr: "",
462
+ command: [...command, "GET", "/platform/release-requests/:id", "GET", "/platform/sellers"],
463
+ json
464
+ };
465
+ }
466
+ catch (err) {
467
+ return {
468
+ ok: false,
469
+ stdout: "",
470
+ stderr: redactSensitive(err.message || "registry release request failed"),
471
+ command
472
+ };
473
+ }
410
474
  }
411
475
  async fetchSellerOperatorJson(appName, operatorSecret, pathName) {
412
476
  const fetchJson = this.options.fetchJson || defaultFetchJson;
@@ -544,16 +608,31 @@ export function runTbAdminJson(args, timeoutMs) {
544
608
  return { ...result, json: parsed };
545
609
  });
546
610
  }
547
- function parseJsonSafely(text) {
611
+ export function parseJsonSafely(text) {
548
612
  if (!text || text.trim().length === 0) {
549
613
  return undefined;
550
614
  }
615
+ const trimmed = text.trim();
551
616
  try {
552
- return JSON.parse(text);
617
+ return JSON.parse(trimmed);
553
618
  }
554
619
  catch {
555
- return undefined;
620
+ // Some flyctl-backed commands write human progress logs before the final
621
+ // structured JSON object. Keep the JSON path tolerant of that stdout shape.
622
+ for (let index = trimmed.length - 1; index >= 0; index -= 1) {
623
+ const char = trimmed[index];
624
+ if (char !== "{" && char !== "[") {
625
+ continue;
626
+ }
627
+ try {
628
+ return JSON.parse(trimmed.slice(index));
629
+ }
630
+ catch {
631
+ // Keep walking backward until the outer JSON value is found.
632
+ }
633
+ }
556
634
  }
635
+ return undefined;
557
636
  }
558
637
  async function defaultFetchJson(url, init) {
559
638
  const response = await fetch(url, init);
@@ -663,7 +742,7 @@ function normalizeCreateSellerRequest(request) {
663
742
  ...request,
664
743
  app,
665
744
  image: stringValue(request.image) || "registry.fly.io/tb-seller:latest",
666
- flyConfig: stringValue(request.flyConfig) || "deploy/fly.io/fly.tb-seller.toml",
745
+ flyConfig: stringValue(request.flyConfig) || undefined,
667
746
  upstreamUrl
668
747
  };
669
748
  if (paymentMethodsFromRequest(normalized).includes("clawtip")) {
@@ -688,6 +767,7 @@ function initialSellerConfig(request, masked) {
688
767
  upstreamUrl: request.upstreamUrl,
689
768
  upstreamApiKey: masked ? "********" : request.upstreamApiKey,
690
769
  upstreamWebsite: request.upstreamWebsite,
770
+ upstreamCapabilities: upstreamCapabilitiesFromPreset(request.upstreamProtocolPreset),
691
771
  upstreamBalanceUrl,
692
772
  upstreamUserId,
693
773
  upstreamRechargeUrl,
@@ -700,6 +780,26 @@ function initialSellerConfig(request, masked) {
700
780
  ...paymentConfig
701
781
  };
702
782
  }
783
+ function upstreamCapabilitiesFromPreset(value) {
784
+ const preset = stringValue(value) || "auto";
785
+ if (preset === "image") {
786
+ return {
787
+ chatCompletions: "unsupported",
788
+ responses: "unsupported",
789
+ messages: "unsupported",
790
+ imagesGenerations: "supported"
791
+ };
792
+ }
793
+ if (preset === "chat") {
794
+ return {
795
+ chatCompletions: "supported",
796
+ responses: "unsupported",
797
+ messages: "unsupported",
798
+ imagesGenerations: "unsupported"
799
+ };
800
+ }
801
+ return undefined;
802
+ }
703
803
  function paymentConfigFromRequest(request, masked) {
704
804
  const paymentMethods = paymentMethodsFromRequest(request);
705
805
  const config = {
@@ -860,6 +960,78 @@ function hostName(value) {
860
960
  function sellerOperatorUrl(appName) {
861
961
  return `https://${appName}.fly.dev`;
862
962
  }
963
+ function trimSlash(value) {
964
+ return String(value || "").replace(/\/+$/, "");
965
+ }
966
+ function stagedSellerIdFromResponse(value) {
967
+ const root = objectValue(value);
968
+ const pending = objectValue(root?.pendingSeller);
969
+ return stringValue(pending?.id);
970
+ }
971
+ function releaseRequestIdFromResponse(value) {
972
+ const root = objectValue(value);
973
+ const release = objectValue(root?.releaseRequest);
974
+ const id = release?.id;
975
+ return typeof id === "number" || typeof id === "string" ? id : undefined;
976
+ }
977
+ function releaseRequestStatusFromResponse(value) {
978
+ const root = objectValue(value);
979
+ const release = objectValue(root?.releaseRequest);
980
+ return stringValue(release?.status);
981
+ }
982
+ async function waitForSubmittedRelease(options) {
983
+ const deadline = Date.now() + 60000;
984
+ let lastRelease;
985
+ let lastSellers;
986
+ let lastStatus = "unknown";
987
+ while (Date.now() < deadline) {
988
+ lastRelease = await options.fetchJson(`${trimSlash(options.baseUrl)}/platform/release-requests/${encodeURIComponent(String(options.releaseId))}`, {
989
+ headers: options.headers
990
+ });
991
+ lastStatus = releaseRequestStatusFromResponse(lastRelease) || "unknown";
992
+ if (lastStatus === "rejected" || lastStatus === "failed") {
993
+ return {
994
+ ok: false,
995
+ error: `Registry release request ${options.releaseId} ended with status ${lastStatus}.`,
996
+ release: lastRelease,
997
+ sellers: lastSellers
998
+ };
999
+ }
1000
+ lastSellers = await options.fetchJson(`${trimSlash(options.baseUrl)}/platform/sellers`, {
1001
+ headers: options.headers
1002
+ });
1003
+ if (lastStatus === "published" && sellerRegistryContains(lastSellers, options.sellerId)) {
1004
+ return { ok: true, release: lastRelease, sellers: lastSellers };
1005
+ }
1006
+ await sleep(1000);
1007
+ }
1008
+ const sellerPart = sellerRegistryContains(lastSellers, options.sellerId)
1009
+ ? "seller is visible"
1010
+ : "seller is not visible";
1011
+ return {
1012
+ ok: false,
1013
+ error: `Registry release request ${options.releaseId} was not published before timeout (last status: ${lastStatus}; ${sellerPart}).`,
1014
+ release: lastRelease,
1015
+ sellers: lastSellers
1016
+ };
1017
+ }
1018
+ function sellerRegistryContains(value, sellerId) {
1019
+ const root = objectValue(value);
1020
+ const sellers = Array.isArray(root?.sellers) ? root.sellers : Array.isArray(value) ? value : [];
1021
+ return sellers.some((seller) => {
1022
+ const entry = objectValue(seller);
1023
+ return entry?.id === sellerId || entry?.name === sellerId || entry?.app === sellerId;
1024
+ });
1025
+ }
1026
+ function releaseSummary(value) {
1027
+ return {
1028
+ ok: true,
1029
+ action: "submit_registry_release",
1030
+ stagedSellerId: value.stagedSellerId,
1031
+ releaseRequestId: releaseRequestIdFromResponse(value.release),
1032
+ releaseStatus: releaseRequestStatusFromResponse(value.published?.release || value.release)
1033
+ };
1034
+ }
863
1035
  function sleep(ms) {
864
1036
  return new Promise((resolve) => setTimeout(resolve, ms));
865
1037
  }
@@ -10,13 +10,14 @@ export function bindAdminUiCommand(program, configManager) {
10
10
  .action(async (options) => {
11
11
  try {
12
12
  const rootOptions = program.opts();
13
- const mgr = rootOptions.config ? new ConfigManager(rootOptions.config) : configManager;
13
+ const mgr = rootOptions.config || rootOptions.workdir
14
+ ? new ConfigManager(rootOptions.config, { cliWorkdir: rootOptions.workdir })
15
+ : configManager;
14
16
  const started = await startAdminUiServer({
15
17
  host: options.host,
16
18
  port: options.port,
17
19
  openBrowser: Boolean(options.open),
18
20
  configManager: mgr,
19
- configPath: rootOptions.config,
20
21
  profile: rootOptions.profile || process.env.TOKENBUDDY_ADMIN_PROFILE || defaultUiProfile(mgr),
21
22
  url: rootOptions.url || process.env.TOKENBUDDY_ADMIN_URL,
22
23
  token: rootOptions.token || process.env.TOKENBUDDY_ADMIN_TOKEN
@@ -60,7 +60,7 @@ export interface SellerRow {
60
60
  error?: string;
61
61
  /**
62
62
  * Step 13 v1.1: 数据源标记. UI 据此决定:
63
- * - "fly" → 灰点 + 「未发布」+ Apply 按钮 (走 vendor-bootstrap stage)
63
+ * - "fly" → 灰点 + 「未发布」
64
64
  * - "registry" → 整行标红 + 立即下线按钮 (registry-only = 重大事故)
65
65
  * - "both" → 正常色 + Activate / Drain 走 vendor path
66
66
  */
@@ -72,8 +72,6 @@ export interface SellerRow {
72
72
  registryAlert?: boolean;
73
73
  /** Step 13 v1.1: 标红原因 (中文短句, UI tooltip 显示). */
74
74
  alertReason?: string;
75
- /** Step 13 v1.1: "未发布" 提示 (fly-only 行的 publishHint 按钮 caption). */
76
- publishHint?: string;
77
75
  /**
78
76
  * Step 13 v1.1: 立即下线按钮 caption (registry-only 行). 文案
79
77
  * 必须含 "registry-only" 让用户知道这**不**删 fly app. 详见 spec.
@@ -197,7 +197,6 @@ export class AdminUiState {
197
197
  const row = baseSellerRow(stubEntry, undefined, "fly", app, specsByApp.get(app.name));
198
198
  row.publishStatus = "unpublished";
199
199
  row.detailStatus = "pending";
200
- row.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
201
200
  rows.push(row);
202
201
  }
203
202
  return rows;
@@ -211,7 +210,7 @@ export class AdminUiState {
211
210
  */
212
211
  async fetchFlyApps() {
213
212
  if (this.options.flyApps) {
214
- return (await this.options.flyApps()).filter((app) => isSellerFlyAppName(app.name));
213
+ return (await this.options.flyApps()).filter((app) => isVisibleFlyInventoryApp(app.name));
215
214
  }
216
215
  // 默认路径: 走 SellerCommandRunner.ls(true) 真 spawn flyctl.
217
216
  // 避免在 ui-state.ts 里 import 整个 seller module 引入循环依赖,
@@ -229,7 +228,7 @@ export class AdminUiState {
229
228
  const runner = new mod.SellerCommandRunner(this.configManager);
230
229
  const result = await runner.ls(true);
231
230
  if (result && typeof result === "object" && "apps" in result) {
232
- return result.apps.filter((app) => isSellerFlyAppName(app.name));
231
+ return result.apps.filter((app) => isVisibleFlyInventoryApp(app.name));
233
232
  }
234
233
  return [];
235
234
  }
@@ -499,9 +498,6 @@ export class AdminUiState {
499
498
  baseRow.alertReason = "registry 收录了但 fly app 失踪 — 严重事故, 立即下线";
500
499
  baseRow.removeHint = "立即下线 (registry-only)";
501
500
  }
502
- else if (dataSource === "fly") {
503
- baseRow.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
504
- }
505
501
  if (dataSource === "registry") {
506
502
  return {
507
503
  row: {
@@ -726,8 +722,8 @@ function baseSellerRow(entry, profile, dataSource = "registry", flyApp, machineS
726
722
  } : undefined
727
723
  };
728
724
  }
729
- function isSellerFlyAppName(name) {
730
- return Boolean(name && (name.startsWith("tbs-") || name.startsWith("tb-seller-")) && name !== "tb-seller");
725
+ function isVisibleFlyInventoryApp(name) {
726
+ return Boolean(name && name !== "tb-registry");
731
727
  }
732
728
  function sellerEntryFromFlyApp(app) {
733
729
  return {
@@ -56,6 +56,13 @@ export function adminUiHtml() {
56
56
  .panel,.bootstrap-card{border:1px solid var(--hairline);background:var(--panel);border-radius:12px;box-shadow:var(--shadow);overflow:hidden}
57
57
  .panel-head{min-height:56px;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--hairline);background:var(--panel)}
58
58
  .title{margin:0;color:var(--ink);font-size:20px;line-height:28px;font-weight:750}
59
+ .fleet-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}
60
+ .fleet-toggle{min-height:38px;border:1px solid var(--hairline-strong);border-radius:8px;background:var(--panel-subtle);color:var(--muted);padding:0 10px;display:inline-flex;align-items:center;gap:8px;font-size:12px;font-weight:800}
61
+ .fleet-toggle input{position:absolute;opacity:0;pointer-events:none}
62
+ .fleet-toggle .switch-track{width:34px;height:20px;border-radius:999px;background:#d8d0ea;box-shadow:inset 0 0 0 1px var(--hairline-strong);position:relative;flex:0 0 auto}
63
+ .fleet-toggle .switch-track:after{content:"";position:absolute;left:3px;top:3px;width:14px;height:14px;border-radius:999px;background:#fff;box-shadow:0 1px 3px rgba(60,41,112,.2);transition:transform .18s ease}
64
+ .fleet-toggle input:checked + .switch-track{background:var(--primary)}
65
+ .fleet-toggle input:checked + .switch-track:after{transform:translateX(14px)}
59
66
  /* ---- Buttons ------------------------------------------------------ */
60
67
  .btn{border:1px solid var(--hairline-strong);border-radius:8px;min-height:38px;padding:0 12px;background:#fff;color:var(--ink);font-size:13px;font-weight:750}
61
68
  .btn:hover{border-color:var(--primary);color:var(--primary)}
@@ -97,8 +104,6 @@ export function adminUiHtml() {
97
104
  .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;display:inline-block}
98
105
  .remove-hint-btn:hover{background:#d63d5a}
99
106
  .remove-hint-btn::before{content:"! ";margin-right:2px}
100
- .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;display:inline-block}
101
- .publish-hint-btn:hover{background:#f5f3ff}
102
107
  /* Status dot — five spec tones (green/amber/red/blue/gray) */
103
108
  .app-dot{width:10px;height:10px;border-radius:999px;background:#c8ced8;box-shadow:0 0 0 4px #edf1f8}
104
109
  .app-dot.tone-green{background:var(--success);box-shadow:0 0 0 4px rgba(16,185,129,.18)}
@@ -222,7 +227,11 @@ export function adminUiHtml() {
222
227
  .progress-title .spinner{width:14px;height:14px;border-width:2px}
223
228
  .progress-meta{display:flex;align-items:center;justify-content:space-between;gap:10px}
224
229
  .progress-toggle{color:var(--primary);font-size:var(--label-fs);font-weight:var(--label-weight);text-transform:uppercase;letter-spacing:var(--label-spacing)}
225
- .progress-log{margin:4px 0 0;max-height:150px;overflow:auto;border:1px solid var(--hairline);border-radius:8px;background:var(--ink);color:#f3f0ff;padding:9px;font-size:11px;white-space:pre-wrap;font-family:var(--font-mono)}
230
+ .progress-log-wrap{position:relative;margin-top:4px}
231
+ .progress-log{margin:4px 0 0;max-height:150px;overflow:auto;border:1px solid var(--hairline);border-radius:8px;background:var(--ink);color:#f3f0ff;padding:36px 9px 9px;font-size:11px;white-space:pre-wrap;font-family:var(--font-mono)}
232
+ .progress-copy{position:absolute;right:7px;top:7px;min-height:28px;border:1px solid rgba(255,255,255,.18);border-radius:6px;background:rgba(255,255,255,.08);color:#f3f0ff;padding:0 8px;display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:800}
233
+ .progress-copy:hover{background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.28);color:#fff}
234
+ .progress-copy svg{width:13px;height:13px;stroke:currentColor;stroke-width:2.1;fill:none;stroke-linecap:round;stroke-linejoin:round}
226
235
  .spinner{width:19px;height:19px;border:2px solid rgba(124,61,240,.18);border-top-color:var(--primary);border-radius:999px;animation:spin .75s linear infinite}
227
236
  .inline-spinner{width:13px;height:13px;border:2px solid rgba(124,61,240,.18);border-top-color:var(--primary);border-radius:999px;animation:spin .75s linear infinite;display:inline-block;vertical-align:middle}
228
237
  @keyframes spin{to{transform:rotate(360deg)}}
@@ -255,7 +264,14 @@ export function adminUiHtml() {
255
264
  <div>
256
265
  <h1 class="title">Seller fleet</h1>
257
266
  </div>
258
- <button id="createSeller" class="btn primary">Create Seller</button>
267
+ <div class="fleet-controls">
268
+ <label class="fleet-toggle" title="Hide Fly apps that have no Machines instances">
269
+ <input id="hideNoInstanceApps" type="checkbox" checked>
270
+ <span class="switch-track" aria-hidden="true"></span>
271
+ <span>Hide no-instance apps</span>
272
+ </label>
273
+ <button id="createSeller" class="btn primary">Create Seller</button>
274
+ </div>
259
275
  </div>
260
276
  <div class="app-list">
261
277
  <div class="app-table-head" role="row">
@@ -369,7 +385,7 @@ async function api(path, options={}){
369
385
  function uiErrorMessage(err){ const message = err instanceof Error ? err.message : String(err || ""); if (/failed to fetch|networkerror|load failed|fetch failed/i.test(message)) return "Admin UI connection lost. Restart tb-admin ui and reload this page."; if (/operator_auth_required/i.test(message)) return "Admin profile authentication failed. Check the configured operator token."; if (/HTTP Error 401/i.test(message)) return "Authentication failed while loading admin data. Check the local admin profile."; return message || "Request failed."; }
370
386
  const sellerStatusRefreshIntervalMs = 30000;
371
387
  const sellerDetailConcurrency = 2;
372
- let currentDetail = null; let sellerRowsCache = []; let editing = false; let deleteReady = false; let sellerInventoryInFlight = false; let sellerDetailQueue = []; let sellerDetailQueueKeys = new Set(); let sellerDetailInFlight = new Set(); let sellerDetailTimers = new Map(); let sellerClockTimer = null; let sellerRefreshLoaded = false; let sellerRefreshStage = "Starting"; let sellerRefreshError = ""; let createJobTimer = null; let currentCreateJob = null; let expandedProgressSteps = new Set(); let createAppSuffix = "";
388
+ let currentDetail = null; let sellerRowsCache = []; let editing = false; let deleteReady = false; let hideNoInstanceApps = true; let sellerInventoryInFlight = false; let sellerDetailQueue = []; let sellerDetailQueueKeys = new Set(); let sellerDetailInFlight = new Set(); let sellerDetailTimers = new Map(); let sellerClockTimer = null; let sellerRefreshLoaded = false; let sellerRefreshStage = "Starting"; let sellerRefreshError = ""; let createJobTimer = null; let currentCreateJob = null; let expandedProgressSteps = new Set(); let copiedProgressStepId = ""; let createAppSuffix = "";
373
389
  const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
374
390
  const missing = value => '<span class="muted-value">'+esc(value)+'</span>';
375
391
  const dash = () => '<span class="muted-value">'+window.__tbFmt.UNKNOWN_VALUE+'</span>';
@@ -405,8 +421,10 @@ async function loadSellers(options={}){
405
421
  updateSellerRefreshMeta();
406
422
  }
407
423
  }
408
- function statusRefreshRow(row){ return { id:row.id, name:row.name, app:row.app, url:row.url, registryStatus:row.registryStatus, nodeStatus:row.nodeStatus, region:row.region, upstreamDomain:row.upstreamDomain, upstreamStatus:row.upstreamStatus, upstreamBalanceUsdMicros:row.upstreamBalanceUsdMicros, upstreamBalanceCurrency:row.upstreamBalanceCurrency, upstreamBalanceSource:row.upstreamBalanceSource, upstreamBalanceFetchedAt:row.upstreamBalanceFetchedAt, upstreamBalanceError:row.upstreamBalanceError, upstreamRechargeUrl:row.upstreamRechargeUrl, discountRatio:row.discountRatio, capacityUsed:row.capacityUsed, capacityLimit:row.capacityLimit, resourceCpuPercent:row.resourceCpuPercent, resourceMemoryPercent:row.resourceMemoryPercent, resourceMemoryRssMb:row.resourceMemoryRssMb, resourceMemoryLimitMb:row.resourceMemoryLimitMb, ttftMs:row.ttftMs, avgInferenceMs:row.avgInferenceMs, lastInferenceMs:row.lastInferenceMs, avgTokensPerSecond:row.avgTokensPerSecond, lastTokensPerSecond:row.lastTokensPerSecond, latencySamples:row.latencySamples, lastSwitchAt:row.lastSwitchAt, modelsCount:row.modelsCount, specs:row.specs, error:row.error, dataSource:row.dataSource, registryAlert:row.registryAlert, alertReason:row.alertReason, publishHint:row.publishHint, removeHint:row.removeHint, flyApp:row.flyApp, publishStatus:row.publishStatus, detailStatus:row.detailStatus, detailUpdatedAt:row.detailUpdatedAt, detailNextRefreshAt:row.detailNextRefreshAt }; }
409
- 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)); }
424
+ function statusRefreshRow(row){ return { id:row.id, name:row.name, app:row.app, url:row.url, registryStatus:row.registryStatus, nodeStatus:row.nodeStatus, region:row.region, upstreamDomain:row.upstreamDomain, upstreamStatus:row.upstreamStatus, upstreamBalanceUsdMicros:row.upstreamBalanceUsdMicros, upstreamBalanceCurrency:row.upstreamBalanceCurrency, upstreamBalanceSource:row.upstreamBalanceSource, upstreamBalanceFetchedAt:row.upstreamBalanceFetchedAt, upstreamBalanceError:row.upstreamBalanceError, upstreamRechargeUrl:row.upstreamRechargeUrl, discountRatio:row.discountRatio, capacityUsed:row.capacityUsed, capacityLimit:row.capacityLimit, resourceCpuPercent:row.resourceCpuPercent, resourceMemoryPercent:row.resourceMemoryPercent, resourceMemoryRssMb:row.resourceMemoryRssMb, resourceMemoryLimitMb:row.resourceMemoryLimitMb, ttftMs:row.ttftMs, avgInferenceMs:row.avgInferenceMs, lastInferenceMs:row.lastInferenceMs, avgTokensPerSecond:row.avgTokensPerSecond, lastTokensPerSecond:row.lastTokensPerSecond, latencySamples:row.latencySamples, lastSwitchAt:row.lastSwitchAt, modelsCount:row.modelsCount, specs:row.specs, error:row.error, dataSource:row.dataSource, registryAlert:row.registryAlert, alertReason:row.alertReason, removeHint:row.removeHint, flyApp:row.flyApp, publishStatus:row.publishStatus, detailStatus:row.detailStatus, detailUpdatedAt:row.detailUpdatedAt, detailNextRefreshAt:row.detailNextRefreshAt }; }
425
+ function hasNoFlyMachineInstance(row){ const machines = Number(row.specs?.machines); return Number.isFinite(machines) && machines === 0; }
426
+ function visibleSellerRows(rows){ return hideNoInstanceApps ? rows.filter(row => row.dataSource !== "fly" || !hasNoFlyMachineInstance(row)) : rows; }
427
+ function renderSellerRows(rows){ sellerRowsCache = rows; const visibleRows = visibleSellerRows(rows); document.getElementById("sellerRows").innerHTML = visibleRows.map(row => sellerRow(row)).join("") || '<div class="status-line">No apps match the current display filter.</div>'; document.querySelectorAll("[data-detail]").forEach(btn => btn.onclick = () => openDetail(btn.dataset.detail)); }
410
428
  function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => { updateSellerRefreshMeta(); updateSellerRowRefreshCountdowns(); }, 1000); window.addEventListener("beforeunload", () => { resetSellerDetailQueue(); clearInterval(sellerClockTimer); }); }
411
429
  function sellerRowKey(row){ return String(row.id || row.app || row.url || ""); }
412
430
  function resetSellerDetailQueue(){ sellerDetailQueue = []; sellerDetailQueueKeys.clear(); sellerDetailInFlight.clear(); sellerDetailTimers.forEach(timer => clearTimeout(timer)); sellerDetailTimers.clear(); }
@@ -554,7 +572,7 @@ function sellerRow(row){
554
572
  const publishExtras = publishRelation === "registry_only" ? '<strong class="registry-incident" title="'+esc(statusTip)+'">严重事故</strong>' +
555
573
  (row.alertReason ? '<span class="alert-reason">'+esc(row.alertReason)+'</span>' : '') +
556
574
  (row.removeHint ? '<span class="remove-hint-btn" data-action="remove" data-seller-id="'+esc(row.id)+'" title="'+esc(row.removeHint)+'">'+esc(row.removeHint)+'</span>' : '')
557
- : publishRelation === "unpublished" && row.publishHint ? '<span class="publish-hint-btn" data-action="publish" data-seller-id="'+esc(row.id)+'" title="'+esc(row.publishHint)+'">'+esc(row.publishHint)+'</span>' : '';
575
+ : '';
558
576
  const publishCell = '<span class="field-cell"><span class="metric-label">Pub</span>'+dsChip+publishExtras+'</span>';
559
577
  const regionCell = '<span class="field-cell"><span class="metric-label">Region</span><strong>'+esc(regionText)+'</strong></span>';
560
578
  const modelsCell = '<span class="field-cell"><span class="metric-label">Models</span><strong>'+esc(modelsText)+'</strong></span>';
@@ -612,15 +630,18 @@ function renderDetail(){
612
630
  const fields = [["registryStatus", registryStatusDisplay(c.registryStatus || d.row.registryStatus)],["region", c.region],["upstreamUrl", c.upstreamUrl],["upstreamApiKey", c.upstreamApiKeyMasked],["upstreamStatus", fmt.normalizeStatusLabel(c.upstreamStatus)],["ttftMs", fmt.formatDuration(d.row.ttftMs)],["avgTokensPerSecond", fmt.formatSpeed(d.row.avgTokensPerSecond)],["lastTokensPerSecond", fmt.formatSpeed(d.row.lastTokensPerSecond)],["lastInferenceMs", fmt.formatDuration(d.row.lastInferenceMs)],["latencySamples", d.row.latencySamples === undefined ? "—" : esc(fmt.formatCount(d.row.latencySamples))],["upstreamBalance", c.upstreamBalance],["upstreamBalanceSource", c.upstreamBalanceSource],["upstreamBalanceFetchedAt", c.upstreamBalanceFetchedAt ? fmt.formatTimeFull(c.upstreamBalanceFetchedAt) : "—"],["upstreamBalanceError", c.upstreamBalanceError],["upstreamBalanceProbeTemplate", c.upstreamBalanceProbeTemplate],["upstreamBalanceProbeUrl", c.upstreamBalanceProbeUrl],["upstreamBalanceProbeUserId", c.upstreamBalanceProbeUserId],["upstreamBalanceProbeRechargeUrl", c.upstreamBalanceProbeRechargeUrl],["markupRatio", c.markupRatio],["discountRatio", c.discountRatio],["maxConnections", c.maxConnections],["maxQueueDepth", c.maxQueueDepth]];
613
631
  document.getElementById("configFields").innerHTML = detailFieldsHtml(fields);
614
632
  const billingOptions = Array.from(new Set(d.models.map(m => m.billingModel).filter(Boolean)));
615
- document.getElementById("modelsTable").innerHTML = '<table><thead><tr><th>Enable</th><th>Upstream model</th><th>Billing model</th><th>Input price</th><th>Output price</th><th>TTFT</th><th>AVG speed</th><th>Samples</th></tr></thead><tbody>'+d.models.map(m => '<tr><td>'+modelEnableHtml(m)+'</td><td>'+esc(m.upstreamModel)+'</td><td>'+(editing ? selectHtml(m.upstreamModel, m.billingModel, billingOptions) : esc(m.billingModel))+'</td><td>'+esc(m.inputPrice || "—")+'</td><td>'+esc(m.outputPrice || "—")+'</td><td>'+esc(m.ttftMs === undefined ? "—" : fmt.formatDuration(m.ttftMs))+'</td><td>'+esc(m.avgTokensPerSecond === undefined ? "—" : fmt.formatSpeed(m.avgTokensPerSecond))+'</td><td>'+esc(m.latencySamples === undefined ? "—" : fmt.formatCount(m.latencySamples))+'</td></tr>').join("")+'</tbody></table>';
633
+ document.getElementById("modelsTable").innerHTML = '<table><thead><tr><th><input id="modelBulkToggle" class="model-toggle" type="checkbox" aria-label="Toggle all models" '+(editing ? "" : "disabled")+'> Enable</th><th>Upstream model</th><th>Billing model</th><th>Input price</th><th>Output price</th><th>TTFT</th><th>AVG speed</th><th>Samples</th></tr></thead><tbody>'+d.models.map(m => '<tr><td>'+modelEnableHtml(m)+'</td><td>'+esc(m.upstreamModel)+'</td><td>'+(editing ? selectHtml(m.upstreamModel, m.billingModel, billingOptions) : esc(m.billingModel))+'</td><td>'+esc(m.inputPrice || "—")+'</td><td>'+esc(m.outputPrice || "—")+'</td><td>'+esc(m.ttftMs === undefined ? "—" : fmt.formatDuration(m.ttftMs))+'</td><td>'+esc(m.avgTokensPerSecond === undefined ? "—" : fmt.formatSpeed(m.avgTokensPerSecond))+'</td><td>'+esc(m.latencySamples === undefined ? "—" : fmt.formatCount(m.latencySamples))+'</td></tr>').join("")+'</tbody></table>';
634
+ setupModelBulkToggle();
616
635
  }
617
636
  function showDetailStatus(message, loading){ const el = document.getElementById("detailStatus"); if (loading){ el.innerHTML = '<span class="spinner" aria-hidden="true"></span>'; el.setAttribute("role", "status"); el.setAttribute("aria-label", message || "Loading"); } else { el.textContent = message || ""; el.removeAttribute("role"); el.removeAttribute("aria-label"); } el.classList.toggle("hidden", !message && !loading); el.classList.toggle("loading", Boolean(loading)); }
618
637
  function metricCell(value){ return value === undefined || value === null || value === "" ? missing("—") : esc(value); }
619
638
  function detailFieldsHtml(fields){ return fields.filter(([key,value]) => !isMissingDetailField(key,value)).map(([key,value]) => { const editable = editing && ["upstreamUrl","upstreamApiKey","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeRechargeUrl","upstreamBalanceProbeUserId","markupRatio","discountRatio","maxConnections","maxQueueDepth"].includes(key); return fieldHtml(key, value, editable); }).join(""); }
620
639
  function isMissingDetailField(key,value){ const fmt = window.__tbFmt; return value === undefined || value === null || value === "" || value === fmt.UNKNOWN_VALUE || (key === "registryStatus" && value === "unknown"); }
621
- function fieldHtml(key,value, editable, options={}){ const fieldId = "field-" + String(key).replace(/[^a-z0-9_-]/gi, "-") + "-" + Math.random().toString(36).slice(2); const label = options.label || key; const labelText = label + (options.required ? ' <span class="required-star">*</span>' : ''); const display = value === undefined || value === null ? "" : value; const className = "field" + (options.full ? " full" : ""); const help = options.help ? '<p class="field-help">'+esc(options.help)+'</p>' : ""; const hiddenInput = '<input type="hidden" data-field="'+esc(key)+'" value="'+esc(display)+'">'; if (options.hidden) return hiddenInput; if (!editable || options.readonly) return '<div class="'+className+'"><label>'+labelText+'</label><div class="readonly-value" title="'+esc(display)+'" data-readonly-field="'+esc(key)+'">'+esc(display || window.__tbFmt.UNKNOWN_VALUE)+'</div>'+(options.submit ? hiddenInput : "")+help+'</div>'; if (key === "upstreamBalanceProbeTemplate") return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><select id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'">'+balanceProbeTemplates.map(t => '<option value="'+esc(t)+'" '+(t === display ? "selected" : "")+'>'+esc(t)+'</option>').join("")+'</select>'+help+'</div>'; const type = options.type || (String(key).toLowerCase().includes("key") ? "password" : "text"); const attrs = options.type === "number" ? ' type="number" step="any"' : ' type="'+esc(type)+'"'; const placeholder = options.placeholder ? ' placeholder="'+esc(options.placeholder)+'"' : ""; return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><input'+attrs+placeholder+' id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'" value="'+esc(display)+'">'+help+'</div>'; }
640
+ function fieldHtml(key,value, editable, options={}){ const fieldId = "field-" + String(key).replace(/[^a-z0-9_-]/gi, "-") + "-" + Math.random().toString(36).slice(2); const label = options.label || key; const labelText = label + (options.required ? ' <span class="required-star">*</span>' : ''); const display = value === undefined || value === null ? "" : value; const className = "field" + (options.full ? " full" : ""); const help = options.help ? '<p class="field-help">'+esc(options.help)+'</p>' : ""; const hiddenInput = '<input type="hidden" data-field="'+esc(key)+'" value="'+esc(display)+'">'; if (options.hidden) return hiddenInput; if (!editable || options.readonly) return '<div class="'+className+'"><label>'+labelText+'</label><div class="readonly-value" title="'+esc(display)+'" data-readonly-field="'+esc(key)+'">'+esc(display || window.__tbFmt.UNKNOWN_VALUE)+'</div>'+(options.submit ? hiddenInput : "")+help+'</div>'; if (key === "upstreamBalanceProbeTemplate") return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><select id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'">'+balanceProbeTemplates.map(t => '<option value="'+esc(t)+'" '+(t === display ? "selected" : "")+'>'+esc(t)+'</option>').join("")+'</select>'+help+'</div>'; if (key === "upstreamProtocolPreset") return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><select id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'">'+["auto","chat","image"].map(t => '<option value="'+esc(t)+'" '+(t === display ? "selected" : "")+'>'+esc(t)+'</option>').join("")+'</select>'+help+'</div>'; const type = options.type || (String(key).toLowerCase().includes("key") ? "password" : "text"); const attrs = options.type === "number" ? ' type="number" step="any"' : ' type="'+esc(type)+'"'; const placeholder = options.placeholder ? ' placeholder="'+esc(options.placeholder)+'"' : ""; return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><input'+attrs+placeholder+' id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'" value="'+esc(display)+'">'+help+'</div>'; }
622
641
  function selectHtml(upstreamModel, billingModel, options){ const values = options.includes(billingModel) ? options : [billingModel, ...options].filter(Boolean); return '<select name="billingModel" aria-label="billingModel" autocomplete="off" data-model="'+esc(upstreamModel)+'">'+values.map(value => '<option '+(value === billingModel ? "selected" : "")+'>'+esc(value)+'</option>').join("")+'</select>'; }
623
642
  function modelEnableHtml(model){ return '<input class="model-toggle" type="checkbox" aria-label="Enable '+esc(model.upstreamModel)+'" data-model-enabled="'+esc(model.upstreamModel)+'" '+(model.enabled === false ? "" : "checked")+' '+(editing ? "" : "disabled")+'>'; }
643
+ function setupModelBulkToggle(){ const bulk = document.getElementById("modelBulkToggle"); if (!bulk) return; const rows = Array.from(document.querySelectorAll("[data-model-enabled]")); rows.forEach(input => input.onchange = updateModelBulkToggle); bulk.onchange = event => { rows.forEach(input => { if (!input.disabled) input.checked = Boolean(event.target.checked); }); updateModelBulkToggle(); }; updateModelBulkToggle(); }
644
+ function updateModelBulkToggle(){ const bulk = document.getElementById("modelBulkToggle"); if (!bulk) return; const rows = Array.from(document.querySelectorAll("[data-model-enabled]")).filter(input => !input.disabled); const checked = rows.filter(input => input.checked).length; const mixed = checked > 0 && checked < rows.length; bulk.checked = rows.length > 0 && checked === rows.length; bulk.indeterminate = mixed; bulk.setAttribute("aria-checked", mixed ? "mixed" : String(bulk.checked)); bulk.disabled = !editing || rows.length === 0; }
624
645
  function registryStatusForAction(action){ if (action === "drain") return "draining"; if (action === "activate") return "active"; return "offline"; }
625
646
  function setStatusActionBusy(busy){ document.querySelectorAll("[data-status-action]").forEach(btn => { btn.disabled = Boolean(busy); }); }
626
647
  function setDetailSavingBusy(busy){ const edit = document.getElementById("editDetail"); edit.disabled = Boolean(busy); edit.textContent = busy ? "Saving" : (editing ? "Save changes" : "Edit config"); document.querySelectorAll("#detailGrid [data-field], #detailGrid [data-model], #detailGrid [data-model-enabled], [data-status-action], #deleteSeller").forEach(input => { input.disabled = Boolean(busy); }); }
@@ -628,17 +649,18 @@ document.getElementById("editDetail").onclick = async () => { if (!editing){ edi
628
649
  function modelConfigPatchFromDetail(){ const enabledByModel = {}; document.querySelectorAll("[data-model-enabled]").forEach(input => enabledByModel[input.dataset.modelEnabled] = Boolean(input.checked)); return (currentDetail?.models || []).map(model => ({ ...(model.configModel || { id:model.upstreamModel }), id:model.upstreamModel, enabled: enabledByModel[model.upstreamModel] !== false })); }
629
650
  document.getElementById("deleteSeller").onclick = async () => { if (!currentDetail) return; if (deleteReady && !confirm("Destroy deployment for "+currentDetail.row.name+"?")) return; const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/deployment", { method:"DELETE", body: JSON.stringify({ confirm: deleteReady }) }); showDetailStatus(result.stdout || (deleteReady ? "Deployment destroy requested." : "Dry-run ready. Click delete again to confirm destroy."), false); deleteReady = !deleteReady; document.getElementById("deleteSeller").title = deleteReady ? "Confirm destroy deployment" : "Delete deployment"; };
630
651
  document.getElementById("closeDetail").onclick = () => { deleteReady = false; document.getElementById("detailModal").classList.remove("open"); };
652
+ document.getElementById("hideNoInstanceApps").onchange = event => { hideNoInstanceApps = Boolean(event.target.checked); renderSellerRows(sellerRowsCache); };
631
653
  document.querySelectorAll("[data-status-action]").forEach(btn => btn.onclick = async () => { if (!currentDetail) return; const action = btn.dataset.statusAction; const id = currentDetail.row.id; const status = registryStatusForAction(action); try { setStatusActionBusy(true); showDetailStatus("Updating registry status", true); const result = await api("/api/sellers/"+encodeURIComponent(id)+"/"+action, { method:"POST" }); if (!result.ok) throw new Error(result.stderr || "Status update failed."); patchSellerRegistryStatus(id, status); deleteReady = false; currentDetail = null; document.getElementById("detailModal").classList.remove("open"); } catch (err) { showDetailStatus(err.message || "Status update failed.", false); } finally { setStatusActionBusy(false); } });
632
654
  document.getElementById("createSeller").onclick = () => { buildCreateForm(); document.getElementById("createModal").classList.add("open"); };
633
655
  document.getElementById("closeCreate").onclick = () => document.getElementById("createModal").classList.remove("open");
634
656
  function buildCreateForm(){ clearInterval(createJobTimer); createJobTimer = null; currentCreateJob = null; expandedProgressSteps = new Set(); createAppSuffix = randomAppSuffix(); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Create seller"; document.getElementById("createStatus").textContent = ""; document.getElementById("createStatus").classList.add("hidden"); document.getElementById("createStatus").classList.remove("loading"); document.getElementById("createProgress").classList.add("hidden"); document.getElementById("createProgress").innerHTML = ""; setCreateFormDisabled(false); const defaults = createDefaults(); document.getElementById("createFields").innerHTML = createFormHtml(defaults); setupCreateFormBehavior(); setupPaymentTabs(); }
635
- function createFormHtml(defaults){ return [createSectionHtml("基础信息设置", ["sellerName","app","region","image","flyConfig"], defaults), createSectionHtml("上游设置", ["upstreamWebsite","upstreamUrl","upstreamApiKey","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeUserId","upstreamBalanceProbeRechargeUrl"], defaults), createSectionHtml("性能与安全", ["maxConnections","maxQueueDepth","markupRatio","discountRatio"], defaults), paymentSectionHtml(defaults)].join(""); }
657
+ function createFormHtml(defaults){ return [createSectionHtml("基础信息设置", ["sellerName","app","region","image","flyConfig"], defaults), createSectionHtml("上游设置", ["upstreamWebsite","upstreamUrl","upstreamApiKey","upstreamProtocolPreset","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeUserId","upstreamBalanceProbeRechargeUrl"], defaults), createSectionHtml("性能与安全", ["maxConnections","maxQueueDepth","markupRatio","discountRatio"], defaults), paymentSectionHtml(defaults)].join(""); }
636
658
  function createSectionHtml(title, fields, defaults){ return '<section class="create-section"><h2>'+esc(title)+'</h2><div class="section-grid">'+fields.map(key => fieldHtml(key, defaults[key], createFieldEditable(key), createFieldOptions(key))).join("")+'</div></section>'; }
637
659
  function paymentSectionHtml(defaults){ const enabled = new Set(defaults.paymentMethods || []); const editableFields = [["clawtipPayTo","payTo"],["clawtipSm4KeyBase64","sm4KeyBase64"]]; const fixedFields = ["clawtipSkillSlug","clawtipSkillId","clawtipDescription","clawtipResourceUrl","clawtipActivationFeeFen","clawtipMicrosPerFen"]; return '<section class="create-section"><h2>支付设置</h2><div class="payment-tabs">'+paymentMethods.map((method,index) => '<button type="button" class="payment-tab '+(index === 0 ? "active" : "")+'" data-payment-tab="'+esc(method)+'">'+esc(method)+'</button>').join("")+'</div><div class="payment-panel" data-payment-panel="clawtip" data-enabled="'+String(enabled.has("clawtip"))+'"><div class="payment-head"><h3>ClawTip</h3>'+paymentToggleHtml("clawtip", enabled.has("clawtip"))+'</div><div class="section-grid">'+editableFields.map(([key,label]) => fieldHtml(key, defaults[key], true, { ...createFieldOptions(key), label })).join("")+fixedFields.map(key => fieldHtml(key, defaults[key], false, { hidden:true })).join("")+'<div class="field full"><label>自动生成参数</label><div class="generated-summary" data-generated-summary="clawtip"></div></div></div></div><div class="payment-panel hidden" data-payment-panel="mock" data-enabled="'+String(enabled.has("mock"))+'"><div class="payment-head"><h3>Mock</h3>'+paymentToggleHtml("mock", enabled.has("mock"))+'</div><div class="section-grid">'+fieldHtml("参数规则", "启用即可使用 mock 支付方式,无需额外参数", false, { full:true })+'</div></div></section>'; }
638
660
  function paymentToggleHtml(method, enabled){ return '<button type="button" class="pill-switch '+(enabled ? "enabled" : "")+'" data-payment-toggle="'+esc(method)+'" aria-pressed="'+String(enabled)+'">'+(enabled ? "已启用" : "未启用")+'</button>'; }
639
661
  function numericFieldOptions(key){ return ["maxConnections","maxQueueDepth","markupRatio","discountRatio","clawtipActivationFeeFen","clawtipMicrosPerFen"].includes(key) ? { type:"number" } : {}; }
640
662
  function createFieldEditable(key){ return !["app","image","flyConfig"].includes(key); }
641
- function createFieldOptions(key){ const labels = { sellerName:"Seller name", app:"Fly app name", region:"Fly region", image:"Seller image", flyConfig:"Fly config file", upstreamWebsite:"Upstream website", upstreamUrl:"OpenAI-compatible base URL", upstreamApiKey:"Upstream API key", upstreamBalanceProbeTemplate:"Balance probe template", upstreamBalanceProbeUrl:"Balance probe URL (auto from template)", upstreamBalanceProbeUserId:"Balance probe user ID", upstreamBalanceProbeRechargeUrl:"Recharge URL", maxConnections:"Max connections", maxQueueDepth:"Max queue depth", markupRatio:"Markup ratio", discountRatio:"Discount ratio", clawtipPayTo:"payTo", clawtipSm4KeyBase64:"sm4KeyBase64" }; const help = { sellerName:"Use a domain-style slug, for example openrouter-ai or moonshot-cn. The Fly app will be generated as tbs-<seller-name>-<random>.", app:"Generated from seller name. Example: tbs-openrouter-ai-k7p9x.", region:"Fly.io region, for example sin, nrt, hkg, lax.", image:"Uses the published seller image by default.", flyConfig:"Uses the standard tb-seller Fly.io config.", upstreamWebsite:"Customer-facing upstream website. Example: https://moonshot.cn", upstreamUrl:"OpenAI-compatible API base URL. Examples: https://api.moonshot.cn/v1 or https://openrouter.ai/api/v1", upstreamApiKey:"Upstream provider API key. This is submitted to the seller config and never shown in the seller list.", upstreamBalanceProbeTemplate:"Choose the parser used for balance lookup. usage_generic calls /v1/usage with the upstream API key.", upstreamBalanceProbeUrl:"Auto-filled and disabled for known templates. NewAPI generic keeps this editable. Example: https://code.shoestravel.xin/api/user/self", upstreamBalanceProbeUserId:"Only needed by NewAPI-style balance endpoints that require a user id.", upstreamBalanceProbeRechargeUrl:"Where operators recharge this upstream account. Example: https://code.shoestravel.xin/topup or https://openrouter.ai/settings/credits", maxConnections:"Seller concurrency limit. Each connection can serve one in-flight request.", maxQueueDepth:"Number of queued requests allowed when all connections are busy.", markupRatio:"Multiplier applied to upstream prices before discount. 1.0 = passthrough.", discountRatio:"Configured discount level after markup. 1.0 = standard price, 0.5 = 5折, 0.0 = free.", clawtipPayTo:"Alipay payTo target for ClawTip payments.", clawtipSm4KeyBase64:"Base64 SM4 symmetric key used to encrypt ClawTip payment requests." }; const options = { ...numericFieldOptions(key) }; if (labels[key]) options.label = labels[key]; if (help[key]) options.help = help[key]; return options; }
663
+ function createFieldOptions(key){ const labels = { sellerName:"Seller name", app:"Fly app name", region:"Fly region", image:"Seller image", flyConfig:"Fly config file", upstreamWebsite:"Upstream website", upstreamUrl:"OpenAI-compatible base URL", upstreamApiKey:"Upstream API key", upstreamProtocolPreset:"Protocol preset", upstreamBalanceProbeTemplate:"Balance probe template", upstreamBalanceProbeUrl:"Balance probe URL (auto from template)", upstreamBalanceProbeUserId:"Balance probe user ID", upstreamBalanceProbeRechargeUrl:"Recharge URL", maxConnections:"Max connections", maxQueueDepth:"Max queue depth", markupRatio:"Markup ratio", discountRatio:"Discount ratio", clawtipPayTo:"payTo", clawtipSm4KeyBase64:"sm4KeyBase64" }; const help = { sellerName:"Use a domain-style slug, for example openrouter-ai or moonshot-cn. The Fly app will be generated as tbs-<seller-name>-<random>.", app:"Generated from seller name. Example: tbs-openrouter-ai-k7p9x.", region:"Fly.io region, for example sin, nrt, hkg, lax.", image:"Uses the published seller image by default.", flyConfig:"Uses the standard tb-seller Fly.io config.", upstreamWebsite:"Customer-facing upstream website. Example: https://moonshot.cn", upstreamUrl:"OpenAI-compatible API base URL. Examples: https://api.moonshot.cn/v1 or https://openrouter.ai/api/v1", upstreamApiKey:"Upstream provider API key. This is submitted to the seller config and never shown in the seller list.", upstreamProtocolPreset:"Use image for nodes that should only publish /v1/images/generations.", upstreamBalanceProbeTemplate:"Choose the parser used for balance lookup. usage_generic calls /v1/usage with the upstream API key.", upstreamBalanceProbeUrl:"Auto-filled and disabled for known templates. NewAPI generic keeps this editable. Example: https://code.shoestravel.xin/api/user/self", upstreamBalanceProbeUserId:"Only needed by NewAPI-style balance endpoints that require a user id.", upstreamBalanceProbeRechargeUrl:"Where operators recharge this upstream account. Example: https://code.shoestravel.xin/topup or https://openrouter.ai/settings/credits", maxConnections:"Seller concurrency limit. Each connection can serve one in-flight request.", maxQueueDepth:"Number of queued requests allowed when all connections are busy.", markupRatio:"Multiplier applied to upstream prices before discount. 1.0 = passthrough.", discountRatio:"Configured ratio applied after markup. UI displays this raw value, for example 1, 0.5, or 0.01.", clawtipPayTo:"Alipay payTo target for ClawTip payments.", clawtipSm4KeyBase64:"Base64 SM4 symmetric key used to encrypt ClawTip payment requests." }; const options = { ...numericFieldOptions(key) }; if (labels[key]) options.label = labels[key]; if (help[key]) options.help = help[key]; if (["app","image","flyConfig"].includes(key)) options.submit = true; return options; }
642
664
  function randomAppSuffix(){ return Math.random().toString(36).replace(/[^a-z0-9]/g, "").slice(2, 7) || "x7p9k"; }
643
665
  function setupCreateFormBehavior(){ ["sellerName","upstreamUrl","upstreamBalanceProbeTemplate"].forEach(key => { const input = document.querySelector('#createFields [data-field="'+key+'"]'); if (!input) return; input.addEventListener("input", updateGeneratedCreateFields); input.addEventListener("change", updateGeneratedCreateFields); }); updateGeneratedCreateFields(); }
644
666
  function updateGeneratedCreateFields(){ const sellerName = createFieldString("sellerName") || "seller"; const app = appNameFromSellerName(sellerName); const template = createFieldString("upstreamBalanceProbeTemplate") || "auto"; const upstreamUrl = createFieldString("upstreamUrl") || ""; const balanceInput = document.querySelector('#createFields [data-field="upstreamBalanceProbeUrl"]'); setCreateFieldValue("app", app); if (balanceInput){ const editableBalanceUrl = template === "newapi_generic"; balanceInput.disabled = !editableBalanceUrl; balanceInput.readOnly = !editableBalanceUrl; if (editableBalanceUrl && balanceInput.dataset.autoGenerated === "true") { setCreateFieldValue("upstreamBalanceProbeUrl", ""); balanceInput.dataset.autoGenerated = "false"; } if (!editableBalanceUrl) { setCreateFieldValue("upstreamBalanceProbeUrl", balanceProbeUrlForTemplate(template, upstreamUrl)); balanceInput.dataset.autoGenerated = "true"; } } setCreateFieldValue("clawtipSkillSlug", app); setCreateFieldValue("clawtipSkillId", "si-" + app); setCreateFieldValue("clawtipDescription", "TokenBuddy Seller " + sellerSlugFromName(sellerName)); setCreateFieldValue("clawtipResourceUrl", "https://" + app + ".fly.dev"); setCreateFieldValue("clawtipActivationFeeFen", "1"); setCreateFieldValue("clawtipMicrosPerFen", "10000"); const summary = document.querySelector('[data-generated-summary="clawtip"]'); if (summary) summary.textContent = "skillSlug=" + app + " · skillId=si-" + app + " · resourceUrl=https://" + app + ".fly.dev · activationFeeFen=1"; }
@@ -654,13 +676,19 @@ function selectPaymentTab(method){ document.querySelectorAll("[data-payment-tab]
654
676
  function togglePaymentMethod(method){ const panel = document.querySelector('[data-payment-panel="'+method+'"]'); if (!panel) return; panel.dataset.enabled = String(panel.dataset.enabled !== "true"); updatePaymentPanels(); }
655
677
  function enabledPaymentMethods(){ return Array.from(document.querySelectorAll("[data-payment-panel]")).filter(panel => panel.dataset.enabled === "true").map(panel => panel.dataset.paymentPanel); }
656
678
  function updatePaymentPanels(){ document.querySelectorAll("[data-payment-panel]").forEach(panel => { const enabled = panel.dataset.enabled === "true"; const toggle = panel.querySelector("[data-payment-toggle]"); if (toggle){ toggle.classList.toggle("enabled", enabled); toggle.setAttribute("aria-pressed", String(enabled)); toggle.textContent = enabled ? "已启用" : "未启用"; } panel.querySelectorAll("[data-field]").forEach(input => { input.disabled = !enabled; }); }); }
657
- function createDefaults(){ const regions = sellerRowsCache.map(row => String(row.region || "").toLowerCase()).filter(Boolean); const region = regions[0] || "sin"; const existing = new Set(sellerRowsCache.flatMap(row => [row.id, row.name, row.app].filter(Boolean))); let index = sellerRowsCache.length + 1; let sellerName = "openrouter-ai"; while (existing.has(sellerName)) sellerName = "openrouter-ai-" + index++; const app = appNameFromSellerName(sellerName); return { sellerName, app, region, image:"registry.fly.io/tb-seller:latest", upstreamWebsite:"https://openrouter.ai", upstreamUrl:"https://openrouter.ai/api/v1", upstreamApiKey:"", upstreamBalanceProbeTemplate:"openrouter", upstreamBalanceProbeUrl:"https://openrouter.ai/api/v1/credits", upstreamBalanceProbeUserId:"", upstreamBalanceProbeRechargeUrl:"https://openrouter.ai/settings/credits", maxConnections:8, maxQueueDepth:4, markupRatio:1.2, discountRatio:1, paymentMethods:["clawtip"], clawtipPayTo:"", clawtipSm4KeyBase64:"", clawtipSkillSlug:app, clawtipSkillId:"si-"+app, clawtipDescription:"TokenBuddy Seller "+sellerName, clawtipResourceUrl:"https://"+app+".fly.dev", clawtipActivationFeeFen:1, clawtipMicrosPerFen:10000, flyConfig:"deploy/fly.io/fly.tb-seller.toml" }; }
679
+ function createDefaults(){ const regions = sellerRowsCache.map(row => String(row.region || "").toLowerCase()).filter(Boolean); const region = regions[0] || "sin"; const existing = new Set(sellerRowsCache.flatMap(row => [row.id, row.name, row.app].filter(Boolean))); let index = sellerRowsCache.length + 1; let sellerName = "openrouter-ai"; while (existing.has(sellerName)) sellerName = "openrouter-ai-" + index++; const app = appNameFromSellerName(sellerName); return { sellerName, app, region, image:"registry.fly.io/tb-seller:latest", upstreamWebsite:"https://openrouter.ai", upstreamUrl:"https://openrouter.ai/api/v1", upstreamApiKey:"", upstreamProtocolPreset:"auto", upstreamBalanceProbeTemplate:"openrouter", upstreamBalanceProbeUrl:"https://openrouter.ai/api/v1/credits", upstreamBalanceProbeUserId:"", upstreamBalanceProbeRechargeUrl:"https://openrouter.ai/settings/credits", maxConnections:8, maxQueueDepth:4, markupRatio:1.2, discountRatio:1, paymentMethods:["clawtip"], clawtipPayTo:"", clawtipSm4KeyBase64:"", clawtipSkillSlug:app, clawtipSkillId:"si-"+app, clawtipDescription:"TokenBuddy Seller "+sellerName, clawtipResourceUrl:"https://"+app+".fly.dev", clawtipActivationFeeFen:1, clawtipMicrosPerFen:10000, flyConfig:"" }; }
658
680
  document.getElementById("submitCreate").onclick = async () => { const body = { paymentMethods: enabledPaymentMethods() }; document.querySelectorAll("#createFields [data-field]").forEach(input => body[input.dataset.field] = fieldValue(input)); if (!confirm("Create seller deployment "+body.sellerName+" on Fly.io?")) return; try { setCreateFormDisabled(true); document.getElementById("submitCreate").disabled = true; document.getElementById("submitCreate").textContent = "Creating"; document.getElementById("createStatus").textContent = ""; document.getElementById("createStatus").classList.add("hidden"); document.getElementById("createStatus").classList.remove("loading"); document.getElementById("createProgress").classList.remove("hidden"); document.getElementById("createProgress").innerHTML = '<div class="progress-step running"><div class="progress-title"><span class="spinner" aria-hidden="true"></span><strong>Starting</strong></div><div class="progress-meta"><span>Preparing create workflow.</span></div></div>'; const response = await api("/api/sellers", { method:"POST", body: JSON.stringify(body) }); renderCreateJob(response.job); pollCreateJob(response.jobId); } catch (err) { setCreateFormDisabled(false); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Create seller"; document.getElementById("createStatus").classList.remove("hidden"); document.getElementById("createStatus").textContent = err.message; document.getElementById("createStatus").classList.remove("loading"); } };
659
681
  function pollCreateJob(jobId){ clearInterval(createJobTimer); createJobTimer = setInterval(async () => { try { const job = await api("/api/jobs/"+encodeURIComponent(jobId)); renderCreateJob(job); if (job.status !== "running"){ clearInterval(createJobTimer); createJobTimer = null; if (job.status === "succeeded") loadSellers(); } } catch (err) { clearInterval(createJobTimer); createJobTimer = null; document.getElementById("createStatus").classList.remove("hidden"); document.getElementById("createStatus").textContent = err.message; document.getElementById("createStatus").classList.remove("loading"); } }, 1200); }
660
682
  function renderCreateJob(job){ if (!job) return; currentCreateJob = job; const done = job.status !== "running"; const events = job.events || []; const progress = document.getElementById("createProgress"); const status = document.getElementById("createStatus"); progress.classList.remove("hidden"); status.classList.toggle("hidden", !done); status.classList.remove("loading"); if (done){ status.textContent = job.status === "succeeded" ? "Created and added to bootstrap registry." : (job.error || "Create seller failed."); status.removeAttribute("role"); } else { status.textContent = ""; } progress.innerHTML = events.map(event => progressStep(event)).join("") || '<div class="progress-step running"><div class="progress-title"><span class="spinner" aria-hidden="true"></span><strong>Starting</strong></div><div class="progress-meta"><span>Preparing create workflow.</span></div></div>'; if (done && job.status === "failed"){ setCreateFormDisabled(false); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Retry create"; } else if (done) { document.getElementById("submitCreate").disabled = true; document.getElementById("submitCreate").textContent = "Created"; } }
661
- 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>'; }
683
+ function progressLogText(event){ const result = event.result || {}; return [result.command ? "$ " + result.command.join(" ") : "", result.stdout || "", result.stderr || ""].filter(Boolean).join("\\n"); }
684
+ function progressCopyIcon(){ return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'; }
685
+ function progressStep(event){ const log = progressLogText(event); const expanded = expandedProgressSteps.has(event.stepId); const copied = copiedProgressStepId === event.stepId; const spinner = event.status === "running" ? '<span class="spinner" aria-hidden="true"></span>' : ""; return '<div role="button" tabindex="0" 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 ? '<div class="progress-log-wrap"><button type="button" class="progress-copy" data-copy-progress-log="'+esc(event.stepId)+'" title="Copy output" aria-label="Copy output">'+progressCopyIcon()+'<span>'+(copied ? "Copied" : "Copy")+'</span></button><pre class="progress-log">'+esc(log)+'</pre></div>' : '')+'</div>'; }
686
+ async function copyProgressLog(stepId){ const event = (currentCreateJob?.events || []).find(item => item.stepId === stepId); if (!event) return; const text = progressLogText(event); try { if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text); else fallbackCopyText(text); copiedProgressStepId = stepId; if (currentCreateJob) renderCreateJob(currentCreateJob); setTimeout(() => { if (copiedProgressStepId === stepId) { copiedProgressStepId = ""; if (currentCreateJob) renderCreateJob(currentCreateJob); } }, 1400); } catch { fallbackCopyText(text); copiedProgressStepId = stepId; if (currentCreateJob) renderCreateJob(currentCreateJob); } }
687
+ function fallbackCopyText(text){ const area = document.createElement("textarea"); area.value = text; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.opacity = "0"; document.body.appendChild(area); area.select(); document.execCommand("copy"); area.remove(); }
662
688
  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(); }
663
- 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); };
689
+ function toggleProgressStep(id){ if (expandedProgressSteps.has(id)) expandedProgressSteps.delete(id); else expandedProgressSteps.add(id); if (currentCreateJob) renderCreateJob(currentCreateJob); }
690
+ document.getElementById("createProgress").onclick = event => { const copy = event.target.closest("[data-copy-progress-log]"); if (copy){ event.stopPropagation(); copyProgressLog(copy.dataset.copyProgressLog); return; } const step = event.target.closest("[data-progress-step]"); if (!step) return; toggleProgressStep(step.dataset.progressStep); };
691
+ document.getElementById("createProgress").onkeydown = event => { if (event.key !== "Enter" && event.key !== " ") return; const step = event.target.closest("[data-progress-step]"); if (!step || event.target.closest("[data-copy-progress-log]")) return; event.preventDefault(); toggleProgressStep(step.dataset.progressStep); };
664
692
  document.getElementById("closeBootstrapConfig").onclick = () => document.getElementById("bootstrapModal").classList.remove("open");
665
693
  function fieldValue(input){ return numeric(input.value); }
666
694
  function numeric(value){ const n = Number(value); return value !== "" && Number.isFinite(n) ? n : value; }