@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,39 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { ConfigManager } from "./config.js";
|
|
3
|
+
import { startAdminUiServer } from "./ui-server.js";
|
|
4
|
+
|
|
5
|
+
export function bindAdminUiCommand(program: Command, configManager: ConfigManager): void {
|
|
6
|
+
program
|
|
7
|
+
.command("ui")
|
|
8
|
+
.description("Start the local TokenBuddy admin UI")
|
|
9
|
+
.option("--host <host>", "Local bind host", "127.0.0.1")
|
|
10
|
+
.option("--port <port>", "Local bind port", parsePort, 17822)
|
|
11
|
+
.option("--no-open", "Do not open a browser")
|
|
12
|
+
.action(async (options) => {
|
|
13
|
+
try {
|
|
14
|
+
const rootOptions = program.opts();
|
|
15
|
+
const started = await startAdminUiServer({
|
|
16
|
+
host: options.host,
|
|
17
|
+
port: options.port,
|
|
18
|
+
openBrowser: Boolean(options.open),
|
|
19
|
+
configManager,
|
|
20
|
+
configPath: rootOptions.config,
|
|
21
|
+
profile: rootOptions.profile || process.env.TOKENBUDDY_ADMIN_PROFILE || "bootstrap",
|
|
22
|
+
url: rootOptions.url || process.env.TOKENBUDDY_ADMIN_URL,
|
|
23
|
+
token: rootOptions.token || process.env.TOKENBUDDY_ADMIN_TOKEN
|
|
24
|
+
});
|
|
25
|
+
console.log(`TokenBuddy admin UI listening on ${started.url}`);
|
|
26
|
+
} catch (err: any) {
|
|
27
|
+
console.error("Error:", err.message);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parsePort(value: string): number {
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
if (!Number.isInteger(parsed)) {
|
|
36
|
+
throw new Error("port must be an integer");
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
package/src/ui-server.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { URL } from "url";
|
|
4
|
+
import { ConfigManager } from "./config.js";
|
|
5
|
+
import { AdminUiState } from "./ui-state.js";
|
|
6
|
+
import { UiActions, type CreateSellerRequest, type UiActionProgressEvent, type UiActionResult } from "./ui-actions.js";
|
|
7
|
+
import { adminUiHtml } from "./ui-static.js";
|
|
8
|
+
|
|
9
|
+
export interface AdminUiServerOptions {
|
|
10
|
+
host: string;
|
|
11
|
+
port: number;
|
|
12
|
+
openBrowser: boolean;
|
|
13
|
+
configManager: ConfigManager;
|
|
14
|
+
configPath?: string;
|
|
15
|
+
profile?: string;
|
|
16
|
+
url?: string;
|
|
17
|
+
token?: string;
|
|
18
|
+
fetchJson?: (url: string, init?: RequestInit) => Promise<unknown>;
|
|
19
|
+
balanceFetch?: typeof fetch;
|
|
20
|
+
commandRunner?: (args: string[], timeoutMs: number) => Promise<UiActionResult>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StartedAdminUiServer {
|
|
24
|
+
server: http.Server;
|
|
25
|
+
url: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type UiJobStatus = "running" | "succeeded" | "failed";
|
|
29
|
+
|
|
30
|
+
interface UiJob {
|
|
31
|
+
id: string;
|
|
32
|
+
kind: "create_seller";
|
|
33
|
+
status: UiJobStatus;
|
|
34
|
+
startedAt: string;
|
|
35
|
+
updatedAt: string;
|
|
36
|
+
title: string;
|
|
37
|
+
events: UiActionProgressEvent[];
|
|
38
|
+
result?: unknown;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function startAdminUiServer(options: AdminUiServerOptions): Promise<StartedAdminUiServer> {
|
|
43
|
+
assertSafeHost(options.host);
|
|
44
|
+
assertSafePort(options.port);
|
|
45
|
+
|
|
46
|
+
const state = new AdminUiState(options);
|
|
47
|
+
const actions = new UiActions(options);
|
|
48
|
+
const jobs = new Map<string, UiJob>();
|
|
49
|
+
const server = http.createServer(async (req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
await routeRequest(req, res, state, actions, jobs);
|
|
52
|
+
} catch (err: any) {
|
|
53
|
+
sendJson(res, 500, { error: err.message || "internal error" });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
server.once("error", reject);
|
|
59
|
+
server.listen(options.port, options.host, () => {
|
|
60
|
+
server.off("error", reject);
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const address = server.address();
|
|
66
|
+
const actualPort = typeof address === "object" && address ? address.port : options.port;
|
|
67
|
+
const url = `http://${options.host}:${actualPort}/`;
|
|
68
|
+
if (options.openBrowser) {
|
|
69
|
+
openLocalBrowser(url).catch(() => undefined);
|
|
70
|
+
}
|
|
71
|
+
return { server, url };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function routeRequest(
|
|
75
|
+
req: http.IncomingMessage,
|
|
76
|
+
res: http.ServerResponse,
|
|
77
|
+
state: AdminUiState,
|
|
78
|
+
actions: UiActions,
|
|
79
|
+
jobs: Map<string, UiJob>
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
const parsed = new URL(req.url || "/", "http://127.0.0.1");
|
|
82
|
+
if (req.method === "GET" && parsed.pathname === "/") {
|
|
83
|
+
sendHtml(res, adminUiHtml());
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (req.method === "GET" && parsed.pathname === "/favicon.ico") {
|
|
87
|
+
res.writeHead(204, { "Cache-Control": "no-store" });
|
|
88
|
+
res.end();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (parsed.pathname.startsWith("/api/") && !isValidUiOrigin(req)) {
|
|
92
|
+
sendJson(res, 403, { error: "invalid UI origin" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (req.method === "GET" && parsed.pathname === "/api/bootstrap") {
|
|
97
|
+
sendJson(res, 200, await state.bootstrap());
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (req.method === "GET" && parsed.pathname === "/api/bootstrap/config") {
|
|
101
|
+
sendJson(res, 200, await state.bootstrapConfig());
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (req.method === "GET" && parsed.pathname === "/api/sellers") {
|
|
105
|
+
sendJson(res, 200, await state.sellers());
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (req.method === "POST" && parsed.pathname === "/api/sellers") {
|
|
109
|
+
const body = await readJson(req) as CreateSellerRequest;
|
|
110
|
+
const job = startCreateSellerJob(jobs, actions, body);
|
|
111
|
+
sendJson(res, 202, { jobId: job.id, job });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const jobMatch = parsed.pathname.match(/^\/api\/jobs\/([^/]+)$/);
|
|
116
|
+
if (req.method === "GET" && jobMatch) {
|
|
117
|
+
const job = jobs.get(decodeURIComponent(jobMatch[1]));
|
|
118
|
+
if (!job) {
|
|
119
|
+
sendJson(res, 404, { error: "job not found" });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
sendJson(res, 200, job);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const sellerMatch = parsed.pathname.match(/^\/api\/sellers\/([^/]+)(?:\/([^/]+))?$/);
|
|
127
|
+
if (sellerMatch) {
|
|
128
|
+
const id = decodeURIComponent(sellerMatch[1]);
|
|
129
|
+
const subroute = sellerMatch[2];
|
|
130
|
+
if (req.method === "GET" && !subroute) {
|
|
131
|
+
sendJson(res, 200, await state.sellerDetail(id));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (req.method === "GET" && subroute === "models") {
|
|
135
|
+
const detail = await state.sellerDetail(id);
|
|
136
|
+
sendJson(res, 200, detail.models);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (req.method === "PUT" && subroute === "config") {
|
|
140
|
+
sendJson(res, 200, await actions.updateSellerConfig(id, await readJson(req) as Record<string, unknown>));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (req.method === "POST" && (subroute === "offline" || subroute === "drain" || subroute === "activate")) {
|
|
144
|
+
const status = subroute === "drain" ? "draining" : subroute === "activate" ? "active" : "offline";
|
|
145
|
+
sendJson(res, 200, await actions.setRegistryStatus(id, status));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (req.method === "DELETE" && subroute === "deployment") {
|
|
149
|
+
const body = await readJson(req).catch(() => ({})) as { confirm?: boolean };
|
|
150
|
+
sendJson(res, 200, await actions.deleteDeployment(id, Boolean(body.confirm)));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
sendJson(res, 404, { error: "not found" });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function startCreateSellerJob(jobs: Map<string, UiJob>, actions: UiActions, request: CreateSellerRequest): UiJob {
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
const job: UiJob = {
|
|
161
|
+
id: randomBytes(12).toString("hex"),
|
|
162
|
+
kind: "create_seller",
|
|
163
|
+
status: "running",
|
|
164
|
+
startedAt: now,
|
|
165
|
+
updatedAt: now,
|
|
166
|
+
title: `Create seller ${request.sellerName || ""}`.trim(),
|
|
167
|
+
events: []
|
|
168
|
+
};
|
|
169
|
+
jobs.set(job.id, job);
|
|
170
|
+
actions.createSeller(request, (event) => {
|
|
171
|
+
const safeEvent = sanitizeProgressEvent(event);
|
|
172
|
+
job.events = [...job.events.filter((item) => item.stepId !== safeEvent.stepId), safeEvent];
|
|
173
|
+
job.updatedAt = new Date().toISOString();
|
|
174
|
+
}).then((result) => {
|
|
175
|
+
job.status = result.result.ok
|
|
176
|
+
&& (!result.readiness || result.readiness.ok)
|
|
177
|
+
&& (!result.configPut || result.configPut.ok)
|
|
178
|
+
&& (!result.modelsRefresh || result.modelsRefresh.ok)
|
|
179
|
+
&& (!result.registryPublish || result.registryPublish.ok)
|
|
180
|
+
? "succeeded"
|
|
181
|
+
: "failed";
|
|
182
|
+
job.result = sanitizeJobResult(result);
|
|
183
|
+
job.updatedAt = new Date().toISOString();
|
|
184
|
+
}).catch((err: any) => {
|
|
185
|
+
job.status = "failed";
|
|
186
|
+
job.error = redactSensitive(err.message || "create seller failed");
|
|
187
|
+
job.updatedAt = new Date().toISOString();
|
|
188
|
+
});
|
|
189
|
+
pruneJobs(jobs);
|
|
190
|
+
return job;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function sanitizeProgressEvent(event: UiActionProgressEvent): UiActionProgressEvent {
|
|
194
|
+
return {
|
|
195
|
+
...event,
|
|
196
|
+
message: event.message ? redactSensitive(event.message) : undefined,
|
|
197
|
+
result: event.result ? sanitizeActionResult(event.result) : undefined
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sanitizeJobResult(value: unknown): unknown {
|
|
202
|
+
if (!value || typeof value !== "object") {
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
const input = value as Record<string, unknown>;
|
|
206
|
+
return Object.fromEntries(Object.entries(input).map(([key, entry]) => {
|
|
207
|
+
if (entry && typeof entry === "object" && "command" in entry) {
|
|
208
|
+
return [key, sanitizeActionResult(entry as unknown as UiActionResult)];
|
|
209
|
+
}
|
|
210
|
+
if (entry && typeof entry === "object") {
|
|
211
|
+
return [key, sanitizeJobResult(entry)];
|
|
212
|
+
}
|
|
213
|
+
return [key, typeof entry === "string" ? redactSensitive(entry) : entry];
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sanitizeActionResult(result: UiActionResult): UiActionResult {
|
|
218
|
+
return {
|
|
219
|
+
ok: result.ok,
|
|
220
|
+
stdout: redactSensitive(result.stdout),
|
|
221
|
+
stderr: redactSensitive(result.stderr),
|
|
222
|
+
command: redactCommand(result.command)
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function redactCommand(command: string[]): string[] {
|
|
227
|
+
return command.map((part, index) => {
|
|
228
|
+
const prev = command[index - 1];
|
|
229
|
+
if (prev === "--operator-secret" || prev === "--token" || prev === "--api-key") {
|
|
230
|
+
return "********";
|
|
231
|
+
}
|
|
232
|
+
return redactSensitive(part);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function redactSensitive(value: string): string {
|
|
237
|
+
return value.replace(/(sk-[A-Za-z0-9_-]+)/g, "********")
|
|
238
|
+
.replace(/(operatorSecret|upstreamApiKey|token|apiKey|sm4KeyBase64|clawtipSm4KeyBase64)["'=:\s]+([^"',\s]+)/gi, "$1=********");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function pruneJobs(jobs: Map<string, UiJob>): void {
|
|
242
|
+
const maxJobs = 20;
|
|
243
|
+
if (jobs.size <= maxJobs) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const stale = Array.from(jobs.values()).sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
|
|
247
|
+
for (const job of stale.slice(0, jobs.size - maxJobs)) {
|
|
248
|
+
jobs.delete(job.id);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function isValidUiOrigin(req: http.IncomingMessage): boolean {
|
|
253
|
+
const origin = headerValue(req.headers.origin);
|
|
254
|
+
if (!origin) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
const host = headerValue(req.headers.host);
|
|
258
|
+
if (!host) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const parsed = new URL(origin);
|
|
263
|
+
return parsed.protocol === "http:" && parsed.host === host;
|
|
264
|
+
} catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function headerValue(value: string | string[] | undefined): string | undefined {
|
|
270
|
+
return Array.isArray(value) ? value[0] : value;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function sendHtml(res: http.ServerResponse, html: string): void {
|
|
274
|
+
res.writeHead(200, {
|
|
275
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
276
|
+
"Cache-Control": "no-store"
|
|
277
|
+
});
|
|
278
|
+
res.end(html);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
|
|
282
|
+
res.writeHead(status, {
|
|
283
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
284
|
+
"Cache-Control": "no-store"
|
|
285
|
+
});
|
|
286
|
+
res.end(JSON.stringify(body));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function readJson(req: http.IncomingMessage): Promise<unknown> {
|
|
290
|
+
const chunks: Buffer[] = [];
|
|
291
|
+
for await (const chunk of req) {
|
|
292
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
293
|
+
}
|
|
294
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
295
|
+
return raw ? JSON.parse(raw) : {};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function assertSafeHost(host: string): void {
|
|
299
|
+
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") {
|
|
300
|
+
throw new Error("tb-admin ui only supports loopback hosts");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function assertSafePort(port: number): void {
|
|
305
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
306
|
+
throw new Error("port must be between 0 and 65535");
|
|
307
|
+
}
|
|
308
|
+
if (port === 12571 || port === 15721 || port === 17820 || port === 17821) {
|
|
309
|
+
throw new Error(`port ${port} is reserved`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function openLocalBrowser(url: string): Promise<void> {
|
|
314
|
+
const { spawn } = await import("child_process");
|
|
315
|
+
const command = process.platform === "darwin"
|
|
316
|
+
? "open"
|
|
317
|
+
: process.platform === "win32"
|
|
318
|
+
? "cmd"
|
|
319
|
+
: "xdg-open";
|
|
320
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
321
|
+
spawn(command, args, { shell: false, stdio: "ignore", detached: true }).unref();
|
|
322
|
+
}
|