@tokenbuddy/tb-admin 1.0.33 → 1.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +28 -0
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/seller.d.ts +40 -1
  5. package/dist/src/seller.d.ts.map +1 -1
  6. package/dist/src/seller.js +132 -2
  7. package/dist/src/seller.js.map +1 -1
  8. package/dist/src/ui-actions.d.ts +2 -0
  9. package/dist/src/ui-actions.d.ts.map +1 -1
  10. package/dist/src/ui-actions.js +8 -6
  11. package/dist/src/ui-actions.js.map +1 -1
  12. package/dist/src/ui-command.d.ts +1 -0
  13. package/dist/src/ui-command.d.ts.map +1 -1
  14. package/dist/src/ui-command.js +7 -2
  15. package/dist/src/ui-command.js.map +1 -1
  16. package/dist/src/ui-server.js +17 -0
  17. package/dist/src/ui-server.js.map +1 -1
  18. package/dist/src/ui-state.d.ts +31 -0
  19. package/dist/src/ui-state.d.ts.map +1 -1
  20. package/dist/src/ui-state.js +461 -115
  21. package/dist/src/ui-state.js.map +1 -1
  22. package/dist/src/ui-static.d.ts.map +1 -1
  23. package/dist/src/ui-static.js +267 -144
  24. package/dist/src/ui-static.js.map +1 -1
  25. package/dist/src/upstream-balance-probe.d.ts +2 -40
  26. package/dist/src/upstream-balance-probe.d.ts.map +1 -1
  27. package/dist/src/upstream-balance-probe.js +1 -378
  28. package/dist/src/upstream-balance-probe.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli.ts +31 -0
  31. package/src/seller.ts +179 -3
  32. package/src/ui-actions.ts +10 -6
  33. package/src/ui-command.ts +7 -2
  34. package/src/ui-server.ts +18 -1
  35. package/src/ui-state.ts +541 -115
  36. package/src/ui-static.ts +267 -144
  37. package/src/upstream-balance-probe.ts +13 -505
  38. package/tests/admin.test.ts +418 -39
  39. package/tests/seller.test.ts +51 -0
  40. package/tests/ui-state-fleet.test.ts +272 -3
  41. package/tests/ui-static-row.test.ts +273 -8
