icebear icebear0828 Claude Opus 4.6 commited on
Commit
85a4cbd
·
unverified ·
1 Parent(s): 4880524

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 ADDED
@@ -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
+ }
shared/i18n/translations.ts CHANGED
@@ -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
 
shared/types.ts CHANGED
@@ -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
+ }
src/auth/account-pool.ts CHANGED
@@ -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.
src/routes/web.ts CHANGED
@@ -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 = c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "";
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) => {
src/tls/curl-binary.ts CHANGED
@@ -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
  */
src/tls/transport.ts CHANGED
@@ -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
  }
web/src/App.tsx CHANGED
@@ -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} />
web/src/components/TestConnection.tsx ADDED
@@ -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
+ }