@tokenbuddy/tb-admin 1.0.36 → 1.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +98 -25
- package/dist/src/config.d.ts +8 -2
- package/dist/src/config.js +17 -5
- package/dist/src/display-format.js +6 -14
- package/dist/src/init-command.d.ts +50 -0
- package/dist/src/init-command.js +347 -0
- package/dist/src/providers/fly-io.d.ts +3 -0
- package/dist/src/providers/fly-io.js +137 -0
- package/dist/src/providers/provider-definition.d.ts +38 -0
- package/dist/src/providers/provider-definition.js +2 -0
- package/dist/src/seller.d.ts +2 -0
- package/dist/src/seller.js +30 -13
- package/dist/src/server-cmd.d.ts +1 -0
- package/dist/src/server-cmd.js +9 -2
- package/dist/src/ui-actions.d.ts +3 -0
- package/dist/src/ui-actions.js +199 -27
- package/dist/src/ui-command.js +3 -2
- package/dist/src/ui-state.d.ts +1 -3
- package/dist/src/ui-state.js +4 -8
- package/dist/src/ui-static.js +43 -15
- package/dist/src/workdir.d.ts +21 -0
- package/dist/src/workdir.js +50 -0
- package/package.json +9 -3
- package/templates/providers/fly.io/admin.toml.example +18 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/README.md +18 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/admin-web.example.env +3 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/cloudflare-r2.example.env +6 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/registry-signing-key.example.json +6 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/registry.example.json +14 -0
- package/templates/providers/fly.io/deploy-secrets/bootstrap/tb-registry.example.yaml +14 -0
- package/templates/providers/fly.io/deploy-secrets/seller-configs/README.md +13 -0
- package/templates/providers/fly.io/deploy-secrets/seller-configs/seller.example.yaml +35 -0
- package/templates/providers/fly.io/env/deploy.env.example +12 -0
- package/templates/providers/fly.io/fly/fly.tb-registry.toml +31 -0
- package/templates/providers/fly.io/fly/fly.tb-seller.toml +25 -0
- package/templates/providers/fly.io/provider.toml.example +10 -0
- package/dist/src/bootstrap-registry.d.ts.map +0 -1
- package/dist/src/bootstrap-registry.js.map +0 -1
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js.map +0 -1
- package/dist/src/client.d.ts.map +0 -1
- package/dist/src/client.js.map +0 -1
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js.map +0 -1
- package/dist/src/display-format.d.ts.map +0 -1
- package/dist/src/display-format.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/provider.d.ts.map +0 -1
- package/dist/src/provider.js.map +0 -1
- package/dist/src/seller.d.ts.map +0 -1
- package/dist/src/seller.js.map +0 -1
- package/dist/src/server-cmd.d.ts.map +0 -1
- package/dist/src/server-cmd.js.map +0 -1
- package/dist/src/ui-actions.d.ts.map +0 -1
- package/dist/src/ui-actions.js.map +0 -1
- package/dist/src/ui-command.d.ts.map +0 -1
- package/dist/src/ui-command.js.map +0 -1
- package/dist/src/ui-server.d.ts.map +0 -1
- package/dist/src/ui-server.js.map +0 -1
- package/dist/src/ui-state.d.ts.map +0 -1
- package/dist/src/ui-state.js.map +0 -1
- package/dist/src/ui-static.d.ts.map +0 -1
- package/dist/src/ui-static.js.map +0 -1
- package/dist/src/upstream-balance-probe.d.ts.map +0 -1
- package/dist/src/upstream-balance-probe.js.map +0 -1
- package/dist/src/vendor-client.d.ts.map +0 -1
- package/dist/src/vendor-client.js.map +0 -1
- package/dist/src/vendor-commands.d.ts.map +0 -1
- package/dist/src/vendor-commands.js.map +0 -1
- package/src/bootstrap-registry.ts +0 -90
- package/src/cli.ts +0 -1614
- package/src/client.ts +0 -179
- package/src/config.ts +0 -194
- package/src/display-format.ts +0 -411
- package/src/index.ts +0 -11
- package/src/provider.ts +0 -150
- package/src/seller.ts +0 -538
- package/src/server-cmd.ts +0 -362
- package/src/ui-actions.ts +0 -1040
- package/src/ui-command.ts +0 -44
- package/src/ui-server.ts +0 -353
- package/src/ui-state.ts +0 -1318
- package/src/ui-static.ts +0 -673
- package/src/upstream-balance-probe.ts +0 -13
- package/src/vendor-client.ts +0 -23
- package/src/vendor-commands.ts +0 -65
- package/tests/admin.test.ts +0 -2162
- package/tests/seller.test.ts +0 -388
- package/tests/ui-state-fleet.test.ts +0 -526
- package/tests/ui-static-row.test.ts +0 -467
- package/tests/vendor-cli.test.ts +0 -241
- package/tsconfig.json +0 -8
package/src/ui-static.ts
DELETED
|
@@ -1,673 +0,0 @@
|
|
|
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
|
-
|
|
7
|
-
import { displayFormatBundle } from "./display-format.js";
|
|
8
|
-
|
|
9
|
-
export function adminUiHtml(): string {
|
|
10
|
-
return `<!doctype html>
|
|
11
|
-
<html lang="en">
|
|
12
|
-
<head>
|
|
13
|
-
<meta charset="utf-8">
|
|
14
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
15
|
-
<title>TokenBuddy Admin</title>
|
|
16
|
-
<style>
|
|
17
|
-
/* ---- Tokens (aligned with DESIGN.md) ----------------------------- */
|
|
18
|
-
:root {
|
|
19
|
-
color-scheme: light;
|
|
20
|
-
--canvas:#f8f7ff;--canvas-soft:#fbfaff;--panel:#ffffff;--panel-subtle:#fdfcff;
|
|
21
|
-
--ink:#201a38;--text:#312a4f;--muted:#6b6384;--faint:#8d86a3;
|
|
22
|
-
--hairline:#eee9fb;--hairline-strong:#ded6f2;
|
|
23
|
-
--primary:#7c3df0;--primary-deep:#5b35d7;--primary-soft:#f1ebff;
|
|
24
|
-
--router:#6d42e8;--spend:#0f766e;--spend-soft:#e8fbf7;
|
|
25
|
-
--tokens:#2563eb;--tokens-soft:#eaf2ff;
|
|
26
|
-
--inventory:#9333ea;--inventory-soft:#f7ecff;
|
|
27
|
-
--success:#10b981;--success-soft:#e8fff6;
|
|
28
|
-
--warning:#f59e0b;--warning-soft:#fff7df;
|
|
29
|
-
--danger:#ef5b78;--danger-soft:#fff0f3;
|
|
30
|
-
--shadow:0 12px 36px rgba(60,41,112,.08);
|
|
31
|
-
/* typography tokens */
|
|
32
|
-
--label-fs:11px;--label-lh:14px;--label-spacing:.08em;--label-weight:800;
|
|
33
|
-
--body-fs:13px;--body-lh:18px;
|
|
34
|
-
--metric-fs:16px;--metric-lh:22px;--metric-weight:800;
|
|
35
|
-
--numeric-fs:12px;--numeric-weight:700;
|
|
36
|
-
--font-sans:Inter,Geist,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
|
37
|
-
--font-mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;
|
|
38
|
-
font-family:var(--font-sans);letter-spacing:0;
|
|
39
|
-
}
|
|
40
|
-
*{box-sizing:border-box}html{scroll-behavior:smooth}
|
|
41
|
-
body{margin:0;min-height:100dvh;color:var(--text);background:var(--canvas);font-variant-numeric:tabular-nums}
|
|
42
|
-
button,input,select,textarea{font:inherit}button{cursor:pointer}
|
|
43
|
-
button,a,input,select{transition:color .18s ease,background-color .18s ease,border-color .18s ease,box-shadow .18s ease,transform .18s ease}
|
|
44
|
-
button:active,a:active{transform:translateY(1px)}
|
|
45
|
-
button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
|
|
46
|
-
/* ---- App shell ---------------------------------------------------- */
|
|
47
|
-
.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}
|
|
48
|
-
.logo{color:var(--ink);font-family:var(--font-mono);font-size:13px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
|
|
49
|
-
.top-links{display:flex;gap:6px;font-weight:650}
|
|
50
|
-
.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}
|
|
51
|
-
.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}
|
|
52
|
-
.top-link:hover{background:var(--panel);color:var(--ink)}
|
|
53
|
-
.top-link.active{color:var(--ink);font-weight:760}
|
|
54
|
-
.top-link.active:after{opacity:1;transform:scaleX(1)}
|
|
55
|
-
.content{width:min(1560px,calc(100vw - 64px));margin:0 auto;padding:20px 0 36px;display:grid;gap:16px}
|
|
56
|
-
.page{display:none}.page.active{display:grid;gap:16px}
|
|
57
|
-
/* ---- Panels ------------------------------------------------------- */
|
|
58
|
-
.panel,.bootstrap-card{border:1px solid var(--hairline);background:var(--panel);border-radius:12px;box-shadow:var(--shadow);overflow:hidden}
|
|
59
|
-
.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)}
|
|
60
|
-
.title{margin:0;color:var(--ink);font-size:20px;line-height:28px;font-weight:750}
|
|
61
|
-
/* ---- Buttons ------------------------------------------------------ */
|
|
62
|
-
.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}
|
|
63
|
-
.btn:hover{border-color:var(--primary);color:var(--primary)}
|
|
64
|
-
.btn.primary{border-color:var(--primary);background:var(--primary);color:#fff;box-shadow:0 8px 18px rgba(124,61,240,.16)}
|
|
65
|
-
.btn.primary:hover{background:var(--primary-deep);color:#fff}
|
|
66
|
-
.btn.danger{color:var(--danger)}
|
|
67
|
-
.btn.icon{width:36px;padding:0;border-radius:8px;display:inline-grid;place-items:center}
|
|
68
|
-
.btn.icon svg{width:17px;height:17px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
69
|
-
/* ---- Seller list -------------------------------------------------- */
|
|
70
|
-
.app-list{--seller-grid:18px minmax(142px,1.1fr) 64px 48px 58px 60px minmax(128px,.82fr) minmax(190px,1.25fr) 84px 62px minmax(132px,.9fr) 76px 112px 28px;display:grid;gap:8px;padding:14px;background:var(--panel-subtle);overflow-x:auto}
|
|
71
|
-
#sellerRows{display:grid;gap:8px;width:100%;min-width:0}
|
|
72
|
-
.app-table-head,.app-row{display:grid;grid-template-columns:var(--seller-grid);align-items:center;gap:10px;width:100%;min-width:1392px;padding:0 16px}
|
|
73
|
-
.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)}
|
|
74
|
-
.app-row{position:relative;border:1px solid var(--hairline);border-left-width:4px;border-radius:8px;background:#fff;min-height:66px;text-align:left}
|
|
75
|
-
.app-row:hover{border-color:var(--hairline-strong);background:#fff;box-shadow:0 8px 22px rgba(60,41,112,.06)}
|
|
76
|
-
.app-row.row-warn{border-left-color:var(--warning)}
|
|
77
|
-
.app-row.row-alert{border-left-color:var(--danger);background:#fffafb}
|
|
78
|
-
/* Step 13 v1.1: 双源 (fly + registry) 4 类行视觉. dataSource 决定
|
|
79
|
-
dataSource="fly" → 灰/中性边, "未发布" 提示
|
|
80
|
-
dataSource="registry" → 整行红边 + 软红底, "立即下线 (registry-only)" 按钮
|
|
81
|
-
dataSource="both" → 正常边, 跟 1.0.31 老样式一致
|
|
82
|
-
*/
|
|
83
|
-
.app-row.app-row-fly-only{border-color:#cdd2db;background:#f8f9fc}
|
|
84
|
-
.app-row.app-row-fly-only:hover{background:#f1f3f8}
|
|
85
|
-
.app-row.app-row-registry-only{border:2px solid var(--danger);background:var(--danger-soft);box-shadow:0 0 0 3px rgba(239,91,120,.08)}
|
|
86
|
-
.app-row.app-row-registry-only:hover{background:#ffe3ea}
|
|
87
|
-
.datasource-chip{display:inline-block;padding:1px 8px;border-radius:999px;font-size:10px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;line-height:16px;vertical-align:middle}
|
|
88
|
-
.datasource-chip.both{background:#e7f6ee;color:#0a8754}
|
|
89
|
-
.datasource-chip.fly{background:#e3e6ee;color:#4a5170}
|
|
90
|
-
.datasource-chip.registry{background:var(--danger-soft);color:var(--danger);border:1px solid var(--danger)}
|
|
91
|
-
.datasource-chip.published{background:#e7f6ee;color:#0a8754}
|
|
92
|
-
.datasource-chip.unpublished{background:#e3e6ee;color:#4a5170}
|
|
93
|
-
.datasource-chip.registry_only{background:var(--danger-soft);color:var(--danger);border:1px solid var(--danger)}
|
|
94
|
-
.datasource-chip.draining{background:var(--warning-soft);color:#98630a;border:1px solid #f6d99b}
|
|
95
|
-
.datasource-chip.offline{background:#f4f1fb;color:#6b6384;border:1px solid var(--hairline-strong)}
|
|
96
|
-
.datasource-chip.pending{background:var(--tokens-soft);color:#1d4ed8;border:1px solid #bfd7ff}
|
|
97
|
-
.datasource-chip.checking,.datasource-chip.unknown{background:#f4f1fb;color:#6b6384;border:1px solid var(--hairline-strong)}
|
|
98
|
-
.alert-reason{color:var(--danger);font-size:11px;line-height:1.4;font-weight:700;margin-top:4px;display:block}
|
|
99
|
-
.remove-hint-btn{margin-top:6px;background:var(--danger);color:#fff;border:0;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:800;display:inline-block}
|
|
100
|
-
.remove-hint-btn:hover{background:#d63d5a}
|
|
101
|
-
.remove-hint-btn::before{content:"! ";margin-right:2px}
|
|
102
|
-
.publish-hint-btn{margin-top:6px;background:#fff;color:var(--primary);border:1px solid var(--hairline-strong);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:800;display:inline-block}
|
|
103
|
-
.publish-hint-btn:hover{background:#f5f3ff}
|
|
104
|
-
/* Status dot — five spec tones (green/amber/red/blue/gray) */
|
|
105
|
-
.app-dot{width:10px;height:10px;border-radius:999px;background:#c8ced8;box-shadow:0 0 0 4px #edf1f8}
|
|
106
|
-
.app-dot.tone-green{background:var(--success);box-shadow:0 0 0 4px rgba(16,185,129,.18)}
|
|
107
|
-
.app-dot.tone-amber{background:var(--warning);box-shadow:0 0 0 4px rgba(245,158,11,.2)}
|
|
108
|
-
.app-dot.tone-red{background:var(--danger);box-shadow:0 0 0 4px rgba(239,91,120,.18)}
|
|
109
|
-
.app-dot.tone-blue{background:var(--tokens);box-shadow:0 0 0 4px rgba(37,99,235,.18)}
|
|
110
|
-
.app-dot.tone-gray{background:#8d86a3;box-shadow:0 0 0 4px rgba(141,134,163,.17)}
|
|
111
|
-
.app-name{display:block;min-width:0}
|
|
112
|
-
.seller-title{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:6px;align-items:center;min-width:0;width:100%}
|
|
113
|
-
.app-name strong,.field-cell strong,.field-cell span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
114
|
-
.app-name strong{color:var(--ink);font-size:14px;font-weight:750}
|
|
115
|
-
.field-cell,.speed-cell{display:grid;gap:2px;min-width:0;color:var(--muted);font-size:var(--body-fs);line-height:var(--body-lh)}
|
|
116
|
-
.field-cell strong,.speed-cell strong{color:var(--ink);font-family:var(--font-mono);font-size:var(--numeric-fs);font-weight:var(--numeric-weight)}
|
|
117
|
-
.metric-label{color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
|
|
118
|
-
.metric-sub{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--faint);font-size:11px;line-height:15px;font-family:var(--font-mono)}
|
|
119
|
-
.metric-sub.danger{color:var(--danger);font-family:var(--font-sans);font-weight:700}
|
|
120
|
-
.op-value{color:var(--ink);font-family:var(--font-mono);font-size:var(--numeric-fs);font-weight:var(--numeric-weight)}
|
|
121
|
-
.op-chip{width:max-content;max-width:100%;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--hairline-strong);border-radius:7px;background:var(--panel-subtle);padding:3px 7px;color:var(--muted);font-family:var(--font-mono);font-size:11px;font-weight:850;line-height:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
122
|
-
.op-chip:before{content:"";width:6px;height:6px;border-radius:999px;background:currentColor;flex:0 0 auto}
|
|
123
|
-
.op-chip.tone-green{border-color:#bde9dc;background:var(--success-soft);color:#0f766e}
|
|
124
|
-
.op-chip.tone-amber{border-color:#f6d99b;background:var(--warning-soft);color:#98630a}
|
|
125
|
-
.op-chip.tone-red{border-color:#f4bdc9;background:var(--danger-soft);color:#c0264e}
|
|
126
|
-
.op-chip.tone-blue{border-color:#bfd7ff;background:var(--tokens-soft);color:#1d4ed8}
|
|
127
|
-
.op-chip.tone-gray{border-color:var(--hairline-strong);background:#f4f1fb;color:#6b6384}
|
|
128
|
-
.status-pill{width:max-content;max-width:100%;border:1px solid var(--hairline-strong);border-radius:999px;background:var(--panel-subtle);padding:2px 8px;color:var(--muted);font-family:var(--font-mono);font-size:11px;font-weight:800;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
129
|
-
.status-pill.tone-green{border-color:#bde9dc;background:var(--success-soft);color:#0f766e}
|
|
130
|
-
.status-pill.tone-amber{border-color:#f6d99b;background:var(--warning-soft);color:#98630a}
|
|
131
|
-
.status-pill.tone-red{border-color:#f4bdc9;background:var(--danger-soft);color:#c0264e}
|
|
132
|
-
.status-pill.tone-blue{border-color:#bfd7ff;background:var(--tokens-soft);color:#1d4ed8}
|
|
133
|
-
.mobile-metrics{display:none}
|
|
134
|
-
.balance-line{display:flex;align-items:center;gap:6px;min-width:0}
|
|
135
|
-
.balance-line .op-chip{max-width:100%}
|
|
136
|
-
.muted-value{color:var(--faint)}
|
|
137
|
-
.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}
|
|
138
|
-
.recharge-btn:hover,.detail-btn:hover{border-color:var(--primary);background:var(--primary-soft)}
|
|
139
|
-
.row-actions{display:flex;gap:8px;justify-content:flex-end}
|
|
140
|
-
.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}
|
|
141
|
-
.spec-tip svg{width:14px;height:14px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
142
|
-
@media(min-width:901px){
|
|
143
|
-
.app-row>.field-cell>.metric-label,.app-row>.speed-cell>.metric-label{display:none}
|
|
144
|
-
}
|
|
145
|
-
/* ---- Entry cards (Bootstrap summary) ----------------------------- */
|
|
146
|
-
.bootstrap-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;padding:16px}
|
|
147
|
-
.entry-card{border:1px solid var(--hairline-strong);border-radius:12px;background:var(--panel);padding:16px;box-shadow:var(--shadow);display:grid;gap:7px}
|
|
148
|
-
.entry-card.router{background:var(--panel);border-color:var(--hairline-strong);box-shadow:var(--shadow)}
|
|
149
|
-
.entry-card.spend{background:var(--spend-soft);border-color:#cceee6;box-shadow:none}
|
|
150
|
-
.entry-card.tokens{background:var(--tokens-soft);border-color:#d9e7ff;box-shadow:none}
|
|
151
|
-
.entry-card.inventory{background:var(--inventory-soft);border-color:#ead5ff;box-shadow:none}
|
|
152
|
-
.entry-card label{color:var(--muted);font-size:var(--label-fs);font-weight:var(--label-weight);text-transform:uppercase;letter-spacing:var(--label-spacing)}
|
|
153
|
-
.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)}
|
|
154
|
-
.entry-card .secondary{color:var(--muted);font-size:12px;line-height:16px;font-family:var(--font-mono)}
|
|
155
|
-
/* ---- Modal -------------------------------------------------------- */
|
|
156
|
-
.modal{position:fixed;inset:0;background:rgba(32,26,56,.28);z-index:40;display:none}
|
|
157
|
-
.modal.open{display:grid}
|
|
158
|
-
.modal-shell{background:var(--canvas);min-height:100dvh;display:grid;grid-template-rows:auto 1fr}
|
|
159
|
-
.modal-head{height:64px;display:flex;align-items:center;gap:16px;padding:0 24px;border-bottom:1px solid var(--hairline);background:var(--panel)}
|
|
160
|
-
.modal-title{display:flex;align-items:center;gap:10px;color:var(--ink);font-size:20px;line-height:28px;font-weight:800}
|
|
161
|
-
.modal-actions{display:flex;gap:8px;align-items:center;margin-left:auto}
|
|
162
|
-
.modal-close{width:44px;height:44px;border:1px solid var(--hairline-strong);border-radius:10px;background:#fff;color:var(--muted);display:inline-grid;place-items:center;padding:0}
|
|
163
|
-
.modal-close:hover{border-color:var(--primary);background:var(--primary-soft);color:var(--primary-deep)}
|
|
164
|
-
.modal-close svg{width:18px;height:18px;stroke:currentColor;stroke-width:2.4;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
165
|
-
.modal-body{padding:20px 24px 36px;display:grid;gap:16px;overflow:auto}
|
|
166
|
-
.detail-grid{display:grid;grid-template-columns:minmax(260px,.72fr) minmax(0,1fr);gap:16px}
|
|
167
|
-
.card{border:1px solid var(--hairline);border-radius:12px;background:#fff;padding:16px;box-shadow:var(--shadow)}
|
|
168
|
-
.card h2{margin:0 0 12px;color:var(--ink);font-size:16px;line-height:22px;font-weight:750}
|
|
169
|
-
.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
|
170
|
-
.create-form{display:grid;gap:16px}
|
|
171
|
-
.create-section{border:1px solid var(--hairline);border-radius:12px;background:#fff;padding:16px;display:grid;gap:12px;box-shadow:var(--shadow)}
|
|
172
|
-
.create-section h2{margin:0;color:var(--ink);font-size:16px;line-height:22px;font-weight:750}
|
|
173
|
-
.section-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
|
174
|
-
/* ---- Tabs & payment --------------------------------------------- */
|
|
175
|
-
.payment-tabs{display:flex;gap:8px;flex-wrap:wrap}
|
|
176
|
-
.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}
|
|
177
|
-
.payment-tab.active{background:var(--primary);color:#fff;border-color:var(--primary)}
|
|
178
|
-
.payment-panel{border:1px solid var(--hairline);border-radius:8px;background:var(--panel-subtle);padding:14px;display:grid;gap:14px}
|
|
179
|
-
.payment-head{display:flex;align-items:center;justify-content:space-between;gap:14px}
|
|
180
|
-
.payment-head h3{margin:0;color:var(--ink);font-size:14px}
|
|
181
|
-
.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}
|
|
182
|
-
.pill-switch:before{content:"";width:18px;height:18px;border-radius:999px;background:#d8d0ea;box-shadow:inset 0 0 0 4px #fff}
|
|
183
|
-
.pill-switch.enabled{border-color:#8bd9ba;background:var(--success-soft);color:#087755}
|
|
184
|
-
.pill-switch.enabled:before{background:var(--success)}
|
|
185
|
-
.field.full{grid-column:1/-1}
|
|
186
|
-
.field-help{margin:0;color:var(--muted);font-size:12px;line-height:1.45}
|
|
187
|
-
.required-star{color:#c0264e}
|
|
188
|
-
.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}
|
|
189
|
-
.field{display:grid;gap:7px}
|
|
190
|
-
.field label{font-size:var(--label-fs);font-weight:var(--label-weight);color:var(--muted);letter-spacing:var(--label-spacing);text-transform:uppercase}
|
|
191
|
-
.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)}
|
|
192
|
-
.field input,.field select{font:inherit}
|
|
193
|
-
.field input:disabled,.field select:disabled{background:#f3f0fb;color:#958daa}
|
|
194
|
-
.model-toggle{width:18px;height:18px;accent-color:var(--primary)}
|
|
195
|
-
.model-toggle:disabled{cursor:default}
|
|
196
|
-
.readonly-value{display:flex;align-items:center;background:var(--panel-subtle);color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
197
|
-
.field input[readonly]{background:var(--panel-subtle);color:var(--muted)}
|
|
198
|
-
/* ---- Tables (audit) ---------------------------------------------- */
|
|
199
|
-
table{width:100%;border-collapse:collapse;font-size:var(--body-fs)}
|
|
200
|
-
th,td{text-align:left;border-bottom:1px solid var(--hairline);padding:10px;color:#50496a}
|
|
201
|
-
th{color:var(--muted);font-size:var(--label-fs);text-transform:uppercase;letter-spacing:var(--label-spacing)}
|
|
202
|
-
/* ---- Status banners ---------------------------------------------- */
|
|
203
|
-
.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)}
|
|
204
|
-
.status-line.loading,.loading-row{border-color:var(--hairline-strong);background:var(--primary-soft);color:var(--primary)}
|
|
205
|
-
.loading-row{min-height:76px;display:grid;place-items:center}
|
|
206
|
-
.status-line.loading{min-height:42px;display:flex;align-items:center;justify-content:center}
|
|
207
|
-
.form-grid>.loading-row{grid-column:1/-1}
|
|
208
|
-
/* ---- Create progress -------------------------------------------- */
|
|
209
|
-
.create-progress{position:relative;display:grid;gap:12px;padding-left:30px}
|
|
210
|
-
.create-progress:before{content:"";position:absolute;left:15px;top:22px;bottom:22px;width:2px;background:#e6def8}
|
|
211
|
-
.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}
|
|
212
|
-
.progress-step:before{content:"";position:absolute;left:-23px;top:18px;width:12px;height:12px;border-radius:999px;background:#c7bedf;border:3px solid var(--canvas)}
|
|
213
|
-
.progress-step strong{color:var(--ink);font-size:var(--body-fs);font-weight:750}
|
|
214
|
-
.progress-step span{color:var(--muted);font-size:12px}
|
|
215
|
-
.progress-step.running{border-color:var(--hairline-strong);background:var(--primary-soft)}
|
|
216
|
-
.progress-step.running:before{background:var(--primary)}
|
|
217
|
-
.progress-step.succeeded{border-color:rgba(16,185,129,.4)}
|
|
218
|
-
.progress-step.succeeded:before{background:var(--success)}
|
|
219
|
-
.progress-step.failed{border-color:#f4bdc9;background:var(--danger-soft)}
|
|
220
|
-
.progress-step.failed:before{background:var(--danger)}
|
|
221
|
-
.progress-step.skipped{background:var(--panel-subtle)}
|
|
222
|
-
.progress-step.skipped:before{background:#b9afd8}
|
|
223
|
-
.progress-title{display:flex;align-items:center;gap:8px}
|
|
224
|
-
.progress-title .spinner{width:14px;height:14px;border-width:2px}
|
|
225
|
-
.progress-meta{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
226
|
-
.progress-toggle{color:var(--primary);font-size:var(--label-fs);font-weight:var(--label-weight);text-transform:uppercase;letter-spacing:var(--label-spacing)}
|
|
227
|
-
.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)}
|
|
228
|
-
.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}
|
|
229
|
-
.inline-spinner{width:13px;height:13px;border:2px solid rgba(124,61,240,.18);border-top-color:var(--primary);border-radius:999px;animation:spin .75s linear infinite;display:inline-block;vertical-align:middle}
|
|
230
|
-
@keyframes spin{to{transform:rotate(360deg)}}
|
|
231
|
-
.hidden{display:none!important}
|
|
232
|
-
/* ---- Mobile ------------------------------------------------------- */
|
|
233
|
-
@media(max-width:900px){
|
|
234
|
-
.topnav{padding:0 20px}
|
|
235
|
-
.content{width:min(100% - 28px,1560px)}
|
|
236
|
-
.app-table-head{display:none}
|
|
237
|
-
.app-row{grid-template-columns:18px minmax(0,1fr) auto;gap:12px;padding:14px;min-height:44px;align-items:flex-start}
|
|
238
|
-
.app-row .field-cell,.app-row .speed-cell{display:none}
|
|
239
|
-
.mobile-metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:4px}
|
|
240
|
-
.mini-metric{border:1px solid var(--hairline);border-radius:8px;background:#fff;padding:8px;display:grid;gap:3px;min-width:0}
|
|
241
|
-
.mini-metric label{color:var(--muted);font-size:10px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
|
|
242
|
-
.mini-metric strong{color:var(--ink);font-family:var(--font-mono);font-size:12px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
243
|
-
.mini-metric span{color:var(--faint);font-size:11px;line-height:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
244
|
-
.detail-grid,.form-grid,.section-grid,.bootstrap-grid{grid-template-columns:1fr}
|
|
245
|
-
.field.full{grid-column:auto}
|
|
246
|
-
.modal-head{padding:0 16px}
|
|
247
|
-
.modal-body{padding:18px 16px 34px}
|
|
248
|
-
}
|
|
249
|
-
</style>
|
|
250
|
-
</head>
|
|
251
|
-
<body>
|
|
252
|
-
<nav class="topnav"><div class="logo">TOKENBUDDY ADMIN</div></nav>
|
|
253
|
-
<main class="content">
|
|
254
|
-
<section id="page-sellers" class="page active">
|
|
255
|
-
<div class="panel">
|
|
256
|
-
<div class="panel-head">
|
|
257
|
-
<div>
|
|
258
|
-
<h1 class="title">Seller fleet</h1>
|
|
259
|
-
</div>
|
|
260
|
-
<button id="createSeller" class="btn primary">Create Seller</button>
|
|
261
|
-
</div>
|
|
262
|
-
<div class="app-list">
|
|
263
|
-
<div class="app-table-head" role="row">
|
|
264
|
-
<span></span><span>Seller</span><span>Pub</span><span>Region</span>
|
|
265
|
-
<span>Models</span><span>Conn</span><span>CPU/RAM</span>
|
|
266
|
-
<span>Upstream</span><span>Status</span><span>Disc</span><span>Balance</span>
|
|
267
|
-
<span>TTFT</span><span>Next</span><span></span>
|
|
268
|
-
</div>
|
|
269
|
-
<div id="sellerRows">
|
|
270
|
-
<div class="loading-row" role="status" aria-label="Loading sellers">
|
|
271
|
-
<span class="spinner" aria-hidden="true"></span>
|
|
272
|
-
</div>
|
|
273
|
-
</div>
|
|
274
|
-
</div>
|
|
275
|
-
</div>
|
|
276
|
-
</section>
|
|
277
|
-
</main>
|
|
278
|
-
<section id="detailModal" class="modal">
|
|
279
|
-
<div class="modal-shell">
|
|
280
|
-
<header class="modal-head">
|
|
281
|
-
<div class="modal-title">
|
|
282
|
-
<span id="detailTitle">seller detail</span>
|
|
283
|
-
<button id="deleteSeller" class="btn icon danger" title="Delete deployment" aria-label="Delete deployment">
|
|
284
|
-
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
285
|
-
<path d="M3 6h18"></path><path d="M8 6V4h8v2"></path>
|
|
286
|
-
<path d="M6 6l1 16h10l1-16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path>
|
|
287
|
-
</svg>
|
|
288
|
-
</button>
|
|
289
|
-
</div>
|
|
290
|
-
<div class="modal-actions">
|
|
291
|
-
<button class="btn" data-status-action="drain">Drain</button>
|
|
292
|
-
<button class="btn" data-status-action="offline">Offline</button>
|
|
293
|
-
<button class="btn" data-status-action="activate">Activate</button>
|
|
294
|
-
<button id="editDetail" class="btn primary">Edit config</button>
|
|
295
|
-
</div>
|
|
296
|
-
<button id="closeDetail" class="modal-close" title="Close detail" aria-label="Close detail">
|
|
297
|
-
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
298
|
-
<path d="M6 6l12 12"></path><path d="M18 6L6 18"></path>
|
|
299
|
-
</svg>
|
|
300
|
-
</button>
|
|
301
|
-
</header>
|
|
302
|
-
<div class="modal-body">
|
|
303
|
-
<div id="detailStatus" class="status-line hidden"></div>
|
|
304
|
-
<div id="detailGrid" class="detail-grid hidden">
|
|
305
|
-
<div class="card"><h2>Seller configuration</h2><div id="configFields" class="form-grid"></div></div>
|
|
306
|
-
<div class="card"><h2>Models</h2><div id="modelsTable"></div></div>
|
|
307
|
-
</div>
|
|
308
|
-
</div>
|
|
309
|
-
</div>
|
|
310
|
-
</section>
|
|
311
|
-
<section id="createModal" class="modal">
|
|
312
|
-
<div class="modal-shell">
|
|
313
|
-
<header class="modal-head">
|
|
314
|
-
<div class="modal-title">New seller</div>
|
|
315
|
-
<div class="modal-actions">
|
|
316
|
-
<button id="submitCreate" class="btn primary">Create seller</button>
|
|
317
|
-
<button id="closeCreate" class="btn">Close</button>
|
|
318
|
-
</div>
|
|
319
|
-
</header>
|
|
320
|
-
<div class="modal-body">
|
|
321
|
-
<div id="createStatus" class="status-line hidden"></div>
|
|
322
|
-
<div id="createProgress" class="card create-progress hidden"></div>
|
|
323
|
-
<div id="createFields" class="create-form"></div>
|
|
324
|
-
</div>
|
|
325
|
-
</div>
|
|
326
|
-
</section>
|
|
327
|
-
<section id="bootstrapModal" class="modal">
|
|
328
|
-
<div class="modal-shell">
|
|
329
|
-
<header class="modal-head">
|
|
330
|
-
<div class="modal-title">Bootstrap service</div>
|
|
331
|
-
<div class="modal-actions"><button id="closeBootstrapConfig" class="btn">Close</button></div>
|
|
332
|
-
</header>
|
|
333
|
-
<div class="modal-body">
|
|
334
|
-
<div id="bootstrapStatus" class="status-line">Ready</div>
|
|
335
|
-
<div class="detail-grid">
|
|
336
|
-
<div class="card"><h2>Bootstrap status</h2><div id="bootstrapConfigMetrics" class="bootstrap-grid"></div></div>
|
|
337
|
-
<div class="card"><h2>Bootstrap configuration</h2><div id="bootstrapConfigFields" class="form-grid"></div></div>
|
|
338
|
-
</div>
|
|
339
|
-
</div>
|
|
340
|
-
</div>
|
|
341
|
-
</section>
|
|
342
|
-
<script>${adminUiScript()}</script>
|
|
343
|
-
</body>
|
|
344
|
-
</html>`;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function adminUiScript(): string {
|
|
348
|
-
// Inlined display-format bundle (must stay in sync with src/display-format.ts).
|
|
349
|
-
return `
|
|
350
|
-
${displayFormatBundle()}
|
|
351
|
-
${adminUiClientScript()}
|
|
352
|
-
`;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function adminUiClientScript(): string {
|
|
356
|
-
return `
|
|
357
|
-
async function api(path, options={}){
|
|
358
|
-
try {
|
|
359
|
-
const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", ...(options.headers || {}) } });
|
|
360
|
-
const text = await response.text();
|
|
361
|
-
let data = {};
|
|
362
|
-
try {
|
|
363
|
-
data = text ? JSON.parse(text) : {};
|
|
364
|
-
} catch {
|
|
365
|
-
data = { error: response.ok ? "Request returned an unreadable response." : "Request failed." };
|
|
366
|
-
}
|
|
367
|
-
if (!response.ok) throw new Error(uiErrorMessage(data.error || response.statusText));
|
|
368
|
-
return data;
|
|
369
|
-
} catch (err) {
|
|
370
|
-
throw new Error(uiErrorMessage(err));
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
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."; }
|
|
374
|
-
const sellerStatusRefreshIntervalMs = 30000;
|
|
375
|
-
const sellerDetailConcurrency = 2;
|
|
376
|
-
let currentDetail = null; let sellerRowsCache = []; let editing = false; let deleteReady = false; let sellerInventoryInFlight = false; let sellerDetailQueue = []; let sellerDetailQueueKeys = new Set(); let sellerDetailInFlight = new Set(); let sellerDetailTimers = new Map(); let sellerClockTimer = null; let sellerRefreshLoaded = false; let sellerRefreshStage = "Starting"; let sellerRefreshError = ""; let createJobTimer = null; let currentCreateJob = null; let expandedProgressSteps = new Set(); let createAppSuffix = "";
|
|
377
|
-
const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
|
|
378
|
-
const missing = value => '<span class="muted-value">'+esc(value)+'</span>';
|
|
379
|
-
const dash = () => '<span class="muted-value">'+window.__tbFmt.UNKNOWN_VALUE+'</span>';
|
|
380
|
-
const balanceProbeTemplates = ["openrouter","deepseek","stepfun","siliconflow","novita","newapi_generic","usage_generic","none","auto"];
|
|
381
|
-
const paymentMethods = ["clawtip","mock"];
|
|
382
|
-
const loadingSpinner = label => '<div class="loading-row" role="status" aria-label="'+esc(label)+'"><span class="spinner" aria-hidden="true"></span></div>';
|
|
383
|
-
async function loadSellers(options={}){
|
|
384
|
-
if (sellerInventoryInFlight) return;
|
|
385
|
-
const initial = Boolean(options.initial);
|
|
386
|
-
sellerInventoryInFlight = true;
|
|
387
|
-
sellerRefreshLoaded = false;
|
|
388
|
-
sellerRefreshError = "";
|
|
389
|
-
resetSellerDetailQueue();
|
|
390
|
-
if (initial) document.getElementById("sellerRows").innerHTML = loadingSpinner("Loading registry");
|
|
391
|
-
try {
|
|
392
|
-
sellerRefreshStage = "Loading registry";
|
|
393
|
-
updateSellerRefreshMeta();
|
|
394
|
-
const registryRows = await api("/api/sellers/registry");
|
|
395
|
-
renderSellerRows(registryRows);
|
|
396
|
-
sellerRefreshStage = "Loading Fly inventory";
|
|
397
|
-
updateSellerRefreshMeta();
|
|
398
|
-
const inventoryRows = await api("/api/sellers/inventory");
|
|
399
|
-
renderSellerRows(inventoryRows);
|
|
400
|
-
sellerRefreshLoaded = true;
|
|
401
|
-
sellerRefreshStage = "Refreshing details";
|
|
402
|
-
enqueueSellerDetails(inventoryRows);
|
|
403
|
-
} catch (err) {
|
|
404
|
-
sellerRefreshError = err.message || "Refresh failed";
|
|
405
|
-
sellerRefreshStage = "Load failed";
|
|
406
|
-
if (initial) document.getElementById("sellerRows").innerHTML = '<div class="status-line">'+esc(sellerRefreshError)+'</div>';
|
|
407
|
-
} finally {
|
|
408
|
-
sellerInventoryInFlight = false;
|
|
409
|
-
updateSellerRefreshMeta();
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
function statusRefreshRow(row){ return { id:row.id, name:row.name, app:row.app, url:row.url, registryStatus:row.registryStatus, nodeStatus:row.nodeStatus, region:row.region, upstreamDomain:row.upstreamDomain, upstreamStatus:row.upstreamStatus, upstreamBalanceUsdMicros:row.upstreamBalanceUsdMicros, upstreamBalanceCurrency:row.upstreamBalanceCurrency, upstreamBalanceSource:row.upstreamBalanceSource, upstreamBalanceFetchedAt:row.upstreamBalanceFetchedAt, upstreamBalanceError:row.upstreamBalanceError, upstreamRechargeUrl:row.upstreamRechargeUrl, discountRatio:row.discountRatio, capacityUsed:row.capacityUsed, capacityLimit:row.capacityLimit, resourceCpuPercent:row.resourceCpuPercent, resourceMemoryPercent:row.resourceMemoryPercent, resourceMemoryRssMb:row.resourceMemoryRssMb, resourceMemoryLimitMb:row.resourceMemoryLimitMb, ttftMs:row.ttftMs, avgInferenceMs:row.avgInferenceMs, lastInferenceMs:row.lastInferenceMs, avgTokensPerSecond:row.avgTokensPerSecond, lastTokensPerSecond:row.lastTokensPerSecond, latencySamples:row.latencySamples, lastSwitchAt:row.lastSwitchAt, modelsCount:row.modelsCount, specs:row.specs, error:row.error, dataSource:row.dataSource, registryAlert:row.registryAlert, alertReason:row.alertReason, publishHint:row.publishHint, removeHint:row.removeHint, flyApp:row.flyApp, publishStatus:row.publishStatus, detailStatus:row.detailStatus, detailUpdatedAt:row.detailUpdatedAt, detailNextRefreshAt:row.detailNextRefreshAt }; }
|
|
413
|
-
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)); }
|
|
414
|
-
function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => { updateSellerRefreshMeta(); updateSellerRowRefreshCountdowns(); }, 1000); window.addEventListener("beforeunload", () => { resetSellerDetailQueue(); clearInterval(sellerClockTimer); }); }
|
|
415
|
-
function sellerRowKey(row){ return String(row.id || row.app || row.url || ""); }
|
|
416
|
-
function resetSellerDetailQueue(){ sellerDetailQueue = []; sellerDetailQueueKeys.clear(); sellerDetailInFlight.clear(); sellerDetailTimers.forEach(timer => clearTimeout(timer)); sellerDetailTimers.clear(); }
|
|
417
|
-
function enqueueSellerDetails(rows){
|
|
418
|
-
rows.forEach(row => {
|
|
419
|
-
if (!shouldRefreshSellerDetail(row)) return;
|
|
420
|
-
const key = sellerRowKey(row);
|
|
421
|
-
if (!key || sellerDetailQueueKeys.has(key) || sellerDetailInFlight.has(key)) return;
|
|
422
|
-
sellerDetailQueue.push(key);
|
|
423
|
-
sellerDetailQueueKeys.add(key);
|
|
424
|
-
patchSellerRow(key, { detailStatus:"queued", detailNextRefreshAt:undefined });
|
|
425
|
-
});
|
|
426
|
-
pumpSellerDetailQueue();
|
|
427
|
-
updateSellerRefreshMeta();
|
|
428
|
-
}
|
|
429
|
-
function shouldRefreshSellerDetail(row){ return row && row.detailStatus !== "skipped" && row.publishStatus !== "registry_only" && row.dataSource !== "registry"; }
|
|
430
|
-
function patchSellerRow(key, patch){ let changed = false; sellerRowsCache = sellerRowsCache.map(row => { if (sellerRowKey(row) !== key) return row; changed = true; return { ...row, ...patch }; }); if (changed) renderSellerRows(sellerRowsCache); }
|
|
431
|
-
function patchSellerRegistryStatus(id, status){ let changed = false; sellerRowsCache = sellerRowsCache.map(row => { if (sellerRowKey(row) !== id && row.id !== id && row.name !== id && row.app !== id) return row; changed = true; return { ...row, registryStatus:status }; }); if (changed) renderSellerRows(sellerRowsCache); }
|
|
432
|
-
function mergeSellerRowUpdate(updated){ const key = sellerRowKey(updated); if (!key) return; let changed = false; sellerRowsCache = sellerRowsCache.map(row => { if (sellerRowKey(row) !== key) return row; changed = true; return { ...row, ...updated }; }); if (changed) renderSellerRows(sellerRowsCache); }
|
|
433
|
-
function latestSellerRow(key){ return sellerRowsCache.find(row => sellerRowKey(row) === key); }
|
|
434
|
-
function scheduleSellerDetailRefresh(row){
|
|
435
|
-
if (!shouldRefreshSellerDetail(row)) return;
|
|
436
|
-
const key = sellerRowKey(row);
|
|
437
|
-
if (!key) return;
|
|
438
|
-
const existing = sellerDetailTimers.get(key);
|
|
439
|
-
if (existing) clearTimeout(existing);
|
|
440
|
-
const nextRefreshAt = new Date(Date.now() + sellerStatusRefreshIntervalMs).toISOString();
|
|
441
|
-
const timer = setTimeout(() => {
|
|
442
|
-
sellerDetailTimers.delete(key);
|
|
443
|
-
const latest = latestSellerRow(key);
|
|
444
|
-
if (!latest) return;
|
|
445
|
-
patchSellerRow(key, { detailStatus:"stale", detailNextRefreshAt:undefined });
|
|
446
|
-
enqueueSellerDetails([latest]);
|
|
447
|
-
}, sellerStatusRefreshIntervalMs);
|
|
448
|
-
sellerDetailTimers.set(key, timer);
|
|
449
|
-
patchSellerRow(key, { detailNextRefreshAt:nextRefreshAt });
|
|
450
|
-
}
|
|
451
|
-
async function pumpSellerDetailQueue(){
|
|
452
|
-
while (sellerDetailInFlight.size < sellerDetailConcurrency && sellerDetailQueue.length > 0) {
|
|
453
|
-
const key = sellerDetailQueue.shift();
|
|
454
|
-
sellerDetailQueueKeys.delete(key);
|
|
455
|
-
const row = latestSellerRow(key);
|
|
456
|
-
if (!row || !shouldRefreshSellerDetail(row)) continue;
|
|
457
|
-
sellerDetailInFlight.add(key);
|
|
458
|
-
patchSellerRow(key, { detailStatus:"loading", detailNextRefreshAt:undefined });
|
|
459
|
-
updateSellerRefreshMeta();
|
|
460
|
-
api("/api/sellers/status", { method:"POST", body: JSON.stringify({ rows:[statusRefreshRow(row)] }) })
|
|
461
|
-
.then(rows => {
|
|
462
|
-
const updated = Array.isArray(rows) ? rows[0] : null;
|
|
463
|
-
if (updated) {
|
|
464
|
-
mergeSellerRowUpdate(updated);
|
|
465
|
-
scheduleSellerDetailRefresh(updated);
|
|
466
|
-
sellerRefreshError = "";
|
|
467
|
-
}
|
|
468
|
-
})
|
|
469
|
-
.catch(err => {
|
|
470
|
-
sellerRefreshError = err.message || "Status refresh failed";
|
|
471
|
-
patchSellerRow(key, { detailStatus:"error", detailUpdatedAt:new Date().toISOString(), error:sellerRefreshError });
|
|
472
|
-
const latest = latestSellerRow(key);
|
|
473
|
-
if (latest) scheduleSellerDetailRefresh(latest);
|
|
474
|
-
})
|
|
475
|
-
.finally(() => {
|
|
476
|
-
sellerDetailInFlight.delete(key);
|
|
477
|
-
updateSellerRefreshMeta();
|
|
478
|
-
pumpSellerDetailQueue();
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
function updateSellerRefreshMeta(){ const state = document.getElementById("sellerRefreshState"); if (!state) return; const busy = sellerInventoryInFlight || sellerDetailInFlight.size > 0 || sellerDetailQueue.length > 0; state.classList.toggle("refreshing", Boolean(busy)); state.classList.toggle("error", Boolean(sellerRefreshError)); if (sellerRefreshError) { state.textContent = sellerRefreshError; return; } if (busy) { state.innerHTML = '<span class="spinner" aria-hidden="true"></span><span>'+esc(sellerRefreshStage || "Refreshing details")+'</span>'; return; } state.textContent = sellerRefreshLoaded ? "Inventory loaded · row timers active" : (sellerRefreshStage || "Loading inventory"); }
|
|
483
|
-
function updateSellerRowRefreshCountdowns(){ if (!sellerRefreshLoaded || sellerRowsCache.length === 0) return; renderSellerRows(sellerRowsCache); }
|
|
484
|
-
function publishStatusLabel(status){ if (status === "published") return "已发布"; if (status === "unpublished") return "未发布"; if (status === "registry_only") return "Registry-only"; if (status === "checking") return "发布待确认"; if (status === "draining") return "下线中"; if (status === "offline") return "已下线"; if (status === "pending") return "待发布"; return "发布未知"; }
|
|
485
|
-
function visiblePublishStatus(row, relation){ const registry = row.registryStatus; if ((relation === "published" || relation === "checking") && ["draining","offline","pending"].includes(registry)) return registry; return relation; }
|
|
486
|
-
function registryStatusDisplay(status){ const value = String(status || "unknown").toLowerCase(); if (["active","draining","offline","pending"].includes(value)) return value; return window.__tbFmt.UNKNOWN_VALUE; }
|
|
487
|
-
function detailStatusLabel(status){ if (status === "queued") return "detail queued"; if (status === "loading") return "detail loading"; if (status === "fresh") return "detail fresh"; if (status === "stale") return "detail stale"; if (status === "error") return "detail error"; if (status === "skipped") return "detail skipped"; return "detail pending"; }
|
|
488
|
-
function detailCountdown(row){ if (!row.detailNextRefreshAt) return ""; const ms = new Date(row.detailNextRefreshAt).getTime() - Date.now(); if (!Number.isFinite(ms)) return ""; if (ms <= 0) return "due now"; const seconds = Math.ceil(ms / 1000); return seconds >= 60 ? Math.ceil(seconds / 60) + "m" : seconds + "s"; }
|
|
489
|
-
function sellerHostMatches(row, domain){ const normalized = String(domain || "").toLowerCase(); if (!normalized) return false; const rowHost = hostName(row.url); const appHost = row.app ? String(row.app).toLowerCase() + ".fly.dev" : ""; const flyHost = row.flyApp?.name ? String(row.flyApp.name).toLowerCase() + ".fly.dev" : ""; return normalized === rowHost || normalized === appHost || normalized === flyHost; }
|
|
490
|
-
function usagePercent(value){ const n = Number(value); if (!Number.isFinite(n)) return "—"; return (Math.round(Math.max(0, Math.min(100, n)) * 10) / 10).toString().replace(/\.0$/, "") + "%"; }
|
|
491
|
-
function percentTone(value){ const n = Number(value); if (!Number.isFinite(n)) return "gray"; if (n >= 90) return "red"; if (n >= 75) return "amber"; return "green"; }
|
|
492
|
-
function worstTone(...tones){ if (tones.includes("red")) return "red"; if (tones.includes("amber")) return "amber"; if (tones.includes("blue")) return "blue"; if (tones.includes("green")) return "green"; return "gray"; }
|
|
493
|
-
function capacityTone(used, limit){ const u = Number(used); const l = Number(limit); if (!Number.isFinite(u) || !Number.isFinite(l) || l <= 0) return "gray"; const ratio = u / l; if (ratio >= 1) return "red"; if (ratio >= .75) return "amber"; return "green"; }
|
|
494
|
-
function latencyTone(ms){ const n = Number(ms); if (!Number.isFinite(n)) return "gray"; if (n >= 5000) return "red"; if (n >= 2000) return "amber"; return "green"; }
|
|
495
|
-
function upstreamStatusTone(status){ const value = String(status || "unknown").toLowerCase(); if (value === "healthy") return "green"; if (value === "degraded") return "amber"; if (value === "unhealthy") return "red"; return "gray"; }
|
|
496
|
-
function renderOpMetric(text, tone, title){ const value = text || "—"; if (tone === "green") return '<strong class="op-value"'+(title ? ' title="'+esc(title)+'"' : '')+'>'+esc(value)+'</strong>'; return '<span class="op-chip tone-'+esc(tone || "gray")+'"'+(title ? ' title="'+esc(title)+'"' : '')+'>'+esc(value)+'</span>'; }
|
|
497
|
-
function renderStatusMetric(status, title){ const tone = upstreamStatusTone(status); const value = tone === "gray" ? "-" : window.__tbFmt.normalizeStatusLabel(status); return '<span class="op-chip tone-'+esc(tone)+'"'+(title ? ' title="'+esc(title)+'"' : '')+'>'+esc(value)+'</span>'; }
|
|
498
|
-
function sellerRow(row){
|
|
499
|
-
const fmt = window.__tbFmt;
|
|
500
|
-
// publishStatus 决定用户可见发布态; dataSource 只作为排障线索.
|
|
501
|
-
// checking/unknown 必须保持中性, 不能提前显示成未发布或事故.
|
|
502
|
-
const ds = row.dataSource || "both";
|
|
503
|
-
const publishRelation = row.publishStatus || (ds === "registry" ? "unknown" : ds === "fly" ? "unpublished" : "published");
|
|
504
|
-
const publish = visiblePublishStatus(row, publishRelation);
|
|
505
|
-
const publishLabel = publishStatusLabel(publish);
|
|
506
|
-
const dsChip = '<span class="datasource-chip '+esc(publish)+'" title="'+esc('Publish: ' + publish + ' · registry: ' + row.registryStatus + ' · source: ' + ds)+'">'+esc(publishLabel)+'</span>';
|
|
507
|
-
const cpuValue = row.specs?.cpuCores ?? row.specs?.cpus ?? row.specs?.cpu;
|
|
508
|
-
const cpuText = cpuValue === undefined || cpuValue === null || cpuValue === "" ? "—" : String(cpuValue) + " vCPU";
|
|
509
|
-
const memoryText = row.specs?.memoryMb ? (row.specs.memoryMb >= 1024 ? (Math.round(row.specs.memoryMb / 102.4) / 10) + " GB" : row.specs.memoryMb + " MB") : (row.specs?.memoryGb ? row.specs.memoryGb + " GB" : "—");
|
|
510
|
-
const cpuUsageText = usagePercent(row.resourceCpuPercent);
|
|
511
|
-
const memoryUsageText = usagePercent(row.resourceMemoryPercent);
|
|
512
|
-
const hasUsage = cpuUsageText !== "—" || memoryUsageText !== "—";
|
|
513
|
-
const resourcePrimary = (cpuUsageText === "—" ? "-" : cpuUsageText) + "/" + (memoryUsageText === "—" ? "-" : memoryUsageText);
|
|
514
|
-
const resourceTone = hasUsage ? worstTone(percentTone(row.resourceCpuPercent), percentTone(row.resourceMemoryPercent)) : "gray";
|
|
515
|
-
const machineText = row.specs?.machines ? ((row.specs.runningMachines ?? row.specs.machines) + "/" + row.specs.machines + " running") : (row.flyApp?.status ? "Fly " + row.flyApp.status : "Fly —");
|
|
516
|
-
const volumeText = row.specs?.volumeGb ? " · vol " + row.specs.volumeGb + " GB" : "";
|
|
517
|
-
const usageDetail = (hasUsage ? "usage CPU " + cpuUsageText + " · memory " + memoryUsageText : "usage unavailable") + (row.resourceMemoryRssMb ? " (" + row.resourceMemoryRssMb + " MB RSS" + (row.resourceMemoryLimitMb ? " / " + row.resourceMemoryLimitMb + " MB" : "") + ")" : "") + " · spec " + cpuText + " / " + memoryText + " · " + machineText + volumeText;
|
|
518
|
-
const ttftText = fmt.formatDuration(row.ttftMs);
|
|
519
|
-
const avgText = fmt.formatSpeed(row.avgTokensPerSecond);
|
|
520
|
-
const avgSpeed = avgText === fmt.UNKNOWN_VALUE ? "Tok/s —" : "Tok/s " + esc(avgText.replace(/ tok\\/s$/i, ""));
|
|
521
|
-
const capacity = fmt.formatSellerCapacity(row.capacityUsed, row.capacityLimit);
|
|
522
|
-
const capacityText = capacity === fmt.UNKNOWN_VALUE ? "—" : capacity;
|
|
523
|
-
const connTone = capacityTone(row.capacityUsed, row.capacityLimit);
|
|
524
|
-
const disc = fmt.formatDiscountRatio(row.discountRatio);
|
|
525
|
-
const balanceRaw = row.upstreamBalanceUsdMicros;
|
|
526
|
-
const hasBalance = balanceRaw !== undefined && balanceRaw !== null;
|
|
527
|
-
const lowBalance = hasBalance && Number(balanceRaw) < 10000000;
|
|
528
|
-
const balanceTone = hasBalance ? (lowBalance ? "red" : "green") : "gray";
|
|
529
|
-
const balanceDisplay = hasBalance ? fmt.formatBalanceAmount(balanceRaw, row.upstreamBalanceCurrency || "USD") : "—";
|
|
530
|
-
const balanceMissingTitle = row.upstreamBalanceError || "Balance probe has not returned data";
|
|
531
|
-
const balanceText = hasBalance ? renderOpMetric(balanceDisplay, balanceTone, lowBalance ? "Upstream balance below 10" : "Upstream balance ok") : '<span class="muted-value" title="'+esc(balanceMissingTitle)+'">-</span>';
|
|
532
|
-
const balancePlain = hasBalance ? fmt.formatBalanceAmount(balanceRaw, row.upstreamBalanceCurrency || "USD") : "-";
|
|
533
|
-
const balanceLabel = hasBalance ? (lowBalance ? "low balance" : "ok") : "-";
|
|
534
|
-
// Step 13 v1.1: 绿点 (status + tone) 仍按 nodeStatus 决定 (probeManifest
|
|
535
|
-
// 200 → active 绿点; 失败 → unknown 灰). registryStatus 单独 tooltip.
|
|
536
|
-
const status = fmt.formatSellerStatus(row.nodeStatus);
|
|
537
|
-
const tone = fmt.sellerStatusTone(row.nodeStatus);
|
|
538
|
-
const statusTip = "registry: " + esc(registryStatusDisplay(row.registryStatus)) + " · upstream: " + esc(fmt.normalizeStatusLabel(row.upstreamStatus)) + " · publish: " + esc(publish) + " · detail: " + esc(row.detailStatus || "pending");
|
|
539
|
-
const upstreamDisplay = sellerHostMatches(row, row.upstreamDomain) ? "—" : (row.upstreamDomain || "—");
|
|
540
|
-
const upstreamTone = upstreamStatusTone(row.upstreamStatus);
|
|
541
|
-
const discountText = disc === fmt.UNKNOWN_VALUE ? "—" : disc;
|
|
542
|
-
const regionText = row.region || row.specs?.region || "—";
|
|
543
|
-
const modelsText = row.modelsCount === undefined || row.modelsCount === null ? "—" : fmt.formatCount(row.modelsCount);
|
|
544
|
-
const ttftTone = latencyTone(row.ttftMs);
|
|
545
|
-
const ttftDisplay = ttftText === fmt.UNKNOWN_VALUE ? "—" : ttftText;
|
|
546
|
-
const refreshCountdown = detailCountdown(row);
|
|
547
|
-
const refreshLoading = row.detailStatus === "queued" || row.detailStatus === "loading";
|
|
548
|
-
const refreshText = refreshLoading ? ""
|
|
549
|
-
: row.detailStatus === "skipped" ? "-"
|
|
550
|
-
: row.detailUpdatedAt ? fmt.formatTimeCompact(row.detailUpdatedAt) + " / " + (refreshCountdown || "—")
|
|
551
|
-
: "-";
|
|
552
|
-
const rowRiskTone = publishRelation === "registry_only" || lowBalance || upstreamTone === "red" || connTone === "red" || resourceTone === "red" || ttftTone === "red" || tone === "red" ? "alert"
|
|
553
|
-
: publishRelation === "unpublished" || publish === "draining" || publish === "offline" || upstreamTone === "amber" || connTone === "amber" || resourceTone === "amber" || ttftTone === "amber" || tone === "amber" ? "warn"
|
|
554
|
-
: "";
|
|
555
|
-
const rowClass = publishRelation === "registry_only" ? "app-row app-row-registry-only"
|
|
556
|
-
: publishRelation === "unpublished" ? "app-row app-row-fly-only"
|
|
557
|
-
: "app-row" + (rowRiskTone ? " row-" + rowRiskTone : "");
|
|
558
|
-
const publishExtras = publishRelation === "registry_only" ? '<strong class="registry-incident" title="'+esc(statusTip)+'">严重事故</strong>' +
|
|
559
|
-
(row.alertReason ? '<span class="alert-reason">'+esc(row.alertReason)+'</span>' : '') +
|
|
560
|
-
(row.removeHint ? '<span class="remove-hint-btn" data-action="remove" data-seller-id="'+esc(row.id)+'" title="'+esc(row.removeHint)+'">'+esc(row.removeHint)+'</span>' : '')
|
|
561
|
-
: publishRelation === "unpublished" && row.publishHint ? '<span class="publish-hint-btn" data-action="publish" data-seller-id="'+esc(row.id)+'" title="'+esc(row.publishHint)+'">'+esc(row.publishHint)+'</span>' : '';
|
|
562
|
-
const publishCell = '<span class="field-cell"><span class="metric-label">Pub</span>'+dsChip+publishExtras+'</span>';
|
|
563
|
-
const regionCell = '<span class="field-cell"><span class="metric-label">Region</span><strong>'+esc(regionText)+'</strong></span>';
|
|
564
|
-
const modelsCell = '<span class="field-cell"><span class="metric-label">Models</span><strong>'+esc(modelsText)+'</strong></span>';
|
|
565
|
-
const connectionCell = '<span class="field-cell"><span class="metric-label">Connection</span>'+renderOpMetric(capacityText, connTone, "Active / max connections")+'</span>';
|
|
566
|
-
const resourcesCell = '<span class="field-cell"><span class="metric-label">Resources</span>'+renderOpMetric(resourcePrimary, resourceTone, usageDetail)+'</span>';
|
|
567
|
-
const upstreamCell = '<span class="field-cell"><span class="metric-label">Upstream</span><strong title="'+esc(row.upstreamDomain || "")+'">'+esc(upstreamDisplay)+'</strong></span>';
|
|
568
|
-
const upstreamStatusCell = '<span class="field-cell"><span class="metric-label">Status</span>'+renderStatusMetric(row.upstreamStatus, "Upstream status from seller /operator/status")+'</span>';
|
|
569
|
-
const discountCell = '<span class="field-cell"><span class="metric-label">Disc</span><strong>'+esc(discountText)+'</strong></span>';
|
|
570
|
-
const balanceCell = '<span class="field-cell"><span class="metric-label">Balance</span><span class="balance-line'+(lowBalance ? ' tone-red' : '')+'">'+balanceText+(row.upstreamRechargeUrl ? '<span class="recharge-btn" title="'+esc(row.upstreamRechargeUrl)+'">↗</span>' : '')+'</span></span>';
|
|
571
|
-
const speedCell = '<span class="speed-cell"><span class="metric-label">TTFT</span>'+renderOpMetric(ttftDisplay, ttftTone, avgSpeed+' · samples '+(row.latencySamples === undefined ? "—" : fmt.formatCount(row.latencySamples)))+'</span>';
|
|
572
|
-
const refreshContent = refreshLoading ? '<span class="inline-spinner" role="status" aria-label="'+esc(detailStatusLabel(row.detailStatus))+'" title="'+esc(detailStatusLabel(row.detailStatus))+'"></span>' : '<strong title="'+esc(row.detailUpdatedAt ? "updated " + fmt.formatTimeCompact(row.detailUpdatedAt) : "")+'">'+esc(refreshText)+'</strong>';
|
|
573
|
-
const refreshCell = '<span class="field-cell"><span class="metric-label">Next</span>'+refreshContent+'</span>';
|
|
574
|
-
const mobileRefreshValue = refreshLoading ? '<span class="inline-spinner" role="status" aria-label="'+esc(detailStatusLabel(row.detailStatus))+'" title="'+esc(detailStatusLabel(row.detailStatus))+'"></span>' : esc(refreshText);
|
|
575
|
-
const mobileUpstreamStatus = upstreamTone === "gray" ? "-" : fmt.normalizeStatusLabel(row.upstreamStatus);
|
|
576
|
-
const mobileMetrics = '<span class="mobile-metrics"><span class="mini-metric"><label>Connection</label><strong>'+esc(capacityText)+'</strong><span>'+esc(status)+'</span></span><span class="mini-metric"><label>Resources</label><strong>'+esc(resourcePrimary)+'</strong><span>'+esc(hasUsage ? cpuText + " / " + memoryText : machineText)+'</span></span><span class="mini-metric"><label>Upstream</label><strong>'+esc(mobileUpstreamStatus)+'</strong><span>'+esc(upstreamDisplay)+'</span></span><span class="mini-metric"><label>Balance</label><strong>'+esc(balancePlain)+'</strong><span>'+esc(hasBalance ? balanceLabel : "-")+'</span></span><span class="mini-metric"><label>Next</label><strong>'+mobileRefreshValue+'</strong><span>'+esc(refreshLoading ? "refreshing" : row.detailStatus || "pending")+'</span></span></span>';
|
|
577
|
-
return '<button class="'+esc(rowClass)+'" type="button" data-detail="'+esc(row.id)+'"><span class="app-dot tone-'+esc(tone)+'" aria-label="'+esc(status)+'" title="'+esc(statusTip)+'"></span><span class="app-name"><strong title="'+esc(row.name)+'">'+esc(row.name)+'</strong>'+mobileMetrics+'</span>'+publishCell+regionCell+modelsCell+connectionCell+resourcesCell+upstreamCell+upstreamStatusCell+discountCell+balanceCell+speedCell+refreshCell+'<span class="row-actions"><span class="detail-btn">›</span></span></button>';
|
|
578
|
-
}
|
|
579
|
-
function entryCardHtml(item){
|
|
580
|
-
const secondary = item.secondary ? '<span class="secondary">'+item.secondary+'</span>' : "";
|
|
581
|
-
return '<div class="entry-card '+(item.tone || "router")+'"><label>'+esc(item.label)+'</label><strong>'+esc(item.value || "—")+'</strong>'+secondary+'</div>';
|
|
582
|
-
}
|
|
583
|
-
async function openBootstrapConfig(){
|
|
584
|
-
const fmt = window.__tbFmt;
|
|
585
|
-
try {
|
|
586
|
-
const data = await api("/api/bootstrap/config");
|
|
587
|
-
document.getElementById("bootstrapStatus").textContent = uiErrorMessage(data.error || "Ready");
|
|
588
|
-
const clawtip = data.clawtip || {};
|
|
589
|
-
const metrics = [
|
|
590
|
-
{ tone: "router", label: "Service", value: data.status || "unknown" },
|
|
591
|
-
{ tone: "spend", label: "Activation", value: clawtip.activationFeeFen === undefined ? "—" : esc(String(clawtip.activationFeeFen)) + " fen" },
|
|
592
|
-
{ tone: "tokens", label: "Micros / fen", value: clawtip.microsPerFen === undefined ? "—" : esc(fmt.formatCount(clawtip.microsPerFen)) },
|
|
593
|
-
{ tone: "inventory", label: "Skill", value: clawtip.skillSlug || "—" }
|
|
594
|
-
];
|
|
595
|
-
document.getElementById("bootstrapConfigMetrics").innerHTML = metrics.map(m => entryCardHtml(m)).join("");
|
|
596
|
-
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]];
|
|
597
|
-
document.getElementById("bootstrapConfigFields").innerHTML = fields.map(([k,v]) => fieldHtml(k,v,false)).join("");
|
|
598
|
-
document.getElementById("bootstrapModal").classList.add("open");
|
|
599
|
-
} catch (err) {
|
|
600
|
-
document.getElementById("bootstrapStatus").textContent = uiErrorMessage(err);
|
|
601
|
-
document.getElementById("bootstrapConfigMetrics").innerHTML = "";
|
|
602
|
-
document.getElementById("bootstrapConfigFields").innerHTML = "";
|
|
603
|
-
document.getElementById("bootstrapModal").classList.add("open");
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
async function openDetail(id){ editing = false; deleteReady = false; currentDetail = null; document.getElementById("detailTitle").textContent = id + " detail"; document.getElementById("detailGrid").classList.add("hidden"); document.getElementById("configFields").innerHTML = ""; document.getElementById("modelsTable").innerHTML = ""; showDetailStatus("Loading seller data", true); document.getElementById("detailModal").classList.add("open"); try { currentDetail = await api("/api/sellers/"+encodeURIComponent(id)); renderDetail(); } catch (err) { document.getElementById("detailGrid").classList.add("hidden"); showDetailStatus(err.message || "Failed to load seller detail", false); } }
|
|
607
|
-
function renderDetail(){
|
|
608
|
-
const fmt = window.__tbFmt;
|
|
609
|
-
const d = currentDetail;
|
|
610
|
-
document.getElementById("detailTitle").textContent = d.row.name + " detail";
|
|
611
|
-
document.getElementById("editDetail").textContent = editing ? "Save changes" : "Edit config";
|
|
612
|
-
document.getElementById("deleteSeller").title = deleteReady ? "Confirm destroy deployment" : "Delete deployment";
|
|
613
|
-
showDetailStatus(d.row.error || "", false);
|
|
614
|
-
document.getElementById("detailGrid").classList.remove("hidden");
|
|
615
|
-
const c = d.configuration;
|
|
616
|
-
const fields = [["registryStatus", registryStatusDisplay(c.registryStatus || d.row.registryStatus)],["region", c.region],["upstreamUrl", c.upstreamUrl],["upstreamApiKey", c.upstreamApiKeyMasked],["upstreamStatus", fmt.normalizeStatusLabel(c.upstreamStatus)],["ttftMs", fmt.formatDuration(d.row.ttftMs)],["avgTokensPerSecond", fmt.formatSpeed(d.row.avgTokensPerSecond)],["lastTokensPerSecond", fmt.formatSpeed(d.row.lastTokensPerSecond)],["lastInferenceMs", fmt.formatDuration(d.row.lastInferenceMs)],["latencySamples", d.row.latencySamples === undefined ? "—" : esc(fmt.formatCount(d.row.latencySamples))],["upstreamBalance", c.upstreamBalance],["upstreamBalanceSource", c.upstreamBalanceSource],["upstreamBalanceFetchedAt", c.upstreamBalanceFetchedAt ? fmt.formatTimeFull(c.upstreamBalanceFetchedAt) : "—"],["upstreamBalanceError", c.upstreamBalanceError],["upstreamBalanceProbeTemplate", c.upstreamBalanceProbeTemplate],["upstreamBalanceProbeUrl", c.upstreamBalanceProbeUrl],["upstreamBalanceProbeUserId", c.upstreamBalanceProbeUserId],["upstreamBalanceProbeRechargeUrl", c.upstreamBalanceProbeRechargeUrl],["markupRatio", c.markupRatio],["discountRatio", c.discountRatio],["maxConnections", c.maxConnections],["maxQueueDepth", c.maxQueueDepth]];
|
|
617
|
-
document.getElementById("configFields").innerHTML = detailFieldsHtml(fields);
|
|
618
|
-
const billingOptions = Array.from(new Set(d.models.map(m => m.billingModel).filter(Boolean)));
|
|
619
|
-
document.getElementById("modelsTable").innerHTML = '<table><thead><tr><th>Enable</th><th>Upstream model</th><th>Billing model</th><th>Input price</th><th>Output price</th><th>TTFT</th><th>AVG speed</th><th>Samples</th></tr></thead><tbody>'+d.models.map(m => '<tr><td>'+modelEnableHtml(m)+'</td><td>'+esc(m.upstreamModel)+'</td><td>'+(editing ? selectHtml(m.upstreamModel, m.billingModel, billingOptions) : esc(m.billingModel))+'</td><td>'+esc(m.inputPrice || "—")+'</td><td>'+esc(m.outputPrice || "—")+'</td><td>'+esc(m.ttftMs === undefined ? "—" : fmt.formatDuration(m.ttftMs))+'</td><td>'+esc(m.avgTokensPerSecond === undefined ? "—" : fmt.formatSpeed(m.avgTokensPerSecond))+'</td><td>'+esc(m.latencySamples === undefined ? "—" : fmt.formatCount(m.latencySamples))+'</td></tr>').join("")+'</tbody></table>';
|
|
620
|
-
}
|
|
621
|
-
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)); }
|
|
622
|
-
function metricCell(value){ return value === undefined || value === null || value === "" ? missing("—") : esc(value); }
|
|
623
|
-
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(""); }
|
|
624
|
-
function isMissingDetailField(key,value){ const fmt = window.__tbFmt; return value === undefined || value === null || value === "" || value === fmt.UNKNOWN_VALUE || (key === "registryStatus" && value === "unknown"); }
|
|
625
|
-
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>'; }
|
|
626
|
-
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>'; }
|
|
627
|
-
function modelEnableHtml(model){ return '<input class="model-toggle" type="checkbox" aria-label="Enable '+esc(model.upstreamModel)+'" data-model-enabled="'+esc(model.upstreamModel)+'" '+(model.enabled === false ? "" : "checked")+' '+(editing ? "" : "disabled")+'>'; }
|
|
628
|
-
function registryStatusForAction(action){ if (action === "drain") return "draining"; if (action === "activate") return "active"; return "offline"; }
|
|
629
|
-
function setStatusActionBusy(busy){ document.querySelectorAll("[data-status-action]").forEach(btn => { btn.disabled = Boolean(busy); }); }
|
|
630
|
-
function setDetailSavingBusy(busy){ const edit = document.getElementById("editDetail"); edit.disabled = Boolean(busy); edit.textContent = busy ? "Saving" : (editing ? "Save changes" : "Edit config"); document.querySelectorAll("#detailGrid [data-field], #detailGrid [data-model], #detailGrid [data-model-enabled], [data-status-action], #deleteSeller").forEach(input => { input.disabled = Boolean(busy); }); }
|
|
631
|
-
document.getElementById("editDetail").onclick = async () => { if (!editing){ editing = true; renderDetail(); return; } const patch = {}; document.querySelectorAll("[data-field]").forEach(input => { const value = input.value; if (value === "" || value === input.dataset.original) return; patch[input.dataset.field] = numeric(value); }); const aliases = {}; document.querySelectorAll("[data-model]").forEach(input => aliases[input.dataset.model] = input.value); patch.modelAliases = aliases; patch.models = modelConfigPatchFromDetail(); try { setDetailSavingBusy(true); showDetailStatus("Saving seller config", true); const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/config", { method:"PUT", body: JSON.stringify(patch) }); if (!result.ok) throw new Error(result.stderr || "Save failed"); currentDetail = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)); editing = false; renderDetail(); loadSellers(); } catch (err) { showDetailStatus(err.message || "Save failed", false); } finally { setDetailSavingBusy(false); } };
|
|
632
|
-
function modelConfigPatchFromDetail(){ const enabledByModel = {}; document.querySelectorAll("[data-model-enabled]").forEach(input => enabledByModel[input.dataset.modelEnabled] = Boolean(input.checked)); return (currentDetail?.models || []).map(model => ({ ...(model.configModel || { id:model.upstreamModel }), id:model.upstreamModel, enabled: enabledByModel[model.upstreamModel] !== false })); }
|
|
633
|
-
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"; };
|
|
634
|
-
document.getElementById("closeDetail").onclick = () => { deleteReady = false; document.getElementById("detailModal").classList.remove("open"); };
|
|
635
|
-
document.querySelectorAll("[data-status-action]").forEach(btn => btn.onclick = async () => { if (!currentDetail) return; const action = btn.dataset.statusAction; const id = currentDetail.row.id; const status = registryStatusForAction(action); try { setStatusActionBusy(true); showDetailStatus("Updating registry status", true); const result = await api("/api/sellers/"+encodeURIComponent(id)+"/"+action, { method:"POST" }); if (!result.ok) throw new Error(result.stderr || "Status update failed."); patchSellerRegistryStatus(id, status); deleteReady = false; currentDetail = null; document.getElementById("detailModal").classList.remove("open"); } catch (err) { showDetailStatus(err.message || "Status update failed.", false); } finally { setStatusActionBusy(false); } });
|
|
636
|
-
document.getElementById("createSeller").onclick = () => { buildCreateForm(); document.getElementById("createModal").classList.add("open"); };
|
|
637
|
-
document.getElementById("closeCreate").onclick = () => document.getElementById("createModal").classList.remove("open");
|
|
638
|
-
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(); }
|
|
639
|
-
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(""); }
|
|
640
|
-
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>'; }
|
|
641
|
-
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>'; }
|
|
642
|
-
function paymentToggleHtml(method, enabled){ return '<button type="button" class="pill-switch '+(enabled ? "enabled" : "")+'" data-payment-toggle="'+esc(method)+'" aria-pressed="'+String(enabled)+'">'+(enabled ? "已启用" : "未启用")+'</button>'; }
|
|
643
|
-
function numericFieldOptions(key){ return ["maxConnections","maxQueueDepth","markupRatio","discountRatio","clawtipActivationFeeFen","clawtipMicrosPerFen"].includes(key) ? { type:"number" } : {}; }
|
|
644
|
-
function createFieldEditable(key){ return !["app","image","flyConfig"].includes(key); }
|
|
645
|
-
function createFieldOptions(key){ const labels = { sellerName:"Seller name", app:"Fly app name", region:"Fly region", image:"Seller image", flyConfig:"Fly config file", upstreamWebsite:"Upstream website", upstreamUrl:"OpenAI-compatible base URL", upstreamApiKey:"Upstream API key", upstreamBalanceProbeTemplate:"Balance probe template", upstreamBalanceProbeUrl:"Balance probe URL (auto from template)", upstreamBalanceProbeUserId:"Balance probe user ID", upstreamBalanceProbeRechargeUrl:"Recharge URL", maxConnections:"Max connections", maxQueueDepth:"Max queue depth", markupRatio:"Markup ratio", discountRatio:"Discount ratio", clawtipPayTo:"payTo", clawtipSm4KeyBase64:"sm4KeyBase64" }; const help = { sellerName:"Use a domain-style slug, for example openrouter-ai or moonshot-cn. The Fly app will be generated as tbs-<seller-name>-<random>.", app:"Generated from seller name. Example: tbs-openrouter-ai-k7p9x.", region:"Fly.io region, for example sin, nrt, hkg, lax.", image:"Uses the published seller image by default.", flyConfig:"Uses the standard tb-seller Fly.io config.", upstreamWebsite:"Customer-facing upstream website. Example: https://moonshot.cn", upstreamUrl:"OpenAI-compatible API base URL. Examples: https://api.moonshot.cn/v1 or https://openrouter.ai/api/v1", upstreamApiKey:"Upstream provider API key. This is submitted to the seller config and never shown in the seller list.", upstreamBalanceProbeTemplate:"Choose the parser used for balance lookup. usage_generic calls /v1/usage with the upstream API key.", upstreamBalanceProbeUrl:"Auto-filled and disabled for known templates. NewAPI generic keeps this editable. Example: https://code.shoestravel.xin/api/user/self", upstreamBalanceProbeUserId:"Only needed by NewAPI-style balance endpoints that require a user id.", upstreamBalanceProbeRechargeUrl:"Where operators recharge this upstream account. Example: https://code.shoestravel.xin/topup or https://openrouter.ai/settings/credits", maxConnections:"Seller concurrency limit. Each connection can serve one in-flight request.", maxQueueDepth:"Number of queued requests allowed when all connections are busy.", markupRatio:"Multiplier applied to upstream prices before discount. 1.0 = passthrough.", discountRatio:"Configured discount level after markup. 1.0 = standard price, 0.5 = 5折, 0.0 = free.", clawtipPayTo:"Alipay payTo target for ClawTip payments.", clawtipSm4KeyBase64:"Base64 SM4 symmetric key used to encrypt ClawTip payment requests." }; const options = { ...numericFieldOptions(key) }; if (labels[key]) options.label = labels[key]; if (help[key]) options.help = help[key]; return options; }
|
|
646
|
-
function randomAppSuffix(){ return Math.random().toString(36).replace(/[^a-z0-9]/g, "").slice(2, 7) || "x7p9k"; }
|
|
647
|
-
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(); }
|
|
648
|
-
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"; }
|
|
649
|
-
function createFieldString(key){ const input = document.querySelector('#createFields [data-field="'+key+'"]'); return input ? String(input.value || "").trim() : ""; }
|
|
650
|
-
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; }); }
|
|
651
|
-
function appNameFromSellerName(value){ return "tbs-" + sellerSlugFromName(value) + "-" + createAppSuffix; }
|
|
652
|
-
function sellerSlugFromName(value){ const slug = String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-"); return slug || "seller"; }
|
|
653
|
-
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 ""; }
|
|
654
|
-
function usageUrl(value){ const base = String(value || "").trim().replace(/\\/+$/,""); if (!base) return ""; return /\\/v1$/i.test(base) ? base + "/usage" : base + "/v1/usage"; }
|
|
655
|
-
function hostName(value){ try { return new URL(String(value || "")).hostname.toLowerCase(); } catch { return ""; } }
|
|
656
|
-
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(); }
|
|
657
|
-
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)); }
|
|
658
|
-
function togglePaymentMethod(method){ const panel = document.querySelector('[data-payment-panel="'+method+'"]'); if (!panel) return; panel.dataset.enabled = String(panel.dataset.enabled !== "true"); updatePaymentPanels(); }
|
|
659
|
-
function enabledPaymentMethods(){ return Array.from(document.querySelectorAll("[data-payment-panel]")).filter(panel => panel.dataset.enabled === "true").map(panel => panel.dataset.paymentPanel); }
|
|
660
|
-
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; }); }); }
|
|
661
|
-
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" }; }
|
|
662
|
-
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"); } };
|
|
663
|
-
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); }
|
|
664
|
-
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"; } }
|
|
665
|
-
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>'; }
|
|
666
|
-
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(); }
|
|
667
|
-
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); };
|
|
668
|
-
document.getElementById("closeBootstrapConfig").onclick = () => document.getElementById("bootstrapModal").classList.remove("open");
|
|
669
|
-
function fieldValue(input){ return numeric(input.value); }
|
|
670
|
-
function numeric(value){ const n = Number(value); return value !== "" && Number.isFinite(n) ? n : value; }
|
|
671
|
-
startSellerRefresh();
|
|
672
|
-
`;
|
|
673
|
-
}
|