@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.
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +286 -13
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +12 -3
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +12 -8
- package/dist/src/client.js.map +1 -1
- package/dist/src/display-format.d.ts +39 -0
- package/dist/src/display-format.d.ts.map +1 -0
- package/dist/src/display-format.js +354 -0
- package/dist/src/display-format.js.map +1 -0
- package/dist/src/server-cmd.d.ts +3 -0
- package/dist/src/server-cmd.d.ts.map +1 -1
- package/dist/src/server-cmd.js +32 -9
- package/dist/src/server-cmd.js.map +1 -1
- package/dist/src/ui-actions.d.ts +2 -0
- package/dist/src/ui-actions.d.ts.map +1 -1
- package/dist/src/ui-actions.js +123 -63
- package/dist/src/ui-actions.js.map +1 -1
- package/dist/src/ui-command.js +1 -1
- package/dist/src/ui-command.js.map +1 -1
- package/dist/src/ui-server.d.ts +0 -1
- package/dist/src/ui-server.d.ts.map +1 -1
- package/dist/src/ui-server.js +25 -9
- package/dist/src/ui-server.js.map +1 -1
- package/dist/src/ui-state.d.ts +7 -1
- package/dist/src/ui-state.d.ts.map +1 -1
- package/dist/src/ui-state.js +55 -24
- package/dist/src/ui-state.js.map +1 -1
- package/dist/src/ui-static.d.ts.map +1 -1
- package/dist/src/ui-static.js +372 -47
- package/dist/src/ui-static.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +326 -13
- package/src/client.ts +13 -8
- package/src/display-format.ts +398 -0
- package/src/server-cmd.ts +35 -9
- package/src/ui-actions.ts +129 -72
- package/src/ui-command.ts +1 -1
- package/src/ui-server.ts +24 -10
- package/src/ui-state.ts +64 -25
- package/src/ui-static.ts +375 -47
- package/tests/admin.test.ts +573 -41
package/dist/src/ui-static.js
CHANGED
|
@@ -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
|
-
--
|
|
12
|
-
--
|
|
13
|
-
--
|
|
14
|
-
|
|
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">
|
|
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"
|
|
40
|
-
|
|
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"
|
|
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"
|
|
47
|
-
|
|
48
|
-
|
|
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",
|
|
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.
|
|
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
|
|
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 => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
|
|
76
|
-
const
|
|
77
|
-
const
|
|
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);
|
|
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");
|
|
88
|
-
function sellerRow(row){
|
|
89
|
-
|
|
90
|
-
|
|
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(){
|
|
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("
|
|
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.
|
|
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(
|
|
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
|
|
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); };
|