@tokenbuddy/tb-admin 1.0.15 → 1.0.27

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 (43) hide show
  1. package/dist/src/cli.d.ts.map +1 -1
  2. package/dist/src/cli.js +286 -13
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/client.d.ts +12 -3
  5. package/dist/src/client.d.ts.map +1 -1
  6. package/dist/src/client.js +12 -8
  7. package/dist/src/client.js.map +1 -1
  8. package/dist/src/display-format.d.ts +39 -0
  9. package/dist/src/display-format.d.ts.map +1 -0
  10. package/dist/src/display-format.js +354 -0
  11. package/dist/src/display-format.js.map +1 -0
  12. package/dist/src/server-cmd.d.ts +3 -0
  13. package/dist/src/server-cmd.d.ts.map +1 -1
  14. package/dist/src/server-cmd.js +32 -9
  15. package/dist/src/server-cmd.js.map +1 -1
  16. package/dist/src/ui-actions.d.ts +2 -0
  17. package/dist/src/ui-actions.d.ts.map +1 -1
  18. package/dist/src/ui-actions.js +123 -63
  19. package/dist/src/ui-actions.js.map +1 -1
  20. package/dist/src/ui-command.js +1 -1
  21. package/dist/src/ui-command.js.map +1 -1
  22. package/dist/src/ui-server.d.ts +0 -1
  23. package/dist/src/ui-server.d.ts.map +1 -1
  24. package/dist/src/ui-server.js +25 -9
  25. package/dist/src/ui-server.js.map +1 -1
  26. package/dist/src/ui-state.d.ts +7 -1
  27. package/dist/src/ui-state.d.ts.map +1 -1
  28. package/dist/src/ui-state.js +55 -24
  29. package/dist/src/ui-state.js.map +1 -1
  30. package/dist/src/ui-static.d.ts.map +1 -1
  31. package/dist/src/ui-static.js +372 -47
  32. package/dist/src/ui-static.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli.ts +326 -13
  35. package/src/client.ts +13 -8
  36. package/src/display-format.ts +398 -0
  37. package/src/server-cmd.ts +35 -9
  38. package/src/ui-actions.ts +129 -72
  39. package/src/ui-command.ts +1 -1
  40. package/src/ui-server.ts +24 -10
  41. package/src/ui-state.ts +64 -25
  42. package/src/ui-static.ts +375 -47
  43. package/tests/admin.test.ts +573 -41
