Spaces:
Paused
feat: add Test Connection diagnostic button (#63)
Browse files* feat: add Test Connection diagnostic button (#61)
Add a "Test Connection" button to the Dashboard that runs 4 sequential
checks (Server, Accounts, Transport, Upstream) and displays pass/fail/skip
results with latency — helps Electron users diagnose connectivity issues
without access to console logs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use socket address for localhost check, static import for buildHeaders
- Replace spoofable x-forwarded-for/x-real-ip with getConnInfo() socket address
for /debug/fingerprint and /debug/diagnostics endpoints
- Add ::ffff:127.0.0.1 match for IPv4-mapped IPv6 addresses
- Change dynamic import() of buildHeaders to static import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- shared/hooks/use-test-connection.ts +29 -0
- shared/i18n/translations.ts +22 -0
- shared/types.ts +16 -0
- src/auth/account-pool.ts +7 -0
- src/routes/web.ts +176 -3
- src/tls/curl-binary.ts +17 -0
- src/tls/transport.ts +22 -0
- web/src/App.tsx +2 -0
- web/src/components/TestConnection.tsx +145 -0
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from "preact/hooks";
|
| 2 |
+
import type { TestConnectionResult } from "../types.js";
|
| 3 |
+
|
| 4 |
+
export function useTestConnection() {
|
| 5 |
+
const [testing, setTesting] = useState(false);
|
| 6 |
+
const [result, setResult] = useState<TestConnectionResult | null>(null);
|
| 7 |
+
const [error, setError] = useState<string | null>(null);
|
| 8 |
+
|
| 9 |
+
const runTest = useCallback(async () => {
|
| 10 |
+
setTesting(true);
|
| 11 |
+
setError(null);
|
| 12 |
+
setResult(null);
|
| 13 |
+
try {
|
| 14 |
+
const resp = await fetch("/admin/test-connection", { method: "POST" });
|
| 15 |
+
if (!resp.ok) {
|
| 16 |
+
setError(`HTTP ${resp.status}`);
|
| 17 |
+
return;
|
| 18 |
+
}
|
| 19 |
+
const data = (await resp.json()) as TestConnectionResult;
|
| 20 |
+
setResult(data);
|
| 21 |
+
} catch (err) {
|
| 22 |
+
setError(err instanceof Error ? err.message : "Network error");
|
| 23 |
+
} finally {
|
| 24 |
+
setTesting(false);
|
| 25 |
+
}
|
| 26 |
+
}, []);
|
| 27 |
+
|
| 28 |
+
return { testing, result, error, runTest };
|
| 29 |
+
}
|
|
@@ -157,6 +157,17 @@ export const translations = {
|
|
| 157 |
apiKeyLabel: "API Key",
|
| 158 |
apiKeySaved: "Saved",
|
| 159 |
apiKeyClear: "Clear key (disable auth)",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
},
|
| 161 |
zh: {
|
| 162 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
@@ -319,6 +330,17 @@ export const translations = {
|
|
| 319 |
apiKeyLabel: "API \u5bc6\u94a5",
|
| 320 |
apiKeySaved: "\u5df2\u4fdd\u5b58",
|
| 321 |
apiKeyClear: "\u6e05\u9664\u5bc6\u94a5\uff08\u5173\u95ed\u9274\u6743\uff09",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
},
|
| 323 |
} as const;
|
| 324 |
|
|
|
|
| 157 |
apiKeyLabel: "API Key",
|
| 158 |
apiKeySaved: "Saved",
|
| 159 |
apiKeyClear: "Clear key (disable auth)",
|
| 160 |
+
testConnection: "Test Connection",
|
| 161 |
+
testing: "Testing...",
|
| 162 |
+
testPassed: "All checks passed",
|
| 163 |
+
testFailed: "Some checks failed",
|
| 164 |
+
checkServer: "Server",
|
| 165 |
+
checkAccounts: "Accounts",
|
| 166 |
+
checkTransport: "Transport",
|
| 167 |
+
checkUpstream: "Upstream",
|
| 168 |
+
statusPass: "Pass",
|
| 169 |
+
statusFail: "Fail",
|
| 170 |
+
statusSkip: "Skip",
|
| 171 |
},
|
| 172 |
zh: {
|
| 173 |
serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
|
|
|
|
| 330 |
apiKeyLabel: "API \u5bc6\u94a5",
|
| 331 |
apiKeySaved: "\u5df2\u4fdd\u5b58",
|
| 332 |
apiKeyClear: "\u6e05\u9664\u5bc6\u94a5\uff08\u5173\u95ed\u9274\u6743\uff09",
|
| 333 |
+
testConnection: "\u8fde\u63a5\u6d4b\u8bd5",
|
| 334 |
+
testing: "\u6d4b\u8bd5\u4e2d...",
|
| 335 |
+
testPassed: "\u6240\u6709\u68c0\u67e5\u5747\u901a\u8fc7",
|
| 336 |
+
testFailed: "\u90e8\u5206\u68c0\u67e5\u672a\u901a\u8fc7",
|
| 337 |
+
checkServer: "\u670d\u52a1\u5668",
|
| 338 |
+
checkAccounts: "\u8d26\u6237",
|
| 339 |
+
checkTransport: "\u4f20\u8f93\u5c42",
|
| 340 |
+
checkUpstream: "\u4e0a\u6e38\u670d\u52a1",
|
| 341 |
+
statusPass: "\u901a\u8fc7",
|
| 342 |
+
statusFail: "\u5931\u8d25",
|
| 343 |
+
statusSkip: "\u8df3\u8fc7",
|
| 344 |
},
|
| 345 |
} as const;
|
| 346 |
|
|
@@ -45,3 +45,19 @@ export interface ProxyAssignment {
|
|
| 45 |
accountId: string;
|
| 46 |
proxyId: string;
|
| 47 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
accountId: string;
|
| 46 |
proxyId: string;
|
| 47 |
}
|
| 48 |
+
|
| 49 |
+
export type DiagnosticStatus = "pass" | "fail" | "skip";
|
| 50 |
+
|
| 51 |
+
export interface DiagnosticCheck {
|
| 52 |
+
name: string;
|
| 53 |
+
status: DiagnosticStatus;
|
| 54 |
+
latencyMs: number;
|
| 55 |
+
detail: string | null;
|
| 56 |
+
error: string | null;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export interface TestConnectionResult {
|
| 60 |
+
checks: DiagnosticCheck[];
|
| 61 |
+
overall: DiagnosticStatus;
|
| 62 |
+
timestamp: string;
|
| 63 |
+
}
|
|
@@ -210,6 +210,13 @@ export class AccountPool {
|
|
| 210 |
this.schedulePersist();
|
| 211 |
}
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
/**
|
| 214 |
* Mark an account as rate-limited after a 429.
|
| 215 |
* P1-6: countRequest option to track 429s as usage without exposing entry internals.
|
|
|
|
| 210 |
this.schedulePersist();
|
| 211 |
}
|
| 212 |
|
| 213 |
+
/**
|
| 214 |
+
* Release an account without counting usage (for diagnostics).
|
| 215 |
+
*/
|
| 216 |
+
releaseWithoutCounting(entryId: string): void {
|
| 217 |
+
this.acquireLocks.delete(entryId);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
/**
|
| 221 |
* Mark an account as rate-limited after a 429.
|
| 222 |
* P1-6: countRequest option to track 429s as usage without exposing entry internals.
|
|
@@ -1,10 +1,14 @@
|
|
| 1 |
import { Hono } from "hono";
|
| 2 |
import { serveStatic } from "@hono/node-server/serve-static";
|
|
|
|
| 3 |
import { readFileSync, existsSync } from "fs";
|
| 4 |
import { resolve } from "path";
|
| 5 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 6 |
import { getConfig, getFingerprint, reloadAllConfigs } from "../config.js";
|
| 7 |
-
import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, isEmbedded } from "../paths.js";
|
|
|
|
|
|
|
|
|
|
| 8 |
import { getUpdateState, checkForUpdate, isUpdateInProgress } from "../update-checker.js";
|
| 9 |
import { getProxyInfo, canSelfUpdate, checkProxySelfUpdate, applyProxySelfUpdate, isProxyUpdateInProgress, getCachedProxyUpdateResult, getDeployMode } from "../self-update.js";
|
| 10 |
import { mutateYaml } from "../utils/yaml-mutate.js";
|
|
@@ -62,8 +66,8 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
|
|
| 62 |
app.get("/debug/fingerprint", (c) => {
|
| 63 |
// Only allow in development or from localhost
|
| 64 |
const isProduction = process.env.NODE_ENV === "production";
|
| 65 |
-
const remoteAddr =
|
| 66 |
-
const isLocalhost = remoteAddr === "" || remoteAddr === "127.0.0.1" || remoteAddr === "::1";
|
| 67 |
if (isProduction && !isLocalhost) {
|
| 68 |
c.status(404);
|
| 69 |
return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
|
|
@@ -123,6 +127,52 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
|
|
| 123 |
});
|
| 124 |
});
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
// --- Update management endpoints ---
|
| 127 |
|
| 128 |
app.get("/admin/update-status", (c) => {
|
|
@@ -231,6 +281,129 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
|
|
| 231 |
return c.json(result);
|
| 232 |
});
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
// --- Settings endpoints ---
|
| 235 |
|
| 236 |
app.get("/admin/settings", (c) => {
|
|
|
|
| 1 |
import { Hono } from "hono";
|
| 2 |
import { serveStatic } from "@hono/node-server/serve-static";
|
| 3 |
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
| 4 |
import { readFileSync, existsSync } from "fs";
|
| 5 |
import { resolve } from "path";
|
| 6 |
import type { AccountPool } from "../auth/account-pool.js";
|
| 7 |
import { getConfig, getFingerprint, reloadAllConfigs } from "../config.js";
|
| 8 |
+
import { getPublicDir, getDesktopPublicDir, getConfigDir, getDataDir, getBinDir, isEmbedded } from "../paths.js";
|
| 9 |
+
import { getTransport, getTransportInfo } from "../tls/transport.js";
|
| 10 |
+
import { getCurlDiagnostics } from "../tls/curl-binary.js";
|
| 11 |
+
import { buildHeaders } from "../fingerprint/manager.js";
|
| 12 |
import { getUpdateState, checkForUpdate, isUpdateInProgress } from "../update-checker.js";
|
| 13 |
import { getProxyInfo, canSelfUpdate, checkProxySelfUpdate, applyProxySelfUpdate, isProxyUpdateInProgress, getCachedProxyUpdateResult, getDeployMode } from "../self-update.js";
|
| 14 |
import { mutateYaml } from "../utils/yaml-mutate.js";
|
|
|
|
| 66 |
app.get("/debug/fingerprint", (c) => {
|
| 67 |
// Only allow in development or from localhost
|
| 68 |
const isProduction = process.env.NODE_ENV === "production";
|
| 69 |
+
const remoteAddr = getConnInfo(c).remote.address ?? "";
|
| 70 |
+
const isLocalhost = remoteAddr === "" || remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
|
| 71 |
if (isProduction && !isLocalhost) {
|
| 72 |
c.status(404);
|
| 73 |
return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
|
|
|
|
| 127 |
});
|
| 128 |
});
|
| 129 |
|
| 130 |
+
app.get("/debug/diagnostics", (c) => {
|
| 131 |
+
const remoteAddr = getConnInfo(c).remote.address ?? "";
|
| 132 |
+
const isLocalhost = remoteAddr === "" || remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
|
| 133 |
+
if (process.env.NODE_ENV === "production" && !isLocalhost) {
|
| 134 |
+
c.status(404);
|
| 135 |
+
return c.json({ error: { message: "Not found", type: "invalid_request_error" } });
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const transport = getTransportInfo();
|
| 139 |
+
const curl = getCurlDiagnostics();
|
| 140 |
+
const poolSummary = accountPool.getPoolSummary();
|
| 141 |
+
const caCertPath = resolve(getBinDir(), "cacert.pem");
|
| 142 |
+
|
| 143 |
+
return c.json({
|
| 144 |
+
transport: {
|
| 145 |
+
type: transport.type,
|
| 146 |
+
initialized: transport.initialized,
|
| 147 |
+
impersonate: transport.impersonate,
|
| 148 |
+
ffi_error: transport.ffi_error,
|
| 149 |
+
},
|
| 150 |
+
curl: {
|
| 151 |
+
binary: curl.binary,
|
| 152 |
+
is_impersonate: curl.is_impersonate,
|
| 153 |
+
profile: curl.profile,
|
| 154 |
+
},
|
| 155 |
+
proxy: { url: curl.proxy_url },
|
| 156 |
+
ca_cert: { found: existsSync(caCertPath), path: caCertPath },
|
| 157 |
+
accounts: {
|
| 158 |
+
total: poolSummary.total,
|
| 159 |
+
active: poolSummary.active,
|
| 160 |
+
authenticated: accountPool.isAuthenticated(),
|
| 161 |
+
},
|
| 162 |
+
paths: {
|
| 163 |
+
bin: getBinDir(),
|
| 164 |
+
config: getConfigDir(),
|
| 165 |
+
data: getDataDir(),
|
| 166 |
+
},
|
| 167 |
+
runtime: {
|
| 168 |
+
platform: process.platform,
|
| 169 |
+
arch: process.arch,
|
| 170 |
+
node_version: process.version,
|
| 171 |
+
embedded: isEmbedded(),
|
| 172 |
+
},
|
| 173 |
+
});
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
// --- Update management endpoints ---
|
| 177 |
|
| 178 |
app.get("/admin/update-status", (c) => {
|
|
|
|
| 281 |
return c.json(result);
|
| 282 |
});
|
| 283 |
|
| 284 |
+
// --- Test connection endpoint ---
|
| 285 |
+
|
| 286 |
+
app.post("/admin/test-connection", async (c) => {
|
| 287 |
+
type DiagStatus = "pass" | "fail" | "skip";
|
| 288 |
+
interface DiagCheck { name: string; status: DiagStatus; latencyMs: number; detail: string | null; error: string | null; }
|
| 289 |
+
const checks: DiagCheck[] = [];
|
| 290 |
+
let overallFailed = false;
|
| 291 |
+
|
| 292 |
+
// 1. Server check — if we're responding, it's a pass
|
| 293 |
+
const serverStart = Date.now();
|
| 294 |
+
checks.push({
|
| 295 |
+
name: "server",
|
| 296 |
+
status: "pass",
|
| 297 |
+
latencyMs: Date.now() - serverStart,
|
| 298 |
+
detail: `PID ${process.pid}`,
|
| 299 |
+
error: null,
|
| 300 |
+
});
|
| 301 |
+
|
| 302 |
+
// 2. Accounts check — any authenticated accounts?
|
| 303 |
+
const accountsStart = Date.now();
|
| 304 |
+
const poolSummary = accountPool.getPoolSummary();
|
| 305 |
+
const hasActive = poolSummary.active > 0;
|
| 306 |
+
checks.push({
|
| 307 |
+
name: "accounts",
|
| 308 |
+
status: hasActive ? "pass" : "fail",
|
| 309 |
+
latencyMs: Date.now() - accountsStart,
|
| 310 |
+
detail: hasActive
|
| 311 |
+
? `${poolSummary.active} active / ${poolSummary.total} total`
|
| 312 |
+
: `0 active / ${poolSummary.total} total`,
|
| 313 |
+
error: hasActive ? null : "No active accounts",
|
| 314 |
+
});
|
| 315 |
+
if (!hasActive) overallFailed = true;
|
| 316 |
+
|
| 317 |
+
// 3. Transport check — TLS transport initialized?
|
| 318 |
+
const transportStart = Date.now();
|
| 319 |
+
const transportInfo = getTransportInfo();
|
| 320 |
+
const caCertPath = resolve(getBinDir(), "cacert.pem");
|
| 321 |
+
const caCertExists = existsSync(caCertPath);
|
| 322 |
+
const transportOk = transportInfo.initialized;
|
| 323 |
+
checks.push({
|
| 324 |
+
name: "transport",
|
| 325 |
+
status: transportOk ? "pass" : "fail",
|
| 326 |
+
latencyMs: Date.now() - transportStart,
|
| 327 |
+
detail: transportOk
|
| 328 |
+
? `${transportInfo.type}, impersonate=${transportInfo.impersonate}, ca_cert=${caCertExists}`
|
| 329 |
+
: null,
|
| 330 |
+
error: transportOk
|
| 331 |
+
? (transportInfo.ffi_error ? `FFI fallback: ${transportInfo.ffi_error}` : null)
|
| 332 |
+
: (transportInfo.ffi_error ?? "Transport not initialized"),
|
| 333 |
+
});
|
| 334 |
+
if (!transportOk) overallFailed = true;
|
| 335 |
+
|
| 336 |
+
// 4. Upstream check — can we reach chatgpt.com?
|
| 337 |
+
if (!hasActive) {
|
| 338 |
+
// Skip upstream if no accounts
|
| 339 |
+
checks.push({
|
| 340 |
+
name: "upstream",
|
| 341 |
+
status: "skip",
|
| 342 |
+
latencyMs: 0,
|
| 343 |
+
detail: "Skipped (no active accounts)",
|
| 344 |
+
error: null,
|
| 345 |
+
});
|
| 346 |
+
} else {
|
| 347 |
+
const upstreamStart = Date.now();
|
| 348 |
+
const acquired = accountPool.acquire();
|
| 349 |
+
if (!acquired) {
|
| 350 |
+
checks.push({
|
| 351 |
+
name: "upstream",
|
| 352 |
+
status: "fail",
|
| 353 |
+
latencyMs: Date.now() - upstreamStart,
|
| 354 |
+
detail: null,
|
| 355 |
+
error: "Could not acquire account for test",
|
| 356 |
+
});
|
| 357 |
+
overallFailed = true;
|
| 358 |
+
} else {
|
| 359 |
+
try {
|
| 360 |
+
const transport = getTransport();
|
| 361 |
+
const config = getConfig();
|
| 362 |
+
const url = `${config.api.base_url}/codex/usage`;
|
| 363 |
+
const headers = buildHeaders(acquired.token, acquired.accountId);
|
| 364 |
+
const resp = await transport.get(url, headers, 15);
|
| 365 |
+
const latency = Date.now() - upstreamStart;
|
| 366 |
+
if (resp.status >= 200 && resp.status < 400) {
|
| 367 |
+
checks.push({
|
| 368 |
+
name: "upstream",
|
| 369 |
+
status: "pass",
|
| 370 |
+
latencyMs: latency,
|
| 371 |
+
detail: `HTTP ${resp.status} (${latency}ms)`,
|
| 372 |
+
error: null,
|
| 373 |
+
});
|
| 374 |
+
} else {
|
| 375 |
+
checks.push({
|
| 376 |
+
name: "upstream",
|
| 377 |
+
status: "fail",
|
| 378 |
+
latencyMs: latency,
|
| 379 |
+
detail: `HTTP ${resp.status}`,
|
| 380 |
+
error: `Upstream returned ${resp.status}`,
|
| 381 |
+
});
|
| 382 |
+
overallFailed = true;
|
| 383 |
+
}
|
| 384 |
+
} catch (err) {
|
| 385 |
+
const latency = Date.now() - upstreamStart;
|
| 386 |
+
checks.push({
|
| 387 |
+
name: "upstream",
|
| 388 |
+
status: "fail",
|
| 389 |
+
latencyMs: latency,
|
| 390 |
+
detail: null,
|
| 391 |
+
error: err instanceof Error ? err.message : String(err),
|
| 392 |
+
});
|
| 393 |
+
overallFailed = true;
|
| 394 |
+
} finally {
|
| 395 |
+
accountPool.releaseWithoutCounting(acquired.entryId);
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
return c.json({
|
| 401 |
+
checks,
|
| 402 |
+
overall: overallFailed ? "fail" as const : "pass" as const,
|
| 403 |
+
timestamp: new Date().toISOString(),
|
| 404 |
+
});
|
| 405 |
+
});
|
| 406 |
+
|
| 407 |
// --- Settings endpoints ---
|
| 408 |
|
| 409 |
app.get("/admin/settings", (c) => {
|
|
@@ -287,6 +287,23 @@ export function getProxyUrl(): string | null {
|
|
| 287 |
return _proxyUrl ?? null;
|
| 288 |
}
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
/**
|
| 291 |
* Reset the cached binary path (useful for testing).
|
| 292 |
*/
|
|
|
|
| 287 |
return _proxyUrl ?? null;
|
| 288 |
}
|
| 289 |
|
| 290 |
+
/**
|
| 291 |
+
* Get curl diagnostic info for /debug/diagnostics endpoint.
|
| 292 |
+
*/
|
| 293 |
+
export function getCurlDiagnostics(): {
|
| 294 |
+
binary: string | null;
|
| 295 |
+
is_impersonate: boolean;
|
| 296 |
+
profile: string;
|
| 297 |
+
proxy_url: string | null;
|
| 298 |
+
} {
|
| 299 |
+
return {
|
| 300 |
+
binary: _resolved,
|
| 301 |
+
is_impersonate: _isImpersonate,
|
| 302 |
+
profile: _resolvedProfile,
|
| 303 |
+
proxy_url: _proxyUrl ?? null,
|
| 304 |
+
};
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
/**
|
| 308 |
* Reset the cached binary path (useful for testing).
|
| 309 |
*/
|
|
@@ -58,6 +58,8 @@ export interface TlsTransport {
|
|
| 58 |
}
|
| 59 |
|
| 60 |
let _transport: TlsTransport | null = null;
|
|
|
|
|
|
|
| 61 |
|
| 62 |
/**
|
| 63 |
* Initialize the transport singleton. Must be called once at startup
|
|
@@ -74,6 +76,7 @@ export async function initTransport(): Promise<TlsTransport> {
|
|
| 74 |
try {
|
| 75 |
const { createLibcurlFfiTransport } = await import("./libcurl-ffi-transport.js");
|
| 76 |
_transport = await createLibcurlFfiTransport();
|
|
|
|
| 77 |
console.log("[TLS] Using libcurl-impersonate FFI transport");
|
| 78 |
return _transport;
|
| 79 |
} catch (err) {
|
|
@@ -81,12 +84,14 @@ export async function initTransport(): Promise<TlsTransport> {
|
|
| 81 |
if (setting === "libcurl-ffi") {
|
| 82 |
throw new Error(`Failed to initialize libcurl FFI transport: ${msg}`);
|
| 83 |
}
|
|
|
|
| 84 |
console.warn(`[TLS] FFI transport unavailable (${msg}), falling back to curl CLI`);
|
| 85 |
}
|
| 86 |
}
|
| 87 |
|
| 88 |
const { CurlCliTransport } = await import("./curl-cli-transport.js");
|
| 89 |
_transport = new CurlCliTransport();
|
|
|
|
| 90 |
console.log("[TLS] Using curl CLI transport");
|
| 91 |
return _transport;
|
| 92 |
}
|
|
@@ -111,7 +116,24 @@ function shouldUseFfi(): boolean {
|
|
| 111 |
return existsSync(dllPath);
|
| 112 |
}
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
/** Reset transport singleton (for testing). */
|
| 115 |
export function resetTransport(): void {
|
| 116 |
_transport = null;
|
|
|
|
|
|
|
| 117 |
}
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
let _transport: TlsTransport | null = null;
|
| 61 |
+
let _transportType: "libcurl-ffi" | "curl-cli" | "none" = "none";
|
| 62 |
+
let _ffiError: string | null = null;
|
| 63 |
|
| 64 |
/**
|
| 65 |
* Initialize the transport singleton. Must be called once at startup
|
|
|
|
| 76 |
try {
|
| 77 |
const { createLibcurlFfiTransport } = await import("./libcurl-ffi-transport.js");
|
| 78 |
_transport = await createLibcurlFfiTransport();
|
| 79 |
+
_transportType = "libcurl-ffi";
|
| 80 |
console.log("[TLS] Using libcurl-impersonate FFI transport");
|
| 81 |
return _transport;
|
| 82 |
} catch (err) {
|
|
|
|
| 84 |
if (setting === "libcurl-ffi") {
|
| 85 |
throw new Error(`Failed to initialize libcurl FFI transport: ${msg}`);
|
| 86 |
}
|
| 87 |
+
_ffiError = msg;
|
| 88 |
console.warn(`[TLS] FFI transport unavailable (${msg}), falling back to curl CLI`);
|
| 89 |
}
|
| 90 |
}
|
| 91 |
|
| 92 |
const { CurlCliTransport } = await import("./curl-cli-transport.js");
|
| 93 |
_transport = new CurlCliTransport();
|
| 94 |
+
_transportType = "curl-cli";
|
| 95 |
console.log("[TLS] Using curl CLI transport");
|
| 96 |
return _transport;
|
| 97 |
}
|
|
|
|
| 116 |
return existsSync(dllPath);
|
| 117 |
}
|
| 118 |
|
| 119 |
+
/** Get transport diagnostic info. */
|
| 120 |
+
export function getTransportInfo(): {
|
| 121 |
+
type: "libcurl-ffi" | "curl-cli" | "none";
|
| 122 |
+
initialized: boolean;
|
| 123 |
+
impersonate: boolean;
|
| 124 |
+
ffi_error: string | null;
|
| 125 |
+
} {
|
| 126 |
+
return {
|
| 127 |
+
type: _transportType,
|
| 128 |
+
initialized: _transport !== null,
|
| 129 |
+
impersonate: _transport?.isImpersonate() ?? false,
|
| 130 |
+
ffi_error: _ffiError,
|
| 131 |
+
};
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
/** Reset transport singleton (for testing). */
|
| 135 |
export function resetTransport(): void {
|
| 136 |
_transport = null;
|
| 137 |
+
_transportType = "none";
|
| 138 |
+
_ffiError = null;
|
| 139 |
}
|
|
@@ -10,6 +10,7 @@ import { ApiConfig } from "./components/ApiConfig";
|
|
| 10 |
import { AnthropicSetup } from "./components/AnthropicSetup";
|
| 11 |
import { CodeExamples } from "./components/CodeExamples";
|
| 12 |
import { SettingsPanel } from "./components/SettingsPanel";
|
|
|
|
| 13 |
import { Footer } from "./components/Footer";
|
| 14 |
import { ProxySettings } from "./pages/ProxySettings";
|
| 15 |
import { useAccounts } from "../../shared/hooks/use-accounts";
|
|
@@ -144,6 +145,7 @@ function Dashboard() {
|
|
| 144 |
serviceTier={status.selectedSpeed}
|
| 145 |
/>
|
| 146 |
<SettingsPanel />
|
|
|
|
| 147 |
</div>
|
| 148 |
</main>
|
| 149 |
<Footer updateStatus={update.status} />
|
|
|
|
| 10 |
import { AnthropicSetup } from "./components/AnthropicSetup";
|
| 11 |
import { CodeExamples } from "./components/CodeExamples";
|
| 12 |
import { SettingsPanel } from "./components/SettingsPanel";
|
| 13 |
+
import { TestConnection } from "./components/TestConnection";
|
| 14 |
import { Footer } from "./components/Footer";
|
| 15 |
import { ProxySettings } from "./pages/ProxySettings";
|
| 16 |
import { useAccounts } from "../../shared/hooks/use-accounts";
|
|
|
|
| 145 |
serviceTier={status.selectedSpeed}
|
| 146 |
/>
|
| 147 |
<SettingsPanel />
|
| 148 |
+
<TestConnection />
|
| 149 |
</div>
|
| 150 |
</main>
|
| 151 |
<Footer updateStatus={update.status} />
|
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "preact/hooks";
|
| 2 |
+
import { useT } from "../../../shared/i18n/context";
|
| 3 |
+
import { useTestConnection } from "../../../shared/hooks/use-test-connection";
|
| 4 |
+
import type { DiagnosticCheck, DiagnosticStatus } from "../../../shared/types";
|
| 5 |
+
|
| 6 |
+
const STATUS_COLORS: Record<DiagnosticStatus, string> = {
|
| 7 |
+
pass: "text-green-600 dark:text-green-400",
|
| 8 |
+
fail: "text-red-500 dark:text-red-400",
|
| 9 |
+
skip: "text-slate-400 dark:text-text-dim",
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const STATUS_BG: Record<DiagnosticStatus, string> = {
|
| 13 |
+
pass: "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800",
|
| 14 |
+
fail: "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800",
|
| 15 |
+
skip: "bg-slate-50 dark:bg-[#161b22] border-slate-200 dark:border-border-dark",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const CHECK_NAME_KEYS: Record<string, string> = {
|
| 19 |
+
server: "checkServer",
|
| 20 |
+
accounts: "checkAccounts",
|
| 21 |
+
transport: "checkTransport",
|
| 22 |
+
upstream: "checkUpstream",
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const STATUS_KEYS: Record<DiagnosticStatus, string> = {
|
| 26 |
+
pass: "statusPass",
|
| 27 |
+
fail: "statusFail",
|
| 28 |
+
skip: "statusSkip",
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
function StatusIcon({ status }: { status: DiagnosticStatus }) {
|
| 32 |
+
if (status === "pass") {
|
| 33 |
+
return (
|
| 34 |
+
<svg class="size-5 text-green-600 dark:text-green-400 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 35 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 36 |
+
</svg>
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
if (status === "fail") {
|
| 40 |
+
return (
|
| 41 |
+
<svg class="size-5 text-red-500 dark:text-red-400 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 42 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 43 |
+
</svg>
|
| 44 |
+
);
|
| 45 |
+
}
|
| 46 |
+
return (
|
| 47 |
+
<svg class="size-5 text-slate-400 dark:text-text-dim shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 48 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 49 |
+
</svg>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function CheckRow({ check }: { check: DiagnosticCheck }) {
|
| 54 |
+
const t = useT();
|
| 55 |
+
const nameKey = CHECK_NAME_KEYS[check.name] ?? check.name;
|
| 56 |
+
const statusKey = STATUS_KEYS[check.status];
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div class={`flex items-start gap-3 p-3 rounded-lg border ${STATUS_BG[check.status]}`}>
|
| 60 |
+
<StatusIcon status={check.status} />
|
| 61 |
+
<div class="flex-1 min-w-0">
|
| 62 |
+
<div class="flex items-center gap-2">
|
| 63 |
+
<span class="text-sm font-semibold text-slate-700 dark:text-text-main">
|
| 64 |
+
{t(nameKey as Parameters<typeof t>[0])}
|
| 65 |
+
</span>
|
| 66 |
+
<span class={`text-xs font-medium ${STATUS_COLORS[check.status]}`}>
|
| 67 |
+
{t(statusKey as Parameters<typeof t>[0])}
|
| 68 |
+
</span>
|
| 69 |
+
{check.latencyMs > 0 && (
|
| 70 |
+
<span class="text-xs text-slate-400 dark:text-text-dim">{check.latencyMs}ms</span>
|
| 71 |
+
)}
|
| 72 |
+
</div>
|
| 73 |
+
{check.detail && (
|
| 74 |
+
<p class="text-xs text-slate-500 dark:text-text-dim mt-0.5 break-all">{check.detail}</p>
|
| 75 |
+
)}
|
| 76 |
+
{check.error && (
|
| 77 |
+
<p class="text-xs text-red-500 dark:text-red-400 mt-0.5 break-all">{check.error}</p>
|
| 78 |
+
)}
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export function TestConnection() {
|
| 85 |
+
const t = useT();
|
| 86 |
+
const { testing, result, error, runTest } = useTestConnection();
|
| 87 |
+
const [collapsed, setCollapsed] = useState(true);
|
| 88 |
+
|
| 89 |
+
return (
|
| 90 |
+
<section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl shadow-sm transition-colors">
|
| 91 |
+
{/* Header */}
|
| 92 |
+
<button
|
| 93 |
+
onClick={() => setCollapsed(!collapsed)}
|
| 94 |
+
class="w-full flex items-center justify-between p-5 cursor-pointer select-none"
|
| 95 |
+
>
|
| 96 |
+
<div class="flex items-center gap-2">
|
| 97 |
+
<svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 98 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
|
| 99 |
+
</svg>
|
| 100 |
+
<h2 class="text-[0.95rem] font-bold">{t("testConnection")}</h2>
|
| 101 |
+
{result && !collapsed && (
|
| 102 |
+
<span class={`text-xs font-medium ml-1 ${result.overall === "pass" ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}`}>
|
| 103 |
+
{result.overall === "pass" ? t("testPassed") : t("testFailed")}
|
| 104 |
+
</span>
|
| 105 |
+
)}
|
| 106 |
+
</div>
|
| 107 |
+
<svg class={`size-5 text-slate-400 dark:text-text-dim transition-transform ${collapsed ? "" : "rotate-180"}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 108 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
| 109 |
+
</svg>
|
| 110 |
+
</button>
|
| 111 |
+
|
| 112 |
+
{/* Content */}
|
| 113 |
+
{!collapsed && (
|
| 114 |
+
<div class="px-5 pb-5 border-t border-slate-100 dark:border-border-dark pt-4">
|
| 115 |
+
{/* Run test button */}
|
| 116 |
+
<button
|
| 117 |
+
onClick={runTest}
|
| 118 |
+
disabled={testing}
|
| 119 |
+
class={`w-full py-2.5 text-sm font-medium rounded-lg transition-colors ${
|
| 120 |
+
testing
|
| 121 |
+
? "bg-slate-100 dark:bg-[#21262d] text-slate-400 dark:text-text-dim cursor-not-allowed"
|
| 122 |
+
: "bg-primary text-white hover:bg-primary/90 cursor-pointer"
|
| 123 |
+
}`}
|
| 124 |
+
>
|
| 125 |
+
{testing ? t("testing") : t("testConnection")}
|
| 126 |
+
</button>
|
| 127 |
+
|
| 128 |
+
{/* Error */}
|
| 129 |
+
{error && (
|
| 130 |
+
<p class="mt-3 text-sm text-red-500">{error}</p>
|
| 131 |
+
)}
|
| 132 |
+
|
| 133 |
+
{/* Results */}
|
| 134 |
+
{result && (
|
| 135 |
+
<div class="mt-4 space-y-2">
|
| 136 |
+
{result.checks.map((check) => (
|
| 137 |
+
<CheckRow key={check.name} check={check} />
|
| 138 |
+
))}
|
| 139 |
+
</div>
|
| 140 |
+
)}
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
</section>
|
| 144 |
+
);
|
| 145 |
+
}
|