@@ -50,18 +50,12 @@ export function adminUiHtml() {
50
50
  .top-link:hover{background:var(--panel);color:var(--ink)}
51
51
  .top-link.active{color:var(--ink);font-weight:760}
52
52
  .top-link.active:after{opacity:1;transform:scaleX(1)}
53
- .content{width:min(1180px,calc(100vw - 48px));margin:0 auto;padding:20px 0 36px;display:grid;gap:16px}
53
+ .content{width:min(1560px,calc(100vw - 64px));margin:0 auto;padding:20px 0 36px;display:grid;gap:16px}
54
54
  .page{display:none}.page.active{display:grid;gap:16px}
55
55
  /* ---- Panels ------------------------------------------------------- */
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
- /* ---- Refresh pill ------------------------------------------------- */
60
- .list-status{display:flex;align-items:center;gap:12px;min-width:0;color:var(--muted);font-size:12px;font-weight:650}
61
- .refresh-pill{border:1px solid var(--hairline-strong);border-radius:8px;background:#fff;padding:6px 10px;color:var(--primary);font-size:12px;font-weight:750}
62
- .refresh-pill.refreshing{display:inline-flex;align-items:center;gap:7px;color:var(--primary-deep);background:var(--primary-soft)}
63
- .refresh-pill.error{color:var(--danger);background:var(--danger-soft);border-color:#f4bdc9}
64
- .refresh-pill .spinner{width:12px;height:12px;border-width:2px}
65
59
  /* ---- Buttons ------------------------------------------------------ */
66
60
  .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}
67
61
  .btn:hover{border-color:var(--primary);color:var(--primary)}
@@ -71,12 +65,14 @@ export function adminUiHtml() {
71
65
  .btn.icon{width:36px;padding:0;border-radius:8px;display:inline-grid;place-items:center}
72
66
  .btn.icon svg{width:17px;height:17px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}
73
67
  /* ---- Seller list -------------------------------------------------- */
74
- .app-list{--seller-grid:22px minmax(150px,1.4fr) minmax(120px,1fr) minmax(64px,.42fr) minmax(80px,.5fr) minmax(110px,.68fr) minmax(110px,.7fr) minmax(80px,.5fr) 36px;display:grid;gap:10px;padding:14px;background:var(--panel-subtle)}
75
- #sellerRows{display:grid;gap:10px;width:100%;min-width:0}
76
- .app-table-head,.app-row{display:grid;grid-template-columns:var(--seller-grid);align-items:center;gap:12px;width:100%;min-width:0;padding:0 18px}
68
+ .app-list{--seller-grid:18px minmax(142px,1.1fr) 64px 48px 58px 60px minmax(128px,.82fr) minmax(190px,1.25fr) 84px 62px minmax(132px,.9fr) 76px 112px 28px;display:grid;gap:8px;padding:14px;background:var(--panel-subtle);overflow-x:auto}
69
+ #sellerRows{display:grid;gap:8px;width:100%;min-width:0}
70
+ .app-table-head,.app-row{display:grid;grid-template-columns:var(--seller-grid);align-items:center;gap:10px;width:100%;min-width:1392px;padding:0 16px}
77
71
  .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)}
78
- .app-row{border:1px solid var(--hairline);border-radius:8px;background:#fff;min-height:76px;text-align:left}
79
- .app-row:hover{border-color:var(--hairline-strong);background:#fff}
72
+ .app-row{position:relative;border:1px solid var(--hairline);border-left-width:4px;border-radius:8px;background:#fff;min-height:66px;text-align:left}
73
+ .app-row:hover{border-color:var(--hairline-strong);background:#fff;box-shadow:0 8px 22px rgba(60,41,112,.06)}
74
+ .app-row.row-warn{border-left-color:var(--warning)}
75
+ .app-row.row-alert{border-left-color:var(--danger);background:#fffafb}
80
76
  /* Step 13 v1.1: 双源 (fly + registry) 4 类行视觉. dataSource 决定
81
77
  dataSource="fly" → 灰/中性边, "未发布" 提示
82
78
  dataSource="registry" → 整行红边 + 软红底, "立即下线 (registry-only)" 按钮
@@ -86,15 +82,22 @@ export function adminUiHtml() {
86
82
  .app-row.app-row-fly-only:hover{background:#f1f3f8}
87
83
  .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)}
88
84
  .app-row.app-row-registry-only:hover{background:#ffe3ea}
89
- .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}
85
+ .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;vertical-align:middle}
90
86
  .datasource-chip.both{background:#e7f6ee;color:#0a8754}
91
87
  .datasource-chip.fly{background:#e3e6ee;color:#4a5170}
92
88
  .datasource-chip.registry{background:var(--danger-soft);color:var(--danger);border:1px solid var(--danger)}
89
+ .datasource-chip.published{background:#e7f6ee;color:#0a8754}
90
+ .datasource-chip.unpublished{background:#e3e6ee;color:#4a5170}
91
+ .datasource-chip.registry_only{background:var(--danger-soft);color:var(--danger);border:1px solid var(--danger)}
92
+ .datasource-chip.draining{background:var(--warning-soft);color:#98630a;border:1px solid #f6d99b}
93
+ .datasource-chip.offline{background:#f4f1fb;color:#6b6384;border:1px solid var(--hairline-strong)}
94
+ .datasource-chip.pending{background:var(--tokens-soft);color:#1d4ed8;border:1px solid #bfd7ff}
95
+ .datasource-chip.checking,.datasource-chip.unknown{background:#f4f1fb;color:#6b6384;border:1px solid var(--hairline-strong)}
93
96
  .alert-reason{color:var(--danger);font-size:11px;line-height:1.4;font-weight:700;margin-top:4px;display:block}
94
- .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{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}
95
98
  .remove-hint-btn:hover{background:#d63d5a}
96
- .remove-hint-btn::before{content:" ";margin-right:2px}
97
- .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}
99
+ .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}
98
101
  .publish-hint-btn:hover{background:#f5f3ff}
99
102
  /* Status dot — five spec tones (green/amber/red/blue/gray) */
100
103
  .app-dot{width:10px;height:10px;border-radius:999px;background:#c8ced8;box-shadow:0 0 0 4px #edf1f8}
@@ -103,19 +106,40 @@ export function adminUiHtml() {
103
106
  .app-dot.tone-red{background:var(--danger);box-shadow:0 0 0 4px rgba(239,91,120,.18)}
104
107
  .app-dot.tone-blue{background:var(--tokens);box-shadow:0 0 0 4px rgba(37,99,235,.18)}
105
108
  .app-dot.tone-gray{background:#8d86a3;box-shadow:0 0 0 4px rgba(141,134,163,.17)}
106
- .app-name{display:grid;gap:6px;min-width:0}
107
- .seller-title{display:inline-flex;gap:8px;align-items:center}
109
+ .app-name{display:block;min-width:0}
110
+ .seller-title{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;min-width:0;width:100%}
108
111
  .app-name strong,.field-cell strong,.field-cell span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
109
- .app-name strong{color:var(--ink);font-size:15px;font-weight:750}
110
- .field-cell{display:grid;gap:4px;min-width:0;color:var(--muted);font-size:var(--body-fs);line-height:var(--body-lh)}
111
- .field-cell strong{color:var(--ink);font-family:var(--font-mono);font-size:var(--numeric-fs);font-weight:var(--numeric-weight)}
112
- .speed-cell{display:grid;gap:3px;font-family:var(--font-mono);font-size:var(--numeric-fs);font-weight:var(--numeric-weight)}
112
+ .app-name strong{color:var(--ink);font-size:14px;font-weight:750}
113
+ .field-cell,.speed-cell{display:grid;gap:2px;min-width:0;color:var(--muted);font-size:var(--body-fs);line-height:var(--body-lh)}
114
+ .field-cell strong,.speed-cell strong{color:var(--ink);font-family:var(--font-mono);font-size:var(--numeric-fs);font-weight:var(--numeric-weight)}
115
+ .metric-label{color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
116
+ .metric-sub{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--faint);font-size:11px;line-height:15px;font-family:var(--font-mono)}
117
+ .metric-sub.danger{color:var(--danger);font-family:var(--font-sans);font-weight:700}
118
+ .op-value{color:var(--ink);font-family:var(--font-mono);font-size:var(--numeric-fs);font-weight:var(--numeric-weight)}
119
+ .op-chip{width:max-content;max-width:100%;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--hairline-strong);border-radius:7px;background:var(--panel-subtle);padding:3px 7px;color:var(--muted);font-family:var(--font-mono);font-size:11px;font-weight:850;line-height:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
120
+ .op-chip:before{content:"";width:6px;height:6px;border-radius:999px;background:currentColor;flex:0 0 auto}
121
+ .op-chip.tone-green{border-color:#bde9dc;background:var(--success-soft);color:#0f766e}
122
+ .op-chip.tone-amber{border-color:#f6d99b;background:var(--warning-soft);color:#98630a}
123
+ .op-chip.tone-red{border-color:#f4bdc9;background:var(--danger-soft);color:#c0264e}
124
+ .op-chip.tone-blue{border-color:#bfd7ff;background:var(--tokens-soft);color:#1d4ed8}
125
+ .op-chip.tone-gray{border-color:var(--hairline-strong);background:#f4f1fb;color:#6b6384}
126
+ .status-pill{width:max-content;max-width:100%;border:1px solid var(--hairline-strong);border-radius:999px;background:var(--panel-subtle);padding:2px 8px;color:var(--muted);font-family:var(--font-mono);font-size:11px;font-weight:800;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
127
+ .status-pill.tone-green{border-color:#bde9dc;background:var(--success-soft);color:#0f766e}
128
+ .status-pill.tone-amber{border-color:#f6d99b;background:var(--warning-soft);color:#98630a}
129
+ .status-pill.tone-red{border-color:#f4bdc9;background:var(--danger-soft);color:#c0264e}
130
+ .status-pill.tone-blue{border-color:#bfd7ff;background:var(--tokens-soft);color:#1d4ed8}
131
+ .mobile-metrics{display:none}
132
+ .balance-line{display:flex;align-items:center;gap:6px;min-width:0}
133
+ .balance-line .op-chip{max-width:100%}
113
134
  .muted-value{color:var(--faint)}
114
135
  .recharge-btn,.detail-btn{width:30px;height:30px;border:1px solid var(--hairline-strong);border-radius:8px;background:#fff;color:var(--primary);display:inline-grid;place-items:center;text-decoration:none;font-weight:900}
115
136
  .recharge-btn:hover,.detail-btn:hover{border-color:var(--primary);background:var(--primary-soft)}
116
137
  .row-actions{display:flex;gap:8px;justify-content:flex-end}
117
138
  .spec-tip{border:1px solid var(--hairline-strong);border-radius:7px;background:#fff;color:var(--primary);width:24px;height:24px;padding:0;display:inline-grid;place-items:center}
118
139
  .spec-tip svg{width:14px;height:14px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}
140
+ @media(min-width:901px){
141
+ .app-row>.field-cell>.metric-label,.app-row>.speed-cell>.metric-label{display:none}
142
+ }
119
143
  /* ---- Entry cards (Bootstrap summary) ----------------------------- */
120
144
  .bootstrap-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:16px}
121
145
  .entry-card{border:1px solid var(--hairline-strong);border-radius:12px;background:var(--panel);padding:16px;box-shadow:var(--shadow);display:grid;gap:7px}
@@ -130,9 +154,12 @@ export function adminUiHtml() {
130
154
  .modal{position:fixed;inset:0;background:rgba(32,26,56,.28);z-index:40;display:none}
131
155
  .modal.open{display:grid}
132
156
  .modal-shell{background:var(--canvas);min-height:100dvh;display:grid;grid-template-rows:auto 1fr}
133
- .modal-head{height:64px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 24px;border-bottom:1px solid var(--hairline);background:var(--panel)}
157
+ .modal-head{height:64px;display:flex;align-items:center;gap:16px;padding:0 24px;border-bottom:1px solid var(--hairline);background:var(--panel)}
134
158
  .modal-title{display:flex;align-items:center;gap:10px;color:var(--ink);font-size:20px;line-height:28px;font-weight:800}
135
- .modal-actions{display:flex;gap:8px;align-items:center}
159
+ .modal-actions{display:flex;gap:8px;align-items:center;margin-left:auto}
160
+ .modal-close{width:44px;height:44px;border:1px solid var(--hairline-strong);border-radius:10px;background:#fff;color:var(--muted);display:inline-grid;place-items:center;padding:0}
161
+ .modal-close:hover{border-color:var(--primary);background:var(--primary-soft);color:var(--primary-deep)}
162
+ .modal-close svg{width:18px;height:18px;stroke:currentColor;stroke-width:2.4;fill:none;stroke-linecap:round;stroke-linejoin:round}
136
163
  .modal-body{padding:20px 24px 36px;display:grid;gap:16px;overflow:auto}
137
164
  .detail-grid{display:grid;grid-template-columns:minmax(260px,.72fr) minmax(0,1fr);gap:16px}
138
165
  .card{border:1px solid var(--hairline);border-radius:12px;background:#fff;padding:16px;box-shadow:var(--shadow)}
@@ -162,6 +189,8 @@ export function adminUiHtml() {
162
189
  .field input,.field select,.readonly-value{min-height:40px;border:1px solid var(--hairline-strong);border-radius:8px;padding:0 10px;background:#fff;color:var(--ink)}
163
190
  .field input,.field select{font:inherit}
164
191
  .field input:disabled,.field select:disabled{background:#f3f0fb;color:#958daa}
192
+ .model-toggle{width:18px;height:18px;accent-color:var(--primary)}
193
+ .model-toggle:disabled{cursor:default}
165
194
  .readonly-value{display:flex;align-items:center;background:var(--panel-subtle);color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
166
195
  .field input[readonly]{background:var(--panel-subtle);color:var(--muted)}
167
196
  /* ---- Tables (audit) ---------------------------------------------- */
@@ -195,15 +224,21 @@ export function adminUiHtml() {
195
224
  .progress-toggle{color:var(--primary);font-size:var(--label-fs);font-weight:var(--label-weight);text-transform:uppercase;letter-spacing:var(--label-spacing)}
196
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)}
197
226
  .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
+ .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}
198
228
  @keyframes spin{to{transform:rotate(360deg)}}
199
229
  .hidden{display:none!important}
200
230
  /* ---- Mobile ------------------------------------------------------- */
201
231
  @media(max-width:900px){
202
232
  .topnav{padding:0 20px}
203
- .content{width:min(100% - 28px,1180px)}
233
+ .content{width:min(100% - 28px,1560px)}
204
234
  .app-table-head{display:none}
205
- .app-row{grid-template-columns:18px minmax(0,1fr) auto;gap:12px;padding:14px;min-height:44px}
235
+ .app-row{grid-template-columns:18px minmax(0,1fr) auto;gap:12px;padding:14px;min-height:44px;align-items:flex-start}
206
236
  .app-row .field-cell,.app-row .speed-cell{display:none}
237
+ .mobile-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:4px}
238
+ .mini-metric{border:1px solid var(--hairline);border-radius:8px;background:#fff;padding:8px;display:grid;gap:3px;min-width:0}
239
+ .mini-metric label{color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
240
+ .mini-metric strong{color:var(--ink);font-family:var(--font-mono);font-size:12px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
241
+ .mini-metric span{color:var(--faint);font-size:11px;line-height:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
207
242
  .detail-grid,.form-grid,.section-grid,.bootstrap-grid{grid-template-columns:1fr}
208
243
  .field.full{grid-column:auto}
209
244
  .modal-head{padding:0 16px}
@@ -212,22 +247,22 @@ export function adminUiHtml() {
212
247
  </style>
213
248
  </head>
214
249
  <body>
215
- <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>
250
+ <nav class="topnav"><div class="logo">TOKENBUDDY ADMIN</div></nav>
216
251
  <main class="content">
217
252
  <section id="page-sellers" class="page active">
218
253
  <div class="panel">
219
254
  <div class="panel-head">
220
255
  <div>
221
256
  <h1 class="title">Seller fleet</h1>
222
- <div class="list-status"><span id="sellerRefreshState" class="refresh-pill">Starting</span></div>
223
257
  </div>
224
258
  <button id="createSeller" class="btn primary">Create Seller</button>
225
259
  </div>
226
260
  <div class="app-list">
227
261
  <div class="app-table-head" role="row">
228
- <span></span><span>Seller</span><span>Upstream</span>
229
- <span>Disc</span><span>Capacity</span><span>TTFT</span>
230
- <span>Balance</span><span>Status</span><span></span>
262
+ <span></span><span>Seller</span><span>Pub</span><span>Region</span>
263
+ <span>Models</span><span>Conn</span><span>CPU/RAM</span>
264
+ <span>Upstream</span><span>Status</span><span>Disc</span><span>Balance</span>
265
+ <span>TTFT</span><span>Next</span><span></span>
231
266
  </div>
232
267
  <div id="sellerRows">
233
268
  <div class="loading-row" role="status" aria-label="Loading sellers">
@@ -237,22 +272,6 @@ export function adminUiHtml() {
237
272
  </div>
238
273
  </div>
239
274
  </section>
240
- <section id="page-releases" class="page">
241
- <div class="bootstrap-card">
242
- <div class="panel-head">
243
- <h1 class="title">Release Requests</h1>
244
- <div class="modal-actions">
245
- <button id="refreshReleases" class="btn">Refresh</button>
246
- </div>
247
- </div>
248
- <p class="hint" style="color:var(--muted);font-size:12px;margin:0 0 12px;">
249
- Pending and historical release requests you have submitted to the wallet-bootstrap
250
- registry. Force-publish is available to the platform super-admin via the registry
251
- admin web; vendors do not publish their own releases.
252
- </p>
253
- <div id="releasesGrid" class="bootstrap-grid"></div>
254
- </div>
255
- </section>
256
275
  </main>
257
276
  <section id="detailModal" class="modal">
258
277
  <div class="modal-shell">
@@ -271,12 +290,16 @@ export function adminUiHtml() {
271
290
  <button class="btn" data-status-action="offline">Offline</button>
272
291
  <button class="btn" data-status-action="activate">Activate</button>
273
292
  <button id="editDetail" class="btn primary">Edit config</button>
274
- <button id="closeDetail" class="btn">Close</button>
275
293
  </div>
294
+ <button id="closeDetail" class="modal-close" title="Close detail" aria-label="Close detail">
295
+ <svg viewBox="0 0 24 24" aria-hidden="true">
296
+ <path d="M6 6l12 12"></path><path d="M18 6L6 18"></path>
297
+ </svg>
298
+ </button>
276
299
  </header>
277
300
  <div class="modal-body">
278
301
  <div id="detailStatus" class="status-line hidden"></div>
279
- <div class="detail-grid">
302
+ <div id="detailGrid" class="detail-grid hidden">
280
303
  <div class="card"><h2>Seller configuration</h2><div id="configFields" class="form-grid"></div></div>
281
304
  <div class="card"><h2>Models</h2><div id="modelsTable"></div></div>
282
305
  </div>
@@ -344,108 +367,210 @@ async function api(path, options={}){
344
367
  }
345
368
  }
346
369
  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."; }
347
- const sellerRefreshIntervalMs = 30000;
348
- let currentDetail = null; let sellerRowsCache = []; let editing = false; let deleteReady = false; let sellerRefreshInFlight = false; let sellerRefreshTimer = null; let sellerClockTimer = null; let sellerRefreshLoaded = false; let sellerNextRefreshAt = null; let sellerRefreshError = ""; let createJobTimer = null; let currentCreateJob = null; let expandedProgressSteps = new Set(); let createAppSuffix = "";
370
+ const sellerStatusRefreshIntervalMs = 30000;
371
+ 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 = "";
349
373
  const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
350
374
  const missing = value => '<span class="muted-value">'+esc(value)+'</span>';
351
375
  const dash = () => '<span class="muted-value">'+window.__tbFmt.UNKNOWN_VALUE+'</span>';
352
- const infoIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h7"></path><path d="M15 7h5"></path><path d="M13 5v4"></path><path d="M4 12h3"></path><path d="M11 12h9"></path><path d="M9 10v4"></path><path d="M4 17h10"></path><path d="M18 17h2"></path><path d="M16 15v4"></path></svg>';
353
376
  const balanceProbeTemplates = ["openrouter","deepseek","stepfun","siliconflow","novita","newapi_generic","usage_generic","none","auto"];
354
377
  const paymentMethods = ["clawtip","mock"];
355
378
  const loadingSpinner = label => '<div class="loading-row" role="status" aria-label="'+esc(label)+'"><span class="spinner" aria-hidden="true"></span></div>';
356
- 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(); });
357
- 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); } }
379
+ async function loadSellers(options={}){
380
+ if (sellerInventoryInFlight) return;
381
+ const initial = Boolean(options.initial);
382
+ sellerInventoryInFlight = true;
383
+ sellerRefreshLoaded = false;
384
+ sellerRefreshError = "";
385
+ resetSellerDetailQueue();
386
+ if (initial) document.getElementById("sellerRows").innerHTML = loadingSpinner("Loading registry");
387
+ try {
388
+ sellerRefreshStage = "Loading registry";
389
+ updateSellerRefreshMeta();
390
+ const registryRows = await api("/api/sellers/registry");
391
+ renderSellerRows(registryRows);
392
+ sellerRefreshStage = "Loading Fly inventory";
393
+ updateSellerRefreshMeta();
394
+ const inventoryRows = await api("/api/sellers/inventory");
395
+ renderSellerRows(inventoryRows);
396
+ sellerRefreshLoaded = true;
397
+ sellerRefreshStage = "Refreshing details";
398
+ enqueueSellerDetails(inventoryRows);
399
+ } catch (err) {
400
+ sellerRefreshError = err.message || "Refresh failed";
401
+ sellerRefreshStage = "Load failed";
402
+ if (initial) document.getElementById("sellerRows").innerHTML = '<div class="status-line">'+esc(sellerRefreshError)+'</div>';
403
+ } finally {
404
+ sellerInventoryInFlight = false;
405
+ updateSellerRefreshMeta();
406
+ }
407
+ }
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 }; }
358
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)); }
359
- function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => updateSellerRefreshMeta(sellerRefreshInFlight), 1000); window.addEventListener("beforeunload", () => { clearTimeout(sellerRefreshTimer); clearInterval(sellerClockTimer); }); }
360
- function scheduleSellerRefresh(){ clearTimeout(sellerRefreshTimer); sellerNextRefreshAt = new Date(Date.now() + sellerRefreshIntervalMs); sellerRefreshTimer = setTimeout(() => loadSellers(), sellerRefreshIntervalMs); }
361
- 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")); }
410
+ function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => { updateSellerRefreshMeta(); updateSellerRowRefreshCountdowns(); }, 1000); window.addEventListener("beforeunload", () => { resetSellerDetailQueue(); clearInterval(sellerClockTimer); }); }
411
+ function sellerRowKey(row){ return String(row.id || row.app || row.url || ""); }
412
+ function resetSellerDetailQueue(){ sellerDetailQueue = []; sellerDetailQueueKeys.clear(); sellerDetailInFlight.clear(); sellerDetailTimers.forEach(timer => clearTimeout(timer)); sellerDetailTimers.clear(); }
413
+ function enqueueSellerDetails(rows){
414
+ rows.forEach(row => {
415
+ if (!shouldRefreshSellerDetail(row)) return;
416
+ const key = sellerRowKey(row);
417
+ if (!key || sellerDetailQueueKeys.has(key) || sellerDetailInFlight.has(key)) return;
418
+ sellerDetailQueue.push(key);
419
+ sellerDetailQueueKeys.add(key);
420
+ patchSellerRow(key, { detailStatus:"queued", detailNextRefreshAt:undefined });
421
+ });
422
+ pumpSellerDetailQueue();
423
+ updateSellerRefreshMeta();
424
+ }
425
+ function shouldRefreshSellerDetail(row){ return row && row.detailStatus !== "skipped" && row.publishStatus !== "registry_only" && row.dataSource !== "registry"; }
426
+ function patchSellerRow(key, patch){ let changed = false; sellerRowsCache = sellerRowsCache.map(row => { if (sellerRowKey(row) !== key) return row; changed = true; return { ...row, ...patch }; }); if (changed) renderSellerRows(sellerRowsCache); }
427
+ function patchSellerRegistryStatus(id, status){ let changed = false; sellerRowsCache = sellerRowsCache.map(row => { if (sellerRowKey(row) !== id && row.id !== id && row.name !== id && row.app !== id) return row; changed = true; return { ...row, registryStatus:status }; }); if (changed) renderSellerRows(sellerRowsCache); }
428
+ function mergeSellerRowUpdate(updated){ const key = sellerRowKey(updated); if (!key) return; let changed = false; sellerRowsCache = sellerRowsCache.map(row => { if (sellerRowKey(row) !== key) return row; changed = true; return { ...row, ...updated }; }); if (changed) renderSellerRows(sellerRowsCache); }
429
+ function latestSellerRow(key){ return sellerRowsCache.find(row => sellerRowKey(row) === key); }
430
+ function scheduleSellerDetailRefresh(row){
431
+ if (!shouldRefreshSellerDetail(row)) return;
432
+ const key = sellerRowKey(row);
433
+ if (!key) return;
434
+ const existing = sellerDetailTimers.get(key);
435
+ if (existing) clearTimeout(existing);
436
+ const nextRefreshAt = new Date(Date.now() + sellerStatusRefreshIntervalMs).toISOString();
437
+ const timer = setTimeout(() => {
438
+ sellerDetailTimers.delete(key);
439
+ const latest = latestSellerRow(key);
440
+ if (!latest) return;
441
+ patchSellerRow(key, { detailStatus:"stale", detailNextRefreshAt:undefined });
442
+ enqueueSellerDetails([latest]);
443
+ }, sellerStatusRefreshIntervalMs);
444
+ sellerDetailTimers.set(key, timer);
445
+ patchSellerRow(key, { detailNextRefreshAt:nextRefreshAt });
446
+ }
447
+ async function pumpSellerDetailQueue(){
448
+ while (sellerDetailInFlight.size < sellerDetailConcurrency && sellerDetailQueue.length > 0) {
449
+ const key = sellerDetailQueue.shift();
450
+ sellerDetailQueueKeys.delete(key);
451
+ const row = latestSellerRow(key);
452
+ if (!row || !shouldRefreshSellerDetail(row)) continue;
453
+ sellerDetailInFlight.add(key);
454
+ patchSellerRow(key, { detailStatus:"loading", detailNextRefreshAt:undefined });
455
+ updateSellerRefreshMeta();
456
+ api("/api/sellers/status", { method:"POST", body: JSON.stringify({ rows:[statusRefreshRow(row)] }) })
457
+ .then(rows => {
458
+ const updated = Array.isArray(rows) ? rows[0] : null;
459
+ if (updated) {
460
+ mergeSellerRowUpdate(updated);
461
+ scheduleSellerDetailRefresh(updated);
462
+ sellerRefreshError = "";
463
+ }
464
+ })
465
+ .catch(err => {
466
+ sellerRefreshError = err.message || "Status refresh failed";
467
+ patchSellerRow(key, { detailStatus:"error", detailUpdatedAt:new Date().toISOString(), error:sellerRefreshError });
468
+ const latest = latestSellerRow(key);
469
+ if (latest) scheduleSellerDetailRefresh(latest);
470
+ })
471
+ .finally(() => {
472
+ sellerDetailInFlight.delete(key);
473
+ updateSellerRefreshMeta();
474
+ pumpSellerDetailQueue();
475
+ });
476
+ }
477
+ }
478
+ function updateSellerRefreshMeta(){ const state = document.getElementById("sellerRefreshState"); if (!state) return; const busy = sellerInventoryInFlight || sellerDetailInFlight.size > 0 || sellerDetailQueue.length > 0; state.classList.toggle("refreshing", Boolean(busy)); state.classList.toggle("error", Boolean(sellerRefreshError)); if (sellerRefreshError) { state.textContent = sellerRefreshError; return; } if (busy) { state.innerHTML = '<span class="spinner" aria-hidden="true"></span><span>'+esc(sellerRefreshStage || "Refreshing details")+'</span>'; return; } state.textContent = sellerRefreshLoaded ? "Inventory loaded · row timers active" : (sellerRefreshStage || "Loading inventory"); }
479
+ function updateSellerRowRefreshCountdowns(){ if (!sellerRefreshLoaded || sellerRowsCache.length === 0) return; renderSellerRows(sellerRowsCache); }
480
+ function publishStatusLabel(status){ if (status === "published") return "已发布"; if (status === "unpublished") return "未发布"; if (status === "registry_only") return "Registry-only"; if (status === "checking") return "发布待确认"; if (status === "draining") return "下线中"; if (status === "offline") return "已下线"; if (status === "pending") return "待发布"; return "发布未知"; }
481
+ function visiblePublishStatus(row, relation){ const registry = row.registryStatus; if ((relation === "published" || relation === "checking") && ["draining","offline","pending"].includes(registry)) return registry; return relation; }
482
+ function registryStatusDisplay(status){ const value = String(status || "unknown").toLowerCase(); if (["active","draining","offline","pending"].includes(value)) return value; return window.__tbFmt.UNKNOWN_VALUE; }
483
+ function detailStatusLabel(status){ if (status === "queued") return "detail queued"; if (status === "loading") return "detail loading"; if (status === "fresh") return "detail fresh"; if (status === "stale") return "detail stale"; if (status === "error") return "detail error"; if (status === "skipped") return "detail skipped"; return "detail pending"; }
484
+ function detailCountdown(row){ if (!row.detailNextRefreshAt) return ""; const ms = new Date(row.detailNextRefreshAt).getTime() - Date.now(); if (!Number.isFinite(ms)) return ""; if (ms <= 0) return "due now"; const seconds = Math.ceil(ms / 1000); return seconds >= 60 ? Math.ceil(seconds / 60) + "m" : seconds + "s"; }
485
+ function sellerHostMatches(row, domain){ const normalized = String(domain || "").toLowerCase(); if (!normalized) return false; const rowHost = hostName(row.url); const appHost = row.app ? String(row.app).toLowerCase() + ".fly.dev" : ""; const flyHost = row.flyApp?.name ? String(row.flyApp.name).toLowerCase() + ".fly.dev" : ""; return normalized === rowHost || normalized === appHost || normalized === flyHost; }
486
+ function usagePercent(value){ const n = Number(value); if (!Number.isFinite(n)) return "—"; return (Math.round(Math.max(0, Math.min(100, n)) * 10) / 10).toString().replace(/\.0$/, "") + "%"; }
487
+ function percentTone(value){ const n = Number(value); if (!Number.isFinite(n)) return "gray"; if (n >= 90) return "red"; if (n >= 75) return "amber"; return "green"; }
488
+ function worstTone(...tones){ if (tones.includes("red")) return "red"; if (tones.includes("amber")) return "amber"; if (tones.includes("blue")) return "blue"; if (tones.includes("green")) return "green"; return "gray"; }
489
+ function capacityTone(used, limit){ const u = Number(used); const l = Number(limit); if (!Number.isFinite(u) || !Number.isFinite(l) || l <= 0) return "gray"; const ratio = u / l; if (ratio >= 1) return "red"; if (ratio >= .75) return "amber"; return "green"; }
490
+ function latencyTone(ms){ const n = Number(ms); if (!Number.isFinite(n)) return "gray"; if (n >= 5000) return "red"; if (n >= 2000) return "amber"; return "green"; }
491
+ function upstreamStatusTone(status){ const value = String(status || "unknown").toLowerCase(); if (value === "healthy") return "green"; if (value === "degraded") return "amber"; if (value === "unhealthy") return "red"; return "gray"; }
492
+ function renderOpMetric(text, tone, title){ const value = text || "—"; if (tone === "green") return '<strong class="op-value"'+(title ? ' title="'+esc(title)+'"' : '')+'>'+esc(value)+'</strong>'; return '<span class="op-chip tone-'+esc(tone || "gray")+'"'+(title ? ' title="'+esc(title)+'"' : '')+'>'+esc(value)+'</span>'; }
493
+ function renderStatusMetric(status, title){ const tone = upstreamStatusTone(status); const value = tone === "gray" ? "-" : window.__tbFmt.normalizeStatusLabel(status); return '<span class="op-chip tone-'+esc(tone)+'"'+(title ? ' title="'+esc(title)+'"' : '')+'>'+esc(value)+'</span>'; }
362
494
  function sellerRow(row){
363
495
  const fmt = window.__tbFmt;
364
- // Step 13 v1.1: dataSource 决定行 class + 标红/灰 + 按钮 + chip.
365
- // row.dataSource "fly" | "registry" | "both". 老 1.0.31 没这个字段,
366
- // 兜底当 "both" (老 UI 看到的所有行, 默认都按已发布处理).
496
+ // publishStatus 决定用户可见发布态; dataSource 只作为排障线索.
497
+ // checking/unknown 必须保持中性, 不能提前显示成未发布或事故.
367
498
  const ds = row.dataSource || "both";
368
- const rowClass = ds === "registry" ? "app-row app-row-registry-only"
369
- : ds === "fly" ? "app-row app-row-fly-only"
370
- : "app-row";
371
- const dsChipLabel = ds === "registry" ? "Registry-only"
372
- : ds === "fly" ? "未发布"
373
- : "Both";
374
- const dsChip = '<span class="datasource-chip '+esc(ds)+'" title="'+esc('Data source: ' + ds + '. 详见 docs/processes/seller-fleet-data-sources.md')+'">'+esc(dsChipLabel)+'</span>';
375
- 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";
499
+ const publishRelation = row.publishStatus || (ds === "registry" ? "unknown" : ds === "fly" ? "unpublished" : "published");
500
+ const publish = visiblePublishStatus(row, publishRelation);
501
+ const publishLabel = publishStatusLabel(publish);
502
+ const dsChip = '<span class="datasource-chip '+esc(publish)+'" title="'+esc('Publish: ' + publish + ' · registry: ' + row.registryStatus + ' · source: ' + ds)+'">'+esc(publishLabel)+'</span>';
503
+ const cpuValue = row.specs?.cpuCores ?? row.specs?.cpus ?? row.specs?.cpu;
504
+ const cpuText = cpuValue === undefined || cpuValue === null || cpuValue === "" ? "—" : String(cpuValue) + " vCPU";
505
+ const memoryText = row.specs?.memoryMb ? (row.specs.memoryMb >= 1024 ? (Math.round(row.specs.memoryMb / 102.4) / 10) + " GB" : row.specs.memoryMb + " MB") : (row.specs?.memoryGb ? row.specs.memoryGb + " GB" : "—");
506
+ const cpuUsageText = usagePercent(row.resourceCpuPercent);
507
+ const memoryUsageText = usagePercent(row.resourceMemoryPercent);
508
+ const hasUsage = cpuUsageText !== "—" || memoryUsageText !== "—";
509
+ const resourcePrimary = (cpuUsageText === "—" ? "-" : cpuUsageText) + "/" + (memoryUsageText === "—" ? "-" : memoryUsageText);
510
+ const resourceTone = hasUsage ? worstTone(percentTone(row.resourceCpuPercent), percentTone(row.resourceMemoryPercent)) : "gray";
511
+ const machineText = row.specs?.machines ? ((row.specs.runningMachines ?? row.specs.machines) + "/" + row.specs.machines + " running") : (row.flyApp?.status ? "Fly " + row.flyApp.status : "Fly —");
512
+ const volumeText = row.specs?.volumeGb ? " · vol " + row.specs.volumeGb + " GB" : "";
513
+ const usageDetail = (hasUsage ? "usage CPU " + cpuUsageText + " · memory " + memoryUsageText : "usage unavailable") + (row.resourceMemoryRssMb ? " (" + row.resourceMemoryRssMb + " MB RSS" + (row.resourceMemoryLimitMb ? " / " + row.resourceMemoryLimitMb + " MB" : "") + ")" : "") + " · spec " + cpuText + " / " + memoryText + " · " + machineText + volumeText;
376
514
  const ttftText = fmt.formatDuration(row.ttftMs);
377
- const ttft = "TTFT: " + (ttftText === fmt.UNKNOWN_VALUE ? "—" : ttftText);
378
515
  const avgText = fmt.formatSpeed(row.avgTokensPerSecond);
379
- const avgSpeed = avgText === fmt.UNKNOWN_VALUE ? missing("AVG: —") : "AVG: " + esc(avgText);
516
+ const avgSpeed = avgText === fmt.UNKNOWN_VALUE ? "Tok/s —" : "Tok/s " + esc(avgText.replace(/ tok\\/s$/i, ""));
380
517
  const capacity = fmt.formatSellerCapacity(row.capacityUsed, row.capacityLimit);
518
+ const capacityText = capacity === fmt.UNKNOWN_VALUE ? "—" : capacity;
519
+ const connTone = capacityTone(row.capacityUsed, row.capacityLimit);
381
520
  const disc = fmt.formatDiscountRatio(row.discountRatio);
382
521
  const balanceRaw = row.upstreamBalanceUsdMicros;
383
- const balanceText = (balanceRaw === undefined || balanceRaw === null) ? dash() : '<strong>'+esc(fmt.formatBalanceAmount(balanceRaw, row.upstreamBalanceCurrency || "USD"))+'</strong>';
384
- const switchText = row.lastSwitchAt ? "Switch " + esc(fmt.formatTimeCompact(row.lastSwitchAt)) : "";
522
+ const hasBalance = balanceRaw !== undefined && balanceRaw !== null;
523
+ const lowBalance = hasBalance && Number(balanceRaw) < 10000000;
524
+ const balanceTone = hasBalance ? (lowBalance ? "red" : "green") : "gray";
525
+ const balanceDisplay = hasBalance ? fmt.formatBalanceAmount(balanceRaw, row.upstreamBalanceCurrency || "USD") : "—";
526
+ const balanceMissingTitle = row.upstreamBalanceError || "Balance probe has not returned data";
527
+ const balanceText = hasBalance ? renderOpMetric(balanceDisplay, balanceTone, lowBalance ? "Upstream balance below 10" : "Upstream balance ok") : '<span class="muted-value" title="'+esc(balanceMissingTitle)+'">-</span>';
528
+ const balancePlain = hasBalance ? fmt.formatBalanceAmount(balanceRaw, row.upstreamBalanceCurrency || "USD") : "-";
529
+ const balanceLabel = hasBalance ? (lowBalance ? "low balance" : "ok") : "-";
385
530
  // Step 13 v1.1: 绿点 (status + tone) 仍按 nodeStatus 决定 (probeManifest
386
531
  // 200 → active 绿点; 失败 → unknown 灰). registryStatus 单独 tooltip.
387
532
  const status = fmt.formatSellerStatus(row.nodeStatus);
388
533
  const tone = fmt.sellerStatusTone(row.nodeStatus);
389
- const statusTip = "registry: " + esc(fmt.formatSellerStatus(row.registryStatus)) + " · upstream: " + esc(fmt.normalizeStatusLabel(row.upstreamStatus)) + " · source: " + esc(ds);
390
- const sellerLine = [
391
- disc !== fmt.UNKNOWN_VALUE ? "Disc " + esc(disc) : null,
392
- capacity !== fmt.UNKNOWN_VALUE ? capacity : null,
393
- ttftText !== fmt.UNKNOWN_VALUE ? ttft.replace("TTFT: ", "TTFT ") : null,
394
- balanceText.includes("<strong>") ? "Balance " + esc(balanceText.replace(/<[^>]+>/g, "")) : null,
395
- switchText || null
396
- ].filter(Boolean).join(" · ");
397
- // Step 13 v1.1: 4 类行的 status cell 文案不同.
398
- // both → 正常 active / draining / offline
399
- // fly-only → "未发布" (publishHint 提示走 vendor-bootstrap stage)
400
- // registry → "**严重事故**" + alertReason 红字
401
- let statusCell;
402
- if (ds === "registry") {
403
- statusCell = '<span class="field-cell"><strong class="registry-incident" title="'+esc(statusTip)+'">严重事故</strong>' +
534
+ const statusTip = "registry: " + esc(registryStatusDisplay(row.registryStatus)) + " · upstream: " + esc(fmt.normalizeStatusLabel(row.upstreamStatus)) + " · publish: " + esc(publish) + " · detail: " + esc(row.detailStatus || "pending");
535
+ const upstreamDisplay = sellerHostMatches(row, row.upstreamDomain) ? "—" : (row.upstreamDomain || "—");
536
+ const upstreamTone = upstreamStatusTone(row.upstreamStatus);
537
+ const discountText = disc === fmt.UNKNOWN_VALUE ? "—" : disc;
538
+ const regionText = row.region || row.specs?.region || "";
539
+ const modelsText = row.modelsCount === undefined || row.modelsCount === null ? "" : fmt.formatCount(row.modelsCount);
540
+ const ttftTone = latencyTone(row.ttftMs);
541
+ const ttftDisplay = ttftText === fmt.UNKNOWN_VALUE ? "—" : ttftText;
542
+ const refreshCountdown = detailCountdown(row);
543
+ const refreshLoading = row.detailStatus === "queued" || row.detailStatus === "loading";
544
+ const refreshText = refreshLoading ? ""
545
+ : row.detailStatus === "skipped" ? "-"
546
+ : row.detailUpdatedAt ? fmt.formatTimeCompact(row.detailUpdatedAt) + " / " + (refreshCountdown || "—")
547
+ : "-";
548
+ const rowRiskTone = publishRelation === "registry_only" || lowBalance || upstreamTone === "red" || connTone === "red" || resourceTone === "red" || ttftTone === "red" || tone === "red" ? "alert"
549
+ : publishRelation === "unpublished" || publish === "draining" || publish === "offline" || upstreamTone === "amber" || connTone === "amber" || resourceTone === "amber" || ttftTone === "amber" || tone === "amber" ? "warn"
550
+ : "";
551
+ const rowClass = publishRelation === "registry_only" ? "app-row app-row-registry-only"
552
+ : publishRelation === "unpublished" ? "app-row app-row-fly-only"
553
+ : "app-row" + (rowRiskTone ? " row-" + rowRiskTone : "");
554
+ const publishExtras = publishRelation === "registry_only" ? '<strong class="registry-incident" title="'+esc(statusTip)+'">严重事故</strong>' +
404
555
  (row.alertReason ? '<span class="alert-reason">'+esc(row.alertReason)+'</span>' : '') +
405
- (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>' : '') +
406
- '</span>';
407
- } else if (ds === "fly") {
408
- statusCell = '<span class="field-cell"><strong title="'+esc(statusTip)+'">未发布</strong>' +
409
- (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>' : '') +
410
- '</span>';
411
- } else {
412
- statusCell = '<span class="field-cell"><strong title="'+esc(statusTip)+'">'+esc(status)+'</strong></span>';
413
- }
414
- 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>';
415
- }
416
- async function loadBootstrap(){
417
- // Step 6 of the registry redesign: the legacy Bootstrap tab now
418
- // surfaces vendor release requests. We keep the function name
419
- // (loadBootstrap) so the existing click wiring continues to work,
420
- // but the rendered content comes from the new
421
- // /api/vendor/release-requests endpoint (added in ui-server.ts).
422
- const fmt = window.__tbFmt;
423
- try {
424
- const data = await api("/api/vendor/release-requests");
425
- const rows = (data.releaseRequests || []);
426
- if (rows.length === 0) {
427
- 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>';
428
- return;
429
- }
430
- const table = '<table style="width:100%;border-collapse:collapse;font-size:13px;">' +
431
- '<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>' +
432
- '<tbody>' + rows.map((r) => {
433
- const summary = r.payloadSummary || { count: 0, sellerIds: [] };
434
- const sellerList = (summary.sellerIds || []).join(", ") || "—";
435
- const version = r.publishedVersion !== null && r.publishedVersion !== undefined ? "v" + esc(String(r.publishedVersion)) : "—";
436
- return '<tr>' +
437
- '<td style="padding:6px 8px;font-family:ui-monospace,monospace;">#' + esc(String(r.id)) + '</td>' +
438
- '<td style="padding:6px 8px;">' + esc(r.status) + '</td>' +
439
- '<td style="padding:6px 8px;">' + esc(String(summary.count)) + ' <span style="color:var(--muted);font-size:11px;">(' + esc(sellerList) + ')</span></td>' +
440
- '<td style="padding:6px 8px;font-family:ui-monospace,monospace;">' + esc(fmt.formatTimeCompact(r.submittedAt)) + '</td>' +
441
- '<td style="padding:6px 8px;">' + version + '</td>' +
442
- '<td style="padding:6px 8px;color:var(--danger);">' + (r.errorMessage ? esc(r.errorMessage) : "—") + '</td>' +
443
- '</tr>';
444
- }).join("") + '</tbody></table>';
445
- document.getElementById("releasesGrid").innerHTML = table;
446
- } catch (err) {
447
- document.getElementById("releasesGrid").innerHTML = '<div class="status-line">'+esc(uiErrorMessage(err))+'</div>';
448
- }
556
+ (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>' : '';
558
+ const publishCell = '<span class="field-cell"><span class="metric-label">Pub</span>'+dsChip+publishExtras+'</span>';
559
+ const regionCell = '<span class="field-cell"><span class="metric-label">Region</span><strong>'+esc(regionText)+'</strong></span>';
560
+ const modelsCell = '<span class="field-cell"><span class="metric-label">Models</span><strong>'+esc(modelsText)+'</strong></span>';
561
+ const connectionCell = '<span class="field-cell"><span class="metric-label">Connection</span>'+renderOpMetric(capacityText, connTone, "Active / max connections")+'</span>';
562
+ const resourcesCell = '<span class="field-cell"><span class="metric-label">Resources</span>'+renderOpMetric(resourcePrimary, resourceTone, usageDetail)+'</span>';
563
+ const upstreamCell = '<span class="field-cell"><span class="metric-label">Upstream</span><strong title="'+esc(row.upstreamDomain || "")+'">'+esc(upstreamDisplay)+'</strong></span>';
564
+ const upstreamStatusCell = '<span class="field-cell"><span class="metric-label">Status</span>'+renderStatusMetric(row.upstreamStatus, "Upstream status from seller /operator/status")+'</span>';
565
+ const discountCell = '<span class="field-cell"><span class="metric-label">Disc</span><strong>'+esc(discountText)+'</strong></span>';
566
+ const balanceCell = '<span class="field-cell"><span class="metric-label">Balance</span><span class="balance-line'+(lowBalance ? ' tone-red' : '')+'">'+balanceText+(row.upstreamRechargeUrl ? '<span class="recharge-btn" title="'+esc(row.upstreamRechargeUrl)+'">↗</span>' : '')+'</span></span>';
567
+ const speedCell = '<span class="speed-cell"><span class="metric-label">TTFT</span>'+renderOpMetric(ttftDisplay, ttftTone, avgSpeed+' · samples '+(row.latencySamples === undefined ? "—" : fmt.formatCount(row.latencySamples)))+'</span>';
568
+ const refreshContent = refreshLoading ? '<span class="inline-spinner" role="status" aria-label="'+esc(detailStatusLabel(row.detailStatus))+'" title="'+esc(detailStatusLabel(row.detailStatus))+'"></span>' : '<strong title="'+esc(row.detailUpdatedAt ? "updated " + fmt.formatTimeCompact(row.detailUpdatedAt) : "")+'">'+esc(refreshText)+'</strong>';
569
+ const refreshCell = '<span class="field-cell"><span class="metric-label">Next</span>'+refreshContent+'</span>';
570
+ const mobileRefreshValue = refreshLoading ? '<span class="inline-spinner" role="status" aria-label="'+esc(detailStatusLabel(row.detailStatus))+'" title="'+esc(detailStatusLabel(row.detailStatus))+'"></span>' : esc(refreshText);
571
+ const mobileUpstreamStatus = upstreamTone === "gray" ? "-" : fmt.normalizeStatusLabel(row.upstreamStatus);
572
+ const mobileMetrics = '<span class="mobile-metrics"><span class="mini-metric"><label>Connection</label><strong>'+esc(capacityText)+'</strong><span>'+esc(status)+'</span></span><span class="mini-metric"><label>Resources</label><strong>'+esc(resourcePrimary)+'</strong><span>'+esc(hasUsage ? cpuText + " / " + memoryText : machineText)+'</span></span><span class="mini-metric"><label>Upstream</label><strong>'+esc(mobileUpstreamStatus)+'</strong><span>'+esc(upstreamDisplay)+'</span></span><span class="mini-metric"><label>Balance</label><strong>'+esc(balancePlain)+'</strong><span>'+esc(hasBalance ? balanceLabel : "-")+'</span></span><span class="mini-metric"><label>Next</label><strong>'+mobileRefreshValue+'</strong><span>'+esc(refreshLoading ? "refreshing" : row.detailStatus || "pending")+'</span></span></span>';
573
+ return '<button class="'+esc(rowClass)+'" type="button" data-detail="'+esc(row.id)+'"><span class="app-dot tone-'+esc(tone)+'" aria-label="'+esc(status)+'" title="'+esc(statusTip)+'"></span><span class="app-name"><strong title="'+esc(row.name)+'">'+esc(row.name)+'</strong>'+mobileMetrics+'</span>'+publishCell+regionCell+modelsCell+connectionCell+resourcesCell+upstreamCell+upstreamStatusCell+discountCell+balanceCell+speedCell+refreshCell+'<span class="row-actions"><span class="detail-btn">›</span></span></button>';
449
574
  }
450
575
  function entryCardHtml(item){
451
576
  const secondary = item.secondary ? '<span class="secondary">'+item.secondary+'</span>' : "";
@@ -474,7 +599,7 @@ async function openBootstrapConfig(){
474
599
  document.getElementById("bootstrapModal").classList.add("open");
475
600
  }
476
601
  }
477
- async function openDetail(id){ editing = false; deleteReady = false; currentDetail = null; document.getElementById("detailTitle").textContent = id + " detail"; showDetailStatus("Loading seller data", true); document.getElementById("configFields").innerHTML = loadingSpinner("Loading configuration"); document.getElementById("modelsTable").innerHTML = loadingSpinner("Loading models"); document.getElementById("detailModal").classList.add("open"); try { currentDetail = await api("/api/sellers/"+encodeURIComponent(id)); renderDetail(); } catch (err) { showDetailStatus(err.message || "Failed to load seller detail", false); } }
602
+ async function openDetail(id){ editing = false; deleteReady = false; currentDetail = null; document.getElementById("detailTitle").textContent = id + " detail"; document.getElementById("detailGrid").classList.add("hidden"); document.getElementById("configFields").innerHTML = ""; document.getElementById("modelsTable").innerHTML = ""; showDetailStatus("Loading seller data", true); document.getElementById("detailModal").classList.add("open"); try { currentDetail = await api("/api/sellers/"+encodeURIComponent(id)); renderDetail(); } catch (err) { document.getElementById("detailGrid").classList.add("hidden"); showDetailStatus(err.message || "Failed to load seller detail", false); } }
478
603
  function renderDetail(){
479
604
  const fmt = window.__tbFmt;
480
605
  const d = currentDetail;
@@ -482,11 +607,12 @@ function renderDetail(){
482
607
  document.getElementById("editDetail").textContent = editing ? "Save changes" : "Edit config";
483
608
  document.getElementById("deleteSeller").title = deleteReady ? "Confirm destroy deployment" : "Delete deployment";
484
609
  showDetailStatus(d.row.error || "", false);
610
+ document.getElementById("detailGrid").classList.remove("hidden");
485
611
  const c = d.configuration;
486
- const fields = [["registryStatus", fmt.formatSellerStatus(c.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]];
612
+ 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]];
487
613
  document.getElementById("configFields").innerHTML = detailFieldsHtml(fields);
488
614
  const billingOptions = Array.from(new Set(d.models.map(m => m.billingModel).filter(Boolean)));
489
- document.getElementById("modelsTable").innerHTML = '<table><thead><tr><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>'+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>';
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>';
490
616
  }
491
617
  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)); }
492
618
  function metricCell(value){ return value === undefined || value === null || value === "" ? missing("—") : esc(value); }
@@ -494,10 +620,15 @@ function detailFieldsHtml(fields){ return fields.filter(([key,value]) => !isMiss
494
620
  function isMissingDetailField(key,value){ const fmt = window.__tbFmt; return value === undefined || value === null || value === "" || value === fmt.UNKNOWN_VALUE || (key === "registryStatus" && value === "unknown"); }
495
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>'; }
496
622
  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>'; }
497
- document.getElementById("editDetail").onclick = async () => { if (!editing){ editing = true; renderDetail(); return; } const patch = {}; document.querySelectorAll("[data-field]").forEach(input => { const value = input.value; if (value === "" || value === input.dataset.original) return; patch[input.dataset.field] = numeric(value); }); const aliases = {}; document.querySelectorAll("[data-model]").forEach(input => aliases[input.dataset.model] = input.value); patch.modelAliases = aliases; const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/config", { method:"PUT", body: JSON.stringify(patch) }); showDetailStatus(result.ok ? "Saved" : (result.stderr || "Save failed"), false); currentDetail = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)); editing = false; renderDetail(); loadSellers(); };
623
+ 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")+'>'; }
624
+ function registryStatusForAction(action){ if (action === "drain") return "draining"; if (action === "activate") return "active"; return "offline"; }
625
+ function setStatusActionBusy(busy){ document.querySelectorAll("[data-status-action]").forEach(btn => { btn.disabled = Boolean(busy); }); }
626
+ 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); }); }
627
+ document.getElementById("editDetail").onclick = async () => { if (!editing){ editing = true; renderDetail(); return; } const patch = {}; document.querySelectorAll("[data-field]").forEach(input => { const value = input.value; if (value === "" || value === input.dataset.original) return; patch[input.dataset.field] = numeric(value); }); const aliases = {}; document.querySelectorAll("[data-model]").forEach(input => aliases[input.dataset.model] = input.value); patch.modelAliases = aliases; patch.models = modelConfigPatchFromDetail(); try { setDetailSavingBusy(true); showDetailStatus("Saving seller config", true); const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/config", { method:"PUT", body: JSON.stringify(patch) }); if (!result.ok) throw new Error(result.stderr || "Save failed"); currentDetail = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)); editing = false; renderDetail(); loadSellers(); } catch (err) { showDetailStatus(err.message || "Save failed", false); } finally { setDetailSavingBusy(false); } };
628
+ 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 })); }
498
629
  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"; };
499
630
  document.getElementById("closeDetail").onclick = () => { deleteReady = false; document.getElementById("detailModal").classList.remove("open"); };
500
- document.querySelectorAll("[data-status-action]").forEach(btn => btn.onclick = async () => { if (!currentDetail) return; const action = btn.dataset.statusAction; if (!confirm("Set "+currentDetail.row.name+" registry status via "+action+"?")) return; const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/"+action, { method:"POST" }); showDetailStatus(result.stdout || (result.ok ? "Registry status updated." : result.stderr || "Status update failed."), false); currentDetail = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)); renderDetail(); loadSellers(); });
631
+ 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); } });
501
632
  document.getElementById("createSeller").onclick = () => { buildCreateForm(); document.getElementById("createModal").classList.add("open"); };
502
633
  document.getElementById("closeCreate").onclick = () => document.getElementById("createModal").classList.remove("open");
503
634
  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(); }
@@ -530,14 +661,6 @@ function renderCreateJob(job){ if (!job) return; currentCreateJob = job; const d
530
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>'; }
531
662
  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(); }
532
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); };
533
- const refreshBootstrapEl = document.getElementById("refreshBootstrap");
534
- if (refreshBootstrapEl) {
535
- refreshBootstrapEl.onclick = loadBootstrap;
536
- }
537
- const refreshReleasesEl = document.getElementById("refreshReleases");
538
- if (refreshReleasesEl) {
539
- refreshReleasesEl.onclick = loadBootstrap;
540
- }
541
664
  document.getElementById("closeBootstrapConfig").onclick = () => document.getElementById("bootstrapModal").classList.remove("open");
542
665
  function fieldValue(input){ return numeric(input.value); }
543
666
  function numeric(value){ const n = Number(value); return value !== "" && Number.isFinite(n) ? n : value; }