@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/dist/src/ui-actions.js
CHANGED
|
@@ -50,9 +50,6 @@ export class UiActions {
|
|
|
50
50
|
if (!operatorSecret) {
|
|
51
51
|
throw new Error("operatorSecret is required in local admin config seller_providers.fly.operator_secret or request body");
|
|
52
52
|
}
|
|
53
|
-
if (!flyConfig) {
|
|
54
|
-
throw new Error("flyConfig is required before creating a seller deployment");
|
|
55
|
-
}
|
|
56
53
|
const configRequest = {
|
|
57
54
|
...normalizedRequest,
|
|
58
55
|
operatorSecret
|
|
@@ -86,13 +83,14 @@ export class UiActions {
|
|
|
86
83
|
normalizedRequest.region,
|
|
87
84
|
"--image",
|
|
88
85
|
normalizedRequest.image,
|
|
89
|
-
"--fly-config",
|
|
90
|
-
flyConfig,
|
|
91
86
|
"--initial-config",
|
|
92
87
|
filePath,
|
|
93
88
|
"--operator-secret",
|
|
94
89
|
operatorSecret
|
|
95
90
|
];
|
|
91
|
+
if (flyConfig) {
|
|
92
|
+
args.push("--fly-config", flyConfig);
|
|
93
|
+
}
|
|
96
94
|
if (normalizedRequest.app) {
|
|
97
95
|
args.push("--app", normalizedRequest.app);
|
|
98
96
|
}
|
|
@@ -388,25 +386,91 @@ export class UiActions {
|
|
|
388
386
|
});
|
|
389
387
|
return failed;
|
|
390
388
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
"--expect-version",
|
|
399
|
-
String(registry.version)
|
|
400
|
-
]), 30000);
|
|
401
|
-
report({
|
|
402
|
-
stepId: "publish_registry",
|
|
403
|
-
status: put.ok ? "succeeded" : "failed",
|
|
404
|
-
title: "Update bootstrap registry",
|
|
405
|
-
message: put.ok ? "Bootstrap registry entry was added. Run registry publish to update R2." : "Bootstrap registry update failed.",
|
|
406
|
-
result: put
|
|
407
|
-
});
|
|
408
|
-
return put;
|
|
389
|
+
const submitted = await this.submitCreatedSellerRelease(entry);
|
|
390
|
+
report({
|
|
391
|
+
stepId: "publish_registry",
|
|
392
|
+
status: submitted.ok ? "succeeded" : "failed",
|
|
393
|
+
title: "Submit registry release",
|
|
394
|
+
message: submitted.ok ? "Seller was staged and a registry release request was submitted." : "Registry release request failed.",
|
|
395
|
+
result: submitted
|
|
409
396
|
});
|
|
397
|
+
return submitted;
|
|
398
|
+
}
|
|
399
|
+
async submitCreatedSellerRelease(entry) {
|
|
400
|
+
const profile = this.state.activeBootstrapProfile();
|
|
401
|
+
const baseUrl = this.options.url || profile.profile?.url;
|
|
402
|
+
const token = this.options.token || profile.profile?.token;
|
|
403
|
+
const command = ["POST", "/platform/sellers/stage", "POST", "/platform/release-requests"];
|
|
404
|
+
if (!baseUrl || !token) {
|
|
405
|
+
return {
|
|
406
|
+
ok: false,
|
|
407
|
+
stdout: "",
|
|
408
|
+
stderr: "No bootstrap vendor profile found. Configure a bootstrap-vendor profile or pass --url and --token.",
|
|
409
|
+
command
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const fetchJson = this.options.fetchJson || defaultFetchJson;
|
|
413
|
+
const headers = {
|
|
414
|
+
"Content-Type": "application/json",
|
|
415
|
+
Authorization: `Bearer ${token}`
|
|
416
|
+
};
|
|
417
|
+
try {
|
|
418
|
+
const stage = await fetchJson(`${trimSlash(baseUrl)}/platform/sellers/stage`, {
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers,
|
|
421
|
+
body: JSON.stringify({ seller: entry })
|
|
422
|
+
});
|
|
423
|
+
const stagedSellerId = stagedSellerIdFromResponse(stage) || entry.id;
|
|
424
|
+
const release = await fetchJson(`${trimSlash(baseUrl)}/platform/release-requests`, {
|
|
425
|
+
method: "POST",
|
|
426
|
+
headers,
|
|
427
|
+
body: JSON.stringify({
|
|
428
|
+
stagedSellerIds: [stagedSellerId],
|
|
429
|
+
note: `tb-admin ui create seller ${entry.id}`
|
|
430
|
+
})
|
|
431
|
+
});
|
|
432
|
+
const releaseId = releaseRequestIdFromResponse(release);
|
|
433
|
+
if (releaseId === undefined) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
stdout: "",
|
|
437
|
+
stderr: "Registry release request did not return an id.",
|
|
438
|
+
command
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const published = await waitForSubmittedRelease({
|
|
442
|
+
baseUrl,
|
|
443
|
+
headers,
|
|
444
|
+
fetchJson,
|
|
445
|
+
releaseId,
|
|
446
|
+
sellerId: stagedSellerId
|
|
447
|
+
});
|
|
448
|
+
if (!published.ok) {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
stdout: "",
|
|
452
|
+
stderr: published.error,
|
|
453
|
+
command: [...command, "GET", "/platform/release-requests/:id", "GET", "/platform/sellers"],
|
|
454
|
+
json: { stage, release, stagedSellerId, publishStatus: published }
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
const json = { stage, release, stagedSellerId, published };
|
|
458
|
+
return {
|
|
459
|
+
ok: true,
|
|
460
|
+
stdout: JSON.stringify(releaseSummary(json), null, 2),
|
|
461
|
+
stderr: "",
|
|
462
|
+
command: [...command, "GET", "/platform/release-requests/:id", "GET", "/platform/sellers"],
|
|
463
|
+
json
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
return {
|
|
468
|
+
ok: false,
|
|
469
|
+
stdout: "",
|
|
470
|
+
stderr: redactSensitive(err.message || "registry release request failed"),
|
|
471
|
+
command
|
|
472
|
+
};
|
|
473
|
+
}
|
|
410
474
|
}
|
|
411
475
|
async fetchSellerOperatorJson(appName, operatorSecret, pathName) {
|
|
412
476
|
const fetchJson = this.options.fetchJson || defaultFetchJson;
|
|
@@ -544,16 +608,31 @@ export function runTbAdminJson(args, timeoutMs) {
|
|
|
544
608
|
return { ...result, json: parsed };
|
|
545
609
|
});
|
|
546
610
|
}
|
|
547
|
-
function parseJsonSafely(text) {
|
|
611
|
+
export function parseJsonSafely(text) {
|
|
548
612
|
if (!text || text.trim().length === 0) {
|
|
549
613
|
return undefined;
|
|
550
614
|
}
|
|
615
|
+
const trimmed = text.trim();
|
|
551
616
|
try {
|
|
552
|
-
return JSON.parse(
|
|
617
|
+
return JSON.parse(trimmed);
|
|
553
618
|
}
|
|
554
619
|
catch {
|
|
555
|
-
|
|
620
|
+
// Some flyctl-backed commands write human progress logs before the final
|
|
621
|
+
// structured JSON object. Keep the JSON path tolerant of that stdout shape.
|
|
622
|
+
for (let index = trimmed.length - 1; index >= 0; index -= 1) {
|
|
623
|
+
const char = trimmed[index];
|
|
624
|
+
if (char !== "{" && char !== "[") {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
return JSON.parse(trimmed.slice(index));
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// Keep walking backward until the outer JSON value is found.
|
|
632
|
+
}
|
|
633
|
+
}
|
|
556
634
|
}
|
|
635
|
+
return undefined;
|
|
557
636
|
}
|
|
558
637
|
async function defaultFetchJson(url, init) {
|
|
559
638
|
const response = await fetch(url, init);
|
|
@@ -663,7 +742,7 @@ function normalizeCreateSellerRequest(request) {
|
|
|
663
742
|
...request,
|
|
664
743
|
app,
|
|
665
744
|
image: stringValue(request.image) || "registry.fly.io/tb-seller:latest",
|
|
666
|
-
flyConfig: stringValue(request.flyConfig) ||
|
|
745
|
+
flyConfig: stringValue(request.flyConfig) || undefined,
|
|
667
746
|
upstreamUrl
|
|
668
747
|
};
|
|
669
748
|
if (paymentMethodsFromRequest(normalized).includes("clawtip")) {
|
|
@@ -688,6 +767,7 @@ function initialSellerConfig(request, masked) {
|
|
|
688
767
|
upstreamUrl: request.upstreamUrl,
|
|
689
768
|
upstreamApiKey: masked ? "********" : request.upstreamApiKey,
|
|
690
769
|
upstreamWebsite: request.upstreamWebsite,
|
|
770
|
+
upstreamCapabilities: upstreamCapabilitiesFromPreset(request.upstreamProtocolPreset),
|
|
691
771
|
upstreamBalanceUrl,
|
|
692
772
|
upstreamUserId,
|
|
693
773
|
upstreamRechargeUrl,
|
|
@@ -700,6 +780,26 @@ function initialSellerConfig(request, masked) {
|
|
|
700
780
|
...paymentConfig
|
|
701
781
|
};
|
|
702
782
|
}
|
|
783
|
+
function upstreamCapabilitiesFromPreset(value) {
|
|
784
|
+
const preset = stringValue(value) || "auto";
|
|
785
|
+
if (preset === "image") {
|
|
786
|
+
return {
|
|
787
|
+
chatCompletions: "unsupported",
|
|
788
|
+
responses: "unsupported",
|
|
789
|
+
messages: "unsupported",
|
|
790
|
+
imagesGenerations: "supported"
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
if (preset === "chat") {
|
|
794
|
+
return {
|
|
795
|
+
chatCompletions: "supported",
|
|
796
|
+
responses: "unsupported",
|
|
797
|
+
messages: "unsupported",
|
|
798
|
+
imagesGenerations: "unsupported"
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
return undefined;
|
|
802
|
+
}
|
|
703
803
|
function paymentConfigFromRequest(request, masked) {
|
|
704
804
|
const paymentMethods = paymentMethodsFromRequest(request);
|
|
705
805
|
const config = {
|
|
@@ -860,6 +960,78 @@ function hostName(value) {
|
|
|
860
960
|
function sellerOperatorUrl(appName) {
|
|
861
961
|
return `https://${appName}.fly.dev`;
|
|
862
962
|
}
|
|
963
|
+
function trimSlash(value) {
|
|
964
|
+
return String(value || "").replace(/\/+$/, "");
|
|
965
|
+
}
|
|
966
|
+
function stagedSellerIdFromResponse(value) {
|
|
967
|
+
const root = objectValue(value);
|
|
968
|
+
const pending = objectValue(root?.pendingSeller);
|
|
969
|
+
return stringValue(pending?.id);
|
|
970
|
+
}
|
|
971
|
+
function releaseRequestIdFromResponse(value) {
|
|
972
|
+
const root = objectValue(value);
|
|
973
|
+
const release = objectValue(root?.releaseRequest);
|
|
974
|
+
const id = release?.id;
|
|
975
|
+
return typeof id === "number" || typeof id === "string" ? id : undefined;
|
|
976
|
+
}
|
|
977
|
+
function releaseRequestStatusFromResponse(value) {
|
|
978
|
+
const root = objectValue(value);
|
|
979
|
+
const release = objectValue(root?.releaseRequest);
|
|
980
|
+
return stringValue(release?.status);
|
|
981
|
+
}
|
|
982
|
+
async function waitForSubmittedRelease(options) {
|
|
983
|
+
const deadline = Date.now() + 60000;
|
|
984
|
+
let lastRelease;
|
|
985
|
+
let lastSellers;
|
|
986
|
+
let lastStatus = "unknown";
|
|
987
|
+
while (Date.now() < deadline) {
|
|
988
|
+
lastRelease = await options.fetchJson(`${trimSlash(options.baseUrl)}/platform/release-requests/${encodeURIComponent(String(options.releaseId))}`, {
|
|
989
|
+
headers: options.headers
|
|
990
|
+
});
|
|
991
|
+
lastStatus = releaseRequestStatusFromResponse(lastRelease) || "unknown";
|
|
992
|
+
if (lastStatus === "rejected" || lastStatus === "failed") {
|
|
993
|
+
return {
|
|
994
|
+
ok: false,
|
|
995
|
+
error: `Registry release request ${options.releaseId} ended with status ${lastStatus}.`,
|
|
996
|
+
release: lastRelease,
|
|
997
|
+
sellers: lastSellers
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
lastSellers = await options.fetchJson(`${trimSlash(options.baseUrl)}/platform/sellers`, {
|
|
1001
|
+
headers: options.headers
|
|
1002
|
+
});
|
|
1003
|
+
if (lastStatus === "published" && sellerRegistryContains(lastSellers, options.sellerId)) {
|
|
1004
|
+
return { ok: true, release: lastRelease, sellers: lastSellers };
|
|
1005
|
+
}
|
|
1006
|
+
await sleep(1000);
|
|
1007
|
+
}
|
|
1008
|
+
const sellerPart = sellerRegistryContains(lastSellers, options.sellerId)
|
|
1009
|
+
? "seller is visible"
|
|
1010
|
+
: "seller is not visible";
|
|
1011
|
+
return {
|
|
1012
|
+
ok: false,
|
|
1013
|
+
error: `Registry release request ${options.releaseId} was not published before timeout (last status: ${lastStatus}; ${sellerPart}).`,
|
|
1014
|
+
release: lastRelease,
|
|
1015
|
+
sellers: lastSellers
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
function sellerRegistryContains(value, sellerId) {
|
|
1019
|
+
const root = objectValue(value);
|
|
1020
|
+
const sellers = Array.isArray(root?.sellers) ? root.sellers : Array.isArray(value) ? value : [];
|
|
1021
|
+
return sellers.some((seller) => {
|
|
1022
|
+
const entry = objectValue(seller);
|
|
1023
|
+
return entry?.id === sellerId || entry?.name === sellerId || entry?.app === sellerId;
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
function releaseSummary(value) {
|
|
1027
|
+
return {
|
|
1028
|
+
ok: true,
|
|
1029
|
+
action: "submit_registry_release",
|
|
1030
|
+
stagedSellerId: value.stagedSellerId,
|
|
1031
|
+
releaseRequestId: releaseRequestIdFromResponse(value.release),
|
|
1032
|
+
releaseStatus: releaseRequestStatusFromResponse(value.published?.release || value.release)
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
863
1035
|
function sleep(ms) {
|
|
864
1036
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
865
1037
|
}
|
package/dist/src/ui-command.js
CHANGED
|
@@ -10,13 +10,14 @@ export function bindAdminUiCommand(program, configManager) {
|
|
|
10
10
|
.action(async (options) => {
|
|
11
11
|
try {
|
|
12
12
|
const rootOptions = program.opts();
|
|
13
|
-
const mgr = rootOptions.config
|
|
13
|
+
const mgr = rootOptions.config || rootOptions.workdir
|
|
14
|
+
? new ConfigManager(rootOptions.config, { cliWorkdir: rootOptions.workdir })
|
|
15
|
+
: configManager;
|
|
14
16
|
const started = await startAdminUiServer({
|
|
15
17
|
host: options.host,
|
|
16
18
|
port: options.port,
|
|
17
19
|
openBrowser: Boolean(options.open),
|
|
18
20
|
configManager: mgr,
|
|
19
|
-
configPath: rootOptions.config,
|
|
20
21
|
profile: rootOptions.profile || process.env.TOKENBUDDY_ADMIN_PROFILE || defaultUiProfile(mgr),
|
|
21
22
|
url: rootOptions.url || process.env.TOKENBUDDY_ADMIN_URL,
|
|
22
23
|
token: rootOptions.token || process.env.TOKENBUDDY_ADMIN_TOKEN
|
package/dist/src/ui-state.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export interface SellerRow {
|
|
|
60
60
|
error?: string;
|
|
61
61
|
/**
|
|
62
62
|
* Step 13 v1.1: 数据源标记. UI 据此决定:
|
|
63
|
-
* - "fly" → 灰点 +
|
|
63
|
+
* - "fly" → 灰点 + 「未发布」
|
|
64
64
|
* - "registry" → 整行标红 + 立即下线按钮 (registry-only = 重大事故)
|
|
65
65
|
* - "both" → 正常色 + Activate / Drain 走 vendor path
|
|
66
66
|
*/
|
|
@@ -72,8 +72,6 @@ export interface SellerRow {
|
|
|
72
72
|
registryAlert?: boolean;
|
|
73
73
|
/** Step 13 v1.1: 标红原因 (中文短句, UI tooltip 显示). */
|
|
74
74
|
alertReason?: string;
|
|
75
|
-
/** Step 13 v1.1: "未发布" 提示 (fly-only 行的 publishHint 按钮 caption). */
|
|
76
|
-
publishHint?: string;
|
|
77
75
|
/**
|
|
78
76
|
* Step 13 v1.1: 立即下线按钮 caption (registry-only 行). 文案
|
|
79
77
|
* 必须含 "registry-only" 让用户知道这**不**删 fly app. 详见 spec.
|
package/dist/src/ui-state.js
CHANGED
|
@@ -197,7 +197,6 @@ export class AdminUiState {
|
|
|
197
197
|
const row = baseSellerRow(stubEntry, undefined, "fly", app, specsByApp.get(app.name));
|
|
198
198
|
row.publishStatus = "unpublished";
|
|
199
199
|
row.detailStatus = "pending";
|
|
200
|
-
row.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
|
|
201
200
|
rows.push(row);
|
|
202
201
|
}
|
|
203
202
|
return rows;
|
|
@@ -211,7 +210,7 @@ export class AdminUiState {
|
|
|
211
210
|
*/
|
|
212
211
|
async fetchFlyApps() {
|
|
213
212
|
if (this.options.flyApps) {
|
|
214
|
-
return (await this.options.flyApps()).filter((app) =>
|
|
213
|
+
return (await this.options.flyApps()).filter((app) => isVisibleFlyInventoryApp(app.name));
|
|
215
214
|
}
|
|
216
215
|
// 默认路径: 走 SellerCommandRunner.ls(true) 真 spawn flyctl.
|
|
217
216
|
// 避免在 ui-state.ts 里 import 整个 seller module 引入循环依赖,
|
|
@@ -229,7 +228,7 @@ export class AdminUiState {
|
|
|
229
228
|
const runner = new mod.SellerCommandRunner(this.configManager);
|
|
230
229
|
const result = await runner.ls(true);
|
|
231
230
|
if (result && typeof result === "object" && "apps" in result) {
|
|
232
|
-
return result.apps.filter((app) =>
|
|
231
|
+
return result.apps.filter((app) => isVisibleFlyInventoryApp(app.name));
|
|
233
232
|
}
|
|
234
233
|
return [];
|
|
235
234
|
}
|
|
@@ -499,9 +498,6 @@ export class AdminUiState {
|
|
|
499
498
|
baseRow.alertReason = "registry 收录了但 fly app 失踪 — 严重事故, 立即下线";
|
|
500
499
|
baseRow.removeHint = "立即下线 (registry-only)";
|
|
501
500
|
}
|
|
502
|
-
else if (dataSource === "fly") {
|
|
503
|
-
baseRow.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
|
|
504
|
-
}
|
|
505
501
|
if (dataSource === "registry") {
|
|
506
502
|
return {
|
|
507
503
|
row: {
|
|
@@ -726,8 +722,8 @@ function baseSellerRow(entry, profile, dataSource = "registry", flyApp, machineS
|
|
|
726
722
|
} : undefined
|
|
727
723
|
};
|
|
728
724
|
}
|
|
729
|
-
function
|
|
730
|
-
return Boolean(name &&
|
|
725
|
+
function isVisibleFlyInventoryApp(name) {
|
|
726
|
+
return Boolean(name && name !== "tb-registry");
|
|
731
727
|
}
|
|
732
728
|
function sellerEntryFromFlyApp(app) {
|
|
733
729
|
return {
|
package/dist/src/ui-static.js
CHANGED
|
@@ -56,6 +56,13 @@ export function adminUiHtml() {
|
|
|
56
56
|
.panel,.bootstrap-card{border:1px solid var(--hairline);background:var(--panel);border-radius:12px;box-shadow:var(--shadow);overflow:hidden}
|
|
57
57
|
.panel-head{min-height:56px;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--hairline);background:var(--panel)}
|
|
58
58
|
.title{margin:0;color:var(--ink);font-size:20px;line-height:28px;font-weight:750}
|
|
59
|
+
.fleet-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}
|
|
60
|
+
.fleet-toggle{min-height:38px;border:1px solid var(--hairline-strong);border-radius:8px;background:var(--panel-subtle);color:var(--muted);padding:0 10px;display:inline-flex;align-items:center;gap:8px;font-size:12px;font-weight:800}
|
|
61
|
+
.fleet-toggle input{position:absolute;opacity:0;pointer-events:none}
|
|
62
|
+
.fleet-toggle .switch-track{width:34px;height:20px;border-radius:999px;background:#d8d0ea;box-shadow:inset 0 0 0 1px var(--hairline-strong);position:relative;flex:0 0 auto}
|
|
63
|
+
.fleet-toggle .switch-track:after{content:"";position:absolute;left:3px;top:3px;width:14px;height:14px;border-radius:999px;background:#fff;box-shadow:0 1px 3px rgba(60,41,112,.2);transition:transform .18s ease}
|
|
64
|
+
.fleet-toggle input:checked + .switch-track{background:var(--primary)}
|
|
65
|
+
.fleet-toggle input:checked + .switch-track:after{transform:translateX(14px)}
|
|
59
66
|
/* ---- Buttons ------------------------------------------------------ */
|
|
60
67
|
.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}
|
|
61
68
|
.btn:hover{border-color:var(--primary);color:var(--primary)}
|
|
@@ -97,8 +104,6 @@ export function adminUiHtml() {
|
|
|
97
104
|
.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}
|
|
98
105
|
.remove-hint-btn:hover{background:#d63d5a}
|
|
99
106
|
.remove-hint-btn::before{content:"! ";margin-right:2px}
|
|
100
|
-
.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}
|
|
101
|
-
.publish-hint-btn:hover{background:#f5f3ff}
|
|
102
107
|
/* Status dot — five spec tones (green/amber/red/blue/gray) */
|
|
103
108
|
.app-dot{width:10px;height:10px;border-radius:999px;background:#c8ced8;box-shadow:0 0 0 4px #edf1f8}
|
|
104
109
|
.app-dot.tone-green{background:var(--success);box-shadow:0 0 0 4px rgba(16,185,129,.18)}
|
|
@@ -222,7 +227,11 @@ export function adminUiHtml() {
|
|
|
222
227
|
.progress-title .spinner{width:14px;height:14px;border-width:2px}
|
|
223
228
|
.progress-meta{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
224
229
|
.progress-toggle{color:var(--primary);font-size:var(--label-fs);font-weight:var(--label-weight);text-transform:uppercase;letter-spacing:var(--label-spacing)}
|
|
225
|
-
.progress-log{
|
|
230
|
+
.progress-log-wrap{position:relative;margin-top:4px}
|
|
231
|
+
.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:36px 9px 9px;font-size:11px;white-space:pre-wrap;font-family:var(--font-mono)}
|
|
232
|
+
.progress-copy{position:absolute;right:7px;top:7px;min-height:28px;border:1px solid rgba(255,255,255,.18);border-radius:6px;background:rgba(255,255,255,.08);color:#f3f0ff;padding:0 8px;display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:800}
|
|
233
|
+
.progress-copy:hover{background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.28);color:#fff}
|
|
234
|
+
.progress-copy svg{width:13px;height:13px;stroke:currentColor;stroke-width:2.1;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
226
235
|
.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}
|
|
227
236
|
.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}
|
|
228
237
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
@@ -255,7 +264,14 @@ export function adminUiHtml() {
|
|
|
255
264
|
<div>
|
|
256
265
|
<h1 class="title">Seller fleet</h1>
|
|
257
266
|
</div>
|
|
258
|
-
<
|
|
267
|
+
<div class="fleet-controls">
|
|
268
|
+
<label class="fleet-toggle" title="Hide Fly apps that have no Machines instances">
|
|
269
|
+
<input id="hideNoInstanceApps" type="checkbox" checked>
|
|
270
|
+
<span class="switch-track" aria-hidden="true"></span>
|
|
271
|
+
<span>Hide no-instance apps</span>
|
|
272
|
+
</label>
|
|
273
|
+
<button id="createSeller" class="btn primary">Create Seller</button>
|
|
274
|
+
</div>
|
|
259
275
|
</div>
|
|
260
276
|
<div class="app-list">
|
|
261
277
|
<div class="app-table-head" role="row">
|
|
@@ -369,7 +385,7 @@ async function api(path, options={}){
|
|
|
369
385
|
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."; }
|
|
370
386
|
const sellerStatusRefreshIntervalMs = 30000;
|
|
371
387
|
const sellerDetailConcurrency = 2;
|
|
372
|
-
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 = "";
|
|
388
|
+
let currentDetail = null; let sellerRowsCache = []; let editing = false; let deleteReady = false; let hideNoInstanceApps = true; 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 copiedProgressStepId = ""; let createAppSuffix = "";
|
|
373
389
|
const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
|
|
374
390
|
const missing = value => '<span class="muted-value">'+esc(value)+'</span>';
|
|
375
391
|
const dash = () => '<span class="muted-value">'+window.__tbFmt.UNKNOWN_VALUE+'</span>';
|
|
@@ -405,8 +421,10 @@ async function loadSellers(options={}){
|
|
|
405
421
|
updateSellerRefreshMeta();
|
|
406
422
|
}
|
|
407
423
|
}
|
|
408
|
-
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,
|
|
409
|
-
function
|
|
424
|
+
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, removeHint:row.removeHint, flyApp:row.flyApp, publishStatus:row.publishStatus, detailStatus:row.detailStatus, detailUpdatedAt:row.detailUpdatedAt, detailNextRefreshAt:row.detailNextRefreshAt }; }
|
|
425
|
+
function hasNoFlyMachineInstance(row){ const machines = Number(row.specs?.machines); return Number.isFinite(machines) && machines === 0; }
|
|
426
|
+
function visibleSellerRows(rows){ return hideNoInstanceApps ? rows.filter(row => row.dataSource !== "fly" || !hasNoFlyMachineInstance(row)) : rows; }
|
|
427
|
+
function renderSellerRows(rows){ sellerRowsCache = rows; const visibleRows = visibleSellerRows(rows); document.getElementById("sellerRows").innerHTML = visibleRows.map(row => sellerRow(row)).join("") || '<div class="status-line">No apps match the current display filter.</div>'; document.querySelectorAll("[data-detail]").forEach(btn => btn.onclick = () => openDetail(btn.dataset.detail)); }
|
|
410
428
|
function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => { updateSellerRefreshMeta(); updateSellerRowRefreshCountdowns(); }, 1000); window.addEventListener("beforeunload", () => { resetSellerDetailQueue(); clearInterval(sellerClockTimer); }); }
|
|
411
429
|
function sellerRowKey(row){ return String(row.id || row.app || row.url || ""); }
|
|
412
430
|
function resetSellerDetailQueue(){ sellerDetailQueue = []; sellerDetailQueueKeys.clear(); sellerDetailInFlight.clear(); sellerDetailTimers.forEach(timer => clearTimeout(timer)); sellerDetailTimers.clear(); }
|
|
@@ -554,7 +572,7 @@ function sellerRow(row){
|
|
|
554
572
|
const publishExtras = publishRelation === "registry_only" ? '<strong class="registry-incident" title="'+esc(statusTip)+'">严重事故</strong>' +
|
|
555
573
|
(row.alertReason ? '<span class="alert-reason">'+esc(row.alertReason)+'</span>' : '') +
|
|
556
574
|
(row.removeHint ? '<span class="remove-hint-btn" data-action="remove" data-seller-id="'+esc(row.id)+'" title="'+esc(row.removeHint)+'">'+esc(row.removeHint)+'</span>' : '')
|
|
557
|
-
:
|
|
575
|
+
: '';
|
|
558
576
|
const publishCell = '<span class="field-cell"><span class="metric-label">Pub</span>'+dsChip+publishExtras+'</span>';
|
|
559
577
|
const regionCell = '<span class="field-cell"><span class="metric-label">Region</span><strong>'+esc(regionText)+'</strong></span>';
|
|
560
578
|
const modelsCell = '<span class="field-cell"><span class="metric-label">Models</span><strong>'+esc(modelsText)+'</strong></span>';
|
|
@@ -612,15 +630,18 @@ function renderDetail(){
|
|
|
612
630
|
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]];
|
|
613
631
|
document.getElementById("configFields").innerHTML = detailFieldsHtml(fields);
|
|
614
632
|
const billingOptions = Array.from(new Set(d.models.map(m => m.billingModel).filter(Boolean)));
|
|
615
|
-
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>';
|
|
633
|
+
document.getElementById("modelsTable").innerHTML = '<table><thead><tr><th><input id="modelBulkToggle" class="model-toggle" type="checkbox" aria-label="Toggle all models" '+(editing ? "" : "disabled")+'> 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>';
|
|
634
|
+
setupModelBulkToggle();
|
|
616
635
|
}
|
|
617
636
|
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)); }
|
|
618
637
|
function metricCell(value){ return value === undefined || value === null || value === "" ? missing("—") : esc(value); }
|
|
619
638
|
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(""); }
|
|
620
639
|
function isMissingDetailField(key,value){ const fmt = window.__tbFmt; return value === undefined || value === null || value === "" || value === fmt.UNKNOWN_VALUE || (key === "registryStatus" && value === "unknown"); }
|
|
621
|
-
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>'; }
|
|
640
|
+
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>'; if (key === "upstreamProtocolPreset") 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)+'">'+["auto","chat","image"].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>'; }
|
|
622
641
|
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>'; }
|
|
623
642
|
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")+'>'; }
|
|
643
|
+
function setupModelBulkToggle(){ const bulk = document.getElementById("modelBulkToggle"); if (!bulk) return; const rows = Array.from(document.querySelectorAll("[data-model-enabled]")); rows.forEach(input => input.onchange = updateModelBulkToggle); bulk.onchange = event => { rows.forEach(input => { if (!input.disabled) input.checked = Boolean(event.target.checked); }); updateModelBulkToggle(); }; updateModelBulkToggle(); }
|
|
644
|
+
function updateModelBulkToggle(){ const bulk = document.getElementById("modelBulkToggle"); if (!bulk) return; const rows = Array.from(document.querySelectorAll("[data-model-enabled]")).filter(input => !input.disabled); const checked = rows.filter(input => input.checked).length; const mixed = checked > 0 && checked < rows.length; bulk.checked = rows.length > 0 && checked === rows.length; bulk.indeterminate = mixed; bulk.setAttribute("aria-checked", mixed ? "mixed" : String(bulk.checked)); bulk.disabled = !editing || rows.length === 0; }
|
|
624
645
|
function registryStatusForAction(action){ if (action === "drain") return "draining"; if (action === "activate") return "active"; return "offline"; }
|
|
625
646
|
function setStatusActionBusy(busy){ document.querySelectorAll("[data-status-action]").forEach(btn => { btn.disabled = Boolean(busy); }); }
|
|
626
647
|
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); }); }
|
|
@@ -628,17 +649,18 @@ document.getElementById("editDetail").onclick = async () => { if (!editing){ edi
|
|
|
628
649
|
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 })); }
|
|
629
650
|
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"; };
|
|
630
651
|
document.getElementById("closeDetail").onclick = () => { deleteReady = false; document.getElementById("detailModal").classList.remove("open"); };
|
|
652
|
+
document.getElementById("hideNoInstanceApps").onchange = event => { hideNoInstanceApps = Boolean(event.target.checked); renderSellerRows(sellerRowsCache); };
|
|
631
653
|
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); } });
|
|
632
654
|
document.getElementById("createSeller").onclick = () => { buildCreateForm(); document.getElementById("createModal").classList.add("open"); };
|
|
633
655
|
document.getElementById("closeCreate").onclick = () => document.getElementById("createModal").classList.remove("open");
|
|
634
656
|
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(); }
|
|
635
|
-
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(""); }
|
|
657
|
+
function createFormHtml(defaults){ return [createSectionHtml("基础信息设置", ["sellerName","app","region","image","flyConfig"], defaults), createSectionHtml("上游设置", ["upstreamWebsite","upstreamUrl","upstreamApiKey","upstreamProtocolPreset","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeUserId","upstreamBalanceProbeRechargeUrl"], defaults), createSectionHtml("性能与安全", ["maxConnections","maxQueueDepth","markupRatio","discountRatio"], defaults), paymentSectionHtml(defaults)].join(""); }
|
|
636
658
|
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>'; }
|
|
637
659
|
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>'; }
|
|
638
660
|
function paymentToggleHtml(method, enabled){ return '<button type="button" class="pill-switch '+(enabled ? "enabled" : "")+'" data-payment-toggle="'+esc(method)+'" aria-pressed="'+String(enabled)+'">'+(enabled ? "已启用" : "未启用")+'</button>'; }
|
|
639
661
|
function numericFieldOptions(key){ return ["maxConnections","maxQueueDepth","markupRatio","discountRatio","clawtipActivationFeeFen","clawtipMicrosPerFen"].includes(key) ? { type:"number" } : {}; }
|
|
640
662
|
function createFieldEditable(key){ return !["app","image","flyConfig"].includes(key); }
|
|
641
|
-
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
|
|
663
|
+
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", upstreamProtocolPreset:"Protocol preset", 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.", upstreamProtocolPreset:"Use image for nodes that should only publish /v1/images/generations.", 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 ratio applied after markup. UI displays this raw value, for example 1, 0.5, or 0.01.", 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]; if (["app","image","flyConfig"].includes(key)) options.submit = true; return options; }
|
|
642
664
|
function randomAppSuffix(){ return Math.random().toString(36).replace(/[^a-z0-9]/g, "").slice(2, 7) || "x7p9k"; }
|
|
643
665
|
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(); }
|
|
644
666
|
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"; }
|
|
@@ -654,13 +676,19 @@ function selectPaymentTab(method){ document.querySelectorAll("[data-payment-tab]
|
|
|
654
676
|
function togglePaymentMethod(method){ const panel = document.querySelector('[data-payment-panel="'+method+'"]'); if (!panel) return; panel.dataset.enabled = String(panel.dataset.enabled !== "true"); updatePaymentPanels(); }
|
|
655
677
|
function enabledPaymentMethods(){ return Array.from(document.querySelectorAll("[data-payment-panel]")).filter(panel => panel.dataset.enabled === "true").map(panel => panel.dataset.paymentPanel); }
|
|
656
678
|
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; }); }); }
|
|
657
|
-
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:"
|
|
679
|
+
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:"", upstreamProtocolPreset:"auto", 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:"" }; }
|
|
658
680
|
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"); } };
|
|
659
681
|
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); }
|
|
660
682
|
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"; } }
|
|
661
|
-
function
|
|
683
|
+
function progressLogText(event){ const result = event.result || {}; return [result.command ? "$ " + result.command.join(" ") : "", result.stdout || "", result.stderr || ""].filter(Boolean).join("\\n"); }
|
|
684
|
+
function progressCopyIcon(){ return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'; }
|
|
685
|
+
function progressStep(event){ const log = progressLogText(event); const expanded = expandedProgressSteps.has(event.stepId); const copied = copiedProgressStepId === event.stepId; const spinner = event.status === "running" ? '<span class="spinner" aria-hidden="true"></span>' : ""; return '<div role="button" tabindex="0" 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 ? '<div class="progress-log-wrap"><button type="button" class="progress-copy" data-copy-progress-log="'+esc(event.stepId)+'" title="Copy output" aria-label="Copy output">'+progressCopyIcon()+'<span>'+(copied ? "Copied" : "Copy")+'</span></button><pre class="progress-log">'+esc(log)+'</pre></div>' : '')+'</div>'; }
|
|
686
|
+
async function copyProgressLog(stepId){ const event = (currentCreateJob?.events || []).find(item => item.stepId === stepId); if (!event) return; const text = progressLogText(event); try { if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text); else fallbackCopyText(text); copiedProgressStepId = stepId; if (currentCreateJob) renderCreateJob(currentCreateJob); setTimeout(() => { if (copiedProgressStepId === stepId) { copiedProgressStepId = ""; if (currentCreateJob) renderCreateJob(currentCreateJob); } }, 1400); } catch { fallbackCopyText(text); copiedProgressStepId = stepId; if (currentCreateJob) renderCreateJob(currentCreateJob); } }
|
|
687
|
+
function fallbackCopyText(text){ const area = document.createElement("textarea"); area.value = text; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.opacity = "0"; document.body.appendChild(area); area.select(); document.execCommand("copy"); area.remove(); }
|
|
662
688
|
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(); }
|
|
663
|
-
|
|
689
|
+
function toggleProgressStep(id){ if (expandedProgressSteps.has(id)) expandedProgressSteps.delete(id); else expandedProgressSteps.add(id); if (currentCreateJob) renderCreateJob(currentCreateJob); }
|
|
690
|
+
document.getElementById("createProgress").onclick = event => { const copy = event.target.closest("[data-copy-progress-log]"); if (copy){ event.stopPropagation(); copyProgressLog(copy.dataset.copyProgressLog); return; } const step = event.target.closest("[data-progress-step]"); if (!step) return; toggleProgressStep(step.dataset.progressStep); };
|
|
691
|
+
document.getElementById("createProgress").onkeydown = event => { if (event.key !== "Enter" && event.key !== " ") return; const step = event.target.closest("[data-progress-step]"); if (!step || event.target.closest("[data-copy-progress-log]")) return; event.preventDefault(); toggleProgressStep(step.dataset.progressStep); };
|
|
664
692
|
document.getElementById("closeBootstrapConfig").onclick = () => document.getElementById("bootstrapModal").classList.remove("open");
|
|
665
693
|
function fieldValue(input){ return numeric(input.value); }
|
|
666
694
|
function numeric(value){ const n = Number(value); return value !== "" && Number.isFinite(n) ? n : value; }
|