diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c189de5..8c85a79 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -979,6 +979,7 @@ function App() { setIsDriverModalOpen(false)} + onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)} /> ; checks: DriverNetworkProbe[]; checkedAt?: string; logPath?: string; }; +const parseOptionalLatency = (value: unknown): number | undefined => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return undefined; + return parsed; +}; + +const sharedInfoAlertIcon = ; + type DriverVersionOption = { version: string; downloadUrl: string; @@ -90,8 +103,15 @@ type DriverVersionOption = { const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`; const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`; const DRIVER_TABLE_SCROLL_X = 1450; +const DRIVER_STATUS_CACHE_TTL_MS = 60 * 1000; +const DRIVER_NETWORK_CACHE_TTL_MS = 5 * 60 * 1000; const normalizeDriverSearchText = (value: string) => String(value || '').trim().toLowerCase(); +let driverStatusSnapshotCache: { rows: DriverStatusRow[]; downloadDir: string; cachedAt: number } | null = null; +let driverNetworkSnapshotCache: { status: DriverNetworkStatus; cachedAt: number } | null = null; + +const isFreshCache = (cachedAt: number, ttlMs: number): boolean => Date.now() - cachedAt <= ttlMs; + const buildVersionSelectOptions = (options: DriverVersionOption[]) => { type SelectOption = { value: string; label: string }; type SelectGroup = { label: string; options: SelectOption[] }; @@ -138,7 +158,11 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => { return grouped; }; -const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { +const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenGlobalProxySettings?: () => void }> = ({ + open, + onClose, + onOpenGlobalProxySettings, +}) => { const theme = useStore((state) => state.theme); const appearance = useStore((state) => state.appearance); const darkMode = theme === 'dark'; @@ -166,6 +190,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ const [versionLoadingMap, setVersionLoadingMap] = useState>({}); const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({}); const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X); + const downloadDirRef = useRef(downloadDir); + + useEffect(() => { + downloadDirRef.current = downloadDir; + }, [downloadDir]); const appendOperationLog = useCallback(( driverType: string, @@ -283,10 +312,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ horizontalSyncSourceRef.current = ''; }, []); - const refreshStatus = useCallback(async (toastOnError = true) => { - setLoading(true); + const refreshStatus = useCallback(async ( + toastOnError = true, + options?: { showLoading?: boolean }, + ) => { + const showLoading = options?.showLoading ?? true; + if (showLoading) { + setLoading(true); + } try { - const res = await GetDriverStatusList(downloadDir, ''); + const res = await GetDriverStatusList(downloadDirRef.current, ''); if (!res?.success) { if (toastOnError) { message.error(res?.message || '拉取驱动状态失败'); @@ -298,6 +333,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ const resolvedDir = String(data.downloadDir || '').trim(); const drivers = Array.isArray(data.drivers) ? data.drivers : []; + const effectiveDownloadDir = resolvedDir || downloadDirRef.current; if (resolvedDir) { setDownloadDir(resolvedDir); } @@ -320,17 +356,30 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ message: String(item.message || '').trim() || undefined, })); setRows(nextRows); + driverStatusSnapshotCache = { + rows: nextRows, + downloadDir: effectiveDownloadDir, + cachedAt: Date.now(), + }; } catch (err: any) { if (toastOnError) { message.error(`拉取驱动状态失败:${err?.message || String(err)}`); } } finally { - setLoading(false); + if (showLoading) { + setLoading(false); + } } - }, [downloadDir]); + }, []); - const checkNetworkStatus = useCallback(async (toastOnError = false) => { - setNetworkChecking(true); + const checkNetworkStatus = useCallback(async ( + toastOnError = false, + options?: { showLoading?: boolean }, + ) => { + const showLoading = options?.showLoading ?? true; + if (showLoading) { + setNetworkChecking(true); + } try { const res = await CheckDriverNetworkStatus(); if (!res?.success) { @@ -345,26 +394,40 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ name: String(item.name || '').trim(), url: String(item.url || '').trim(), reachable: !!item.reachable, - httpStatus: Number(item.httpStatus || 0) || undefined, - latencyMs: Number(item.latencyMs || 0) || undefined, + httpStatus: parseOptionalLatency(item.httpStatus), + latencyMs: parseOptionalLatency(item.latencyMs), + tcpLatencyMs: parseOptionalLatency(item.tcpLatencyMs), + httpLatencyMs: parseOptionalLatency(item.httpLatencyMs), + method: String(item.method || '').trim().toUpperCase() || undefined, error: String(item.error || '').trim() || undefined, })); - setNetworkStatus({ + const nextStatus: DriverNetworkStatus = { reachable: !!data.reachable, summary: String(data.summary || '').trim() || '驱动网络检测已完成', recommendedProxy: !!data.recommendedProxy, proxyConfigured: !!data.proxyConfigured, + downloadChainReachable: typeof data.downloadChainReachable === 'boolean' ? data.downloadChainReachable : undefined, + downloadRequiredHosts: Array.isArray(data.downloadRequiredHosts) + ? data.downloadRequiredHosts.map((item: unknown) => String(item || '').trim()).filter(Boolean) + : undefined, proxyEnv: (data.proxyEnv || {}) as Record, checkedAt: String(data.checkedAt || '').trim() || undefined, checks: normalizedChecks, logPath: String(data.logPath || '').trim() || undefined, - }); + }; + setNetworkStatus(nextStatus); + driverNetworkSnapshotCache = { + status: nextStatus, + cachedAt: Date.now(), + }; } catch (err: any) { if (toastOnError) { message.error(`驱动网络检测失败:${err?.message || String(err)}`); } } finally { - setNetworkChecking(false); + if (showLoading) { + setNetworkChecking(false); + } } }, []); @@ -523,8 +586,29 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ tableScrollTargetsRef.current = []; return; } - refreshStatus(false); - checkNetworkStatus(false); + + const cachedStatus = driverStatusSnapshotCache; + const hasCachedStatus = !!cachedStatus; + if (cachedStatus) { + setRows(cachedStatus.rows); + if (cachedStatus.downloadDir) { + setDownloadDir(cachedStatus.downloadDir); + } + } + const shouldRefreshStatus = !cachedStatus || !isFreshCache(cachedStatus.cachedAt, DRIVER_STATUS_CACHE_TTL_MS); + if (shouldRefreshStatus) { + void refreshStatus(false, { showLoading: !hasCachedStatus }); + } + + const cachedNetwork = driverNetworkSnapshotCache; + const hasCachedNetwork = !!cachedNetwork; + if (cachedNetwork) { + setNetworkStatus(cachedNetwork.status); + } + const shouldRefreshNetwork = !cachedNetwork || !isFreshCache(cachedNetwork.cachedAt, DRIVER_NETWORK_CACHE_TTL_MS); + if (shouldRefreshNetwork) { + void checkNetworkStatus(false, { showLoading: !hasCachedNetwork }); + } }, [checkNetworkStatus, open, refreshStatus]); useEffect(() => { @@ -1106,6 +1190,18 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ const activeDriverLogs = operationLogMap[logDriverType] || []; const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`); const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {}); + const downloadRequiredHosts = (networkStatus?.downloadRequiredHosts || []).filter(Boolean); + const showDownloadChainAlert = networkStatus?.downloadChainReachable === false; + const networkUnreachable = networkStatus?.reachable === false; + const downloadRequiredHostText = (downloadRequiredHosts.length > 0 + ? downloadRequiredHosts + : ['github.com', 'api.github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'raw.githubusercontent.com']).join('、'); + const githubConnectivityProbe = networkStatus?.checks.find((item) => item.name === 'GitHub API') + || networkStatus?.checks.find((item) => item.name === 'GitHub 驱动发布') + || null; + const githubConnectivityLatencyMs = githubConnectivityProbe + ? (githubConnectivityProbe.httpLatencyMs ?? githubConnectivityProbe.latencyMs ?? githubConnectivityProbe.tcpLatencyMs) + : undefined; const logBlockBackground = darkMode ? `rgba(28, 28, 28, ${Math.max(opacity, 0.82)})` : `rgba(255, 255, 255, ${Math.max(opacity, 0.92)})`; @@ -1156,15 +1252,43 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ 除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。 {networkStatus ? ( - - - 驱动下载依赖 GitHub 与 Go 模块代理网络。若检测失败,建议先启用 HTTP/HTTPS/SOCKS5 代理后重试。 - + networkUnreachable ? ( + + {showDownloadChainAlert ? ( + <> + + 当前可能能访问 GitHub 页面,但驱动包下载会跳转到资产域名。 + 请优先在 GoNavi 顶部“代理”中启用全局代理(填写代理应用本地地址和端口)。 + + {onOpenGlobalProxySettings ? ( + + ) : null} + + 若仍失败,请在代理规则放行:{downloadRequiredHostText};仍无法调整规则时,再考虑开启 TUN 模式。 + + + ) : ( + {networkStatus.summary} + )} + {proxyEnvEntries.length > 0 ? ( + + 检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')} + + ) : null} + + )} + /> + ) : ( + void }> = ({ label: '查看网络检测明细', children: ( - {networkStatus.checks.map((item) => ( - - {item.name}:{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `,HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `,${item.latencyMs}ms` : ''}{item.error ? `,${item.error}` : ''} - - ))} + + 代理链路到 GitHub 连通性延迟:{githubConnectivityProbe ? (githubConnectivityProbe.reachable ? '可达' : '不可达') : '暂无结果'} + {githubConnectivityLatencyMs !== undefined ? `,${githubConnectivityLatencyMs}ms` : ''} + {githubConnectivityProbe?.error ? `,${githubConnectivityProbe.error}` : ''} + {proxyEnvEntries.length > 0 ? ( 检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')} @@ -1190,30 +1314,47 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }, ]} /> - - )} - /> + )} + /> + ) ) : ( - + )} - 自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。 - 行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。 - - 驱动根目录:{downloadDir || '-'} - - {networkStatus?.logPath ? ( - - 运行日志文件:{networkStatus.logPath} - - ) : null} - + + 自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。 + 行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。 + + 驱动根目录:{downloadDir || '-'} + + {networkStatus?.logPath ? ( + + 运行日志文件:{networkStatus.logPath} + + ) : null} + + ), + }, + ]} + /> )} /> diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 4dd6052..a0ec8ca 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -83,12 +83,15 @@ type driverDownloadProgressPayload struct { } type driverNetworkProbeItem struct { - Name string `json:"name"` - URL string `json:"url"` - Reachable bool `json:"reachable"` - HTTPStatus int `json:"httpStatus,omitempty"` - LatencyMs int64 `json:"latencyMs,omitempty"` - Error string `json:"error,omitempty"` + Name string `json:"name"` + URL string `json:"url"` + Reachable bool `json:"reachable"` + HTTPStatus int `json:"httpStatus,omitempty"` + LatencyMs int64 `json:"latencyMs,omitempty"` + TCPLatency int64 `json:"tcpLatencyMs,omitempty"` + HTTPLatency int64 `json:"httpLatencyMs,omitempty"` + Method string `json:"method,omitempty"` + Error string `json:"error,omitempty"` } type pinnedDriverPackage struct { @@ -201,6 +204,7 @@ const ( driverBundleIndexMaxSize = 1 << 20 driverManifestMaxSize = 2 << 20 driverNetworkProbeTimeout = 4 * time.Second + driverNetworkProbeTCPTimeout = 3 * time.Second localDriverDirectoryScanMaxEntries = 20000 driverChecksumPolicyStrict = "strict" driverChecksumPolicyWarn = "warn" @@ -647,24 +651,43 @@ func (a *App) CheckDriverNetworkStatus() connection.QueryResult { Name: "GitHub 驱动发布", URL: fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", updateRepo, optionalDriverBundleAssetName), }, + { + Name: "GitHub Release 资产域名", + URL: "https://release-assets.githubusercontent.com/", + }, { Name: "Go 模块代理", URL: "https://proxy.golang.org/github.com/go-sql-driver/mysql/@v/list", }, } + client := newHTTPClientWithGlobalProxy(driverNetworkProbeTimeout) allReachable := true for index := range checks { - checks[index] = probeDriverNetworkEndpoint(checks[index]) + checks[index] = probeDriverNetworkEndpoint(client, checks[index]) if !checks[index].Reachable { allReachable = false } } + findProbe := func(name string) (driverNetworkProbeItem, bool) { + for _, item := range checks { + if strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(name)) { + return item, true + } + } + return driverNetworkProbeItem{}, false + } + githubAPICheck, _ := findProbe("GitHub API") + githubReleaseCheck, _ := findProbe("GitHub 驱动发布") + releaseAssetsCheck, _ := findProbe("GitHub Release 资产域名") + downloadChainReachable := githubReleaseCheck.Reachable && releaseAssetsCheck.Reachable proxyEnv := collectDriverProxyEnv() proxyConfigured := len(proxyEnv) > 0 summary := "驱动下载网络检测通过,可直接安装驱动。" - if !allReachable { + if githubAPICheck.Reachable && !downloadChainReachable { + summary = "重要提醒:GitHub API 可达,但驱动下载链路不可达。请优先在 GoNavi 启用全局代理(填写代理应用本地地址和端口),并在代理规则中放行 github.com、api.github.com、release-assets.githubusercontent.com、objects.githubusercontent.com、raw.githubusercontent.com;若仍失败,再考虑开启 TUN 模式。" + } else if !allReachable { if proxyConfigured { summary = "检测到部分驱动下载地址不可达,请确认系统代理配置有效后重试。" } else { @@ -678,6 +701,14 @@ func (a *App) CheckDriverNetworkStatus() connection.QueryResult { "recommendedProxy": !allReachable, "proxyConfigured": proxyConfigured, "proxyEnv": proxyEnv, + "downloadChainReachable": downloadChainReachable, + "downloadRequiredHosts": []string{ + "github.com", + "api.github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", + "raw.githubusercontent.com", + }, "checkedAt": time.Now().Format(time.RFC3339), "checks": checks, } @@ -890,12 +921,15 @@ func (a *App) emitDriverDownloadProgress(driverType string, status string, downl runtime.EventsEmit(a.ctx, driverDownloadProgressEvent, payload) } -func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeItem { +func probeDriverNetworkEndpoint(client *http.Client, item driverNetworkProbeItem) driverNetworkProbeItem { probed := item probed.Reachable = false probed.HTTPStatus = 0 probed.Error = "" probed.LatencyMs = 0 + probed.TCPLatency = 0 + probed.HTTPLatency = 0 + probed.Method = "" urlText := strings.TrimSpace(item.URL) if urlText == "" { @@ -903,33 +937,34 @@ func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeI return probed } - client := newHTTPClientWithGlobalProxy(driverNetworkProbeTimeout) - start := time.Now() - req, err := http.NewRequest(http.MethodHead, urlText, nil) - if err != nil { - probed.Error = normalizeErrorMessage(err) - return probed + if tcpLatency, tcpErr := probeDriverTCPLatency(urlText); tcpErr == nil { + probed.TCPLatency = tcpLatency + probed.LatencyMs = tcpLatency } - req.Header.Set("User-Agent", "GoNavi-DriverManager") - resp, err := client.Do(req) - if err != nil { - // 某些网关不支持 HEAD,请回退为 GET(不读取正文)。 - reqGet, reqErr := http.NewRequest(http.MethodGet, urlText, nil) - if reqErr != nil { - probed.Error = normalizeErrorMessage(reqErr) - probed.LatencyMs = time.Since(start).Milliseconds() - return probed - } - reqGet.Header.Set("User-Agent", "GoNavi-DriverManager") - resp, err = client.Do(reqGet) + if client == nil { + client = newHTTPClientWithGlobalProxy(driverNetworkProbeTimeout) + } + start := time.Now() + resp, method, err := doDriverProbeRequest(client, urlText, http.MethodGet) + if err != nil || shouldFallbackHeadProbe(resp) { + if resp != nil { + _ = resp.Body.Close() + } + // 回退到 HEAD 时重置计时,避免把失败重试耗时累计到最终延迟指标里。 + start = time.Now() + resp, method, err = doDriverProbeRequest(client, urlText, http.MethodHead) + } + probed.HTTPLatency = time.Since(start).Milliseconds() + if probed.LatencyMs <= 0 { + probed.LatencyMs = probed.HTTPLatency } - probed.LatencyMs = time.Since(start).Milliseconds() if err != nil { probed.Error = normalizeDriverNetworkError(err) return probed } defer resp.Body.Close() + probed.Method = method probed.HTTPStatus = resp.StatusCode if resp.StatusCode >= 500 { @@ -940,6 +975,121 @@ func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeI return probed } +func probeDriverTCPLatency(rawURL string) (int64, error) { + dialAddr, err := resolveDriverProbeDialAddress(rawURL) + if err != nil { + return 0, err + } + start := time.Now() + conn, err := net.DialTimeout("tcp", dialAddr, driverNetworkProbeTCPTimeout) + elapsed := time.Since(start) + latency := elapsed.Milliseconds() + if elapsed > 0 && latency <= 0 { + latency = 1 + } + if err != nil { + return latency, err + } + _ = conn.Close() + return latency, nil +} + +func resolveDriverProbeDialAddress(rawURL string) (string, error) { + urlText := strings.TrimSpace(rawURL) + if urlText == "" { + return "", fmt.Errorf("检测地址为空") + } + parsed, err := url.Parse(urlText) + if err != nil { + return "", err + } + + targetHost := strings.TrimSpace(parsed.Hostname()) + if targetHost == "" { + return "", fmt.Errorf("检测地址缺少主机") + } + targetPort := strings.TrimSpace(parsed.Port()) + if targetPort == "" { + if strings.EqualFold(parsed.Scheme, "http") { + targetPort = "80" + } else { + targetPort = "443" + } + } + + if proxyURL := resolveDriverProbeProxyURL(parsed); proxyURL != nil { + proxyHost := strings.TrimSpace(proxyURL.Hostname()) + if proxyHost == "" { + return net.JoinHostPort(targetHost, targetPort), nil + } + proxyPort := strings.TrimSpace(proxyURL.Port()) + if proxyPort == "" { + proxyPort = defaultPortForScheme(proxyURL.Scheme) + } + return net.JoinHostPort(proxyHost, proxyPort), nil + } + + return net.JoinHostPort(targetHost, targetPort), nil +} + +func resolveDriverProbeProxyURL(target *url.URL) *url.URL { + if target == nil { + return nil + } + + snapshot := currentGlobalProxyConfig() + if snapshot.Enabled { + proxyURL, err := buildProxyURLFromConfig(snapshot.Proxy) + if err == nil { + return proxyURL + } + } + + req := &http.Request{URL: target} + proxyURL, err := http.ProxyFromEnvironment(req) + if err != nil { + return nil + } + return proxyURL +} + +func defaultPortForScheme(scheme string) string { + switch strings.ToLower(strings.TrimSpace(scheme)) { + case "https": + return "443" + case "socks5", "socks5h": + return "1080" + case "http": + fallthrough + default: + return "80" + } +} + +func doDriverProbeRequest(client *http.Client, urlText string, method string) (*http.Response, string, error) { + req, err := http.NewRequest(method, urlText, nil) + if err != nil { + return nil, "", err + } + req.Header.Set("User-Agent", "GoNavi-DriverManager") + // 用 GET+Range 探测可更接近真实下载链路,同时避免下载正文。 + if strings.EqualFold(method, http.MethodGet) { + req.Header.Set("Range", "bytes=0-0") + } + resp, err := client.Do(req) + if err != nil { + return nil, method, err + } + return resp, method, nil +} + +func shouldFallbackHeadProbe(resp *http.Response) bool { + if resp == nil { + return false + } + return resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented +} + func normalizeDriverNetworkError(err error) string { if err == nil { return ""