@@ -1,3 +1,9 @@
1
+ // tb-admin UI — HTML/CSS/JS bundle served by `tb-admin ui`.
2
+ //
3
+ // Visual system follows `DESIGN.md` (TokenBuddy UI design standard).
4
+ // Data display helpers live in `./display-format.js` and are inlined
5
+ // into the page script via `displayFormatBundle()` below.
6
+ import { displayFormatBundle } from "./display-format.js";
1
7
  export function adminUiHtml() {
2
8
  return `<!doctype html>
3
9
  <html lang="en">
@@ -6,56 +12,301 @@ export function adminUiHtml() {
6
12
  <meta name="viewport" content="width=device-width, initial-scale=1">
7
13
  <title>TokenBuddy Admin</title>
8
14
  <style>
15
+ /* ---- Tokens (aligned with DESIGN.md) ----------------------------- */
9
16
  :root {
10
17
  color-scheme: light;
11
- --ink:#17152b;--text:#312a4f;--muted:#665f80;--faint:#837d9b;--page:#fbfaff;
12
- --panel:rgba(255,255,255,.88);--line:#ddd6f1;--line2:#eee9fb;--purple:#6f3ee8;
13
- --green:#34d399;--amber:#f6b73c;--red:#ef5b78;--blue:#5b7cfa;--shadow:0 12px 34px rgba(96,70,170,.12);
14
- font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0;
18
+ --canvas:#f8f7ff;--canvas-soft:#fbfaff;--panel:#ffffff;--panel-subtle:#fdfcff;
19
+ --ink:#201a38;--text:#312a4f;--muted:#6b6384;--faint:#8d86a3;
20
+ --hairline:#eee9fb;--hairline-strong:#ded6f2;
21
+ --primary:#7c3df0;--primary-deep:#5b35d7;--primary-soft:#f1ebff;
22
+ --router:#6d42e8;--spend:#0f766e;--spend-soft:#e8fbf7;
23
+ --tokens:#2563eb;--tokens-soft:#eaf2ff;
24
+ --inventory:#9333ea;--inventory-soft:#f7ecff;
25
+ --success:#10b981;--success-soft:#e8fff6;
26
+ --warning:#f59e0b;--warning-soft:#fff7df;
27
+ --danger:#ef5b78;--danger-soft:#fff0f3;
28
+ --shadow:0 12px 36px rgba(60,41,112,.08);
29
+ /* typography tokens */
30
+ --label-fs:11px;--label-lh:14px;--label-spacing:.08em;--label-weight:800;
31
+ --body-fs:13px;--body-lh:18px;
32
+ --metric-fs:16px;--metric-lh:22px;--metric-weight:800;
33
+ --numeric-fs:12px;--numeric-weight:700;
34
+ --font-sans:Inter,Geist,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
35
+ --font-mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;
36
+ font-family:var(--font-sans);letter-spacing:0;
37
+ }
38
+ *{box-sizing:border-box}html{scroll-behavior:smooth}
39
+ body{margin:0;min-height:100dvh;color:var(--text);background:var(--canvas);font-variant-numeric:tabular-nums}
40
+ button,input,select,textarea{font:inherit}button{cursor:pointer}
41
+ button,a,input,select{transition:color .18s ease,background-color .18s ease,border-color .18s ease,box-shadow .18s ease,transform .18s ease}
42
+ button:active,a:active{transform:translateY(1px)}
43
+ button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
44
+ /* ---- App shell ---------------------------------------------------- */
45
+ .topnav{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;border-bottom:1px solid var(--hairline);background:var(--canvas);position:sticky;top:0;z-index:20}
46
+ .logo{color:var(--ink);font-family:var(--font-mono);font-size:13px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
47
+ .top-links{display:flex;gap:6px;font-weight:650}
48
+ .top-link{position:relative;border:0;border-radius:8px;background:transparent;color:var(--muted);min-height:36px;padding:0 11px;font-size:13px;font-weight:650}
49
+ .top-link:after{content:"";position:absolute;left:11px;right:11px;bottom:-7px;height:2px;border-radius:999px;background:var(--primary);opacity:0;transform:scaleX(.55);transition:opacity .18s ease,transform .18s ease}
50
+ .top-link:hover{background:var(--panel);color:var(--ink)}
51
+ .top-link.active{color:var(--ink);font-weight:760}
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}
54
+ .page{display:none}.page.active{display:grid;gap:16px}
55
+ /* ---- Panels ------------------------------------------------------- */
56
+ .panel,.bootstrap-card{border:1px solid var(--hairline);background:var(--panel);border-radius:12px;box-shadow:var(--shadow);overflow:hidden}
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
+ .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
+ /* ---- Buttons ------------------------------------------------------ */
66
+ .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
+ .btn:hover{border-color:var(--primary);color:var(--primary)}
68
+ .btn.primary{border-color:var(--primary);background:var(--primary);color:#fff;box-shadow:0 8px 18px rgba(124,61,240,.16)}
69
+ .btn.primary:hover{background:var(--primary-deep);color:#fff}
70
+ .btn.danger{color:var(--danger)}
71
+ .btn.icon{width:36px;padding:0;border-radius:8px;display:inline-grid;place-items:center}
72
+ .btn.icon svg{width:17px;height:17px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}
73
+ /* ---- 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}
77
+ .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}
80
+ /* Status dot — five spec tones (green/amber/red/blue/gray) */
81
+ .app-dot{width:10px;height:10px;border-radius:999px;background:#c8ced8;box-shadow:0 0 0 4px #edf1f8}
82
+ .app-dot.tone-green{background:var(--success);box-shadow:0 0 0 4px rgba(16,185,129,.18)}
83
+ .app-dot.tone-amber{background:var(--warning);box-shadow:0 0 0 4px rgba(245,158,11,.2)}
84
+ .app-dot.tone-red{background:var(--danger);box-shadow:0 0 0 4px rgba(239,91,120,.18)}
85
+ .app-dot.tone-blue{background:var(--tokens);box-shadow:0 0 0 4px rgba(37,99,235,.18)}
86
+ .app-dot.tone-gray{background:#8d86a3;box-shadow:0 0 0 4px rgba(141,134,163,.17)}
87
+ .app-name{display:grid;gap:6px;min-width:0}
88
+ .seller-title{display:inline-flex;gap:8px;align-items:center}
89
+ .app-name strong,.field-cell strong,.field-cell span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
90
+ .app-name strong{color:var(--ink);font-size:15px;font-weight:750}
91
+ .field-cell{display:grid;gap:4px;min-width:0;color:var(--muted);font-size:var(--body-fs);line-height:var(--body-lh)}
92
+ .field-cell strong{color:var(--ink);font-family:var(--font-mono);font-size:var(--numeric-fs);font-weight:var(--numeric-weight)}
93
+ .speed-cell{display:grid;gap:3px;font-family:var(--font-mono);font-size:var(--numeric-fs);font-weight:var(--numeric-weight)}
94
+ .muted-value{color:var(--faint)}
95
+ .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}
96
+ .recharge-btn:hover,.detail-btn:hover{border-color:var(--primary);background:var(--primary-soft)}
97
+ .row-actions{display:flex;gap:8px;justify-content:flex-end}
98
+ .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}
99
+ .spec-tip svg{width:14px;height:14px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}
100
+ /* ---- Entry cards (Bootstrap summary) ----------------------------- */
101
+ .bootstrap-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:16px}
102
+ .entry-card{border:1px solid var(--hairline-strong);border-radius:12px;background:var(--panel);padding:16px;box-shadow:var(--shadow);display:grid;gap:7px}
103
+ .entry-card.router{background:var(--panel);border-color:var(--hairline-strong);box-shadow:var(--shadow)}
104
+ .entry-card.spend{background:var(--spend-soft);border-color:#cceee6;box-shadow:none}
105
+ .entry-card.tokens{background:var(--tokens-soft);border-color:#d9e7ff;box-shadow:none}
106
+ .entry-card.inventory{background:var(--inventory-soft);border-color:#ead5ff;box-shadow:none}
107
+ .entry-card label{color:var(--muted);font-size:var(--label-fs);font-weight:var(--label-weight);text-transform:uppercase;letter-spacing:var(--label-spacing)}
108
+ .entry-card strong{color:var(--ink);font-family:var(--font-mono);font-size:var(--metric-fs);line-height:var(--metric-lh);font-weight:var(--metric-weight)}
109
+ .entry-card .secondary{color:var(--muted);font-size:12px;line-height:16px;font-family:var(--font-mono)}
110
+ /* ---- Modal -------------------------------------------------------- */
111
+ .modal{position:fixed;inset:0;background:rgba(32,26,56,.28);z-index:40;display:none}
112
+ .modal.open{display:grid}
113
+ .modal-shell{background:var(--canvas);min-height:100dvh;display:grid;grid-template-rows:auto 1fr}
114
+ .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)}
115
+ .modal-title{display:flex;align-items:center;gap:10px;color:var(--ink);font-size:20px;line-height:28px;font-weight:800}
116
+ .modal-actions{display:flex;gap:8px;align-items:center}
117
+ .modal-body{padding:20px 24px 36px;display:grid;gap:16px;overflow:auto}
118
+ .detail-grid{display:grid;grid-template-columns:minmax(260px,.72fr) minmax(0,1fr);gap:16px}
119
+ .card{border:1px solid var(--hairline);border-radius:12px;background:#fff;padding:16px;box-shadow:var(--shadow)}
120
+ .card h2{margin:0 0 12px;color:var(--ink);font-size:16px;line-height:22px;font-weight:750}
121
+ .form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
122
+ .create-form{display:grid;gap:16px}
123
+ .create-section{border:1px solid var(--hairline);border-radius:12px;background:#fff;padding:16px;display:grid;gap:12px;box-shadow:var(--shadow)}
124
+ .create-section h2{margin:0;color:var(--ink);font-size:16px;line-height:22px;font-weight:750}
125
+ .section-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
126
+ /* ---- Tabs & payment --------------------------------------------- */
127
+ .payment-tabs{display:flex;gap:8px;flex-wrap:wrap}
128
+ .payment-tab{border:1px solid var(--hairline-strong);border-radius:8px;background:#fff;color:var(--primary);min-height:34px;padding:0 12px;font-weight:800}
129
+ .payment-tab.active{background:var(--primary);color:#fff;border-color:var(--primary)}
130
+ .payment-panel{border:1px solid var(--hairline);border-radius:8px;background:var(--panel-subtle);padding:14px;display:grid;gap:14px}
131
+ .payment-head{display:flex;align-items:center;justify-content:space-between;gap:14px}
132
+ .payment-head h3{margin:0;color:var(--ink);font-size:14px}
133
+ .pill-switch{border:1px solid var(--hairline-strong);border-radius:8px;background:#fff;color:var(--muted);min-height:34px;padding:0 12px;display:inline-flex;align-items:center;gap:8px;font-weight:800}
134
+ .pill-switch:before{content:"";width:18px;height:18px;border-radius:999px;background:#d8d0ea;box-shadow:inset 0 0 0 4px #fff}
135
+ .pill-switch.enabled{border-color:#8bd9ba;background:var(--success-soft);color:#087755}
136
+ .pill-switch.enabled:before{background:var(--success)}
137
+ .field.full{grid-column:1/-1}
138
+ .field-help{margin:0;color:var(--muted);font-size:12px;line-height:1.45}
139
+ .required-star{color:#c0264e}
140
+ .generated-summary{border:1px solid var(--hairline);border-radius:8px;background:#fff;padding:10px 12px;color:var(--muted);font-size:12px;line-height:1.5}
141
+ .field{display:grid;gap:7px}
142
+ .field label{font-size:var(--label-fs);font-weight:var(--label-weight);color:var(--muted);letter-spacing:var(--label-spacing);text-transform:uppercase}
143
+ .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)}
144
+ .field input,.field select{font:inherit}
145
+ .field input:disabled,.field select:disabled{background:#f3f0fb;color:#958daa}
146
+ .readonly-value{display:flex;align-items:center;background:var(--panel-subtle);color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
147
+ .field input[readonly]{background:var(--panel-subtle);color:var(--muted)}
148
+ /* ---- Tables (audit) ---------------------------------------------- */
149
+ table{width:100%;border-collapse:collapse;font-size:var(--body-fs)}
150
+ th,td{text-align:left;border-bottom:1px solid var(--hairline);padding:10px;color:#50496a}
151
+ th{color:var(--muted);font-size:var(--label-fs);text-transform:uppercase;letter-spacing:var(--label-spacing)}
152
+ /* ---- Status banners ---------------------------------------------- */
153
+ .status-line,.loading-row{padding:10px 14px;border:1px solid var(--hairline-strong);border-radius:8px;background:#fff;color:var(--muted);font-size:var(--body-fs)}
154
+ .status-line.loading,.loading-row{border-color:var(--hairline-strong);background:var(--primary-soft);color:var(--primary)}
155
+ .loading-row{min-height:76px;display:grid;place-items:center}
156
+ .status-line.loading{min-height:42px;display:flex;align-items:center;justify-content:center}
157
+ .form-grid>.loading-row{grid-column:1/-1}
158
+ /* ---- Create progress -------------------------------------------- */
159
+ .create-progress{position:relative;display:grid;gap:12px;padding-left:30px}
160
+ .create-progress:before{content:"";position:absolute;left:15px;top:22px;bottom:22px;width:2px;background:#e6def8}
161
+ .progress-step{position:relative;border:1px solid var(--hairline);border-radius:8px;background:#fff;padding:12px 14px;display:grid;gap:6px;text-align:left;color:inherit}
162
+ .progress-step:before{content:"";position:absolute;left:-23px;top:18px;width:12px;height:12px;border-radius:999px;background:#c7bedf;border:3px solid var(--canvas)}
163
+ .progress-step strong{color:var(--ink);font-size:var(--body-fs);font-weight:750}
164
+ .progress-step span{color:var(--muted);font-size:12px}
165
+ .progress-step.running{border-color:var(--hairline-strong);background:var(--primary-soft)}
166
+ .progress-step.running:before{background:var(--primary)}
167
+ .progress-step.succeeded{border-color:rgba(16,185,129,.4)}
168
+ .progress-step.succeeded:before{background:var(--success)}
169
+ .progress-step.failed{border-color:#f4bdc9;background:var(--danger-soft)}
170
+ .progress-step.failed:before{background:var(--danger)}
171
+ .progress-step.skipped{background:var(--panel-subtle)}
172
+ .progress-step.skipped:before{background:#b9afd8}
173
+ .progress-title{display:flex;align-items:center;gap:8px}
174
+ .progress-title .spinner{width:14px;height:14px;border-width:2px}
175
+ .progress-meta{display:flex;align-items:center;justify-content:space-between;gap:10px}
176
+ .progress-toggle{color:var(--primary);font-size:var(--label-fs);font-weight:var(--label-weight);text-transform:uppercase;letter-spacing:var(--label-spacing)}
177
+ .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)}
178
+ .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}
179
+ @keyframes spin{to{transform:rotate(360deg)}}
180
+ .hidden{display:none!important}
181
+ /* ---- Mobile ------------------------------------------------------- */
182
+ @media(max-width:900px){
183
+ .topnav{padding:0 20px}
184
+ .content{width:min(100% - 28px,1180px)}
185
+ .app-table-head{display:none}
186
+ .app-row{grid-template-columns:18px minmax(0,1fr) auto;gap:12px;padding:14px;min-height:44px}
187
+ .app-row .field-cell,.app-row .speed-cell{display:none}
188
+ .detail-grid,.form-grid,.section-grid,.bootstrap-grid{grid-template-columns:1fr}
189
+ .field.full{grid-column:auto}
190
+ .modal-head{padding:0 16px}
191
+ .modal-body{padding:18px 16px 34px}
15
192
  }
16
- *{box-sizing:border-box}body{margin:0;min-height:100dvh;color:var(--text);background:linear-gradient(135deg,#eef5ff 0%,#fbf7ff 47%,#f8f6ff 100%)}
17
- button,input,select,textarea{font:inherit}button{cursor:pointer}.topnav{height:76px;display:flex;align-items:center;justify-content:space-between;padding:0 44px;border-bottom:1px solid rgba(205,197,231,.54);position:sticky;top:0;z-index:20}
18
- .logo{color:var(--ink);font-size:20px;font-weight:900}.top-links{display:flex;gap:26px;font-weight:820}.top-link{border:0;background:transparent;color:#5d5675;min-height:44px}.top-link.active{color:var(--ink)}
19
- .content{width:min(1280px,calc(100vw - 64px));margin:0 auto;padding:30px 0 48px;display:grid;gap:28px}.page{display:none}.page.active{display:grid;gap:28px}
20
- .panel,.bootstrap-card{border:1px solid #d9cfef;background:var(--panel);border-radius:8px;box-shadow:var(--shadow);overflow:hidden;backdrop-filter:blur(12px)}
21
- .panel-head{min-height:62px;padding:14px 18px;display:flex;align-items:center;justify-content:space-between;gap:20px;border-bottom:1px solid var(--line)}
22
- .list-status{display:flex;align-items:center;gap:12px;min-width:0;color:#756f91;font-size:12px;font-weight:760}.refresh-pill{border:1px solid #d9cfef;border-radius:999px;background:#fff;padding:5px 10px;color:#5b4aa8}.refresh-pill.refreshing{display:inline-flex;align-items:center;gap:7px;color:#5f4f91;background:#f8f6ff}.refresh-pill.error{color:#b42342;background:#fff5f7;border-color:#f4bdc9}.refresh-pill .spinner{width:12px;height:12px;border-width:2px}.last-updated{white-space:nowrap;color:#837d9b}
23
- .title{margin:0;color:var(--ink);font-size:22px;line-height:1.15;font-weight:850}.btn{border:1px solid #cfc3ef;border-radius:8px;min-height:40px;padding:0 14px;background:white;color:#4c3aa1;font-weight:800}.btn.primary{border:0;background:var(--purple);color:white}.btn.danger{color:#b42342}.btn.icon{width:38px;padding:0;border-radius:999px;display:inline-grid;place-items:center}.btn.icon svg{width:17px;height:17px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}
24
- .app-list{--seller-grid:22px minmax(130px,1.25fr) minmax(120px,1fr) minmax(64px,.42fr) minmax(76px,.5fr) minmax(104px,.68fr) minmax(96px,.62fr) minmax(82px,.52fr) 36px;display:grid;gap:10px;padding:14px;background:rgba(247,245,255,.72)}#sellerRows{display:grid;gap:10px;width:100%;min-width:0}.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}
25
- .app-table-head{min-height:34px;color:#81799f;font-size:11px;font-weight:900;text-transform:uppercase}.app-row{border:1px solid #d8d0ee;border-radius:8px;background:rgba(255,255,255,.82);min-height:76px;text-align:left}
26
- .app-dot{width:18px;height:18px;border-radius:999px;background:#c8ced8;box-shadow:inset 0 0 0 6px #edf1f8}.app-dot.active{background:var(--green);box-shadow:0 0 0 7px rgba(52,211,153,.19)}.app-dot.pending,.app-dot.draining{background:var(--amber);box-shadow:0 0 0 7px rgba(246,183,60,.22)}.app-dot.offline,.app-dot.unknown{background:#6d7390;box-shadow:0 0 0 7px rgba(109,115,144,.17)}
27
- .app-name{display:grid;gap:6px;min-width:0}.seller-title{display:inline-flex;gap:9px;align-items:center}.app-name strong,.field-cell strong,.field-cell span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-name strong{color:#635986;font-size:17px}.field-cell{display:grid;gap:4px;min-width:0;color:#5f5877;font-size:13px}.field-cell strong{color:#29223f}.speed-cell{display:grid;gap:3px;font-size:12px;font-weight:800}.muted-value{color:#8a83a3}.recharge-btn,.detail-btn{width:32px;height:32px;border:1px solid #d7cfef;border-radius:999px;background:#fff;color:#6142db;display:inline-grid;place-items:center;text-decoration:none;font-weight:900}.row-actions{display:flex;gap:8px;justify-content:flex-end}
28
- .spec-tip{border:1px solid #d8cff0;border-radius:999px;background:#fff;color:#6a55a4;width:24px;height:24px;padding:0;display:inline-grid;place-items:center}.spec-tip svg{width:14px;height:14px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}.bootstrap-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;padding:18px}.metric{border:1px solid var(--line2);border-radius:8px;background:#fff;padding:14px}.metric label{display:block;color:#8a83a3;font-size:11px;font-weight:900;text-transform:uppercase}.metric strong{display:block;margin-top:7px;color:var(--ink);font-size:20px}
29
- .modal{position:fixed;inset:0;background:rgba(24,18,45,.36);z-index:40;display:none}.modal.open{display:grid}.modal-shell{background:#fbfaff;min-height:100dvh;display:grid;grid-template-rows:auto 1fr}.modal-head{height:76px;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:0 28px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.92)}.modal-title{display:flex;align-items:center;gap:12px;color:var(--ink);font-size:22px;font-weight:900}.modal-actions{display:flex;gap:10px;align-items:center}
30
- .modal-body{padding:24px 28px 40px;display:grid;gap:18px;overflow:auto}.detail-grid{display:grid;grid-template-columns:minmax(260px,.72fr) minmax(0,1fr);gap:18px}.card{border:1px solid #d9cfef;border-radius:8px;background:white;padding:18px}.card h2{margin:0 0 14px;color:var(--ink);font-size:18px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.create-form{display:grid;gap:18px}.create-section{border:1px solid #d9cfef;border-radius:8px;background:white;padding:18px;display:grid;gap:14px}.create-section h2{margin:0;color:var(--ink);font-size:18px}.section-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.payment-tabs{display:flex;gap:8px;flex-wrap:wrap}.payment-tab{border:1px solid #d8cff0;border-radius:999px;background:#fff;color:#5b4aa8;min-height:34px;padding:0 14px;font-weight:900}.payment-tab.active{background:#6f3ee8;color:#fff;border-color:#6f3ee8}.payment-panel{border:1px solid var(--line2);border-radius:8px;background:#faf9ff;padding:14px;display:grid;gap:14px}.payment-head{display:flex;align-items:center;justify-content:space-between;gap:14px}.payment-head h3{margin:0;color:#3b315c;font-size:14px}.pill-switch{border:1px solid #cfc3ef;border-radius:999px;background:#fff;color:#6b6385;min-height:34px;padding:0 12px;display:inline-flex;align-items:center;gap:8px;font-weight:900}.pill-switch:before{content:"";width:22px;height:22px;border-radius:999px;background:#d8d0ea;box-shadow:inset 0 0 0 4px #fff}.pill-switch.enabled{border-color:#8bd9ba;background:#effdf7;color:#087755}.pill-switch.enabled:before{background:#34d399}.field.full{grid-column:1/-1}.field-help{margin:0;color:#746c8e;font-size:12px;line-height:1.45}.required-star{color:#c0264e}.generated-summary{border:1px solid #e4def6;border-radius:8px;background:#fff;padding:10px 12px;color:#5f5877;font-size:12px;line-height:1.5}.field{display:grid;gap:7px}.field label{font-size:12px;font-weight:900;color:#766f94;text-transform:uppercase}.field input,.field select,.readonly-value{min-height:40px;border:1px solid #cfc3ef;border-radius:8px;padding:0 10px;background:#fff;color:var(--ink)}.field input,.field select{font:inherit}.field input:disabled,.field select:disabled{background:#f3f0fb;color:#958daa}.readonly-value{display:flex;align-items:center;background:#f8f6ff;color:#5f5877;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field input[readonly]{background:#f8f6ff;color:#5f5877}
31
- table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;border-bottom:1px solid var(--line2);padding:10px;color:#50496a}th{color:#81799f;font-size:11px;text-transform:uppercase}.status-line,.loading-row{padding:10px 14px;border:1px solid var(--line);border-radius:8px;background:#fff;color:#5f5877;font-size:13px}.status-line.loading,.loading-row{border-color:#d8cff0;background:#f8f6ff;color:#5f4f91}.loading-row{min-height:76px;display:grid;place-items:center}.status-line.loading{min-height:42px;display:flex;align-items:center;justify-content:center}.form-grid>.loading-row{grid-column:1/-1}.create-progress{position:relative;display:grid;gap:12px;padding-left:30px}.create-progress:before{content:"";position:absolute;left:15px;top:22px;bottom:22px;width:2px;background:#e6def8}.progress-step{position:relative;border:1px solid var(--line2);border-radius:8px;background:#fff;padding:12px 14px;display:grid;gap:6px;text-align:left;color:inherit}.progress-step:before{content:"";position:absolute;left:-23px;top:18px;width:12px;height:12px;border-radius:999px;background:#c7bedf;border:3px solid #fbfaff}.progress-step strong{color:var(--ink);font-size:13px}.progress-step span{color:#756f91;font-size:12px}.progress-step.running{border-color:#d8cff0;background:#f8f6ff}.progress-step.running:before{background:var(--purple)}.progress-step.succeeded{border-color:rgba(52,211,153,.4)}.progress-step.succeeded:before{background:var(--green)}.progress-step.failed{border-color:#f4bdc9;background:#fff5f7}.progress-step.failed:before{background:var(--red)}.progress-step.skipped{background:#faf9ff}.progress-step.skipped:before{background:#b9afd8}.progress-title{display:flex;align-items:center;gap:8px}.progress-title .spinner{width:14px;height:14px;border-width:2px}.progress-meta{display:flex;align-items:center;justify-content:space-between;gap:10px}.progress-toggle{color:#6f3ee8;font-size:11px;font-weight:900;text-transform:uppercase}.progress-log{margin:4px 0 0;max-height:150px;overflow:auto;border:1px solid var(--line2);border-radius:8px;background:#17152b;color:#f3f0ff;padding:9px;font-size:11px;white-space:pre-wrap}.spinner{width:19px;height:19px;border:2px solid rgba(111,62,232,.18);border-top-color:var(--purple);border-radius:999px;animation:spin .75s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.hidden{display:none!important}
32
- @media(max-width:900px){.topnav{padding:0 20px}.content{width:min(100% - 28px,1280px)}.app-table-head{display:none}.app-row{grid-template-columns:18px minmax(0,1fr) auto;gap:12px;padding:14px}.app-row .field-cell,.app-row .speed-cell{display:none}.detail-grid,.form-grid,.section-grid,.bootstrap-grid{grid-template-columns:1fr}.field.full{grid-column:auto}.modal-head{padding:0 16px}.modal-body{padding:18px 16px 34px}}
33
193
  </style>
34
194
  </head>
35
195
  <body>
36
- <nav class="topnav"><div class="logo">TokenBuddy</div><div class="top-links"><button class="top-link active" data-page="sellers">Sellers</button><button class="top-link" data-page="bootstrap">Bootstrap</button></div></nav>
196
+ <nav class="topnav"><div class="logo">TOKENBUDDY ADMIN</div><div class="top-links"><button class="top-link active" data-page="sellers">Sellers</button><button class="top-link" data-page="bootstrap">Bootstrap</button></div></nav>
37
197
  <main class="content">
38
198
  <section id="page-sellers" class="page active">
39
- <div class="panel"><div class="panel-head"><div class="list-status"><span id="sellerRefreshState" class="refresh-pill">Starting</span><span id="sellerLastUpdated" class="last-updated">Last updated: never</span></div><button id="createSeller" class="btn primary">Create Seller</button></div>
40
- <div class="app-list"><div class="app-table-head"><span></span><span>Seller</span><span>Upstream</span><span>Discount</span><span>Capacity</span><span>Latency</span><span>Balance</span><span>Status</span><span></span></div><div id="sellerRows"><div class="loading-row" role="status" aria-label="Loading sellers"><span class="spinner" aria-hidden="true"></span></div></div></div></div>
199
+ <div class="panel">
200
+ <div class="panel-head">
201
+ <div>
202
+ <h1 class="title">Seller fleet</h1>
203
+ <div class="list-status"><span id="sellerRefreshState" class="refresh-pill">Starting</span></div>
204
+ </div>
205
+ <button id="createSeller" class="btn primary">Create Seller</button>
206
+ </div>
207
+ <div class="app-list">
208
+ <div class="app-table-head" role="row">
209
+ <span></span><span>Seller</span><span>Upstream</span>
210
+ <span>Disc</span><span>Capacity</span><span>TTFT</span>
211
+ <span>Balance</span><span>Status</span><span></span>
212
+ </div>
213
+ <div id="sellerRows">
214
+ <div class="loading-row" role="status" aria-label="Loading sellers">
215
+ <span class="spinner" aria-hidden="true"></span>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ </div>
41
220
  </section>
42
221
  <section id="page-bootstrap" class="page">
43
- <div class="bootstrap-card"><div class="panel-head"><h1 class="title">Bootstrap</h1><div class="modal-actions"><button id="openBootstrapConfig" class="btn primary">Edit Bootstrap Config</button><button id="refreshBootstrap" class="btn">Refresh</button></div></div><div id="bootstrapGrid" class="bootstrap-grid"></div></div>
222
+ <div class="bootstrap-card">
223
+ <div class="panel-head">
224
+ <h1 class="title">Bootstrap</h1>
225
+ <div class="modal-actions">
226
+ <button id="openBootstrapConfig" class="btn primary">Edit Bootstrap Config</button>
227
+ <button id="refreshBootstrap" class="btn">Refresh</button>
228
+ </div>
229
+ </div>
230
+ <div id="bootstrapGrid" class="bootstrap-grid"></div>
231
+ </div>
44
232
  </section>
45
233
  </main>
46
- <section id="detailModal" class="modal"><div class="modal-shell"><header class="modal-head"><div class="modal-title"><span id="detailTitle">seller detail</span><button id="deleteSeller" class="btn icon danger" title="Delete deployment" aria-label="Delete deployment"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18"></path><path d="M8 6V4h8v2"></path><path d="M6 6l1 16h10l1-16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path></svg></button></div><div class="modal-actions"><button class="btn" data-status-action="drain">Drain</button><button class="btn" data-status-action="offline">Offline</button><button class="btn" data-status-action="activate">Activate</button><button id="editDetail" class="btn primary">Edit config</button><button id="closeDetail" class="btn">Close</button></div></header><div class="modal-body"><div id="detailStatus" class="status-line hidden"></div><div class="detail-grid"><div class="card"><h2>Seller configuration</h2><div id="configFields" class="form-grid"></div></div><div class="card"><h2>Models</h2><div id="modelsTable"></div></div></div></div></div></section>
47
- <section id="createModal" class="modal"><div class="modal-shell"><header class="modal-head"><div class="modal-title">New seller</div><div class="modal-actions"><button id="submitCreate" class="btn primary">Create seller</button><button id="closeCreate" class="btn">Close</button></div></header><div class="modal-body"><div id="createStatus" class="status-line hidden"></div><div id="createProgress" class="card create-progress hidden"></div><div id="createFields" class="create-form"></div></div></div></section>
48
- <section id="bootstrapModal" class="modal"><div class="modal-shell"><header class="modal-head"><div class="modal-title">Bootstrap service</div><div class="modal-actions"><button id="closeBootstrapConfig" class="btn">Close</button></div></header><div class="modal-body"><div id="bootstrapStatus" class="status-line">Ready</div><div class="detail-grid"><div class="card"><h2>Bootstrap status</h2><div id="bootstrapConfigMetrics" class="bootstrap-grid"></div></div><div class="card"><h2>Bootstrap configuration</h2><div id="bootstrapConfigFields" class="form-grid"></div></div></div></div></div></section>
234
+ <section id="detailModal" class="modal">
235
+ <div class="modal-shell">
236
+ <header class="modal-head">
237
+ <div class="modal-title">
238
+ <span id="detailTitle">seller detail</span>
239
+ <button id="deleteSeller" class="btn icon danger" title="Delete deployment" aria-label="Delete deployment">
240
+ <svg viewBox="0 0 24 24" aria-hidden="true">
241
+ <path d="M3 6h18"></path><path d="M8 6V4h8v2"></path>
242
+ <path d="M6 6l1 16h10l1-16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path>
243
+ </svg>
244
+ </button>
245
+ </div>
246
+ <div class="modal-actions">
247
+ <button class="btn" data-status-action="drain">Drain</button>
248
+ <button class="btn" data-status-action="offline">Offline</button>
249
+ <button class="btn" data-status-action="activate">Activate</button>
250
+ <button id="editDetail" class="btn primary">Edit config</button>
251
+ <button id="closeDetail" class="btn">Close</button>
252
+ </div>
253
+ </header>
254
+ <div class="modal-body">
255
+ <div id="detailStatus" class="status-line hidden"></div>
256
+ <div class="detail-grid">
257
+ <div class="card"><h2>Seller configuration</h2><div id="configFields" class="form-grid"></div></div>
258
+ <div class="card"><h2>Models</h2><div id="modelsTable"></div></div>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ </section>
263
+ <section id="createModal" class="modal">
264
+ <div class="modal-shell">
265
+ <header class="modal-head">
266
+ <div class="modal-title">New seller</div>
267
+ <div class="modal-actions">
268
+ <button id="submitCreate" class="btn primary">Create seller</button>
269
+ <button id="closeCreate" class="btn">Close</button>
270
+ </div>
271
+ </header>
272
+ <div class="modal-body">
273
+ <div id="createStatus" class="status-line hidden"></div>
274
+ <div id="createProgress" class="card create-progress hidden"></div>
275
+ <div id="createFields" class="create-form"></div>
276
+ </div>
277
+ </div>
278
+ </section>
279
+ <section id="bootstrapModal" class="modal">
280
+ <div class="modal-shell">
281
+ <header class="modal-head">
282
+ <div class="modal-title">Bootstrap service</div>
283
+ <div class="modal-actions"><button id="closeBootstrapConfig" class="btn">Close</button></div>
284
+ </header>
285
+ <div class="modal-body">
286
+ <div id="bootstrapStatus" class="status-line">Ready</div>
287
+ <div class="detail-grid">
288
+ <div class="card"><h2>Bootstrap status</h2><div id="bootstrapConfigMetrics" class="bootstrap-grid"></div></div>
289
+ <div class="card"><h2>Bootstrap configuration</h2><div id="bootstrapConfigFields" class="form-grid"></div></div>
290
+ </div>
291
+ </div>
292
+ </div>
293
+ </section>
49
294
  <script>${adminUiScript()}</script>
50
295
  </body>
51
296
  </html>`;
52
297
  }
