From 98c1600e1301f0bea2f2fcdfeddd2fc31a251cb5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Feb 2026 11:33:21 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E2=9C=A8=20feat(driver-manager):=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E9=A9=B1=E5=8A=A8=E7=AE=A1=E7=90=86=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=AF=BC=E5=85=A5=E5=B9=B6=E7=BB=9F=E4=B8=80=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增驱动目录批量导入入口,支持覆盖已安装开关与去重处理 - 行内本地导入聚焦单文件场景,目录导入与单文件导入流程统一 - 已安装驱动版本选择锁定,避免安装后误改版本 - 补充驱动下载网络检测与日志可见性,提升问题定位效率 - 重构驱动管理横向滚动条实现,修复双滚动条/消失/位置异常问题 --- frontend/src/App.css | 50 ++ .../src/components/DriverManagerModal.tsx | 470 +++++++++++++++--- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/methods_driver.go | 227 ++++++++- 5 files changed, 656 insertions(+), 97 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index e532c84..713d6b9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -92,3 +92,53 @@ body[data-theme='dark'] { background-color: #ff4d4f !important; color: #fff !important; } + +/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */ +.driver-manager-table .ant-table-sticky-scroll { + display: none !important; +} + +/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */ +.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content, +.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body { + overflow-x: auto !important; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal, +.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal { + height: 0 !important; +} + +.driver-manager-table-wrap { + width: 100%; + max-width: 100%; + overflow-x: hidden; +} + +.driver-manager-footer { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} + +.driver-manager-footer-actions { + width: 100%; + display: flex; + justify-content: flex-end; +} + +.driver-manager-hscroll { + width: 100%; + height: 12px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-gutter: stable; + background: transparent; +} + +.driver-manager-hscroll-inner { + height: 1px; +} diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index d4e7190..82a57df 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd'; -import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Alert, Button, Collapse, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd'; +import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, ReloadOutlined } from '@ant-design/icons'; import { EventsOn } from '../../wailsjs/runtime/runtime'; import { CheckDriverNetworkStatus, @@ -10,6 +10,7 @@ import { GetDriverStatusList, InstallLocalDriverPackage, RemoveDriverPackage, + SelectDriverPackageDirectory, SelectDriverPackageFile, } from '../../wailsjs/go/app/App'; @@ -46,6 +47,8 @@ type ProgressState = { percent: number; }; +type DriverActionKind = '' | 'install' | 'remove' | 'local'; + type DriverLogEntry = { time: string; text: string; @@ -84,6 +87,7 @@ 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 buildVersionSelectOptions = (options: DriverVersionOption[]) => { type SelectOption = { value: string; label: string }; @@ -132,20 +136,28 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => { }; const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { + const modalContentRef = useRef(null); + const tableContainerRef = useRef(null); + const tableScrollTargetsRef = useRef([]); + const externalHScrollRef = useRef(null); + const horizontalSyncSourceRef = useRef<'table' | 'external' | ''>(''); const [loading, setLoading] = useState(false); const [downloadDir, setDownloadDir] = useState(''); const [networkChecking, setNetworkChecking] = useState(false); const [networkStatus, setNetworkStatus] = useState(null); const [rows, setRows] = useState([]); - const [actionDriver, setActionDriver] = useState(''); + const [actionState, setActionState] = useState<{ driverType: string; kind: DriverActionKind }>({ driverType: '', kind: '' }); const [progressMap, setProgressMap] = useState>({}); const [operationLogMap, setOperationLogMap] = useState>({}); const [logDriverType, setLogDriverType] = useState(''); const [logModalOpen, setLogModalOpen] = useState(false); + const [batchDirectoryImporting, setBatchDirectoryImporting] = useState(false); + const [forceOverwriteInstalled, setForceOverwriteInstalled] = useState(false); const [versionMap, setVersionMap] = useState>({}); const [selectedVersionMap, setSelectedVersionMap] = useState>({}); const [versionLoadingMap, setVersionLoadingMap] = useState>({}); const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState>({}); + const [horizontalScrollWidth, setHorizontalScrollWidth] = useState(DRIVER_TABLE_SCROLL_X); const appendOperationLog = useCallback(( driverType: string, @@ -193,6 +205,76 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }); }, []); + const refreshHorizontalScrollState = useCallback(() => { + const tableContainer = tableContainerRef.current; + const targets = tableContainer + ? [ + ...new Set( + [ + ...Array.from(tableContainer.querySelectorAll('.ant-table-content')), + ...Array.from(tableContainer.querySelectorAll('.ant-table-body')), + ].filter((node): node is HTMLElement => node instanceof HTMLElement), + ), + ] + : tableScrollTargetsRef.current; + if (!targets || targets.length === 0) { + setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X); + return; + } + + const nextWidth = Math.max( + DRIVER_TABLE_SCROLL_X, + ...targets.map((target) => Math.max(0, target.scrollWidth)), + ); + setHorizontalScrollWidth((prev) => (prev === nextWidth ? prev : nextWidth)); + + const externalScroll = externalHScrollRef.current; + if (!externalScroll || horizontalSyncSourceRef.current === 'external') { + return; + } + const preferredTarget = + targets.find((target) => target.scrollWidth > target.clientWidth + 1) || + targets[0]; + const targetScrollLeft = preferredTarget?.scrollLeft || 0; + if (Math.abs(externalScroll.scrollLeft - targetScrollLeft) > 1) { + externalScroll.scrollLeft = targetScrollLeft; + } + }, []); + + const applyExternalScrollToTableTargets = useCallback(() => { + const tableContainer = tableContainerRef.current; + const externalScroll = externalHScrollRef.current; + if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) { + return; + } + if (horizontalSyncSourceRef.current === 'table') { + return; + } + + const liveTargets = [ + ...new Set( + [ + ...Array.from(tableContainer.querySelectorAll('.ant-table-content')), + ...Array.from(tableContainer.querySelectorAll('.ant-table-body')), + ].filter((node): node is HTMLElement => node instanceof HTMLElement), + ), + ]; + if (liveTargets.length === 0) { + return; + } + + horizontalSyncSourceRef.current = 'external'; + liveTargets.forEach((target) => { + if (target.scrollWidth <= target.clientWidth + 1) { + return; + } + if (Math.abs(target.scrollLeft - externalScroll.scrollLeft) > 1) { + target.scrollLeft = externalScroll.scrollLeft; + } + }); + horizontalSyncSourceRef.current = ''; + }, []); + const refreshStatus = useCallback(async (toastOnError = true) => { setLoading(true); try { @@ -429,12 +511,125 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ useEffect(() => { if (!open) { + setHorizontalScrollWidth(DRIVER_TABLE_SCROLL_X); + tableScrollTargetsRef.current = []; return; } refreshStatus(false); checkNetworkStatus(false); }, [checkNetworkStatus, open, refreshStatus]); + useEffect(() => { + if (!open) { + return; + } + const tableContainer = tableContainerRef.current; + const externalScroll = externalHScrollRef.current; + if (!(tableContainer instanceof HTMLElement) || !(externalScroll instanceof HTMLDivElement)) { + return; + } + + let currentTargets: HTMLElement[] = []; + let rafId: number | null = null; + let bodyResizeObserver: ResizeObserver | null = null; + let containerResizeObserver: ResizeObserver | null = null; + + const pickSyncTarget = () => { + if (currentTargets.length === 0) { + return null; + } + return currentTargets.find((target) => target.scrollWidth > target.clientWidth + 1) || currentTargets[0]; + }; + + const syncFromTableTarget = (event?: Event) => { + const source = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null; + const activeTarget = source || pickSyncTarget(); + if (!activeTarget) { + return; + } + if (horizontalSyncSourceRef.current === 'external') { + return; + } + horizontalSyncSourceRef.current = 'table'; + if (Math.abs(externalScroll.scrollLeft - activeTarget.scrollLeft) > 1) { + externalScroll.scrollLeft = activeTarget.scrollLeft; + } + horizontalSyncSourceRef.current = ''; + }; + + const bindCurrentTableTargets = () => { + const nextTargets = [ + ...new Set( + [ + ...Array.from(tableContainer.querySelectorAll('.ant-table-content')), + ...Array.from(tableContainer.querySelectorAll('.ant-table-body')), + ].filter((node): node is HTMLElement => node instanceof HTMLElement), + ), + ]; + + const sameTargets = + nextTargets.length === currentTargets.length && + nextTargets.every((target, index) => target === currentTargets[index]); + if (sameTargets) { + return; + } + + currentTargets.forEach((target) => { + target.removeEventListener('scroll', syncFromTableTarget); + bodyResizeObserver?.unobserve(target); + }); + + currentTargets = nextTargets; + tableScrollTargetsRef.current = nextTargets; + currentTargets.forEach((target) => { + target.addEventListener('scroll', syncFromTableTarget, { passive: true }); + bodyResizeObserver?.observe(target); + }); + + refreshHorizontalScrollState(); + syncFromTableTarget(); + }; + + const scheduleRefresh = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + bindCurrentTableTargets(); + refreshHorizontalScrollState(); + }); + }; + + const mutationObserver = new MutationObserver(scheduleRefresh); + mutationObserver.observe(tableContainer, { childList: true, subtree: true }); + + bodyResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null; + containerResizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleRefresh) : null; + containerResizeObserver?.observe(tableContainer); + if (typeof ResizeObserver !== 'undefined') { + modalContentRef.current && containerResizeObserver?.observe(modalContentRef.current); + } + window.addEventListener('resize', scheduleRefresh); + + scheduleRefresh(); + return () => { + mutationObserver.disconnect(); + window.removeEventListener('resize', scheduleRefresh); + currentTargets.forEach((target) => { + target.removeEventListener('scroll', syncFromTableTarget); + }); + if (bodyResizeObserver) { + bodyResizeObserver.disconnect(); + } + if (containerResizeObserver) { + containerResizeObserver.disconnect(); + } + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [open, refreshHorizontalScrollState]); + useEffect(() => { if (!open) { return; @@ -470,7 +665,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }, [appendOperationLog, open]); const installDriver = useCallback(async (row: DriverStatusRow) => { - setActionDriver(row.type); + setActionState({ driverType: row.type, kind: 'install' }); setProgressMap((prev) => ({ ...prev, [row.type]: { @@ -505,25 +700,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ message.success(`${row.name}${versionTip} 已安装启用`); refreshStatus(false); } finally { - setActionDriver(''); + setActionState({ driverType: '', kind: '' }); } }, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]); - const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => { - const fileRes = await SelectDriverPackageFile(downloadDir); - if (!fileRes?.success) { - if (String(fileRes?.message || '') !== 'Cancelled') { - message.error(fileRes?.message || '选择本地驱动包失败'); + const installDriverFromLocalPath = useCallback(async ( + row: DriverStatusRow, + sourcePath: string, + sourceLabel: '文件' | '目录', + options?: { silentToast?: boolean; skipRefresh?: boolean }, + ) => { + const pathText = String(sourcePath || '').trim(); + if (!pathText) { + if (!options?.silentToast) { + message.error(`未选择有效的本地导入${sourceLabel}`); } - return; - } - const filePath = String((fileRes?.data as any)?.path || '').trim(); - if (!filePath) { - message.error('未选择有效的驱动包文件'); - return; + return false; } - setActionDriver(row.type); + setActionState({ driverType: row.type, kind: 'local' }); setProgressMap((prev) => ({ ...prev, [row.type]: { @@ -532,23 +727,122 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ percent: 0, }, })); - appendOperationLog(row.type, `[START] 开始本地导入:${filePath}`); + appendOperationLog(row.type, `[START] 开始本地导入(${sourceLabel}):${pathText}`); try { - const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir); + const result = await InstallLocalDriverPackage(row.type, pathText, downloadDir); if (!result?.success) { const errText = result?.message || `导入 ${row.name} 本地驱动包失败`; appendOperationLog(row.type, `[ERROR] ${errText}`); - message.error(errText); - return; + if (!options?.silentToast) { + message.error(errText); + } + return false; } appendOperationLog(row.type, '[DONE] 本地导入安装完成'); - message.success(`${row.name} 本地驱动包已安装启用`); - refreshStatus(false); + if (!options?.silentToast) { + message.success(`${row.name} 本地驱动包已安装启用`); + } + if (!options?.skipRefresh) { + await refreshStatus(false); + } + return true; } finally { - setActionDriver(''); + setActionState({ driverType: '', kind: '' }); } }, [appendOperationLog, downloadDir, refreshStatus]); + const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => { + const fileRes = await SelectDriverPackageFile(downloadDir); + if (!fileRes?.success) { + if (String(fileRes?.message || '') !== 'Cancelled') { + message.error(fileRes?.message || '选择本地驱动包文件失败'); + } + return; + } + const filePath = String((fileRes?.data as any)?.path || '').trim(); + if (!filePath) { + message.error('未选择有效的驱动包文件'); + return; + } + await installDriverFromLocalPath(row, filePath, '文件'); + }, [downloadDir, installDriverFromLocalPath]); + + const installDriversFromDirectory = useCallback(async () => { + const directoryRes = await SelectDriverPackageDirectory(downloadDir); + if (!directoryRes?.success) { + if (String(directoryRes?.message || '') !== 'Cancelled') { + message.error(directoryRes?.message || '选择本地驱动包目录失败'); + } + return; + } + + const directoryPath = String((directoryRes?.data as any)?.path || '').trim(); + if (!directoryPath) { + message.error('未选择有效的驱动包目录'); + return; + } + const optionalRows = rows.filter((item) => !item.builtIn); + if (optionalRows.length === 0) { + message.info('当前没有可导入的外置驱动'); + return; + } + + let successCount = 0; + let failCount = 0; + let dedupeSkipCount = 0; + let slimSkipCount = 0; + + setBatchDirectoryImporting(true); + try { + for (const row of optionalRows) { + const alreadyInstalled = row.packageInstalled || row.connectable; + if (alreadyInstalled && !forceOverwriteInstalled) { + dedupeSkipCount += 1; + appendOperationLog(row.type, '[SKIP] 已检测到驱动已安装,目录导入去重跳过'); + continue; + } + if (alreadyInstalled && forceOverwriteInstalled) { + appendOperationLog(row.type, '[INFO] 已启用覆盖已安装模式,执行重装导入'); + } + const isSlimBuildUnavailable = (row.message || '').includes('精简构建') && !row.packageInstalled; + if (isSlimBuildUnavailable) { + slimSkipCount += 1; + appendOperationLog(row.type, '[WARN] 当前发行包为精简构建,已跳过目录导入'); + continue; + } + const ok = await installDriverFromLocalPath(row, directoryPath, '目录', { silentToast: true, skipRefresh: true }); + if (ok) { + successCount += 1; + } else { + failCount += 1; + } + } + await refreshStatus(false); + } finally { + setBatchDirectoryImporting(false); + } + + const skipParts: string[] = []; + if (dedupeSkipCount > 0) { + skipParts.push(`去重跳过 ${dedupeSkipCount}`); + } + if (slimSkipCount > 0) { + skipParts.push(`精简版跳过 ${slimSkipCount}`); + } + const skipTip = skipParts.length > 0 ? `,${skipParts.join(',')}` : ''; + + const forceTip = forceOverwriteInstalled ? '(覆盖已安装)' : ''; + if (failCount === 0) { + message.success(`目录导入完成${forceTip}:成功 ${successCount}${skipTip}`); + return; + } + if (successCount > 0) { + message.warning(`目录导入完成${forceTip}:成功 ${successCount},失败 ${failCount}${skipTip}`); + return; + } + message.error(`目录导入失败${forceTip}:失败 ${failCount}${skipTip}`); + }, [appendOperationLog, downloadDir, forceOverwriteInstalled, installDriverFromLocalPath, refreshStatus, rows]); + const openDriverLog = useCallback((driverType: string) => { const normalized = String(driverType || '').trim().toLowerCase(); if (!normalized) { @@ -559,7 +853,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }, []); const removeDriver = useCallback(async (row: DriverStatusRow) => { - setActionDriver(row.type); + setActionState({ driverType: row.type, kind: 'remove' }); appendOperationLog(row.type, '[START] 开始移除驱动'); try { const result = await RemoveDriverPackage(row.type, downloadDir); @@ -578,7 +872,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ }); refreshStatus(false); } finally { - setActionDriver(''); + setActionState({ driverType: '', kind: '' }); } }, [appendOperationLog, downloadDir, refreshStatus]); @@ -590,25 +884,6 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ key: 'name', width: 150, }, - { - title: '安装位置', - key: 'installPath', - width: 260, - render: (_: string, row: DriverStatusRow) => { - if (row.builtIn) { - return 内置; - } - const installPath = row.executablePath || row.installDir || '-'; - if (installPath === '-') { - return -; - } - return ( - - {installPath} - - ); - }, - }, { title: '安装包大小', dataIndex: 'packageSizeText', @@ -688,6 +963,14 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ if (row.builtIn) { return -; } + const versionLocked = row.packageInstalled || row.connectable; + if (versionLocked) { + const installedVersion = String(row.installedVersion || '').trim(); + if (installedVersion) { + return {installedVersion}(已安装,移除后可更换); + } + return 已安装(移除后可更换); + } const options = versionMap[row.type] || []; const selectedKey = selectedVersionMap[row.type]; const selectOptions = buildVersionSelectOptions(options); @@ -696,7 +979,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ size="small" style={{ width: '100%' }} loading={!!versionLoadingMap[row.type]} - disabled={actionDriver === row.type} + disabled={actionState.driverType === row.type} placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'} value={selectedKey} options={selectOptions as any} @@ -726,7 +1009,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ return -; } const isSlimBuildUnavailable = (row.message || '').includes('精简构建'); - const loadingAction = actionDriver === row.type; + const loadingInstallOrRemove = + actionState.driverType === row.type && (actionState.kind === 'install' || actionState.kind === 'remove'); + const loadingLocal = actionState.driverType === row.type && actionState.kind === 'local'; if (isSlimBuildUnavailable && !row.packageInstalled) { return 需 Full 版; } @@ -738,7 +1023,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ , - , - , - ]} + footer={( +
+
+
+
+ + + + + +
+ )} > +
除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。 {networkStatus ? ( @@ -868,7 +1166,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ description={( 自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。 - 手动导入支持单个驱动代理文件(如 `mariadb-driver-agent` / `mariadb-driver-agent.exe`)或驱动总包 `GoNavi-DriverAgents.zip`。 + 行内“本地导入”仅用于单个驱动文件/总包(如 `mariadb-driver-agent`、`mariadb-driver-agent.exe`、`GoNavi-DriverAgents.zip`);批量导入请使用上方“导入驱动目录”。 驱动根目录:{downloadDir || '-'} @@ -881,16 +1179,42 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ )} /> - + + + 覆盖已安装 + setForceOverwriteInstalled(checked)} + disabled={batchDirectoryImporting} + /> + + + + +
+
+ + ; +export function SelectDriverPackageDirectory(arg1:string):Promise; + export function SelectDriverPackageFile(arg1:string):Promise; export function SelectSSHKeyFile(arg1:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 6dba529..6879fc2 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -322,6 +322,10 @@ export function SelectDriverDownloadDirectory(arg1) { return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1); } +export function SelectDriverPackageDirectory(arg1) { + return window['go']['app']['App']['SelectDriverPackageDirectory'](arg1); +} + export function SelectDriverPackageFile(arg1) { return window['go']['app']['App']['SelectDriverPackageFile'](arg1); } diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index 0c3a3e9..350a21c 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "io/fs" "net" "net/http" "net/url" @@ -200,6 +201,7 @@ const ( driverBundleIndexMaxSize = 1 << 20 driverManifestMaxSize = 2 << 20 driverNetworkProbeTimeout = 4 * time.Second + localDriverDirectoryScanMaxEntries = 20000 driverChecksumPolicyStrict = "strict" driverChecksumPolicyWarn = "warn" driverChecksumPolicyOff = "off" @@ -228,18 +230,19 @@ const builtinDriverManifestJSON = `{ }` var ( - driverManifestCacheMu sync.RWMutex - driverManifestCache = make(map[string]driverManifestCacheEntry) - driverReleaseSizeMu sync.RWMutex - driverReleaseSizeMap = make(map[string]driverReleaseAssetSizeCacheEntry) - driverReleaseListMu sync.RWMutex - driverReleaseList = driverManifestReleaseListCache{} - driverModuleLatestMu sync.RWMutex - driverModuleLatestMap = make(map[string]goModuleLatestVersionCacheEntry) - driverModuleVersionMu sync.RWMutex - driverModuleVersionMap = make(map[string]goModuleVersionListCacheEntry) - driverVersionWarmupMu sync.Mutex - driverVersionWarmup = driverVersionWarmupState{} + driverManifestCacheMu sync.RWMutex + driverManifestCache = make(map[string]driverManifestCacheEntry) + driverReleaseSizeMu sync.RWMutex + driverReleaseSizeMap = make(map[string]driverReleaseAssetSizeCacheEntry) + driverReleaseListMu sync.RWMutex + driverReleaseList = driverManifestReleaseListCache{} + driverModuleLatestMu sync.RWMutex + driverModuleLatestMap = make(map[string]goModuleLatestVersionCacheEntry) + driverModuleVersionMu sync.RWMutex + driverModuleVersionMap = make(map[string]goModuleVersionListCacheEntry) + driverVersionWarmupMu sync.Mutex + driverVersionWarmup = driverVersionWarmupState{} + errLocalDriverDirScanLimit = errors.New("local_driver_directory_scan_limit_exceeded") ) type driverVersionWarmupState struct { @@ -360,9 +363,6 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: "选择驱动包文件", DefaultDirectory: defaultDir, - Filters: []runtime.FileFilter{ - {DisplayName: "所有文件", Pattern: "*"}, - }, }) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} @@ -377,6 +377,36 @@ func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}} } +func (a *App) SelectDriverPackageDirectory(currentPath string) connection.QueryResult { + defaultDir := strings.TrimSpace(currentPath) + if defaultDir == "" { + defaultDir = defaultDriverDownloadDirectory() + } + if filepath.Ext(defaultDir) != "" { + defaultDir = filepath.Dir(defaultDir) + } + if !filepath.IsAbs(defaultDir) { + if abs, err := filepath.Abs(defaultDir); err == nil { + defaultDir = abs + } + } + + selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "选择驱动包目录", + DefaultDirectory: defaultDir, + }) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if strings.TrimSpace(selection) == "" { + return connection.QueryResult{Success: false, Message: "Cancelled"} + } + if abs, err := filepath.Abs(selection); err == nil { + selection = abs + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}} +} + func (a *App) ResolveDriverDownloadDirectory(directory string) connection.QueryResult { resolved, err := resolveDriverDownloadDirectory(directory) if err != nil { @@ -684,7 +714,7 @@ func (a *App) InstallLocalDriverPackage(driverType string, filePath string, down a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, "开始安装本地驱动包") selectedVersion := resolveDriverInstallVersion(definition.PinnedVersion, "local://manual", definition) - meta, installErr := installOptionalDriverAgentFromLocalFile(definition, filePath, resolvedDir, selectedVersion) + meta, installErr := installOptionalDriverAgentFromLocalPath(definition, filePath, resolvedDir, selectedVersion) if installErr != nil { errText := normalizeErrorMessage(installErr) a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText) @@ -2194,7 +2224,7 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele }, nil } -func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) { +func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) { driverType := normalizeDriverType(definition.Type) displayName := resolveDriverDisplayName(definition) pathText := strings.TrimSpace(filePath) @@ -2208,9 +2238,6 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa if statErr != nil { return installedDriverPackage{}, fmt.Errorf("读取本地驱动包失败:%w", statErr) } - if info.IsDir() { - return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为目录:%s", pathText) - } executablePath, err := db.ResolveOptionalDriverAgentExecutablePath(resolvedDir, driverType) if err != nil { @@ -2220,8 +2247,23 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa return installedDriverPackage{}, fmt.Errorf("创建 %s 驱动目录失败:%w", displayName, mkErr) } + sourcePath := pathText + sourceName := filepath.Base(pathText) downloadSource := fmt.Sprintf("local://manual/%s", filepath.Base(pathText)) - if strings.EqualFold(filepath.Ext(pathText), ".zip") { + if info.IsDir() { + matchedPath, matchedEntry, resolveErr := resolveLocalDriverAgentFromDirectory(pathText, driverType) + if resolveErr != nil { + return installedDriverPackage{}, resolveErr + } + sourcePath = matchedPath + sourceName = filepath.Base(matchedPath) + downloadSource = fmt.Sprintf("local://manual-dir/%s", filepath.Base(pathText)) + if strings.TrimSpace(matchedEntry) != "" { + downloadSource = downloadSource + "#" + matchedEntry + } + } + + if !info.IsDir() && strings.EqualFold(filepath.Ext(pathText), ".zip") { entryName, extractErr := installOptionalDriverAgentFromLocalZip(pathText, definition, executablePath) if extractErr != nil { return installedDriverPackage{}, extractErr @@ -2230,7 +2272,7 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa downloadSource = downloadSource + "#" + entryName } } else { - if copyErr := copyAgentBinary(pathText, executablePath); copyErr != nil { + if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil { return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr) } } @@ -2242,8 +2284,8 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa return installedDriverPackage{ DriverType: driverType, Version: strings.TrimSpace(selectedVersion), - FilePath: pathText, - FileName: filepath.Base(pathText), + FilePath: sourcePath, + FileName: sourceName, ExecutablePath: executablePath, DownloadURL: downloadSource, SHA256: hash, @@ -2251,6 +2293,143 @@ func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePa }, nil } +type localDriverCandidate struct { + absPath string + relativePath string + depth int + inPlatformDir bool +} + +func resolveLocalDriverAgentFromDirectory(directoryPath string, driverType string) (string, string, error) { + root := strings.TrimSpace(directoryPath) + if root == "" { + return "", "", fmt.Errorf("本地驱动目录路径为空") + } + if absPath, absErr := filepath.Abs(root); absErr == nil { + root = absPath + } + info, statErr := os.Stat(root) + if statErr != nil { + return "", "", fmt.Errorf("读取本地驱动目录失败:%w", statErr) + } + if !info.IsDir() { + return "", "", fmt.Errorf("本地驱动目录路径不是目录:%s", root) + } + + normalizedType := normalizeDriverType(driverType) + displayDefinition, found := resolveDriverDefinition(normalizedType) + if !found { + displayDefinition = driverDefinition{Type: normalizedType, Name: normalizedType} + } + displayName := resolveDriverDisplayName(displayDefinition) + platformDir := optionalDriverBundlePlatformDir(stdRuntime.GOOS) + assetName := optionalDriverReleaseAssetName(normalizedType) + baseName := optionalDriverExecutableBaseName(normalizedType) + + exactRelativePath := filepath.ToSlash(filepath.Join(platformDir, assetName)) + exactPath := filepath.Join(root, platformDir, assetName) + if exactInfo, err := os.Stat(exactPath); err == nil && !exactInfo.IsDir() { + return exactPath, exactRelativePath, nil + } + + rootAssetPath := filepath.Join(root, assetName) + if rootAssetInfo, err := os.Stat(rootAssetPath); err == nil && !rootAssetInfo.IsDir() { + return rootAssetPath, filepath.ToSlash(assetName), nil + } + + assetCandidates := make([]localDriverCandidate, 0, 8) + baseCandidates := make([]localDriverCandidate, 0, 8) + visited := 0 + walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + visited++ + if visited > localDriverDirectoryScanMaxEntries { + return errLocalDriverDirScanLimit + } + if d.IsDir() { + return nil + } + name := strings.TrimSpace(d.Name()) + if name == "" { + return nil + } + + relative, relErr := filepath.Rel(root, path) + if relErr != nil { + relative = name + } + normalizedRelative := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(relative), "./")) + if normalizedRelative == "" { + normalizedRelative = name + } + normalizedLower := strings.ToLower(normalizedRelative) + platformPrefix := strings.ToLower(platformDir) + "/" + inPlatformDir := normalizedLower == strings.ToLower(platformDir) || strings.HasPrefix(normalizedLower, platformPrefix) + depth := strings.Count(normalizedRelative, "/") + candidate := localDriverCandidate{ + absPath: path, + relativePath: normalizedRelative, + depth: depth, + inPlatformDir: inPlatformDir, + } + + if strings.EqualFold(name, assetName) { + assetCandidates = append(assetCandidates, candidate) + return nil + } + if strings.EqualFold(name, baseName) { + baseCandidates = append(baseCandidates, candidate) + } + return nil + }) + if errors.Is(walkErr, errLocalDriverDirScanLimit) { + return "", "", fmt.Errorf("本地驱动目录条目过多(超过 %d),请缩小目录范围或直接选择 zip/单文件", localDriverDirectoryScanMaxEntries) + } + if walkErr != nil { + return "", "", fmt.Errorf("扫描本地驱动目录失败:%w", walkErr) + } + + selectBest := func(candidates []localDriverCandidate) (localDriverCandidate, bool) { + if len(candidates) == 0 { + return localDriverCandidate{}, false + } + sort.Slice(candidates, func(i, j int) bool { + left := candidates[i] + right := candidates[j] + if left.inPlatformDir != right.inPlatformDir { + return left.inPlatformDir + } + if left.depth != right.depth { + return left.depth < right.depth + } + leftRelative := strings.ToLower(left.relativePath) + rightRelative := strings.ToLower(right.relativePath) + if leftRelative != rightRelative { + return leftRelative < rightRelative + } + return strings.ToLower(left.absPath) < strings.ToLower(right.absPath) + }) + return candidates[0], true + } + + if candidate, ok := selectBest(assetCandidates); ok { + return candidate.absPath, candidate.relativePath, nil + } + if candidate, ok := selectBest(baseCandidates); ok { + return candidate.absPath, candidate.relativePath, nil + } + + return "", "", fmt.Errorf( + "目录中未找到 %s 代理文件(优先路径 %s,候选文件名 %s / %s)", + displayName, + exactRelativePath, + assetName, + baseName, + ) +} + func installOptionalDriverAgentFromLocalZip(zipPath string, definition driverDefinition, executablePath string) (string, error) { driverType := normalizeDriverType(definition.Type) displayName := resolveDriverDisplayName(definition) From 884d72f3d37a23fc41da2faec0b52fddd2fc313d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Feb 2026 11:56:59 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(clickhouse)?= =?UTF-8?q?:=20=E4=BD=BF=E7=94=A8=E7=BB=93=E6=9E=84=E5=8C=96=20Options=20?= =?UTF-8?q?=E6=9B=BF=E4=BB=A3=20DSN=20=E8=BF=9E=E6=8E=A5=E6=9E=84=E9=80=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 buildClickHouseOptions 收敛连接参数生成逻辑 - 将连接入口改为 clickhouse.OpenDB(Options) - 清理 DSN 中的 write_timeout/read_timeout/dial_timeout 透传路径 - 同步重写 ClickHouse 相关测试断言 - refs #138 --- internal/db/clickhouse_impl.go | 39 +++++++++++++--------------------- internal/db/dsn_test.go | 39 +++++++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/internal/db/clickhouse_impl.go b/internal/db/clickhouse_impl.go index 4ba1c85..b20a359 100644 --- a/internal/db/clickhouse_impl.go +++ b/internal/db/clickhouse_impl.go @@ -17,7 +17,7 @@ import ( "GoNavi-Wails/internal/ssh" "GoNavi-Wails/internal/utils" - _ "github.com/ClickHouse/clickhouse-go/v2" + clickhouse "github.com/ClickHouse/clickhouse-go/v2" ) const ( @@ -100,25 +100,20 @@ func applyClickHouseURI(config connection.ConnectionConfig) connection.Connectio return config } -func (c *ClickHouseDB) getDSN(config connection.ConnectionConfig) string { - u := &url.URL{ - Scheme: "clickhouse", - Host: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), - Path: "/" + strings.TrimPrefix(strings.TrimSpace(config.Database), "/"), +func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig) *clickhouse.Options { + timeout := getConnectTimeout(config) + return &clickhouse.Options{ + Addr: []string{ + net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), + }, + Auth: clickhouse.Auth{ + Database: strings.TrimSpace(config.Database), + Username: strings.TrimSpace(config.User), + Password: config.Password, + }, + DialTimeout: timeout, + ReadTimeout: timeout, } - if strings.TrimSpace(config.Password) != "" { - u.User = url.UserPassword(strings.TrimSpace(config.User), config.Password) - } else { - u.User = url.User(strings.TrimSpace(config.User)) - } - - timeoutSeconds := getConnectTimeoutSeconds(config) - query := u.Query() - query.Set("dial_timeout", fmt.Sprintf("%ds", timeoutSeconds)) - query.Set("read_timeout", fmt.Sprintf("%ds", timeoutSeconds)) - query.Set("write_timeout", fmt.Sprintf("%ds", timeoutSeconds)) - u.RawQuery = query.Encode() - return u.String() } func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error { @@ -165,11 +160,7 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error { logger.Infof("ClickHouse 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port) } - dbConn, err := sql.Open("clickhouse", c.getDSN(runConfig)) - if err != nil { - return fmt.Errorf("打开数据库连接失败:%w", err) - } - c.conn = dbConn + c.conn = clickhouse.OpenDB(c.buildClickHouseOptions(runConfig)) if err := c.Ping(); err != nil { _ = c.Close() diff --git a/internal/db/dsn_test.go b/internal/db/dsn_test.go index f3d9392..87ec9f6 100644 --- a/internal/db/dsn_test.go +++ b/internal/db/dsn_test.go @@ -5,6 +5,7 @@ package db import ( "strings" "testing" + "time" "GoNavi-Wails/internal/connection" ) @@ -115,7 +116,7 @@ func TestTDengineDSN_UsesWebSocketFormat(t *testing.T) { } } -func TestClickHouseDSN_EscapesPasswordAndSetsTimeout(t *testing.T) { +func TestClickHouseOptions_UsesStructuredTimeoutAndAuth(t *testing.T) { c := &ClickHouseDB{} cfg := normalizeClickHouseConfig(connection.ConnectionConfig{ Type: "clickhouse", @@ -127,17 +128,35 @@ func TestClickHouseDSN_EscapesPasswordAndSetsTimeout(t *testing.T) { Timeout: 15, }) - dsn := c.getDSN(cfg) - if strings.Contains(dsn, cfg.Password) { - t.Fatalf("dsn 包含原始密码:%s", dsn) + opts := c.buildClickHouseOptions(cfg) + if opts == nil { + t.Fatal("options 为空") } - if !strings.Contains(dsn, "p%40ss%3Awo%2Frd") { - t.Fatalf("dsn 未正确转义密码:%s", dsn) + if len(opts.Addr) != 1 || opts.Addr[0] != "127.0.0.1:9000" { + t.Fatalf("addr 不符合预期:%v", opts.Addr) } - if !strings.Contains(dsn, "dial_timeout=15s") { - t.Fatalf("dsn 缺少 dial_timeout 参数:%s", dsn) + if opts.Auth.Username != "default" { + t.Fatalf("username 不符合预期:%s", opts.Auth.Username) } - if !strings.Contains(dsn, "/analytics") { - t.Fatalf("dsn 缺少数据库路径:%s", dsn) + if opts.Auth.Password != cfg.Password { + t.Fatalf("password 不符合预期:%s", opts.Auth.Password) + } + if opts.Auth.Database != "analytics" { + t.Fatalf("database 不符合预期:%s", opts.Auth.Database) + } + if opts.DialTimeout != 15*time.Second { + t.Fatalf("dial timeout 不符合预期:%s", opts.DialTimeout) + } + if opts.ReadTimeout != 15*time.Second { + t.Fatalf("read timeout 不符合预期:%s", opts.ReadTimeout) + } + if _, ok := opts.Settings["write_timeout"]; ok { + t.Fatalf("options 不应包含 write_timeout 设置:%v", opts.Settings) + } + if _, ok := opts.Settings["read_timeout"]; ok { + t.Fatalf("options 不应通过 settings 传递 read_timeout:%v", opts.Settings) + } + if _, ok := opts.Settings["dial_timeout"]; ok { + t.Fatalf("options 不应通过 settings 传递 dial_timeout:%v", opts.Settings) } } From 439625a49cc7f7466dbb5b8bac611e1b6a091735 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Feb 2026 12:14:34 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=F0=9F=94=A7=20fix(duckdb-pagination):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DuckDB=20=E6=80=BB=E6=95=B0=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=AF=BC=E8=87=B4=E5=88=86=E9=A1=B5=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正 DataViewer 在 hasMore 与 totalKnown 冲突时的分页状态处理 - 增强 DuckDB COUNT(*) 结果解析,兼容字段名与数值类型差异 - 将分页兜底逻辑收敛为 DuckDB 专用,避免影响其他数据库 - 修复 total=0 时分页文案显示异常 - refs #136 --- frontend/src/components/DataGrid.tsx | 6 +++ frontend/src/components/DataViewer.tsx | 60 ++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index f1903f8..534cdfe 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -549,6 +549,8 @@ const DataGrid: React.FC = ({ const showColumnComment = queryOptions?.showColumnComment !== false; const showColumnType = queryOptions?.showColumnType !== false; const selectionColumnWidth = 46; + const connTypeLower = String(connections.find(c => c.id === connectionId)?.config?.type || '').trim().toLowerCase(); + const isDuckDBConnection = connTypeLower === 'duckdb'; // Background Helper const getBg = (darkHex: string) => { @@ -3089,6 +3091,10 @@ const DataGrid: React.FC = ({ pageSize={pagination.pageSize} total={pagination.total} showTotal={(total, range) => { + if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) { + if (pagination.totalKnown === false) return '当前 0 条 / 正在统计总数...'; + return '当前 0 条 / 共 0 条'; + } const currentCount = Math.max(0, range[1] - range[0] + 1); if (pagination.totalKnown === false) return `当前 ${currentCount} 条 / 正在统计总数...`; return `当前 ${currentCount} 条 / 共 ${total} 条`; diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index 2fd8e83..575ee0b 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -6,6 +6,43 @@ import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App'; import DataGrid, { GONAVI_ROW_KEY } from './DataGrid'; import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; +const toNonNegativeFiniteNumber = (value: unknown): number | null => { + if (typeof value === 'number') { + return Number.isFinite(value) && value >= 0 ? value : null; + } + if (typeof value === 'bigint') { + return value >= 0n ? Number(value) : null; + } + if (typeof value === 'string') { + const text = value.trim(); + if (!text) return null; + const parsed = Number(text); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; + } + return null; +}; + +const parseTotalFromCountRow = (row: any): number | null => { + if (!row || typeof row !== 'object') return null; + const entries = Object.entries(row as Record); + if (entries.length === 0) return null; + + for (const [key, raw] of entries) { + const normalized = String(key || '').trim().toLowerCase(); + if (normalized === 'total' || normalized === 'count' || normalized.includes('count')) { + const parsed = toNonNegativeFiniteNumber(raw); + if (parsed !== null) return parsed; + } + } + + for (const [, raw] of entries) { + const parsed = toNonNegativeFiniteNumber(raw); + if (parsed !== null) return parsed; + } + + return null; +}; + const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const [data, setData] = useState([]); const [columnNames, setColumnNames] = useState([]); @@ -157,6 +194,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`; const derivedTotalKnown = !hasMore; const derivedTotal = derivedTotalKnown ? offset + resultData.length : page * size + 1; + const isDuckDB = dbTypeLower === 'duckdb'; + const minExpectedTotal = hasMore ? offset + resultData.length + 1 : offset + resultData.length; if (derivedTotalKnown) countKeyRef.current = countKey; setPagination(prev => { @@ -164,7 +203,14 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: true }; } if (prev.totalKnown && countKeyRef.current === countKey) { - return { ...prev, current: page, pageSize: size }; + if (!isDuckDB) { + return { ...prev, current: page, pageSize: size }; + } + // 当当前页存在“下一页”信号时,已知总数至少应大于当前页末尾。 + // 若旧总数不满足该条件(例如历史统计值为 0),降级为未知总数并回退到 derivedTotal。 + if (Number.isFinite(prev.total) && prev.total >= minExpectedTotal) { + return { ...prev, current: page, pageSize: size }; + } } return { ...prev, current: page, pageSize: size, total: derivedTotal, totalKnown: false }; }); @@ -198,8 +244,16 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { if (!resCount.success) return; if (!Array.isArray(resCount.data) || resCount.data.length === 0) return; - const total = Number(resCount.data[0]?.['total']); - if (!Number.isFinite(total) || total < 0) return; + let total: number | null = null; + if (dbTypeLower === 'duckdb') { + total = parseTotalFromCountRow(resCount.data[0]); + } else { + const parsed = Number(resCount.data[0]?.['total']); + if (Number.isFinite(parsed) && parsed >= 0) { + total = parsed; + } + } + if (total === null) return; setPagination(prev => ({ ...prev, total, totalKnown: true })); }) From 4de3f408c51b50e4da83d33a230f3c03276d54cb Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Feb 2026 12:32:22 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=90=9B=20fix(redis-scan):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=A7=E6=95=B0=E6=8D=AE=E9=87=8F=E4=B8=8B?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4=E5=8A=A0=E8=BD=BD=E4=B8=8D?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前后端 Redis SCAN 游标统一为字符串传递,避免 Number 精度丢失 - RedisScanKeys 增加 string/number 游标兼容解析,异常游标降级并告警 - 新增游标解析单测 - refs #135 --- frontend/src/components/RedisViewer.tsx | 35 ++++++--- frontend/src/types.ts | 2 +- frontend/wailsjs/go/app/App.d.ts | 2 +- internal/app/methods_redis.go | 89 ++++++++++++++++++++++- internal/app/methods_redis_cursor_test.go | 50 +++++++++++++ internal/redis/redis.go | 2 +- internal/redis/redis_impl.go | 2 +- 7 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 internal/app/methods_redis_cursor_test.go diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 0aa3750..c62aced 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -281,6 +281,23 @@ const getRedisScanLoadCount = (pattern: string, append: boolean): number => { return append ? REDIS_KEY_SEARCH_LOAD_MORE_COUNT : REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT; }; +const normalizeRedisCursor = (value: unknown): string => { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed === '' ? '0' : trimmed; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return '0'; + } + return Math.trunc(value).toString(); + } + if (typeof value === 'bigint') { + return value.toString(); + } + return '0'; +}; + const normalizeKeySegment = (segment: string): string => { return segment === '' ? EMPTY_SEGMENT_LABEL : segment; }; @@ -384,7 +401,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(false); const [searchPattern, setSearchPattern] = useState('*'); - const [cursor, setCursor] = useState(0); + const [cursor, setCursor] = useState('0'); const [hasMore, setHasMore] = useState(false); const [selectedKey, setSelectedKey] = useState(null); const [keyValue, setKeyValue] = useState(null); @@ -433,7 +450,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const loadKeys = useCallback(async ( pattern: string = '*', - fromCursor: number = 0, + fromCursor: string = '0', append: boolean = false, targetCount?: number ) => { @@ -454,7 +471,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { if (res.success) { const result = res.data; const scannedKeys = Array.isArray(result?.keys) ? result.keys : []; - const nextCursor = Number(result?.cursor || 0); + const nextCursor = normalizeRedisCursor(result?.cursor); if (append) { setKeys(prev => { const keyMap = new Map(); @@ -466,7 +483,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { setKeys(scannedKeys); } setCursor(nextCursor); - setHasMore(nextCursor !== 0); + setHasMore(nextCursor !== '0'); } else { message.error('加载 Key 失败: ' + res.message); } @@ -483,14 +500,14 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }, [getConfig]); useEffect(() => { - loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false)); + loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); }, [redisDB]); const handleSearch = (value: string) => { const pattern = value.trim() || '*'; setSearchPattern(pattern); - setCursor(0); - loadKeys(pattern, 0, false, getRedisScanLoadCount(pattern, false)); + setCursor('0'); + loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false)); }; const handleLoadMore = () => { @@ -501,8 +518,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { }; const handleRefresh = () => { - setCursor(0); - loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false)); + setCursor('0'); + loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); }; const loadKeyValue = async (key: string) => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f700677..e8a6cb4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -137,7 +137,7 @@ export interface RedisKeyInfo { export interface RedisScanResult { keys: RedisKeyInfo[]; - cursor: number; + cursor: string; } export interface RedisValue { diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index ce594ff..8f3c0e9 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -124,7 +124,7 @@ export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:n export function RedisRenameKey(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; -export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:number):Promise; +export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:string|number,arg4:number):Promise; export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise; diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index f356277..e88d79d 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -4,6 +4,9 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" + "math" + "strconv" "strings" "sync" @@ -107,14 +110,20 @@ func (a *App) RedisTestConnection(config connection.ConnectionConfig) connection } // RedisScanKeys scans keys matching a pattern -func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, cursor uint64, count int64) connection.QueryResult { +func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, cursor any, count int64) connection.QueryResult { config.Type = "redis" client, err := a.getRedisClient(config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - result, err := client.ScanKeys(pattern, cursor, count) + parsedCursor, err := parseRedisScanCursor(cursor) + if err != nil { + logger.Warnf("RedisScanKeys 游标解析失败,已回退到起始游标:cursor=%v err=%v", cursor, err) + parsedCursor = 0 + } + + result, err := client.ScanKeys(pattern, parsedCursor, count) if err != nil { logger.Error(err, "RedisScanKeys 扫描失败:pattern=%s", pattern) return connection.QueryResult{Success: false, Message: err.Error()} @@ -123,6 +132,82 @@ func (a *App) RedisScanKeys(config connection.ConnectionConfig, pattern string, return connection.QueryResult{Success: true, Data: result} } +func parseRedisScanCursor(cursor any) (uint64, error) { + switch v := cursor.(type) { + case nil: + return 0, nil + case uint64: + return v, nil + case uint32: + return uint64(v), nil + case uint16: + return uint64(v), nil + case uint8: + return uint64(v), nil + case uint: + return uint64(v), nil + case int64: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case int32: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case int16: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case int8: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case int: + if v < 0 { + return 0, fmt.Errorf("游标不能为负数: %d", v) + } + return uint64(v), nil + case float64: + return parseRedisScanCursorFromFloat(v) + case float32: + return parseRedisScanCursorFromFloat(float64(v)) + case json.Number: + return parseRedisScanCursor(strings.TrimSpace(v.String())) + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return 0, nil + } + parsed, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil { + return 0, fmt.Errorf("无效游标: %q", v) + } + return parsed, nil + default: + return 0, fmt.Errorf("不支持的游标类型: %T", cursor) + } +} + +func parseRedisScanCursorFromFloat(value float64) (uint64, error) { + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0, fmt.Errorf("无效浮点游标: %v", value) + } + if value < 0 { + return 0, fmt.Errorf("游标不能为负数: %v", value) + } + if math.Trunc(value) != value { + return 0, fmt.Errorf("游标必须为整数: %v", value) + } + if value > float64(math.MaxUint64) { + return 0, fmt.Errorf("游标超出范围: %v", value) + } + return uint64(value), nil +} + // RedisGetValue gets the value of a key func (a *App) RedisGetValue(config connection.ConnectionConfig, key string) connection.QueryResult { config.Type = "redis" diff --git a/internal/app/methods_redis_cursor_test.go b/internal/app/methods_redis_cursor_test.go new file mode 100644 index 0000000..e121d8f --- /dev/null +++ b/internal/app/methods_redis_cursor_test.go @@ -0,0 +1,50 @@ +package app + +import ( + "encoding/json" + "testing" +) + +func TestParseRedisScanCursor(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input any + want uint64 + wantErr bool + }{ + {name: "nil defaults to zero", input: nil, want: 0}, + {name: "empty string defaults to zero", input: " ", want: 0}, + {name: "string cursor", input: "123", want: 123}, + {name: "uint64 cursor", input: uint64(456), want: 456}, + {name: "int cursor", input: int(789), want: 789}, + {name: "float cursor", input: float64(42), want: 42}, + {name: "json number cursor", input: json.Number("88"), want: 88}, + {name: "negative int rejected", input: -1, wantErr: true}, + {name: "fraction float rejected", input: float64(1.5), wantErr: true}, + {name: "invalid string rejected", input: "abc", wantErr: true}, + {name: "unsupported type rejected", input: true, wantErr: true}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := parseRedisScanCursor(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil (value=%d)", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("parseRedisScanCursor() mismatch, want=%d got=%d", tc.want, got) + } + }) + } +} diff --git a/internal/redis/redis.go b/internal/redis/redis.go index 7e0416d..80e58f6 100644 --- a/internal/redis/redis.go +++ b/internal/redis/redis.go @@ -26,7 +26,7 @@ type RedisKeyInfo struct { // RedisScanResult represents the result of a SCAN operation type RedisScanResult struct { Keys []RedisKeyInfo `json:"keys"` - Cursor uint64 `json:"cursor"` + Cursor string `json:"cursor"` } // RedisClient defines the interface for Redis operations diff --git a/internal/redis/redis_impl.go b/internal/redis/redis_impl.go index 50df382..044f16d 100644 --- a/internal/redis/redis_impl.go +++ b/internal/redis/redis_impl.go @@ -175,7 +175,7 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) ( return &RedisScanResult{ Keys: r.loadRedisKeyInfos(ctx, keys), - Cursor: currentCursor, + Cursor: strconv.FormatUint(currentCursor, 10), }, nil } From 87aac277ec8007ec25bc201dc4a3d82abc30c40f Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Feb 2026 12:53:06 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=8E=A8=20style(redis-viewer):=20?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20Redis=20=E6=8B=96=E6=8B=BD=E5=88=86?= =?UTF-8?q?=E5=89=B2=E6=9D=A1=E4=B8=8E=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6=E8=B0=83=E6=95=B4=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分割条宽度调整为与 host 侧边栏一致 - 分割条背景统一为 transparent,去除 hover 强对比效果 - 保持拖拽命中区与提示文案,提升整体样式一致性 --- frontend/src/components/RedisViewer.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index c62aced..23855aa 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -218,18 +218,17 @@ const ResizableDivider: React.FC<{
(e.currentTarget.style.background = '#d9d9d9')} - onMouseLeave={(e) => (e.currentTarget.style.background = '#f0f0f0')} + title="拖动调整宽度" > -
); }; From 210106cde7135c67ddf21a0d6256ee581625c4c9 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Feb 2026 13:27:49 +0800 Subject: [PATCH 06/12] =?UTF-8?q?=F0=9F=90=9B=20fix(driver-modal):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A9=B1=E5=8A=A8=E6=97=A5=E5=BF=97=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=9C=A8=E9=80=8F=E6=98=8E=E6=9A=97=E8=89=B2=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E4=B8=8B=E5=AF=B9=E6=AF=94=E5=BA=A6=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将日志内容容器改为 dark/light 双模式自适应样式 - 使用全局外观透明度参数参与日志背景渲染 - 保持驱动安装与日志采集逻辑不变,仅修复显示层 --- frontend/src/components/DriverManagerModal.tsx | 13 ++++++++++++- frontend/wailsjs/go/app/App.d.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index 82a57df..b198d5b 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Alert, Button, Collapse, Modal, Progress, Select, Space, Switch, Table, Tag, Typography, message } from 'antd'; import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, FolderOpenOutlined, ReloadOutlined } from '@ant-design/icons'; import { EventsOn } from '../../wailsjs/runtime/runtime'; +import { useStore } from '../store'; +import { normalizeOpacityForPlatform } from '../utils/appearance'; import { CheckDriverNetworkStatus, DownloadDriverPackage, @@ -136,6 +138,10 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => { }; const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { + const theme = useStore((state) => state.theme); + const appearance = useStore((state) => state.appearance); + const darkMode = theme === 'dark'; + const opacity = normalizeOpacityForPlatform(appearance.opacity); const modalContentRef = useRef(null); const tableContainerRef = useRef(null); const tableScrollTargetsRef = useRef([]); @@ -1073,6 +1079,11 @@ 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 logBlockBackground = darkMode + ? `rgba(28, 28, 28, ${Math.max(opacity, 0.82)})` + : `rgba(255, 255, 255, ${Math.max(opacity, 0.92)})`; + const logBlockBorderColor = darkMode ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.12)'; + const logBlockTextColor = darkMode ? 'rgba(255, 255, 255, 0.88)' : 'rgba(0, 0, 0, 0.88)'; return ( void }> = ({ ) : null} {activeDriverLogLines.length > 0 ? ( -
+            
               {activeDriverLogLines.join('\n')}
             
) : ( diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 8f3c0e9..0bf094a 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -124,7 +124,7 @@ export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:n export function RedisRenameKey(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; -export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:string|number,arg4:number):Promise; +export function RedisScanKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:any,arg4:number):Promise; export function RedisSelectDB(arg1:connection.ConnectionConfig,arg2:number):Promise; From 20923989b99b5ea07770e88997b46d07030635ff Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sat, 28 Feb 2026 13:37:19 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=90=9B=20fix(connection-modal):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=8F=E6=98=8E=E6=9A=97=E8=89=B2=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=B8=8B=20SSH/=E4=BB=A3=E7=90=86=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8C=BA=E5=9D=97=E7=99=BD=E5=BA=95=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为连接弹窗接入主题与透明度状态,按模式动态计算区块背景 - 将 SSH 与代理配置容器统一替换为自适应样式并补齐边框层次 - 保持连接测试与保存逻辑不变,仅修复显示层 --- frontend/src/components/ConnectionModal.tsx | 28 +++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index d21d9e3..025b343 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd'; import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons'; import { useStore } from '../store'; +import { normalizeOpacityForPlatform } from '../utils/appearance'; import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; @@ -78,10 +79,33 @@ const ConnectionModal: React.FC<{ const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); const updateConnection = useStore((state) => state.updateConnection); + const theme = useStore((state) => state.theme); + const appearance = useStore((state) => state.appearance); + const darkMode = theme === 'dark'; + const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity); const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single'; const mongoTopology = Form.useWatch('mongoTopology', form) || 'single'; const mongoSrv = Form.useWatch('mongoSrv', form) || false; + const getSectionBg = (darkHex: string) => { + if (!darkMode) { + return `rgba(245, 245, 245, ${Math.max(effectiveOpacity, 0.92)})`; + } + const hex = darkHex.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${Math.max(effectiveOpacity, 0.82)})`; + }; + + const tunnelSectionStyle: React.CSSProperties = { + padding: '12px', + background: getSectionBg('#2a2a2a'), + borderRadius: 6, + marginTop: 12, + border: darkMode ? '1px solid rgba(255, 255, 255, 0.16)' : '1px solid rgba(0, 0, 0, 0.06)', + }; + const fetchDriverStatusMap = async (): Promise> => { const result: Record = {}; const res = await GetDriverStatusList('', ''); @@ -1598,7 +1622,7 @@ const ConnectionModal: React.FC<{ {useSSH && ( -
+
@@ -1634,7 +1658,7 @@ const ConnectionModal: React.FC<{ {useProxy && ( -
+