@tokenbuddy/tb-admin 1.0.14 → 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/bootstrap-registry.d.ts +1 -0
- package/dist/src/bootstrap-registry.d.ts.map +1 -1
- package/dist/src/bootstrap-registry.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +294 -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 +25 -1
- package/dist/src/server-cmd.d.ts.map +1 -1
- package/dist/src/server-cmd.js +116 -16
- package/dist/src/server-cmd.js.map +1 -1
- package/dist/src/ui-actions.d.ts +90 -0
- package/dist/src/ui-actions.d.ts.map +1 -0
- package/dist/src/ui-actions.js +823 -0
- package/dist/src/ui-actions.js.map +1 -0
- package/dist/src/ui-command.d.ts +4 -0
- package/dist/src/ui-command.d.ts.map +1 -0
- package/dist/src/ui-command.js +37 -0
- package/dist/src/ui-command.js.map +1 -0
- package/dist/src/ui-server.d.ts +22 -0
- package/dist/src/ui-server.d.ts.map +1 -0
- package/dist/src/ui-server.js +261 -0
- package/dist/src/ui-server.js.map +1 -0
- package/dist/src/ui-state.d.ts +140 -0
- package/dist/src/ui-state.d.ts.map +1 -0
- package/dist/src/ui-state.js +438 -0
- package/dist/src/ui-state.js.map +1 -0
- package/dist/src/ui-static.d.ts +2 -0
- package/dist/src/ui-static.d.ts.map +1 -0
- package/dist/src/ui-static.js +469 -0
- package/dist/src/ui-static.js.map +1 -0
- package/dist/src/upstream-balance-probe.d.ts +41 -0
- package/dist/src/upstream-balance-probe.d.ts.map +1 -0
- package/dist/src/upstream-balance-probe.js +379 -0
- package/dist/src/upstream-balance-probe.js.map +1 -0
- package/package.json +1 -1
- package/src/bootstrap-registry.ts +1 -0
- package/src/cli.ts +335 -13
- package/src/client.ts +13 -8
- package/src/display-format.ts +398 -0
- package/src/server-cmd.ts +145 -20
- package/src/ui-actions.ts +958 -0
- package/src/ui-command.ts +39 -0
- package/src/ui-server.ts +322 -0
- package/src/ui-state.ts +614 -0
- package/src/ui-static.ts +472 -0
- package/src/upstream-balance-probe.ts +505 -0
- package/tests/admin.test.ts +1404 -2
|
@@ -0,0 +1,469 @@
|
|
|
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";
|
|
7
|
+
export function adminUiHtml() {
|
|
8
|
+
return `<!doctype html>
|
|
9
|
+
<html lang="en">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charset="utf-8">
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
13
|
+
<title>TokenBuddy Admin</title>
|
|
14
|
+
<style>
|
|
15
|
+
/* ---- Tokens (aligned with DESIGN.md) ----------------------------- */
|
|
16
|
+
:root {
|
|
17
|
+
color-scheme: light;
|
|
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}
|
|
192
|
+
}
|
|
193
|
+
</style>
|
|
194
|
+
</head>
|
|
195
|
+
<body>
|
|
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>
|
|
197
|
+
<main class="content">
|
|
198
|
+
<section id="page-sellers" class="page active">
|
|
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>
|
|
220
|
+
</section>
|
|
221
|
+
<section id="page-bootstrap" class="page">
|
|
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>
|
|
232
|
+
</section>
|
|
233
|
+
</main>
|
|
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>
|
|
294
|
+
<script>${adminUiScript()}</script>
|
|
295
|
+
</body>
|
|
296
|
+
</html>`;
|
|
297
|
+
}
|
|
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() {
|
|
306
|
+
return `
|
|
307
|
+
async function api(path, options={}){
|
|
308
|
+
try {
|
|
309
|
+
const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options.headers || {}) } });
|
|
310
|
+
const text = await response.text();
|
|
311
|
+
let data = {};
|
|
312
|
+
try {
|
|
313
|
+
data = text ? JSON.parse(text) : {};
|
|
314
|
+
} catch {
|
|
315
|
+
data = { error: response.ok ? "Request returned an unreadable response." : "Request failed." };
|
|
316
|
+
}
|
|
317
|
+
if (!response.ok) throw new Error(uiErrorMessage(data.error || response.statusText));
|
|
318
|
+
return data;
|
|
319
|
+
} catch (err) {
|
|
320
|
+
throw new Error(uiErrorMessage(err));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
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."; }
|
|
324
|
+
const sellerRefreshIntervalMs = 30000;
|
|
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 = "";
|
|
326
|
+
const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
|
|
327
|
+
const missing = value => '<span class="muted-value">'+esc(value)+'</span>';
|
|
328
|
+
const dash = () => '<span class="muted-value">'+window.__tbFmt.UNKNOWN_VALUE+'</span>';
|
|
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>';
|
|
330
|
+
const balanceProbeTemplates = ["openrouter","deepseek","stepfun","siliconflow","novita","newapi_generic","usage_generic","none","auto"];
|
|
331
|
+
const paymentMethods = ["clawtip","mock"];
|
|
332
|
+
const loadingSpinner = label => '<div class="loading-row" role="status" aria-label="'+esc(label)+'"><span class="spinner" aria-hidden="true"></span></div>';
|
|
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(); });
|
|
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); } }
|
|
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)); }
|
|
336
|
+
function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => updateSellerRefreshMeta(sellerRefreshInFlight), 1000); window.addEventListener("beforeunload", () => { clearTimeout(sellerRefreshTimer); clearInterval(sellerClockTimer); }); }
|
|
337
|
+
function scheduleSellerRefresh(){ clearTimeout(sellerRefreshTimer); sellerNextRefreshAt = new Date(Date.now() + sellerRefreshIntervalMs); sellerRefreshTimer = setTimeout(() => loadSellers(), sellerRefreshIntervalMs); }
|
|
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
|
+
}
|
|
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); } }
|
|
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
|
+
}
|
|
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)); }
|
|
420
|
+
function metricCell(value){ return value === undefined || value === null || value === "" ? missing("—") : esc(value); }
|
|
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(""); }
|
|
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>'; }
|
|
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>'; }
|
|
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(); };
|
|
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"; };
|
|
427
|
+
document.getElementById("closeDetail").onclick = () => { deleteReady = false; document.getElementById("detailModal").classList.remove("open"); };
|
|
428
|
+
document.querySelectorAll("[data-status-action]").forEach(btn => btn.onclick = async () => { if (!currentDetail) return; const action = btn.dataset.statusAction; if (!confirm("Set "+currentDetail.row.name+" registry status via "+action+"?")) return; const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/"+action, { method:"POST" }); showDetailStatus(result.stdout || (result.ok ? "Registry status updated." : result.stderr || "Status update failed."), false); currentDetail = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)); renderDetail(); loadSellers(); });
|
|
429
|
+
document.getElementById("createSeller").onclick = () => { buildCreateForm(); document.getElementById("createModal").classList.add("open"); };
|
|
430
|
+
document.getElementById("closeCreate").onclick = () => document.getElementById("createModal").classList.remove("open");
|
|
431
|
+
function buildCreateForm(){ clearInterval(createJobTimer); createJobTimer = null; currentCreateJob = null; expandedProgressSteps = new Set(); createAppSuffix = randomAppSuffix(); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Create seller"; document.getElementById("createStatus").textContent = ""; document.getElementById("createStatus").classList.add("hidden"); document.getElementById("createStatus").classList.remove("loading"); document.getElementById("createProgress").classList.add("hidden"); document.getElementById("createProgress").innerHTML = ""; setCreateFormDisabled(false); const defaults = createDefaults(); document.getElementById("createFields").innerHTML = createFormHtml(defaults); setupCreateFormBehavior(); setupPaymentTabs(); }
|
|
432
|
+
function createFormHtml(defaults){ return [createSectionHtml("基础信息设置", ["sellerName","app","region","image","flyConfig"], defaults), createSectionHtml("上游设置", ["upstreamWebsite","upstreamUrl","upstreamApiKey","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeUserId","upstreamBalanceProbeRechargeUrl"], defaults), createSectionHtml("性能与安全", ["maxConnections","maxQueueDepth","markupRatio","discountRatio"], defaults), paymentSectionHtml(defaults)].join(""); }
|
|
433
|
+
function createSectionHtml(title, fields, defaults){ return '<section class="create-section"><h2>'+esc(title)+'</h2><div class="section-grid">'+fields.map(key => fieldHtml(key, defaults[key], createFieldEditable(key), createFieldOptions(key))).join("")+'</div></section>'; }
|
|
434
|
+
function paymentSectionHtml(defaults){ const enabled = new Set(defaults.paymentMethods || []); const editableFields = [["clawtipPayTo","payTo"],["clawtipSm4KeyBase64","sm4KeyBase64"]]; const fixedFields = ["clawtipSkillSlug","clawtipSkillId","clawtipDescription","clawtipResourceUrl","clawtipActivationFeeFen","clawtipMicrosPerFen"]; return '<section class="create-section"><h2>支付设置</h2><div class="payment-tabs">'+paymentMethods.map((method,index) => '<button type="button" class="payment-tab '+(index === 0 ? "active" : "")+'" data-payment-tab="'+esc(method)+'">'+esc(method)+'</button>').join("")+'</div><div class="payment-panel" data-payment-panel="clawtip" data-enabled="'+String(enabled.has("clawtip"))+'"><div class="payment-head"><h3>ClawTip</h3>'+paymentToggleHtml("clawtip", enabled.has("clawtip"))+'</div><div class="section-grid">'+editableFields.map(([key,label]) => fieldHtml(key, defaults[key], true, { ...createFieldOptions(key), label })).join("")+fixedFields.map(key => fieldHtml(key, defaults[key], false, { hidden:true })).join("")+'<div class="field full"><label>自动生成参数</label><div class="generated-summary" data-generated-summary="clawtip"></div></div></div></div><div class="payment-panel hidden" data-payment-panel="mock" data-enabled="'+String(enabled.has("mock"))+'"><div class="payment-head"><h3>Mock</h3>'+paymentToggleHtml("mock", enabled.has("mock"))+'</div><div class="section-grid">'+fieldHtml("参数规则", "启用即可使用 mock 支付方式,无需额外参数", false, { full:true })+'</div></div></section>'; }
|
|
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>'; }
|
|
436
|
+
function numericFieldOptions(key){ return ["maxConnections","maxQueueDepth","markupRatio","discountRatio","clawtipActivationFeeFen","clawtipMicrosPerFen"].includes(key) ? { type:"number" } : {}; }
|
|
437
|
+
function createFieldEditable(key){ return !["app","image","flyConfig"].includes(key); }
|
|
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; }
|
|
439
|
+
function randomAppSuffix(){ return Math.random().toString(36).replace(/[^a-z0-9]/g, "").slice(2, 7) || "x7p9k"; }
|
|
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(); }
|
|
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"; }
|
|
442
|
+
function createFieldString(key){ const input = document.querySelector('#createFields [data-field="'+key+'"]'); return input ? String(input.value || "").trim() : ""; }
|
|
443
|
+
function setCreateFieldValue(key,value){ document.querySelectorAll('#createFields [data-field="'+key+'"]').forEach(input => { input.value = value; }); document.querySelectorAll('#createFields [data-readonly-field="'+key+'"]').forEach(el => { el.textContent = value; el.title = value; }); }
|
|
444
|
+
function appNameFromSellerName(value){ return "tbs-" + sellerSlugFromName(value) + "-" + createAppSuffix; }
|
|
445
|
+
function sellerSlugFromName(value){ const slug = String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-"); return slug || "seller"; }
|
|
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 ""; }
|
|
447
|
+
function usageUrl(value){ const base = String(value || "").trim().replace(/\/+$/,""); if (!base) return ""; return /\/v1$/i.test(base) ? base + "/usage" : base + "/v1/usage"; }
|
|
448
|
+
function hostName(value){ try { return new URL(String(value || "")).hostname.toLowerCase(); } catch { return ""; } }
|
|
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(); }
|
|
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)); }
|
|
451
|
+
function togglePaymentMethod(method){ const panel = document.querySelector('[data-payment-panel="'+method+'"]'); if (!panel) return; panel.dataset.enabled = String(panel.dataset.enabled !== "true"); updatePaymentPanels(); }
|
|
452
|
+
function enabledPaymentMethods(){ return Array.from(document.querySelectorAll("[data-payment-panel]")).filter(panel => panel.dataset.enabled === "true").map(panel => panel.dataset.paymentPanel); }
|
|
453
|
+
function updatePaymentPanels(){ document.querySelectorAll("[data-payment-panel]").forEach(panel => { const enabled = panel.dataset.enabled === "true"; const toggle = panel.querySelector("[data-payment-toggle]"); if (toggle){ toggle.classList.toggle("enabled", enabled); toggle.setAttribute("aria-pressed", String(enabled)); toggle.textContent = enabled ? "已启用" : "未启用"; } panel.querySelectorAll("[data-field]").forEach(input => { input.disabled = !enabled; }); }); }
|
|
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" }; }
|
|
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"); } };
|
|
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); }
|
|
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"; } }
|
|
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>'; }
|
|
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(); }
|
|
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); };
|
|
461
|
+
document.getElementById("refreshBootstrap").onclick = loadBootstrap;
|
|
462
|
+
document.getElementById("openBootstrapConfig").onclick = openBootstrapConfig;
|
|
463
|
+
document.getElementById("closeBootstrapConfig").onclick = () => document.getElementById("bootstrapModal").classList.remove("open");
|
|
464
|
+
function fieldValue(input){ return numeric(input.value); }
|
|
465
|
+
function numeric(value){ const n = Number(value); return value !== "" && Number.isFinite(n) ? n : value; }
|
|
466
|
+
startSellerRefresh();
|
|
467
|
+
`;
|
|
468
|
+
}
|
|
469
|
+
//# sourceMappingURL=ui-static.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ui-static.js","sourceRoot":"","sources":["../../src/ui-static.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAC5D,EAAE;AACF,qEAAqE;AACrE,qEAAqE;AACrE,0DAA0D;AAE1D,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE1D,MAAM,UAAU,WAAW;IACzB,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YA8RG,aAAa,EAAE;;QAEnB,CAAC;AACT,CAAC;AAED,SAAS,aAAa;IACpB,gFAAgF;IAChF,OAAO;EACP,mBAAmB,EAAE;EACrB,mBAAmB,EAAE;CACtB,CAAC;AACF,CAAC;AAED,SAAS,mBAAmB;IAC1B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiKR,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type BalanceCurrency = "USD" | "CNY" | null;
|
|
2
|
+
export type BalanceSource = "deepseek" | "stepfun" | "siliconflow" | "openrouter" | "novita" | "newapi_generic" | "usage_generic" | "unknown";
|
|
3
|
+
export type BalanceProbeTemplate = "auto" | "deepseek" | "stepfun" | "siliconflow" | "openrouter" | "novita" | "newapi_generic" | "usage_generic" | "none";
|
|
4
|
+
export interface BalanceSnapshot {
|
|
5
|
+
rawAmount: number | null;
|
|
6
|
+
amountUsdMicros: number | null;
|
|
7
|
+
currency: BalanceCurrency;
|
|
8
|
+
source: BalanceSource;
|
|
9
|
+
fetchedAt: number;
|
|
10
|
+
error?: {
|
|
11
|
+
httpStatus: number;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface BalanceProbeConfig {
|
|
16
|
+
upstreamUrl?: string;
|
|
17
|
+
upstreamBalanceUrl?: string;
|
|
18
|
+
upstreamApiKey?: string;
|
|
19
|
+
upstreamUserId?: string;
|
|
20
|
+
upstreamBalanceProbe?: {
|
|
21
|
+
template?: BalanceProbeTemplate;
|
|
22
|
+
url?: string;
|
|
23
|
+
userId?: string;
|
|
24
|
+
rechargeUrl?: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface BalanceProbeOptions {
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
cnyUsdRate?: number;
|
|
32
|
+
cache?: BalanceProbeCache;
|
|
33
|
+
}
|
|
34
|
+
export declare class BalanceProbeCache {
|
|
35
|
+
private readonly entries;
|
|
36
|
+
get(key: string, now: number): BalanceSnapshot | undefined;
|
|
37
|
+
setFailure(key: string, snapshot: BalanceSnapshot, now: number): void;
|
|
38
|
+
}
|
|
39
|
+
export declare const defaultBalanceProbeCache: BalanceProbeCache;
|
|
40
|
+
export declare function probeUpstreamBalance(config: BalanceProbeConfig, options?: BalanceProbeOptions): Promise<BalanceSnapshot>;
|
|
41
|
+
//# sourceMappingURL=upstream-balance-probe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upstream-balance-probe.d.ts","sourceRoot":"","sources":["../../src/upstream-balance-probe.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,KAAK,GAAG,IAAI,CAAC;AACnD,MAAM,MAAM,aAAa,GACrB,UAAU,GACV,SAAS,GACT,aAAa,GACb,YAAY,GACZ,QAAQ,GACR,gBAAgB,GAChB,eAAe,GACf,SAAS,CAAC;AAEd,MAAM,MAAM,oBAAoB,GAC5B,MAAM,GACN,UAAU,GACV,SAAS,GACT,aAAa,GACb,YAAY,GACZ,QAAQ,GACR,gBAAgB,GAChB,eAAe,GACf,MAAM,CAAC;AAEX,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,EAAE,eAAe,CAAC;IAC1B,MAAM,EAAE,aAAa,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE;QACN,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;CACH;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oBAAoB,CAAC,EAAE;QACrB,QAAQ,CAAC,EAAE,oBAAoB,CAAC;QAChC,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;CACH;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,iBAAiB,CAAC;CAC3B;AAaD,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuE;IAExF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS;IAW1D,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;CAM7E;AAED,eAAO,MAAM,wBAAwB,mBAA0B,CAAC;AAEhE,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,kBAAkB,EAC1B,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,eAAe,CAAC,CAsD1B"}
|