53
298
  function adminUiScript() {
299
+ // Inlined display-format bundle (must stay in sync with src/display-format.ts).
300
+ return `
301
+ ${displayFormatBundle()}
302
+ ${adminUiClientScript()}
303
+ `;
304
+ }
305
+ function adminUiClientScript() {
54
306
  return `
55
- const session = new URLSearchParams(location.search).get("session") || "";
56
307
  async function api(path, options={}){
57
308
  try {
58
- const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", "X-TokenBuddy-Ui-Session": session, ...(options.headers || {}) } });
309
+ const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options.headers || {}) } });
59
310
  const text = await response.text();
60
311
  let data = {};
61
312
  try {
@@ -69,33 +320,107 @@ async function api(path, options={}){
69
320
  throw new Error(uiErrorMessage(err));
70
321
  }
71
322
  }
72
- 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. Reopen the latest tb-admin UI URL and reload this page."; if (/operator_auth_required/i.test(message)) return "Admin UI session expired. Reopen the latest tb-admin UI URL."; if (/HTTP Error 401/i.test(message)) return "Authentication failed while loading admin data. Reopen the latest tb-admin UI URL and check the local admin profile."; return message || "Request failed."; }
323
+ 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."; }
73
324
  const sellerRefreshIntervalMs = 30000;
74
- let currentDetail = null; let sellerRowsCache = []; let editing = false; let deleteReady = false; let sellerRefreshInFlight = false; let sellerRefreshTimer = null; let sellerClockTimer = null; let sellerLastUpdatedAt = null; let sellerNextRefreshAt = null; let sellerRefreshError = ""; let createJobTimer = null; let currentCreateJob = null; let expandedProgressSteps = new Set(); let createAppSuffix = "";
325
+ 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 = "";
75
326
  const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
76
- const fmt = value => value === undefined || value === null || value === "" ? "unknown" : value;
77
- const missing = label => '<span class="muted-value">'+esc(label)+'</span>';
327
+ const missing = value => '<span class="muted-value">'+esc(value)+'</span>';
328
+ const dash = () => '<span class="muted-value">'+window.__tbFmt.UNKNOWN_VALUE+'</span>';
78
329
  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>';
79
330
  const balanceProbeTemplates = ["openrouter","deepseek","stepfun","siliconflow","novita","newapi_generic","usage_generic","none","auto"];
80
331
  const paymentMethods = ["clawtip","mock"];
81
332
  const loadingSpinner = label => '<div class="loading-row" role="status" aria-label="'+esc(label)+'"><span class="spinner" aria-hidden="true"></span></div>';
82
333
  document.querySelectorAll(".top-link").forEach(btn => btn.onclick = () => { document.querySelectorAll(".top-link").forEach(b => b.classList.remove("active")); btn.classList.add("active"); document.querySelectorAll(".page").forEach(p => p.classList.remove("active")); document.getElementById("page-" + btn.dataset.page).classList.add("active"); if (btn.dataset.page === "bootstrap") loadBootstrap(); });
83
- 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); sellerLastUpdatedAt = new Date(); 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); } }
334
+ 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); } }
84
335
  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)); }
85
336
  function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => updateSellerRefreshMeta(sellerRefreshInFlight), 1000); window.addEventListener("beforeunload", () => { clearTimeout(sellerRefreshTimer); clearInterval(sellerClockTimer); }); }
86
337
  function scheduleSellerRefresh(){ clearTimeout(sellerRefreshTimer); sellerNextRefreshAt = new Date(Date.now() + sellerRefreshIntervalMs); sellerRefreshTimer = setTimeout(() => loadSellers(), sellerRefreshIntervalMs); }
87
- function updateSellerRefreshMeta(refreshing){ const state = document.getElementById("sellerRefreshState"); const updated = document.getElementById("sellerLastUpdated"); if (!state || !updated) 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 || (sellerLastUpdatedAt ? "Next refresh: " + nextSeconds + "s" : "Starting")); if (!sellerLastUpdatedAt){ updated.textContent = "Last updated: never"; return; } const agoSeconds = Math.max(0, Math.floor((Date.now() - sellerLastUpdatedAt.getTime()) / 1000)); updated.textContent = "Last updated: " + sellerLastUpdatedAt.toLocaleTimeString() + " (" + agoSeconds + "s ago)"; }
88
- function sellerRow(row){ 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"; const ttft = row.ttftMs === undefined || row.ttftMs === null ? "TTFT n/a" : "TTFT " + row.ttftMs + "ms"; const avgInference = row.avgInferenceMs === undefined || row.avgInferenceMs === null ? missing("avg n/a") : "avg " + esc(row.avgInferenceMs) + "ms"; const balance = row.upstreamBalance === undefined || row.upstreamBalance === null || row.upstreamBalance === "" ? missing("not reported") : '<strong>'+esc(row.upstreamBalance)+'</strong>'; return '<button class="app-row" type="button" data-detail="'+esc(row.id)+'"><span class="app-dot '+esc(row.nodeStatus)+'"></span><span class="app-name"><span class="seller-title"><strong>'+esc(row.name)+'</strong><span class="spec-tip" title="'+esc(tip)+'" aria-label="Seller specs">'+infoIcon+'</span></span></span><span class="field-cell"><strong>'+esc(row.upstreamDomain)+'</strong></span><span class="field-cell"><strong>'+esc(fmt(row.discountRatio))+'</strong></span><span class="field-cell"><strong>'+esc(fmt(row.capacityUsed))+' / '+esc(fmt(row.capacityLimit))+'</strong></span><span class="speed-cell"><strong>'+esc(ttft)+'</strong><span>'+avgInference+'</span></span><span class="field-cell"><span class="balance-line">'+balance+(row.upstreamRechargeUrl ? '<a class="recharge-btn" href="'+esc(row.upstreamRechargeUrl)+'" target="_blank" rel="noreferrer">↗</a>' : '')+'</span></span><span class="field-cell"><strong>'+esc(row.upstreamStatus)+'</strong></span><span class="row-actions"><span class="detail-btn">›</span></span></button>'; }
89
- async function loadBootstrap(){ try { const data = await api("/api/bootstrap"); const items = [["Status", data.status],["Registry", data.registryVersion],["Seller entries", data.sellerEntries],["Regions", (data.regions || []).join(", ") || "unknown"]]; document.getElementById("bootstrapGrid").innerHTML = items.map(([k,v]) => '<div class="metric"><label>'+esc(k)+'</label><strong>'+esc(fmt(v))+'</strong></div>').join(""); } catch (err) { document.getElementById("bootstrapGrid").innerHTML = '<div class="status-line">'+esc(uiErrorMessage(err))+'</div>'; } }
90
- async function openBootstrapConfig(){ try { const data = await api("/api/bootstrap/config"); document.getElementById("bootstrapStatus").textContent = uiErrorMessage(data.error || "Ready"); const clawtip = data.clawtip || {}; const metrics = [["service", data.status],["skill", clawtip.skillSlug],["activation", clawtip.activationFeeFen],["micros/fen", clawtip.microsPerFen]]; document.getElementById("bootstrapConfigMetrics").innerHTML = metrics.map(([k,v]) => '<div class="metric"><label>'+esc(k)+'</label><strong>'+esc(fmt(v))+'</strong></div>').join(""); const fields = [["sellerRegistryPath", data.sellerRegistryPath],["bind.host", data.bindHost],["bind.port", data.bindPort],["allowLocalSellerUrls", String(Boolean(data.allowLocalSellerUrls))],["clawtip.payTo", clawtip.payToMasked],["clawtip.sm4KeyBase64", clawtip.sm4KeyMasked],["clawtip.skillId", clawtip.skillId],["clawtip.description", clawtip.description],["clawtip.resourceUrl", clawtip.resourceUrl]]; document.getElementById("bootstrapConfigFields").innerHTML = fields.map(([k,v]) => fieldHtml(k,v,false)).join(""); document.getElementById("bootstrapModal").classList.add("open"); } catch (err) { document.getElementById("bootstrapStatus").textContent = uiErrorMessage(err); document.getElementById("bootstrapConfigMetrics").innerHTML = ""; document.getElementById("bootstrapConfigFields").innerHTML = ""; document.getElementById("bootstrapModal").classList.add("open"); } }
338
+ 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")); }
339
+ function sellerRow(row){
340
+ const fmt = window.__tbFmt;
341
+ 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";
342
+ const ttftText = fmt.formatDuration(row.ttftMs);
343
+ const ttft = "TTFT: " + (ttftText === fmt.UNKNOWN_VALUE ? "—" : ttftText);
344
+ const avgText = fmt.formatSpeed(row.avgTokensPerSecond);
345
+ const avgSpeed = avgText === fmt.UNKNOWN_VALUE ? missing("AVG: —") : "AVG: " + esc(avgText);
346
+ const capacity = fmt.formatSellerCapacity(row.capacityUsed, row.capacityLimit);
347
+ const disc = fmt.formatDiscountRatio(row.discountRatio);
348
+ const balanceRaw = row.upstreamBalanceUsdMicros;
349
+ const balanceText = (balanceRaw === undefined || balanceRaw === null) ? dash() : '<strong>'+esc(fmt.formatBalanceAmount(balanceRaw, row.upstreamBalanceCurrency || "USD"))+'</strong>';
350
+ const switchText = row.lastSwitchAt ? "Switch " + esc(fmt.formatTimeCompact(row.lastSwitchAt)) : "";
351
+ const status = fmt.formatSellerStatus(row.nodeStatus);
352
+ const tone = fmt.sellerStatusTone(row.nodeStatus);
353
+ const statusTip = "registry: " + esc(fmt.formatSellerStatus(row.registryStatus)) + " · upstream: " + esc(fmt.normalizeStatusLabel(row.upstreamStatus));
354
+ const sellerLine = [
355
+ disc !== fmt.UNKNOWN_VALUE ? "Disc " + esc(disc) : null,
356
+ capacity !== fmt.UNKNOWN_VALUE ? capacity : null,
357
+ ttftText !== fmt.UNKNOWN_VALUE ? ttft.replace("TTFT: ", "TTFT ") : null,
358
+ balanceText.includes("<strong>") ? "Balance " + esc(balanceText.replace(/<[^>]+>/g, "")) : null,
359
+ switchText || null
360
+ ].filter(Boolean).join(" · ");
361
+ return '<button class="app-row" type="button" data-detail="'+esc(row.id)+'"><span class="app-dot tone-'+esc(tone)+'" aria-label="'+esc(status)+'"></span><span class="app-name"><span class="seller-title"><strong>'+esc(row.name)+'</strong><span class="spec-tip" title="'+esc(tip)+'" aria-label="Seller specs">'+infoIcon+'</span></span><span class="muted-value" style="font-size:12px;font-family:var(--font-mono)">'+esc(sellerLine || row.app || row.id)+'</span></span><span class="field-cell"><strong>'+esc(row.upstreamDomain)+'</strong></span><span class="field-cell"><strong>'+esc(disc === fmt.UNKNOWN_VALUE ? "—" : disc)+'</strong></span><span class="field-cell"><strong>'+esc(capacity)+'</strong></span><span class="speed-cell"><strong>'+esc(ttftText === fmt.UNKNOWN_VALUE ? "—" : ttftText)+'</strong><span>'+avgSpeed+'</span></span><span class="field-cell"><span class="balance-line">'+balanceText+(row.upstreamRechargeUrl ? '<a class="recharge-btn" href="'+esc(row.upstreamRechargeUrl)+'" target="_blank" rel="noreferrer">↗</a>' : '')+'</span></span><span class="field-cell"><strong title="'+esc(statusTip)+'">'+esc(status)+'</strong></span><span class="row-actions"><span class="detail-btn">›</span></span></button>';
362
+ }
363
+ async function loadBootstrap(){
364
+ const fmt = window.__tbFmt;
365
+ try {
366
+ const data = await api("/api/bootstrap");
367
+ const items = [
368
+ { tone: "router", label: "Status", value: data.status || "unknown", secondary: data.url ? esc(fmt.formatSellerId(data.url)) : null },
369
+ { tone: "spend", label: "Registry", value: data.registryVersion === undefined ? "—" : "#" + esc(String(data.registryVersion)), secondary: data.registryUpdatedAt ? "Updated " + esc(fmt.formatTimeCompact(data.registryUpdatedAt)) : null },
370
+ { tone: "tokens", label: "Sellers", value: data.sellerEntries === undefined ? "—" : esc(String(data.sellerEntries)), secondary: data.regions && data.regions.length ? esc(data.regions.join(", ")) : null },
371
+ { tone: "inventory", label: "Default", value: data.defaultSeller || "—", secondary: data.profile ? "Profile " + esc(data.profile) : null }
372
+ ];
373
+ document.getElementById("bootstrapGrid").innerHTML = items.map(item => entryCardHtml(item)).join("");
374
+ } catch (err) {
375
+ document.getElementById("bootstrapGrid").innerHTML = '<div class="status-line">'+esc(uiErrorMessage(err))+'</div>';
376
+ }
377
+ }
378
+ function entryCardHtml(item){
379
+ const secondary = item.secondary ? '<span class="secondary">'+item.secondary+'</span>' : "";
380
+ return '<div class="entry-card '+(item.tone || "router")+'"><label>'+esc(item.label)+'</label><strong>'+esc(item.value || "—")+'</strong>'+secondary+'</div>';
381
+ }
382
+ async function openBootstrapConfig(){
383
+ const fmt = window.__tbFmt;
384
+ try {
385
+ const data = await api("/api/bootstrap/config");
386
+ document.getElementById("bootstrapStatus").textContent = uiErrorMessage(data.error || "Ready");
387
+ const clawtip = data.clawtip || {};
388
+ const metrics = [
389
+ { tone: "router", label: "Service", value: data.status || "unknown" },
390
+ { tone: "spend", label: "Activation", value: clawtip.activationFeeFen === undefined ? "—" : esc(String(clawtip.activationFeeFen)) + " fen" },
391
+ { tone: "tokens", label: "Micros / fen", value: clawtip.microsPerFen === undefined ? "—" : esc(fmt.formatCount(clawtip.microsPerFen)) },
392
+ { tone: "inventory", label: "Skill", value: clawtip.skillSlug || "—" }
393
+ ];
394
+ document.getElementById("bootstrapConfigMetrics").innerHTML = metrics.map(m => entryCardHtml(m)).join("");
395
+ const fields = [["sellerRegistryPath", data.sellerRegistryPath],["bind.host", data.bindHost],["bind.port", data.bindPort],["allowLocalSellerUrls", String(Boolean(data.allowLocalSellerUrls))],["clawtip.payTo", clawtip.payToMasked],["clawtip.sm4KeyBase64", clawtip.sm4KeyMasked],["clawtip.skillId", clawtip.skillId],["clawtip.description", clawtip.description],["clawtip.resourceUrl", clawtip.resourceUrl]];
396
+ document.getElementById("bootstrapConfigFields").innerHTML = fields.map(([k,v]) => fieldHtml(k,v,false)).join("");
397
+ document.getElementById("bootstrapModal").classList.add("open");
398
+ } catch (err) {
399
+ document.getElementById("bootstrapStatus").textContent = uiErrorMessage(err);
400
+ document.getElementById("bootstrapConfigMetrics").innerHTML = "";
401
+ document.getElementById("bootstrapConfigFields").innerHTML = "";
402
+ document.getElementById("bootstrapModal").classList.add("open");
403
+ }
404
+ }
91
405
  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); } }
92
- function renderDetail(){ const d = currentDetail; document.getElementById("detailTitle").textContent = d.row.name + " detail"; document.getElementById("editDetail").textContent = editing ? "Save changes" : "Edit config"; document.getElementById("deleteSeller").title = deleteReady ? "Confirm destroy deployment" : "Delete deployment"; showDetailStatus(d.row.error || "", false); const c = d.configuration; const fields = [["registryStatus", c.registryStatus],["region", c.region],["upstreamUrl", c.upstreamUrl],["upstreamApiKey", c.upstreamApiKeyMasked],["upstreamStatus", c.upstreamStatus],["ttftMs", msValue(d.row.ttftMs)],["avgInferenceMs", msValue(d.row.avgInferenceMs)],["lastInferenceMs", msValue(d.row.lastInferenceMs)],["latencySamples", d.row.latencySamples],["upstreamBalance", c.upstreamBalance],["upstreamBalanceSource", c.upstreamBalanceSource],["upstreamBalanceFetchedAt", 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]]; document.getElementById("configFields").innerHTML = detailFieldsHtml(fields); const billingOptions = Array.from(new Set(d.models.map(m => m.billingModel).filter(Boolean))); 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 infer</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(fmt(m.inputPrice))+'</td><td>'+esc(fmt(m.outputPrice))+'</td><td>'+metricCell(m.ttftMs)+'</td><td>'+metricCell(m.avgInferenceMs)+'</td><td>'+metricCell(m.latencySamples)+'</td></tr>').join("")+'</tbody></table>'; }
406
+ function renderDetail(){
407
+ const fmt = window.__tbFmt;
408
+ const d = currentDetail;
409
+ document.getElementById("detailTitle").textContent = d.row.name + " detail";
410
+ document.getElementById("editDetail").textContent = editing ? "Save changes" : "Edit config";
411
+ document.getElementById("deleteSeller").title = deleteReady ? "Confirm destroy deployment" : "Delete deployment";
412
+ showDetailStatus(d.row.error || "", false);
413
+ const c = d.configuration;
414
+ 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]];
415
+ document.getElementById("configFields").innerHTML = detailFieldsHtml(fields);
416
+ const billingOptions = Array.from(new Set(d.models.map(m => m.billingModel).filter(Boolean)));
417
+ 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>';
418
+ }
93
419
  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)); }
94
- function metricCell(value){ return value === undefined || value === null || value === "" ? missing("n/a") : esc(value); }
95
- function msValue(value){ return value === undefined || value === null || value === "" ? undefined : value + "ms"; }
420
+ function metricCell(value){ return value === undefined || value === null || value === "" ? missing("") : esc(value); }
96
421
  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(""); }
97
- function isMissingDetailField(key,value){ return value === undefined || value === null || value === "" || (key === "registryStatus" && value === "unknown"); }
98
- 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)+'</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>'; }
422
+ function isMissingDetailField(key,value){ const fmt = window.__tbFmt; return value === undefined || value === null || value === "" || value === fmt.UNKNOWN_VALUE || (key === "registryStatus" && value === "unknown"); }
423
+ 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>'; }
99
424
  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>'; }
100
425
  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(); };
101
426
  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"; };
@@ -110,7 +435,7 @@ function paymentSectionHtml(defaults){ const enabled = new Set(defaults.paymentM
110
435
  function paymentToggleHtml(method, enabled){ return '<button type="button" class="pill-switch '+(enabled ? "enabled" : "")+'" data-payment-toggle="'+esc(method)+'" aria-pressed="'+String(enabled)+'">'+(enabled ? "已启用" : "未启用")+'</button>'; }
111
436
  function numericFieldOptions(key){ return ["maxConnections","maxQueueDepth","markupRatio","discountRatio","clawtipActivationFeeFen","clawtipMicrosPerFen"].includes(key) ? { type:"number" } : {}; }
112
437
  function createFieldEditable(key){ return !["app","image","flyConfig"].includes(key); }
113
- 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. Example: 8.", maxQueueDepth:"Pending request queue limit. Example: 4.", markupRatio:"Seller price multiplier before discount. Example: 1.2.", discountRatio:"Bootstrap registry discount multiplier. Example: 1.", clawtipPayTo:"ClawTip payment target, for example the payTo value issued for this seller.", clawtipSm4KeyBase64:"Base64 SM4 key for ClawTip payment verification." }; const required = ["sellerName","region","upstreamWebsite","upstreamUrl","upstreamApiKey","maxConnections","maxQueueDepth","markupRatio","discountRatio","clawtipPayTo","clawtipSm4KeyBase64"].includes(key); const options = { ...numericFieldOptions(key), label: labels[key] || key, help: help[key], required }; if (["app","image","flyConfig"].includes(key)) return { ...options, readonly:true, submit:true }; if (key === "upstreamBalanceProbeUrl") return { ...options, placeholder:"https://code.shoestravel.xin/api/user/self" }; if (key === "upstreamBalanceProbeUserId") return { ...options, placeholder:"user_123 for NewAPI generic" }; if (key === "upstreamBalanceProbeRechargeUrl") return { ...options, placeholder:"https://code.shoestravel.xin/topup" }; if (key === "upstreamWebsite") return { ...options, placeholder:"https://moonshot.cn" }; if (key === "upstreamUrl") return { ...options, placeholder:"https://api.moonshot.cn/v1" }; return options; }
438
+ 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:"Discount applied on top of the markup. 1.0 = no discount, 0.5 = 50% off, 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; }
114
439
  function randomAppSuffix(){ return Math.random().toString(36).replace(/[^a-z0-9]/g, "").slice(2, 7) || "x7p9k"; }
115
440
  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(); }
116
441
  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"; }
@@ -119,7 +444,7 @@ function setCreateFieldValue(key,value){ document.querySelectorAll('#createField
119
444
  function appNameFromSellerName(value){ return "tbs-" + sellerSlugFromName(value) + "-" + createAppSuffix; }
120
445
  function sellerSlugFromName(value){ const slug = String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-"); return slug || "seller"; }
121
446
  function balanceProbeUrlForTemplate(template, upstreamUrl){ const host = hostName(upstreamUrl); if (template === "none") return ""; if (template === "openrouter") return "https://openrouter.ai/api/v1/credits"; if (template === "deepseek") return "https://api.deepseek.com/user/balance"; if (template === "stepfun") return "https://api.stepfun.com/v1/accounts"; if (template === "novita") return "https://api.novita.ai/v3/user/balance"; if (template === "siliconflow") return host.endsWith(".com") ? "https://api.siliconflow.com/v1/user/info" : "https://api.siliconflow.cn/v1/user/info"; if (template === "usage_generic") return usageUrl(upstreamUrl); if (template === "auto"){ if (host === "api.deepseek.com") return "https://api.deepseek.com/user/balance"; if (host === "api.stepfun.ai" || host === "api.stepfun.com") return "https://api.stepfun.com/v1/accounts"; if (host === "api.siliconflow.cn") return "https://api.siliconflow.cn/v1/user/info"; if (host === "api.siliconflow.com") return "https://api.siliconflow.com/v1/user/info"; if (host === "openrouter.ai") return "https://openrouter.ai/api/v1/credits"; if (host === "api.novita.ai") return "https://api.novita.ai/v3/user/balance"; return usageUrl(upstreamUrl); } return ""; }
122
- function usageUrl(value){ const base = String(value || "").trim().replace(/\\/+$/,""); if (!base) return ""; return /\\/v1$/i.test(base) ? base + "/usage" : base + "/v1/usage"; }
447
+ function usageUrl(value){ const base = String(value || "").trim().replace(/\/+$/,""); if (!base) return ""; return /\/v1$/i.test(base) ? base + "/usage" : base + "/v1/usage"; }
123
448
  function hostName(value){ try { return new URL(String(value || "")).hostname.toLowerCase(); } catch { return ""; } }
124
449
  function setupPaymentTabs(){ document.querySelectorAll("[data-payment-tab]").forEach(tab => tab.onclick = () => selectPaymentTab(tab.dataset.paymentTab)); document.querySelectorAll("[data-payment-toggle]").forEach(toggle => toggle.onclick = () => togglePaymentMethod(toggle.dataset.paymentToggle)); selectPaymentTab("clawtip"); updatePaymentPanels(); }
125
450
  function selectPaymentTab(method){ document.querySelectorAll("[data-payment-tab]").forEach(tab => tab.classList.toggle("active", tab.dataset.paymentTab === method)); document.querySelectorAll("[data-payment-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.paymentPanel !== method)); }
@@ -129,7 +454,7 @@ function updatePaymentPanels(){ document.querySelectorAll("[data-payment-panel]"
129
454
  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" }; }
130
455
  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"); } };
131
456
  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); }
132
- function renderCreateJob(job){ if (!job) return; currentCreateJob = job; const done = job.status !== "running"; const events = job.events || []; const deploymentStarted = events.some(event => event.stepId === "create_deployment"); 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 published 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" && !deploymentStarted){ setCreateFormDisabled(false); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Create seller"; } else if (done) { document.getElementById("submitCreate").disabled = true; document.getElementById("submitCreate").textContent = job.status === "succeeded" ? "Created" : "Create failed"; } }
457
+ 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"; } }
133
458
  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>'; }
134
459
  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(); }
135
460
  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); };