@tokenbuddy/tb-admin 1.0.14 → 1.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/bootstrap-registry.d.ts +1 -0
- package/dist/src/bootstrap-registry.d.ts.map +1 -1
- package/dist/src/bootstrap-registry.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +294 -13
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +12 -3
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +12 -8
- package/dist/src/client.js.map +1 -1
- package/dist/src/display-format.d.ts +39 -0
- package/dist/src/display-format.d.ts.map +1 -0
- package/dist/src/display-format.js +354 -0
- package/dist/src/display-format.js.map +1 -0
- package/dist/src/server-cmd.d.ts +25 -1
- package/dist/src/server-cmd.d.ts.map +1 -1
- package/dist/src/server-cmd.js +116 -16
- package/dist/src/server-cmd.js.map +1 -1
- package/dist/src/ui-actions.d.ts +90 -0
- package/dist/src/ui-actions.d.ts.map +1 -0
- package/dist/src/ui-actions.js +823 -0
- package/dist/src/ui-actions.js.map +1 -0
- package/dist/src/ui-command.d.ts +4 -0
- package/dist/src/ui-command.d.ts.map +1 -0
- package/dist/src/ui-command.js +37 -0
- package/dist/src/ui-command.js.map +1 -0
- package/dist/src/ui-server.d.ts +22 -0
- package/dist/src/ui-server.d.ts.map +1 -0
- package/dist/src/ui-server.js +261 -0
- package/dist/src/ui-server.js.map +1 -0
- package/dist/src/ui-state.d.ts +140 -0
- package/dist/src/ui-state.d.ts.map +1 -0
- package/dist/src/ui-state.js +438 -0
- package/dist/src/ui-state.js.map +1 -0
- package/dist/src/ui-static.d.ts +2 -0
- package/dist/src/ui-static.d.ts.map +1 -0
- package/dist/src/ui-static.js +469 -0
- package/dist/src/ui-static.js.map +1 -0
- package/dist/src/upstream-balance-probe.d.ts +41 -0
- package/dist/src/upstream-balance-probe.d.ts.map +1 -0
- package/dist/src/upstream-balance-probe.js +379 -0
- package/dist/src/upstream-balance-probe.js.map +1 -0
- package/package.json +1 -1
- package/src/bootstrap-registry.ts +1 -0
- package/src/cli.ts +335 -13
- package/src/client.ts +13 -8
- package/src/display-format.ts +398 -0
- package/src/server-cmd.ts +145 -20
- package/src/ui-actions.ts +958 -0
- package/src/ui-command.ts +39 -0
- package/src/ui-server.ts +322 -0
- package/src/ui-state.ts +614 -0
- package/src/ui-static.ts +472 -0
- package/src/upstream-balance-probe.ts +505 -0
- package/tests/admin.test.ts +1404 -2
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// Shared display formatters for TokenBuddy UI surfaces.
|
|
2
|
+
//
|
|
3
|
+
// This module is a portable copy of `packages/tb-ui/src/lib/display-format.ts`
|
|
4
|
+
// and the canonical formatter functions declared in `DESIGN.md`
|
|
5
|
+
// ("Formatter Implementation" section). Keep both files in sync — the
|
|
6
|
+
// rule from DESIGN.md is:
|
|
7
|
+
// "Do not add new per-page formatting helpers for these domains.
|
|
8
|
+
// If a page needs a different display, add an explicit option
|
|
9
|
+
// to the shared formatter."
|
|
10
|
+
//
|
|
11
|
+
// Differences vs. the React copy:
|
|
12
|
+
// 1. No React-only imports.
|
|
13
|
+
// 2. Adds `formatBalanceAmount` and `formatSellerStatus` for the
|
|
14
|
+
// admin surface, because the admin UI surfaces live balance
|
|
15
|
+
// numbers and node-status dots that the buyer UI never shows.
|
|
16
|
+
|
|
17
|
+
export const UNKNOWN_VALUE = "—";
|
|
18
|
+
|
|
19
|
+
const PAD2 = (n: number) => (n < 10 ? `0${n}` : `${n}`);
|
|
20
|
+
|
|
21
|
+
export function formatTokenCount(
|
|
22
|
+
value: number | undefined | null,
|
|
23
|
+
options: { compact?: boolean } = {}
|
|
24
|
+
): string {
|
|
25
|
+
if (!Number.isFinite(value)) return UNKNOWN_VALUE;
|
|
26
|
+
const numeric = value as number;
|
|
27
|
+
if (options.compact === false) return Math.round(numeric).toLocaleString("en-US");
|
|
28
|
+
if (numeric < 10_000) return Math.round(numeric).toLocaleString("en-US");
|
|
29
|
+
if (numeric < 1_000_000) return `${(numeric / 1_000).toFixed(1)}K`;
|
|
30
|
+
if (numeric < 1_000_000_000) return `${(numeric / 1_000_000).toFixed(1)}M`;
|
|
31
|
+
return `${(numeric / 1_000_000_000).toFixed(2)}B`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatTokenPair(
|
|
35
|
+
input: number | undefined | null,
|
|
36
|
+
output: number | undefined | null,
|
|
37
|
+
options: { compact?: boolean; separator?: string } = {}
|
|
38
|
+
): string {
|
|
39
|
+
const separator = options.separator ?? " / ";
|
|
40
|
+
return `In ${formatTokenCount(input, options)}${separator}Out ${formatTokenCount(output, options)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatCount(value: number | undefined | null): string {
|
|
44
|
+
if (!Number.isFinite(value)) return UNKNOWN_VALUE;
|
|
45
|
+
return Math.round(value as number).toLocaleString("en-US");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatMoney(
|
|
49
|
+
micros: number | undefined | null,
|
|
50
|
+
options: { digits?: number; ledger?: boolean; signed?: boolean } = {}
|
|
51
|
+
): string {
|
|
52
|
+
if (!Number.isFinite(micros)) return UNKNOWN_VALUE;
|
|
53
|
+
const amount = Math.abs((micros as number) / 1_000_000);
|
|
54
|
+
const digits = options.digits ?? (options.ledger && amount < 0.01 ? 6 : 4);
|
|
55
|
+
const formatted = `$${((micros as number) / 1_000_000).toFixed(digits)}`;
|
|
56
|
+
return options.signed && (micros as number) >= 0 ? `+${formatted}` : formatted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatMoneyPair(
|
|
60
|
+
actualMicros: number | undefined | null,
|
|
61
|
+
referenceMicros: number | undefined | null,
|
|
62
|
+
options: { digits?: number } = {}
|
|
63
|
+
): string {
|
|
64
|
+
if (!Number.isFinite(actualMicros) && !Number.isFinite(referenceMicros)) return UNKNOWN_VALUE;
|
|
65
|
+
return `${formatMoney(actualMicros, options)} / ${formatMoney(referenceMicros, options)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatDuration(valueMs: number | undefined | null): string {
|
|
69
|
+
if (!Number.isFinite(valueMs)) return UNKNOWN_VALUE;
|
|
70
|
+
const ms = Math.max(0, Math.round(valueMs as number));
|
|
71
|
+
if (ms < 1000) return `${ms}ms`;
|
|
72
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatPercent(value: number | undefined | null): string {
|
|
76
|
+
if (!Number.isFinite(value)) return UNKNOWN_VALUE;
|
|
77
|
+
return `${Math.round((value as number) * 100)}%`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatDiscountRatio(discountRatio: number | undefined | null): string {
|
|
81
|
+
if (!Number.isFinite(discountRatio)) return UNKNOWN_VALUE;
|
|
82
|
+
const discount = Math.max(0, 1 - (discountRatio as number));
|
|
83
|
+
return formatPercent(discount);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatPriceMicrosPerMillion(value: number | undefined | null): string {
|
|
87
|
+
return formatMoney(value, { digits: 4 });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function formatPricePair(
|
|
91
|
+
inputMicros: number | undefined | null,
|
|
92
|
+
outputMicros: number | undefined | null
|
|
93
|
+
): string {
|
|
94
|
+
return formatMoneyPair(inputMicros, outputMicros, { digits: 4 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function normalizeStatusLabel(status: string | undefined | null): string {
|
|
98
|
+
if (!status) return UNKNOWN_VALUE;
|
|
99
|
+
return status.trim().toLowerCase().replaceAll("_", " ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type StatusTone = "green" | "amber" | "red" | "blue" | "gray";
|
|
103
|
+
|
|
104
|
+
export function statusTone(status: string | undefined | null): StatusTone {
|
|
105
|
+
const normalized = normalizeStatusLabel(status);
|
|
106
|
+
if (
|
|
107
|
+
normalized === "ok" ||
|
|
108
|
+
normalized === "online" ||
|
|
109
|
+
normalized === "configured" ||
|
|
110
|
+
normalized === "settled" ||
|
|
111
|
+
normalized === "completed" ||
|
|
112
|
+
normalized === "success" ||
|
|
113
|
+
normalized === "active" ||
|
|
114
|
+
normalized === "healthy"
|
|
115
|
+
) {
|
|
116
|
+
return "green";
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
normalized === "fallback" ||
|
|
120
|
+
normalized === "pending" ||
|
|
121
|
+
normalized === "degraded" ||
|
|
122
|
+
normalized === "preview" ||
|
|
123
|
+
normalized === "draining" ||
|
|
124
|
+
normalized === "busy capacity" ||
|
|
125
|
+
normalized === "auth unknown"
|
|
126
|
+
) {
|
|
127
|
+
return "amber";
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
normalized === "failed" ||
|
|
131
|
+
normalized === "error" ||
|
|
132
|
+
normalized === "canceled" ||
|
|
133
|
+
normalized === "unhealthy" ||
|
|
134
|
+
normalized === "offline"
|
|
135
|
+
) {
|
|
136
|
+
return "red";
|
|
137
|
+
}
|
|
138
|
+
if (normalized === "running") return "blue";
|
|
139
|
+
return "gray";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatHash(value: string | undefined | null, length = 32): string {
|
|
143
|
+
if (!value) return UNKNOWN_VALUE;
|
|
144
|
+
return value.length > length ? `${value.slice(0, length)}...` : value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function formatSellerId(value: string | undefined | null): string {
|
|
148
|
+
if (!value) return UNKNOWN_VALUE;
|
|
149
|
+
if (value.startsWith("tbs-") && value.length > 10) return value.slice(0, 10);
|
|
150
|
+
if (value.length <= 12) return value;
|
|
151
|
+
return value.slice(0, 12);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function formatTimeCompact(iso: string | undefined | null): string {
|
|
155
|
+
if (!iso) return UNKNOWN_VALUE;
|
|
156
|
+
const d = new Date(iso);
|
|
157
|
+
if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
|
|
158
|
+
const now = new Date();
|
|
159
|
+
const sameDay =
|
|
160
|
+
d.getFullYear() === now.getFullYear() &&
|
|
161
|
+
d.getMonth() === now.getMonth() &&
|
|
162
|
+
d.getDate() === now.getDate();
|
|
163
|
+
const time = `${PAD2(d.getHours())}:${PAD2(d.getMinutes())}`;
|
|
164
|
+
if (sameDay) return time;
|
|
165
|
+
return `${PAD2(d.getMonth() + 1)}/${PAD2(d.getDate())} ${time}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function formatTimeFull(iso: string | undefined | null): string {
|
|
169
|
+
if (!iso) return UNKNOWN_VALUE;
|
|
170
|
+
const d = new Date(iso);
|
|
171
|
+
if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
|
|
172
|
+
return (
|
|
173
|
+
`${d.getFullYear()}-${PAD2(d.getMonth() + 1)}-${PAD2(d.getDate())} ` +
|
|
174
|
+
`${PAD2(d.getHours())}:${PAD2(d.getMinutes())}:${PAD2(d.getSeconds())}`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function formatTimeLedger(iso: string | undefined | null): string {
|
|
179
|
+
if (!iso) return UNKNOWN_VALUE;
|
|
180
|
+
const d = new Date(iso);
|
|
181
|
+
if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
|
|
182
|
+
return (
|
|
183
|
+
`${d.getFullYear()}/${PAD2(d.getMonth() + 1)}/${PAD2(d.getDate())} ` +
|
|
184
|
+
`${PAD2(d.getHours())}:${PAD2(d.getMinutes())}:${PAD2(d.getSeconds())}`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function formatRouteSwitchTime(iso: string | undefined | null): string {
|
|
189
|
+
return formatTimeCompact(iso);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- admin-only extensions ---------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export function formatBalanceAmount(
|
|
195
|
+
usdMicros: number | undefined | null,
|
|
196
|
+
currency: string | undefined | null
|
|
197
|
+
): string {
|
|
198
|
+
if (!Number.isFinite(usdMicros)) return UNKNOWN_VALUE;
|
|
199
|
+
const amount = (usdMicros as number) / 1_000_000;
|
|
200
|
+
const digits = Math.abs(amount) >= 100 ? 0 : 2;
|
|
201
|
+
const code = (currency || "USD").toUpperCase();
|
|
202
|
+
return `${code} ${amount.toFixed(digits)}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function formatSellerCapacity(
|
|
206
|
+
used: number | undefined | null,
|
|
207
|
+
limit: number | undefined | null
|
|
208
|
+
): string {
|
|
209
|
+
if (!Number.isFinite(used) && !Number.isFinite(limit)) return UNKNOWN_VALUE;
|
|
210
|
+
const u = Number.isFinite(used) ? Math.round(used as number) : UNKNOWN_VALUE;
|
|
211
|
+
const l = Number.isFinite(limit) ? Math.round(limit as number) : UNKNOWN_VALUE;
|
|
212
|
+
return `${u} / ${l}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function formatSpeed(value: number | undefined | null): string {
|
|
216
|
+
if (!Number.isFinite(value)) return UNKNOWN_VALUE;
|
|
217
|
+
return `${(value as number).toFixed(1)} tok/s`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export type CanonicalStatus =
|
|
221
|
+
| "ok"
|
|
222
|
+
| "online"
|
|
223
|
+
| "configured"
|
|
224
|
+
| "pending"
|
|
225
|
+
| "degraded"
|
|
226
|
+
| "error"
|
|
227
|
+
| "unknown";
|
|
228
|
+
|
|
229
|
+
const SELLER_STATUS_MAP: Record<string, CanonicalStatus> = {
|
|
230
|
+
active: "ok",
|
|
231
|
+
healthy: "ok",
|
|
232
|
+
online: "online",
|
|
233
|
+
configured: "configured",
|
|
234
|
+
pending: "pending",
|
|
235
|
+
draining: "degraded",
|
|
236
|
+
degraded: "degraded",
|
|
237
|
+
busy_capacity: "degraded",
|
|
238
|
+
offline: "error",
|
|
239
|
+
unhealthy: "error",
|
|
240
|
+
error: "error",
|
|
241
|
+
auth_unknown: "unknown",
|
|
242
|
+
unknown: "unknown"
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export function formatSellerStatus(status: string | undefined | null): CanonicalStatus {
|
|
246
|
+
const key = String(status || "unknown")
|
|
247
|
+
.trim()
|
|
248
|
+
.toLowerCase()
|
|
249
|
+
.replace(/-/g, "_");
|
|
250
|
+
return SELLER_STATUS_MAP[key] || "unknown";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function sellerStatusTone(status: string | undefined | null): StatusTone {
|
|
254
|
+
return statusTone(formatSellerStatus(status));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Inlined browser bundle — emits a self-invoking function that
|
|
258
|
+
// attaches the same formatter API to `window.__tbFmt`. The HTML
|
|
259
|
+
// served by `tb-admin ui` includes this bundle verbatim so the
|
|
260
|
+
// page can use the shared spec-compliant helpers without an
|
|
261
|
+
// extra <script src> round-trip. The JS bodies below MUST stay
|
|
262
|
+
// in lockstep with the TS implementations above.
|
|
263
|
+
export function displayFormatBundle(): string {
|
|
264
|
+
return `(() => {
|
|
265
|
+
const UNKNOWN_VALUE = ${JSON.stringify(UNKNOWN_VALUE)};
|
|
266
|
+
const PAD2 = (n) => (n < 10 ? "0" + n : "" + n);
|
|
267
|
+
function formatTokenCount(value, options) {
|
|
268
|
+
options = options || {};
|
|
269
|
+
if (!Number.isFinite(value)) return UNKNOWN_VALUE;
|
|
270
|
+
const numeric = value;
|
|
271
|
+
if (options.compact === false) return Math.round(numeric).toLocaleString("en-US");
|
|
272
|
+
if (numeric < 10000) return Math.round(numeric).toLocaleString("en-US");
|
|
273
|
+
if (numeric < 1000000) return (numeric / 1000).toFixed(1) + "K";
|
|
274
|
+
if (numeric < 1000000000) return (numeric / 1000000).toFixed(1) + "M";
|
|
275
|
+
return (numeric / 1000000000).toFixed(2) + "B";
|
|
276
|
+
}
|
|
277
|
+
function formatTokenPair(input, output, options) {
|
|
278
|
+
options = options || {};
|
|
279
|
+
const separator = options.separator == null ? " / " : options.separator;
|
|
280
|
+
return "In " + formatTokenCount(input, options) + separator + "Out " + formatTokenCount(output, options);
|
|
281
|
+
}
|
|
282
|
+
function formatCount(value) {
|
|
283
|
+
if (!Number.isFinite(value)) return UNKNOWN_VALUE;
|
|
284
|
+
return Math.round(value).toLocaleString("en-US");
|
|
285
|
+
}
|
|
286
|
+
function formatMoney(micros, options) {
|
|
287
|
+
options = options || {};
|
|
288
|
+
if (!Number.isFinite(micros)) return UNKNOWN_VALUE;
|
|
289
|
+
const amount = Math.abs(micros / 1000000);
|
|
290
|
+
const digits = options.digits != null ? options.digits : (options.ledger && amount < 0.01 ? 6 : 4);
|
|
291
|
+
const formatted = "$" + (micros / 1000000).toFixed(digits);
|
|
292
|
+
return options.signed && micros >= 0 ? "+" + formatted : formatted;
|
|
293
|
+
}
|
|
294
|
+
function formatMoneyPair(actualMicros, referenceMicros, options) {
|
|
295
|
+
options = options || {};
|
|
296
|
+
if (!Number.isFinite(actualMicros) && !Number.isFinite(referenceMicros)) return UNKNOWN_VALUE;
|
|
297
|
+
return formatMoney(actualMicros, options) + " / " + formatMoney(referenceMicros, options);
|
|
298
|
+
}
|
|
299
|
+
function formatDuration(valueMs) {
|
|
300
|
+
if (!Number.isFinite(valueMs)) return UNKNOWN_VALUE;
|
|
301
|
+
const ms = Math.max(0, Math.round(valueMs));
|
|
302
|
+
if (ms < 1000) return ms + "ms";
|
|
303
|
+
return (ms / 1000).toFixed(2) + "s";
|
|
304
|
+
}
|
|
305
|
+
function formatPercent(value) {
|
|
306
|
+
if (!Number.isFinite(value)) return UNKNOWN_VALUE;
|
|
307
|
+
return Math.round(value * 100) + "%";
|
|
308
|
+
}
|
|
309
|
+
function formatDiscountRatio(discountRatio) {
|
|
310
|
+
if (!Number.isFinite(discountRatio)) return UNKNOWN_VALUE;
|
|
311
|
+
const discount = Math.max(0, 1 - discountRatio);
|
|
312
|
+
return formatPercent(discount);
|
|
313
|
+
}
|
|
314
|
+
function formatPriceMicrosPerMillion(value) { return formatMoney(value, { digits: 4 }); }
|
|
315
|
+
function formatPricePair(inputMicros, outputMicros) { return formatMoneyPair(inputMicros, outputMicros, { digits: 4 }); }
|
|
316
|
+
function normalizeStatusLabel(status) {
|
|
317
|
+
if (!status) return UNKNOWN_VALUE;
|
|
318
|
+
return String(status).trim().toLowerCase().replaceAll("_", " ");
|
|
319
|
+
}
|
|
320
|
+
function statusTone(status) {
|
|
321
|
+
const n = normalizeStatusLabel(status);
|
|
322
|
+
if (n === "ok" || n === "online" || n === "configured" || n === "settled" || n === "completed" || n === "success" || n === "active" || n === "healthy") return "green";
|
|
323
|
+
if (n === "fallback" || n === "pending" || n === "degraded" || n === "preview" || n === "draining" || n === "busy capacity" || n === "auth unknown") return "amber";
|
|
324
|
+
if (n === "failed" || n === "error" || n === "canceled" || n === "unhealthy" || n === "offline") return "red";
|
|
325
|
+
if (n === "running") return "blue";
|
|
326
|
+
return "gray";
|
|
327
|
+
}
|
|
328
|
+
function formatHash(value, length) {
|
|
329
|
+
if (length == null) length = 32;
|
|
330
|
+
if (!value) return UNKNOWN_VALUE;
|
|
331
|
+
return value.length > length ? value.slice(0, length) + "..." : value;
|
|
332
|
+
}
|
|
333
|
+
function formatSellerId(value) {
|
|
334
|
+
if (!value) return UNKNOWN_VALUE;
|
|
335
|
+
if (value.startsWith("tbs-") && value.length > 10) return value.slice(0, 10);
|
|
336
|
+
if (value.length <= 12) return value;
|
|
337
|
+
return value.slice(0, 12);
|
|
338
|
+
}
|
|
339
|
+
function formatTimeCompact(iso) {
|
|
340
|
+
if (!iso) return UNKNOWN_VALUE;
|
|
341
|
+
const d = new Date(iso);
|
|
342
|
+
if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
|
|
343
|
+
const now = new Date();
|
|
344
|
+
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
|
345
|
+
const time = PAD2(d.getHours()) + ":" + PAD2(d.getMinutes());
|
|
346
|
+
if (sameDay) return time;
|
|
347
|
+
return PAD2(d.getMonth() + 1) + "/" + PAD2(d.getDate()) + " " + time;
|
|
348
|
+
}
|
|
349
|
+
function formatTimeFull(iso) {
|
|
350
|
+
if (!iso) return UNKNOWN_VALUE;
|
|
351
|
+
const d = new Date(iso);
|
|
352
|
+
if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
|
|
353
|
+
return d.getFullYear() + "-" + PAD2(d.getMonth() + 1) + "-" + PAD2(d.getDate()) + " " + PAD2(d.getHours()) + ":" + PAD2(d.getMinutes()) + ":" + PAD2(d.getSeconds());
|
|
354
|
+
}
|
|
355
|
+
function formatTimeLedger(iso) {
|
|
356
|
+
if (!iso) return UNKNOWN_VALUE;
|
|
357
|
+
const d = new Date(iso);
|
|
358
|
+
if (Number.isNaN(d.getTime())) return UNKNOWN_VALUE;
|
|
359
|
+
return d.getFullYear() + "/" + PAD2(d.getMonth() + 1) + "/" + PAD2(d.getDate()) + " " + PAD2(d.getHours()) + ":" + PAD2(d.getMinutes()) + ":" + PAD2(d.getSeconds());
|
|
360
|
+
}
|
|
361
|
+
function formatRouteSwitchTime(iso) { return formatTimeCompact(iso); }
|
|
362
|
+
function formatBalanceAmount(usdMicros, currency) {
|
|
363
|
+
if (!Number.isFinite(usdMicros)) return UNKNOWN_VALUE;
|
|
364
|
+
const amount = usdMicros / 1000000;
|
|
365
|
+
const digits = Math.abs(amount) >= 100 ? 0 : 2;
|
|
366
|
+
const code = (currency || "USD").toUpperCase();
|
|
367
|
+
return code + " " + amount.toFixed(digits);
|
|
368
|
+
}
|
|
369
|
+
function formatSellerCapacity(used, limit) {
|
|
370
|
+
if (!Number.isFinite(used) && !Number.isFinite(limit)) return UNKNOWN_VALUE;
|
|
371
|
+
const u = Number.isFinite(used) ? Math.round(used) : UNKNOWN_VALUE;
|
|
372
|
+
const l = Number.isFinite(limit) ? Math.round(limit) : UNKNOWN_VALUE;
|
|
373
|
+
return u + " / " + l;
|
|
374
|
+
}
|
|
375
|
+
function formatSpeed(value) {
|
|
376
|
+
if (!Number.isFinite(value)) return UNKNOWN_VALUE;
|
|
377
|
+
return value.toFixed(1) + " tok/s";
|
|
378
|
+
}
|
|
379
|
+
const SELLER_STATUS_MAP = {
|
|
380
|
+
active: "ok", healthy: "ok", online: "online", configured: "configured",
|
|
381
|
+
pending: "pending", draining: "degraded", degraded: "degraded",
|
|
382
|
+
busy_capacity: "degraded", offline: "error", unhealthy: "error",
|
|
383
|
+
error: "error", auth_unknown: "unknown", unknown: "unknown"
|
|
384
|
+
};
|
|
385
|
+
function formatSellerStatus(status) {
|
|
386
|
+
const key = String(status || "unknown").trim().toLowerCase().replace(/-/g, "_");
|
|
387
|
+
return SELLER_STATUS_MAP[key] || "unknown";
|
|
388
|
+
}
|
|
389
|
+
function sellerStatusTone(status) { return statusTone(formatSellerStatus(status)); }
|
|
390
|
+
window.__tbFmt = {
|
|
391
|
+
UNKNOWN_VALUE, formatTokenCount, formatTokenPair, formatCount, formatMoney, formatMoneyPair,
|
|
392
|
+
formatDuration, formatPercent, formatDiscountRatio, formatPriceMicrosPerMillion, formatPricePair,
|
|
393
|
+
normalizeStatusLabel, statusTone, formatHash, formatSellerId, formatTimeCompact, formatTimeFull,
|
|
394
|
+
formatTimeLedger, formatRouteSwitchTime, formatBalanceAmount, formatSellerCapacity, formatSpeed,
|
|
395
|
+
formatSellerStatus, sellerStatusTone
|
|
396
|
+
};
|
|
397
|
+
})();`;
|
|
398
|
+
}
|
package/src/server-cmd.ts
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
import { execSync } from "child_process";
|
|
1
|
+
import { execSync, spawnSync, type SpawnSyncReturns } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
2
3
|
import { SellerProviderConfig } from "./config.js";
|
|
3
4
|
|
|
5
|
+
type ExecRunner = (command: string, options?: Parameters<typeof execSync>[1]) => string | Buffer;
|
|
6
|
+
type SpawnRunner = (command: string, args?: string[], options?: Parameters<typeof spawnSync>[2]) => SpawnSyncReturns<string | Buffer>;
|
|
7
|
+
|
|
8
|
+
export interface DockerImageInspection {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
error?: string;
|
|
11
|
+
exitCode?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ImageInspectRunner = (image: string) => DockerImageInspection;
|
|
15
|
+
|
|
16
|
+
export interface FlyProviderRuntime {
|
|
17
|
+
checkFlyctlInstalled?: (flyctlPath?: string) => boolean;
|
|
18
|
+
execSync?: ExecRunner;
|
|
19
|
+
spawnSync?: SpawnRunner;
|
|
20
|
+
imageInspector?: ImageInspectRunner;
|
|
21
|
+
}
|
|
22
|
+
|
|
4
23
|
/**
|
|
5
24
|
* 检查 flyctl 是否在 PATH 中(或在指定路径)。
|
|
6
25
|
*
|
|
@@ -16,6 +35,41 @@ export function checkFlyctlInstalled(flyctlPath?: string): boolean {
|
|
|
16
35
|
}
|
|
17
36
|
}
|
|
18
37
|
|
|
38
|
+
export function inspectDockerImage(image: string): DockerImageInspection {
|
|
39
|
+
const result = spawnSync("docker", ["buildx", "imagetools", "inspect", image], {
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
42
|
+
});
|
|
43
|
+
return dockerImageInspectionFromResult(result);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function requirePublishedDockerImage(image: string, inspectImage: ImageInspectRunner = inspectDockerImage): void {
|
|
47
|
+
const inspection = inspectImage(image);
|
|
48
|
+
if (inspection.ok) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const detail = inspection.error ? ` Detail: ${inspection.error.trim()}` : "";
|
|
52
|
+
throw new Error(
|
|
53
|
+
`seller image is not published or is not accessible: ${image}. ` +
|
|
54
|
+
`Publish it first with RELEASE_VERSION=<v> bash scripts/release/all.sh, or pass an existing registry.fly.io/tb-seller:<v> tag. ` +
|
|
55
|
+
`No Fly app was created.${detail}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function dockerImageInspectionFromResult(result: SpawnSyncReturns<string>): DockerImageInspection {
|
|
60
|
+
if (result.error) {
|
|
61
|
+
return { ok: false, error: result.error.message };
|
|
62
|
+
}
|
|
63
|
+
if (result.status !== 0) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
exitCode: result.status ?? undefined,
|
|
67
|
+
error: (result.stderr || result.stdout || "").toString()
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
19
73
|
export function parseFlyMachineIds(json: string, app: string): string[] {
|
|
20
74
|
let parsed: unknown;
|
|
21
75
|
try {
|
|
@@ -60,6 +114,7 @@ export interface SellerCreateOptions {
|
|
|
60
114
|
volumeSizeGb?: number;
|
|
61
115
|
volumeId?: string;
|
|
62
116
|
volumeSnapshotRetentionDays?: number;
|
|
117
|
+
initialConfigPath?: string;
|
|
63
118
|
dryRun?: boolean;
|
|
64
119
|
}
|
|
65
120
|
|
|
@@ -69,23 +124,57 @@ export interface SellerCreateOptions {
|
|
|
69
124
|
*/
|
|
70
125
|
export class FlyProvider {
|
|
71
126
|
private providerConfig?: SellerProviderConfig;
|
|
127
|
+
private readonly runtime: Required<FlyProviderRuntime>;
|
|
72
128
|
|
|
73
|
-
constructor(providerConfig?: SellerProviderConfig) {
|
|
129
|
+
constructor(providerConfig?: SellerProviderConfig, runtime?: FlyProviderRuntime) {
|
|
74
130
|
this.providerConfig = providerConfig;
|
|
131
|
+
this.runtime = {
|
|
132
|
+
checkFlyctlInstalled,
|
|
133
|
+
execSync,
|
|
134
|
+
spawnSync,
|
|
135
|
+
imageInspector: inspectDockerImage,
|
|
136
|
+
...runtime
|
|
137
|
+
};
|
|
75
138
|
}
|
|
76
139
|
|
|
77
140
|
private get flyctl(): string {
|
|
78
141
|
return this.providerConfig?.flyctl_path || "flyctl";
|
|
79
142
|
}
|
|
80
143
|
|
|
144
|
+
private flyExecOptions(options: Parameters<typeof execSync>[1] = {}): Parameters<typeof execSync>[1] {
|
|
145
|
+
return {
|
|
146
|
+
...options,
|
|
147
|
+
env: this.flyEnv(options.env)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private flySpawnOptions(options: Parameters<typeof spawnSync>[2] = {}): Parameters<typeof spawnSync>[2] {
|
|
152
|
+
return {
|
|
153
|
+
...options,
|
|
154
|
+
env: this.flyEnv(options.env)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private flyEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
|
|
159
|
+
const configuredToken = this.providerConfig?.token;
|
|
160
|
+
const merged = {
|
|
161
|
+
...process.env,
|
|
162
|
+
...(env || {})
|
|
163
|
+
};
|
|
164
|
+
if (configuredToken && !merged.FLY_API_TOKEN) {
|
|
165
|
+
merged.FLY_API_TOKEN = configuredToken;
|
|
166
|
+
}
|
|
167
|
+
return merged;
|
|
168
|
+
}
|
|
169
|
+
|
|
81
170
|
/**
|
|
82
171
|
* List apps on Fly.io
|
|
83
172
|
*/
|
|
84
173
|
public listApps(): string {
|
|
85
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
174
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
86
175
|
throw new Error(`\`${this.flyctl}\` is not installed on your system PATH.`);
|
|
87
176
|
}
|
|
88
|
-
return execSync(`${this.flyctl} apps list`, { encoding: "utf8" });
|
|
177
|
+
return this.runtime.execSync(`${this.flyctl} apps list`, this.flyExecOptions({ encoding: "utf8" })) as string;
|
|
89
178
|
}
|
|
90
179
|
|
|
91
180
|
/**
|
|
@@ -112,6 +201,7 @@ export class FlyProvider {
|
|
|
112
201
|
|| 1;
|
|
113
202
|
const volumeSnapshotRetentionDays = options.volumeSnapshotRetentionDays;
|
|
114
203
|
const volumeId = options.volumeId;
|
|
204
|
+
const initialConfigPath = options.initialConfigPath;
|
|
115
205
|
|
|
116
206
|
if (!targetImage) {
|
|
117
207
|
throw new Error("seller create requires --image registry.fly.io/tb-seller:<v>");
|
|
@@ -129,35 +219,59 @@ export class FlyProvider {
|
|
|
129
219
|
` Volume: ${volumeName} (${volumeSizeGb}GB)`,
|
|
130
220
|
];
|
|
131
221
|
if (flyConfig) lines.push(` Fly config: ${flyConfig}`);
|
|
222
|
+
if (initialConfigPath) lines.push(` Initial config secret: TOKENBUDDY_SELLER_CONFIG_B64 from ${initialConfigPath}`);
|
|
132
223
|
if (volumeId) lines.push(` Volume ID: ${volumeId}`);
|
|
133
224
|
if (volumeSnapshotRetentionDays !== undefined) lines.push(` Volume snapshot retention: ${volumeSnapshotRetentionDays} days`);
|
|
134
225
|
return lines.join("\n");
|
|
135
226
|
}
|
|
136
227
|
|
|
137
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
228
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
138
229
|
throw new Error(`\`${this.flyctl}\` is not installed on PATH.`);
|
|
139
230
|
}
|
|
140
231
|
|
|
141
232
|
if (!operatorSecret) {
|
|
142
233
|
throw new Error("operator_secret is required. Provide --operator-secret or configure seller_providers.fly.operator_secret");
|
|
143
234
|
}
|
|
235
|
+
requireReadableFile(flyConfig, "Fly config");
|
|
236
|
+
if (initialConfigPath) {
|
|
237
|
+
requireReadableFile(initialConfigPath, "Initial seller config");
|
|
238
|
+
}
|
|
239
|
+
requirePublishedDockerImage(targetImage, this.runtime.imageInspector);
|
|
144
240
|
|
|
145
241
|
console.log(`[Fly.io] Creating app ${appName}...`);
|
|
146
|
-
execSync(`${this.flyctl} apps create ${appName} --machines`, { stdio: "inherit" });
|
|
242
|
+
this.runtime.execSync(`${this.flyctl} apps create ${appName} --machines`, this.flyExecOptions({ stdio: "inherit" }));
|
|
147
243
|
|
|
148
244
|
console.log(`[Fly.io] Setting secrets...`);
|
|
149
|
-
|
|
150
|
-
`${this.flyctl} secrets set ALLOW_MOCK=false OPERATOR_SECRET=${operatorSecret} --app ${appName}`,
|
|
151
|
-
{ stdio: "inherit" }
|
|
152
|
-
);
|
|
245
|
+
this.importCreateSecrets(appName, operatorSecret, initialConfigPath);
|
|
153
246
|
|
|
154
247
|
console.log(`[Fly.io] Deploying image ${targetImage}...`);
|
|
155
|
-
const deployCmd = `${this.flyctl} deploy -c ${flyConfig} --image ${targetImage} --region ${targetRegion} --app ${appName} --now`;
|
|
156
|
-
execSync(deployCmd, { stdio: "inherit" });
|
|
248
|
+
const deployCmd = `${this.flyctl} deploy -c ${flyConfig} --image ${targetImage} --primary-region ${targetRegion} --app ${appName} --now`;
|
|
249
|
+
this.runtime.execSync(deployCmd, this.flyExecOptions({ stdio: "inherit" }));
|
|
157
250
|
|
|
158
251
|
return `Successfully deployed ${appName} on Fly.io`;
|
|
159
252
|
}
|
|
160
253
|
|
|
254
|
+
private importCreateSecrets(appName: string, operatorSecret: string, initialConfigPath: string | undefined): void {
|
|
255
|
+
const lines = [
|
|
256
|
+
"ALLOW_MOCK=false",
|
|
257
|
+
`OPERATOR_SECRET=${operatorSecret}`
|
|
258
|
+
];
|
|
259
|
+
if (initialConfigPath) {
|
|
260
|
+
const configContent = fs.readFileSync(initialConfigPath, "utf8");
|
|
261
|
+
lines.push(`TOKENBUDDY_SELLER_CONFIG_B64=${Buffer.from(configContent, "utf8").toString("base64")}`);
|
|
262
|
+
}
|
|
263
|
+
const result = this.runtime.spawnSync(this.flyctl, ["secrets", "import", "--stage", "--app", appName], this.flySpawnOptions({
|
|
264
|
+
input: `${lines.join("\n")}\n`,
|
|
265
|
+
stdio: ["pipe", "inherit", "inherit"]
|
|
266
|
+
}));
|
|
267
|
+
if (result.error) {
|
|
268
|
+
throw result.error;
|
|
269
|
+
}
|
|
270
|
+
if (result.status !== 0) {
|
|
271
|
+
throw new Error(`flyctl secrets import failed with exit code ${result.status}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
161
275
|
/**
|
|
162
276
|
* Destroy a seller app on Fly.io.
|
|
163
277
|
* @param nameOrApp Either a bare name (e.g. "86d81e") or a full app name (e.g. "tbs-86d81e").
|
|
@@ -170,12 +284,12 @@ export class FlyProvider {
|
|
|
170
284
|
return `[DRY-RUN] Will destroy fly app: ${appName}`;
|
|
171
285
|
}
|
|
172
286
|
|
|
173
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
287
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
174
288
|
throw new Error(`\`${this.flyctl}\` is not installed.`);
|
|
175
289
|
}
|
|
176
290
|
|
|
177
291
|
console.log(`[Fly.io] Destroying app ${appName}...`);
|
|
178
|
-
execSync(`${this.flyctl} apps destroy ${appName} --yes`, { stdio: "inherit" });
|
|
292
|
+
this.runtime.execSync(`${this.flyctl} apps destroy ${appName} --yes`, this.flyExecOptions({ stdio: "inherit" }));
|
|
179
293
|
|
|
180
294
|
return `Successfully destroyed ${appName} on Fly.io`;
|
|
181
295
|
}
|
|
@@ -185,10 +299,10 @@ export class FlyProvider {
|
|
|
185
299
|
*/
|
|
186
300
|
public statusApp(name: string): string {
|
|
187
301
|
const appName = name.includes("-") ? name : `tb-seller-${name}`;
|
|
188
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
302
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
189
303
|
throw new Error(`\`${this.flyctl}\` is not installed.`);
|
|
190
304
|
}
|
|
191
|
-
return execSync(`${this.flyctl} status --app ${appName}`, { encoding: "utf8" });
|
|
305
|
+
return this.runtime.execSync(`${this.flyctl} status --app ${appName}`, this.flyExecOptions({ encoding: "utf8" })) as string;
|
|
192
306
|
}
|
|
193
307
|
|
|
194
308
|
/**
|
|
@@ -218,20 +332,31 @@ export class FlyProvider {
|
|
|
218
332
|
return lines.join("\n");
|
|
219
333
|
}
|
|
220
334
|
|
|
221
|
-
if (!checkFlyctlInstalled(this.flyctl)) {
|
|
335
|
+
if (!this.runtime.checkFlyctlInstalled(this.flyctl)) {
|
|
222
336
|
throw new Error(`\`${this.flyctl}\` is not installed.`);
|
|
223
337
|
}
|
|
224
338
|
|
|
225
|
-
const machinesJson = execSync(`${this.flyctl} machines list --app ${app} --json`, { encoding: "utf8" });
|
|
339
|
+
const machinesJson = this.runtime.execSync(`${this.flyctl} machines list --app ${app} --json`, this.flyExecOptions({ encoding: "utf8" })) as string;
|
|
226
340
|
const machineIds = parseFlyMachineIds(machinesJson, app);
|
|
227
341
|
|
|
228
342
|
console.log(`[Fly.io] Updating ${app} image on ${machineIds.length} machine(s)...`);
|
|
229
343
|
for (const machineId of machineIds) {
|
|
230
|
-
execSync(
|
|
344
|
+
this.runtime.execSync(
|
|
231
345
|
`${this.flyctl} machine update ${machineId} --app ${app} --image ${targetImage} --yes`,
|
|
232
|
-
{ stdio: "inherit" }
|
|
346
|
+
this.flyExecOptions({ stdio: "inherit" })
|
|
233
347
|
);
|
|
234
348
|
}
|
|
235
349
|
return `Successfully updated ${app} image`;
|
|
236
350
|
}
|
|
237
351
|
}
|
|
352
|
+
|
|
353
|
+
function requireReadableFile(filePath: string, label: string): void {
|
|
354
|
+
if (!fs.existsSync(filePath)) {
|
|
355
|
+
throw new Error(`${label} file does not exist: ${filePath}`);
|
|
356
|
+
}
|
|
357
|
+
const stat = fs.statSync(filePath);
|
|
358
|
+
if (!stat.isFile()) {
|
|
359
|
+
throw new Error(`${label} path is not a file: ${filePath}`);
|
|
360
|
+
}
|
|
361
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
362
|
+
}
|