mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-12 12:19:47 +08:00
Compare commits
34 Commits
fix/table-
...
v0.4.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb594b7741 | ||
|
|
587ed3444b | ||
|
|
e366a61910 | ||
|
|
5986b71c4d | ||
|
|
cb18bc3067 | ||
|
|
d676ac9084 | ||
|
|
7fcbcb2471 | ||
|
|
c680e50e74 | ||
|
|
9685102229 | ||
|
|
3505b4428a | ||
|
|
9ebdf7f053 | ||
|
|
9ad852c10b | ||
|
|
2a8fff4d93 | ||
|
|
eca560b4e5 | ||
|
|
2f475dddc0 | ||
|
|
ad9d8a12be | ||
|
|
095b22951e | ||
|
|
7350a011e3 | ||
|
|
53b5802add | ||
|
|
54e7077317 | ||
|
|
4cb5071b0b | ||
|
|
96de46cf1e | ||
|
|
7d5592d8d9 | ||
|
|
d0ba8822f3 | ||
|
|
140db73ef4 | ||
|
|
7ae5341c1c | ||
|
|
01940e74b7 | ||
|
|
30210bc40e | ||
|
|
e90a3e2db6 | ||
|
|
5df95730d8 | ||
|
|
67a9c454d0 | ||
|
|
c17493952b | ||
|
|
dd258bd46c | ||
|
|
505c89066b |
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -135,11 +135,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_ARGS=()
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
TAG_ARGS+=(-tags "${{ matrix.wails_tags }}")
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
else
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
fi
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} "${TAG_ARGS[@]}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
|
||||
- name: Build Optional Driver Agents
|
||||
if: ${{ matrix.build_optional_agents }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||
GOOS="${TARGET_PLATFORM%%/*}"
|
||||
GOARCH="${TARGET_PLATFORM##*/}"
|
||||
DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine)
|
||||
DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
@@ -203,7 +203,9 @@ jobs:
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
|
||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||
codesign --force --options runtime --deep --sign - "$APP_NAME"
|
||||
# 注意:Ad-hoc + hardened runtime(--options runtime)在未配置 entitlements 时,
|
||||
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
FINAL_NAME="GoNavi-$VERSION-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.dmg"
|
||||
|
||||
12
cmd/optional-driver-agent/provider_clickhouse.go
Normal file
12
cmd/optional-driver-agent/provider_clickhouse.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_clickhouse_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "clickhouse"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.ClickHouseDB{}
|
||||
}
|
||||
}
|
||||
@@ -3,79 +3,85 @@
|
||||
"drivers": {
|
||||
"mariadb": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mariadb"
|
||||
},
|
||||
"diros": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/diros"
|
||||
},
|
||||
"sphinx": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sphinx"
|
||||
},
|
||||
"sqlserver": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.9.6",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sqlserver"
|
||||
},
|
||||
"sqlite": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.44.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/sqlite"
|
||||
},
|
||||
"duckdb": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "2.5.5",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/duckdb"
|
||||
},
|
||||
"dameng": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.8.22",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/dameng"
|
||||
},
|
||||
"kingbase": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "0.0.0-20201021123113-29bd62a876c3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/kingbase"
|
||||
},
|
||||
"highgo": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "0.0.0-local",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/highgo"
|
||||
},
|
||||
"vastbase": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/vastbase"
|
||||
},
|
||||
"mongodb": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "2.5.0",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mongodb"
|
||||
},
|
||||
"tdengine": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "3.7.8",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/tdengine"
|
||||
},
|
||||
"clickhouse": {
|
||||
"engine": "go",
|
||||
"version": "2.43.0",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/clickhouse"
|
||||
},
|
||||
"postgres": {
|
||||
"engine": "go",
|
||||
"version": "go-embedded",
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/postgres"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress } from 'antd';
|
||||
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Environment, EventsOn } from '../wailsjs/runtime/runtime';
|
||||
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
@@ -12,7 +12,7 @@ import LogPanel from './components/LogPanel';
|
||||
import { useStore } from './store';
|
||||
import { SavedConnection } from './types';
|
||||
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform } from './utils/appearance';
|
||||
import { SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
@@ -26,12 +26,18 @@ function App() {
|
||||
const setTheme = useStore(state => state.setTheme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const setAppearance = useStore(state => state.setAppearance);
|
||||
const startupFullscreen = useStore(state => state.startupFullscreen);
|
||||
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
|
||||
const globalProxy = useStore(state => state.globalProxy);
|
||||
const setGlobalProxy = useStore(state => state.setGlobalProxy);
|
||||
const darkMode = themeMode === 'dark';
|
||||
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
|
||||
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
|
||||
const blurFilter = blurToFilter(effectiveBlur);
|
||||
const windowCornerRadius = 14;
|
||||
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
|
||||
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
|
||||
const globalProxyInvalidHintShownRef = React.useRef(false);
|
||||
|
||||
// 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView,
|
||||
// 避免 GPU 持续计算窗口背后的模糊合成
|
||||
@@ -56,6 +62,161 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStoreHydrated) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = useStore.persist.onFinishHydration(() => {
|
||||
setIsStoreHydrated(true);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [isStoreHydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStoreHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const host = String(globalProxy.host || '').trim();
|
||||
const port = Number(globalProxy.port);
|
||||
const portValid = Number.isFinite(port) && port > 0 && port <= 65535;
|
||||
const invalidWhenEnabled = globalProxy.enabled && (!host || !portValid);
|
||||
|
||||
if (invalidWhenEnabled) {
|
||||
if (!globalProxyInvalidHintShownRef.current) {
|
||||
message.warning({
|
||||
content: '全局代理已开启,但地址或端口无效,当前按未启用处理',
|
||||
key: 'global-proxy-invalid',
|
||||
});
|
||||
globalProxyInvalidHintShownRef.current = true;
|
||||
}
|
||||
} else {
|
||||
globalProxyInvalidHintShownRef.current = false;
|
||||
message.destroy('global-proxy-invalid');
|
||||
}
|
||||
|
||||
const enabledForBackend = globalProxy.enabled && !invalidWhenEnabled;
|
||||
let cancelled = false;
|
||||
ConfigureGlobalProxy(enabledForBackend, {
|
||||
type: globalProxy.type,
|
||||
host,
|
||||
port: portValid ? port : (globalProxy.type === 'http' ? 8080 : 1080),
|
||||
user: String(globalProxy.user || '').trim(),
|
||||
password: globalProxy.password || '',
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled || res?.success) {
|
||||
return;
|
||||
}
|
||||
message.error({
|
||||
content: '全局代理配置失败: ' + (res?.message || '未知错误'),
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err || '未知错误');
|
||||
message.error({
|
||||
content: '全局代理配置失败: ' + errMsg,
|
||||
key: 'global-proxy-sync-error',
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isStoreHydrated,
|
||||
globalProxy.enabled,
|
||||
globalProxy.type,
|
||||
globalProxy.host,
|
||||
globalProxy.port,
|
||||
globalProxy.user,
|
||||
globalProxy.password,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let startupWindowTimer: number | null = null;
|
||||
const maxApplyAttempts = 6;
|
||||
const applyRetryDelayMs = 400;
|
||||
const settleDelayMs = 160;
|
||||
|
||||
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (await WindowIsFullscreen()) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (await WindowIsMaximised()) {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const applyStartupWindowPreference = (attempt: number) => {
|
||||
if (startupWindowTimer !== null) {
|
||||
window.clearTimeout(startupWindowTimer);
|
||||
}
|
||||
startupWindowTimer = window.setTimeout(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!useStore.getState().startupFullscreen) {
|
||||
return;
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
return;
|
||||
}
|
||||
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
|
||||
WindowFullscreen();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
return;
|
||||
}
|
||||
WindowMaximise();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
|
||||
if (await checkStartupPreferenceApplied()) {
|
||||
return;
|
||||
}
|
||||
if (attempt < maxApplyAttempts) {
|
||||
applyStartupWindowPreference(attempt + 1);
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
||||
if (useStore.persist.hasHydrated()) {
|
||||
applyStartupWindowPreference(1);
|
||||
}
|
||||
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
applyStartupWindowPreference(1);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (startupWindowTimer !== null) {
|
||||
window.clearTimeout(startupWindowTimer);
|
||||
}
|
||||
unsubscribeHydration();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Background Helper
|
||||
const getBg = (darkHex: string, lightHex: string) => {
|
||||
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
|
||||
@@ -412,6 +573,7 @@ function App() {
|
||||
];
|
||||
|
||||
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
|
||||
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
|
||||
|
||||
|
||||
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
|
||||
@@ -734,6 +896,7 @@ function App() {
|
||||
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft">
|
||||
<Button type="text" icon={<ToolOutlined />} title="工具">工具</Button>
|
||||
</Dropdown>
|
||||
<Button type="text" icon={<GlobalOutlined />} title="代理" onClick={() => setIsProxyModalOpen(true)}>代理</Button>
|
||||
<Dropdown menu={{ items: themeMenu }} placement="bottomLeft">
|
||||
<Button type="text" icon={<SkinOutlined />} title="主题">主题</Button>
|
||||
</Dropdown>
|
||||
@@ -874,7 +1037,7 @@ function App() {
|
||||
open={isAppearanceModalOpen}
|
||||
onCancel={() => setIsAppearanceModalOpen(false)}
|
||||
footer={null}
|
||||
width={400}
|
||||
width={460}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
|
||||
<div>
|
||||
@@ -915,6 +1078,91 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>启动窗口</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>启动时全屏</span>
|
||||
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
* 修改后下次启动生效
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="全局代理设置"
|
||||
open={isProxyModalOpen}
|
||||
onCancel={() => setIsProxyModalOpen(false)}
|
||||
footer={null}
|
||||
width={460}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24, padding: '12px 0' }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>全局代理</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>启用全局代理</span>
|
||||
<Switch checked={globalProxy.enabled} onChange={(checked) => setGlobalProxy({ enabled: checked })} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, opacity: globalProxy.enabled ? 1 : 0.7 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>代理类型</div>
|
||||
<Select
|
||||
value={globalProxy.type}
|
||||
disabled={!globalProxy.enabled}
|
||||
options={[
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
]}
|
||||
onChange={(value) => setGlobalProxy({ type: value as 'socks5' | 'http' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>端口</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={65535}
|
||||
style={{ width: '100%' }}
|
||||
value={globalProxy.port}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(value) => setGlobalProxy({
|
||||
port: typeof value === 'number' ? value : (globalProxy.type === 'http' ? 8080 : 1080),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / span 2' }}>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>代理地址</div>
|
||||
<Input
|
||||
placeholder="例如:127.0.0.1"
|
||||
value={globalProxy.host}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(e) => setGlobalProxy({ host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>用户名(可选)</div>
|
||||
<Input
|
||||
placeholder="proxy-user"
|
||||
value={globalProxy.user}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(e) => setGlobalProxy({ user: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontSize: 12, color: '#8c8c8c' }}>密码(可选)</div>
|
||||
<Input.Password
|
||||
placeholder="proxy-password"
|
||||
value={globalProxy.password}
|
||||
disabled={!globalProxy.enabled}
|
||||
onChange={(e) => setGlobalProxy({ password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 6 }}>
|
||||
* 作用于更新检查、驱动管理网络请求,以及未单独配置代理的数据库连接
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Se
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
|
||||
import { MongoMemberInfo, SavedConnection } from '../types';
|
||||
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
|
||||
|
||||
const { Meta } = Card;
|
||||
const { Text } = Typography;
|
||||
@@ -16,6 +16,7 @@ const getDefaultPortByType = (type: string) => {
|
||||
case 'mysql': return 3306;
|
||||
case 'diros': return 9030;
|
||||
case 'sphinx': return 9306;
|
||||
case 'clickhouse': return 9000;
|
||||
case 'postgres': return 5432;
|
||||
case 'redis': return 6379;
|
||||
case 'tdengine': return 6041;
|
||||
@@ -58,6 +59,7 @@ const ConnectionModal: React.FC<{
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useSSH, setUseSSH] = useState(false);
|
||||
const [useProxy, setUseProxy] = useState(false);
|
||||
const [dbType, setDbType] = useState('mysql');
|
||||
const [step, setStep] = useState(1); // 1: Select Type, 2: Configure
|
||||
const [activeGroup, setActiveGroup] = useState(0); // Active category index in step 1
|
||||
@@ -406,6 +408,31 @@ const ConnectionModal: React.FC<{
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'clickhouse') {
|
||||
const parsed = parseMultiHostUri(trimmedUri, 'clickhouse');
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
|
||||
return null;
|
||||
}
|
||||
const hostList = normalizeAddressList(parsed.hosts, 9000);
|
||||
if (!hostList.length) {
|
||||
return null;
|
||||
}
|
||||
const primary = parseHostPort(hostList[0] || 'localhost:9000', 9000);
|
||||
return {
|
||||
host: primary?.host || 'localhost',
|
||||
port: primary?.port || 9000,
|
||||
user: parsed.username,
|
||||
password: parsed.password,
|
||||
database: parsed.database || '',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -440,6 +467,9 @@ const ConnectionModal: React.FC<{
|
||||
if (dbType === 'mongodb') {
|
||||
return 'mongodb+srv://user:pass@cluster0.example.com/db_name?authSource=admin&authMechanism=SCRAM-SHA-256';
|
||||
}
|
||||
if (dbType === 'clickhouse') {
|
||||
return 'clickhouse://default:pass@127.0.0.1:9000/default';
|
||||
}
|
||||
return '例如: postgres://user:pass@127.0.0.1:5432/db_name';
|
||||
};
|
||||
|
||||
@@ -655,6 +685,12 @@ const ConnectionModal: React.FC<{
|
||||
sshUser: config.ssh?.user,
|
||||
sshPassword: config.ssh?.password,
|
||||
sshKeyPath: config.ssh?.keyPath,
|
||||
useProxy: config.useProxy,
|
||||
proxyType: config.proxy?.type || 'socks5',
|
||||
proxyHost: config.proxy?.host,
|
||||
proxyPort: config.proxy?.port,
|
||||
proxyUser: config.proxy?.user,
|
||||
proxyPassword: config.proxy?.password,
|
||||
driver: config.driver,
|
||||
dsn: config.dsn,
|
||||
timeout: config.timeout || 30,
|
||||
@@ -674,6 +710,7 @@ const ConnectionModal: React.FC<{
|
||||
mongoReplicaPassword: config.mongoReplicaPassword || ''
|
||||
});
|
||||
setUseSSH(config.useSSH || false);
|
||||
setUseProxy(config.useProxy || false);
|
||||
setDbType(configType);
|
||||
// 如果是 Redis 编辑模式,设置已保存的 Redis 数据库列表
|
||||
if (configType === 'redis') {
|
||||
@@ -684,6 +721,7 @@ const ConnectionModal: React.FC<{
|
||||
setStep(1);
|
||||
form.resetFields();
|
||||
setUseSSH(false);
|
||||
setUseProxy(false);
|
||||
setDbType('mysql');
|
||||
setActiveGroup(0);
|
||||
}
|
||||
@@ -733,6 +771,7 @@ const ConnectionModal: React.FC<{
|
||||
setLoading(false);
|
||||
form.resetFields();
|
||||
setUseSSH(false);
|
||||
setUseProxy(false);
|
||||
setDbType('mysql');
|
||||
setStep(1);
|
||||
onClose();
|
||||
@@ -852,7 +891,7 @@ const ConnectionModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const buildConfig = async (values: any, forPersist: boolean) => {
|
||||
const buildConfig = async (values: any, forPersist: boolean): Promise<ConnectionConfig> => {
|
||||
const mergedValues = { ...values };
|
||||
const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type);
|
||||
const isEmptyField = (value: unknown) => (
|
||||
@@ -951,6 +990,22 @@ const ConnectionModal: React.FC<{
|
||||
password: mergedValues.sshPassword || "",
|
||||
keyPath: mergedValues.sshKeyPath || ""
|
||||
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
|
||||
const effectiveUseProxy = !isFileDbType && !!mergedValues.useProxy;
|
||||
const proxyTypeRaw = String(mergedValues.proxyType || 'socks5').toLowerCase();
|
||||
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
|
||||
const proxyConfig: NonNullable<ConnectionConfig['proxy']> = effectiveUseProxy ? {
|
||||
type: proxyType,
|
||||
host: String(mergedValues.proxyHost || '').trim(),
|
||||
port: Number(mergedValues.proxyPort || (proxyTypeRaw === 'http' ? 8080 : 1080)),
|
||||
user: String(mergedValues.proxyUser || '').trim(),
|
||||
password: mergedValues.proxyPassword || "",
|
||||
} : {
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: 1080,
|
||||
user: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const keepPassword = !forPersist || savePassword;
|
||||
|
||||
@@ -964,6 +1019,8 @@ const ConnectionModal: React.FC<{
|
||||
database: mergedValues.database || "",
|
||||
useSSH: !!mergedValues.useSSH,
|
||||
ssh: sshConfig,
|
||||
useProxy: effectiveUseProxy,
|
||||
proxy: proxyConfig,
|
||||
driver: mergedValues.driver,
|
||||
dsn: mergedValues.dsn,
|
||||
timeout: Number(mergedValues.timeout || 30),
|
||||
@@ -997,6 +1054,7 @@ const ConnectionModal: React.FC<{
|
||||
const defaultPort = getDefaultPortByType(type);
|
||||
if (isFileDatabaseType(type)) {
|
||||
setUseSSH(false);
|
||||
setUseProxy(false);
|
||||
form.setFieldsValue({
|
||||
host: '',
|
||||
port: 0,
|
||||
@@ -1009,6 +1067,12 @@ const ConnectionModal: React.FC<{
|
||||
sshUser: '',
|
||||
sshPassword: '',
|
||||
sshKeyPath: '',
|
||||
useProxy: false,
|
||||
proxyType: 'socks5',
|
||||
proxyHost: '',
|
||||
proxyPort: 1080,
|
||||
proxyUser: '',
|
||||
proxyPassword: '',
|
||||
mysqlTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
mongoSrv: false,
|
||||
@@ -1025,7 +1089,10 @@ const ConnectionModal: React.FC<{
|
||||
mongoReplicaPassword: '',
|
||||
});
|
||||
} else if (type !== 'custom') {
|
||||
const defaultUser = type === 'clickhouse' ? 'default' : 'root';
|
||||
form.setFieldsValue({
|
||||
user: defaultUser,
|
||||
database: '',
|
||||
port: defaultPort,
|
||||
mysqlTopology: 'single',
|
||||
mongoTopology: 'single',
|
||||
@@ -1066,6 +1133,7 @@ const ConnectionModal: React.FC<{
|
||||
{ key: 'mariadb', name: 'MariaDB', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#003545' }} /> },
|
||||
{ key: 'diros', name: 'Diros', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#0050b3' }} /> },
|
||||
{ key: 'sphinx', name: 'Sphinx', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#2F5D62' }} /> },
|
||||
{ key: 'clickhouse', name: 'ClickHouse', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#FFCC01' }} /> },
|
||||
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
|
||||
{ key: 'sqlserver', name: 'SQL Server', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#CC2927' }} /> },
|
||||
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
|
||||
@@ -1164,9 +1232,13 @@ const ConnectionModal: React.FC<{
|
||||
type: 'mysql',
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
database: '',
|
||||
user: 'root',
|
||||
useSSH: false,
|
||||
sshPort: 22,
|
||||
useProxy: false,
|
||||
proxyType: 'socks5',
|
||||
proxyPort: 1080,
|
||||
timeout: 30,
|
||||
uri: '',
|
||||
mysqlTopology: 'single',
|
||||
@@ -1191,6 +1263,21 @@ const ConnectionModal: React.FC<{
|
||||
setUriFeedback(null);
|
||||
}
|
||||
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
|
||||
if (changed.useProxy !== undefined) setUseProxy(changed.useProxy);
|
||||
if (changed.proxyType !== undefined) {
|
||||
const nextType = String(changed.proxyType || 'socks5').toLowerCase();
|
||||
if (nextType === 'http') {
|
||||
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
|
||||
if (!currentPort || currentPort === 1080) {
|
||||
form.setFieldValue('proxyPort', 8080);
|
||||
}
|
||||
} else {
|
||||
const currentPort = Number(form.getFieldValue('proxyPort') || 0);
|
||||
if (!currentPort || currentPort === 8080) {
|
||||
form.setFieldValue('proxyPort', 1080);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Type change handled by step 1, but keep sync if select changes (hidden now)
|
||||
if (changed.type !== undefined) setDbType(changed.type);
|
||||
if (
|
||||
@@ -1285,6 +1372,16 @@ const ConnectionModal: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && (
|
||||
<Form.Item
|
||||
name="database"
|
||||
label="默认连接数据库(可选)"
|
||||
help="留空会自动尝试 postgres、template1、与当前用户名同名数据库"
|
||||
>
|
||||
<Input placeholder="例如:appdb" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && (
|
||||
<>
|
||||
<Form.Item name="mysqlTopology" label="连接模式">
|
||||
@@ -1531,6 +1628,38 @@ const ConnectionModal: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item name="useProxy" valuePropName="checked" style={{ marginBottom: 0 }}>
|
||||
<Checkbox>使用代理 (SOCKS5 / HTTP CONNECT)</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
{useProxy && (
|
||||
<div style={{ padding: '12px', background: '#f5f5f5', borderRadius: 6, marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="proxyType" label="代理类型" rules={[{ required: useProxy, message: '请选择代理类型' }]} style={{ width: 180 }}>
|
||||
<Select options={[
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
{ value: 'http', label: 'HTTP CONNECT' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxyHost" label="代理主机" rules={[{ required: useProxy, message: '请输入代理主机' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder="例如: 127.0.0.1 或 proxy.company.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxyPort" label="端口" rules={[{ required: useProxy, message: '请输入代理端口' }]} style={{ width: 120 }}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="proxyUser" label="代理用户名(可选)" style={{ flex: 1 }}>
|
||||
<Input placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
<Form.Item name="proxyPassword" label="代理密码(可选)" style={{ flex: 1 }}>
|
||||
<Input.Password placeholder="留空表示无认证" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Collapse
|
||||
|
||||
@@ -605,7 +605,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
dataIndex: '',
|
||||
title: '',
|
||||
});
|
||||
const [cellSetValueInput, setCellSetValueInput] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pendingScrollToBottomRef = useRef(false);
|
||||
|
||||
@@ -619,6 +618,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
// 使用 ref 来优化拖拽性能,完全避免状态更新
|
||||
const cellSelectionRafRef = useRef<number | null>(null);
|
||||
const cellSelectionScrollRafRef = useRef<number | null>(null);
|
||||
const cellSelectionAutoScrollRafRef = useRef<number | null>(null);
|
||||
const cellSelectionPointerRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// 导入预览 Modal 状态
|
||||
@@ -669,7 +670,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
dataIndex,
|
||||
title: titleText,
|
||||
});
|
||||
setCellSetValueInput(toFormText(record[dataIndex]));
|
||||
}, []);
|
||||
|
||||
// Helper to export specific data
|
||||
@@ -1102,6 +1102,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
}, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]);
|
||||
|
||||
@@ -1111,8 +1116,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const EDGE_THRESHOLD_PX = 28;
|
||||
const MIN_SCROLL_STEP = 8;
|
||||
const MAX_SCROLL_STEP = 24;
|
||||
|
||||
const getCellInfo = (target: HTMLElement): { rowKey: string; colName: string } | null => {
|
||||
const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => {
|
||||
if (!target) return null;
|
||||
const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement;
|
||||
if (!td) return null;
|
||||
const rowKey = td.getAttribute('data-row-key');
|
||||
@@ -1121,35 +1130,12 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
return { rowKey, colName };
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const cellInfo = getCellInfo(e.target as HTMLElement);
|
||||
if (!cellInfo) return;
|
||||
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
const currentData = displayDataRef.current;
|
||||
const nextRowIndexMap = new Map<string, number>();
|
||||
currentData.forEach((r, idx) => {
|
||||
const k = r?.[GONAVI_ROW_KEY];
|
||||
if (k === undefined) return;
|
||||
nextRowIndexMap.set(String(k), idx);
|
||||
});
|
||||
rowIndexMapRef.current = nextRowIndexMap;
|
||||
|
||||
const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1;
|
||||
const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
|
||||
selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex };
|
||||
currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]);
|
||||
updateCellSelection(currentSelectionRef.current);
|
||||
const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => {
|
||||
const target = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||
return getCellInfo(target);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current || !selectionStartRef.current) return;
|
||||
|
||||
const cellInfo = getCellInfo(e.target as HTMLElement);
|
||||
if (!cellInfo) return;
|
||||
|
||||
// 使用 RAF 节流
|
||||
const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => {
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
}
|
||||
@@ -1188,9 +1174,124 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const stopAutoScroll = () => {
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const getScrollStep = (distanceToEdge: number): number => {
|
||||
const ratio = Math.min(1, Math.max(0, distanceToEdge / EDGE_THRESHOLD_PX));
|
||||
return Math.round(MIN_SCROLL_STEP + (MAX_SCROLL_STEP - MIN_SCROLL_STEP) * ratio);
|
||||
};
|
||||
|
||||
const autoScrollTick = () => {
|
||||
if (!isDraggingRef.current || !selectionStartRef.current) {
|
||||
stopAutoScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const pointer = cellSelectionPointerRef.current;
|
||||
const tableBody = container.querySelector('.ant-table-body') as HTMLElement | null;
|
||||
if (!pointer || !tableBody) {
|
||||
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = tableBody.getBoundingClientRect();
|
||||
const maxScrollTop = Math.max(0, tableBody.scrollHeight - tableBody.clientHeight);
|
||||
const maxScrollLeft = Math.max(0, tableBody.scrollWidth - tableBody.clientWidth);
|
||||
let deltaY = 0;
|
||||
let deltaX = 0;
|
||||
|
||||
if (pointer.y < rect.top + EDGE_THRESHOLD_PX && tableBody.scrollTop > 0) {
|
||||
const distance = rect.top + EDGE_THRESHOLD_PX - pointer.y;
|
||||
deltaY = -getScrollStep(distance);
|
||||
} else if (pointer.y > rect.bottom - EDGE_THRESHOLD_PX && tableBody.scrollTop < maxScrollTop) {
|
||||
const distance = pointer.y - (rect.bottom - EDGE_THRESHOLD_PX);
|
||||
deltaY = getScrollStep(distance);
|
||||
}
|
||||
|
||||
if (pointer.x < rect.left + EDGE_THRESHOLD_PX && tableBody.scrollLeft > 0) {
|
||||
const distance = rect.left + EDGE_THRESHOLD_PX - pointer.x;
|
||||
deltaX = -getScrollStep(distance);
|
||||
} else if (pointer.x > rect.right - EDGE_THRESHOLD_PX && tableBody.scrollLeft < maxScrollLeft) {
|
||||
const distance = pointer.x - (rect.right - EDGE_THRESHOLD_PX);
|
||||
deltaX = getScrollStep(distance);
|
||||
}
|
||||
|
||||
let didScroll = false;
|
||||
if (deltaY !== 0) {
|
||||
const nextTop = Math.max(0, Math.min(maxScrollTop, tableBody.scrollTop + deltaY));
|
||||
if (nextTop !== tableBody.scrollTop) {
|
||||
tableBody.scrollTop = nextTop;
|
||||
didScroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (deltaX !== 0) {
|
||||
const nextLeft = Math.max(0, Math.min(maxScrollLeft, tableBody.scrollLeft + deltaX));
|
||||
if (nextLeft !== tableBody.scrollLeft) {
|
||||
tableBody.scrollLeft = nextLeft;
|
||||
didScroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (didScroll) {
|
||||
const cellInfo = getCellInfoFromPoint(pointer.x, pointer.y);
|
||||
if (cellInfo) scheduleSelectionUpdate(cellInfo);
|
||||
}
|
||||
|
||||
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
|
||||
};
|
||||
|
||||
const ensureAutoScroll = () => {
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) return;
|
||||
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
const cellInfo = getCellInfo(target);
|
||||
if (!cellInfo) return;
|
||||
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY };
|
||||
const currentData = displayDataRef.current;
|
||||
const nextRowIndexMap = new Map<string, number>();
|
||||
currentData.forEach((r, idx) => {
|
||||
const k = r?.[GONAVI_ROW_KEY];
|
||||
if (k === undefined) return;
|
||||
nextRowIndexMap.set(String(k), idx);
|
||||
});
|
||||
rowIndexMapRef.current = nextRowIndexMap;
|
||||
|
||||
const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1;
|
||||
const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
|
||||
selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex };
|
||||
currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]);
|
||||
updateCellSelection(currentSelectionRef.current);
|
||||
ensureAutoScroll();
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current || !selectionStartRef.current) return;
|
||||
cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY };
|
||||
ensureAutoScroll();
|
||||
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY);
|
||||
if (!cellInfo) return;
|
||||
scheduleSelectionUpdate(cellInfo);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
stopAutoScroll();
|
||||
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
@@ -1231,6 +1332,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
stopAutoScroll();
|
||||
cellSelectionPointerRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
}, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]);
|
||||
@@ -1304,6 +1407,18 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const hasChanges = addedRows.length > 0 || Object.keys(modifiedRows).length > 0 || deletedRowKeys.size > 0;
|
||||
|
||||
const addedRowKeySet = useMemo(() => {
|
||||
const next = new Set<string>();
|
||||
addedRows.forEach((row) => {
|
||||
const key = row?.[GONAVI_ROW_KEY];
|
||||
if (key === undefined || key === null) return;
|
||||
next.add(rowKeyStr(key));
|
||||
});
|
||||
return next;
|
||||
}, [addedRows, rowKeyStr]);
|
||||
|
||||
const modifiedRowKeySet = useMemo(() => new Set(Object.keys(modifiedRows)), [modifiedRows]);
|
||||
|
||||
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
|
||||
if (isResizingRef.current) return; // Block sort if resizing
|
||||
if (sorter.field) {
|
||||
@@ -1455,12 +1570,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}, [cellContextMenu, handleCellSave]);
|
||||
|
||||
const handleCellSetValue = useCallback(() => {
|
||||
if (!cellContextMenu.record) return;
|
||||
handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: cellSetValueInput });
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}, [cellContextMenu, cellSetValueInput, handleCellSave]);
|
||||
|
||||
const handleCellEditorSave = useCallback(() => {
|
||||
if (!cellEditorMeta) return;
|
||||
const apply = cellEditorApplyRef.current;
|
||||
@@ -1783,6 +1892,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
{formatCellValue(text)}
|
||||
</div>
|
||||
),
|
||||
shouldCellUpdate: (record: Item, prevRecord: Item) => {
|
||||
const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY];
|
||||
if (rowKeyChanged) return true;
|
||||
return !isCellValueEqualForDiff(record?.[key], prevRecord?.[key]);
|
||||
},
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResizeStart: handleResizeStart(key), // Only need start
|
||||
@@ -2275,6 +2389,31 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
header: { cell: ResizableTitle }
|
||||
}), []);
|
||||
|
||||
const dataContextValue = useMemo(() => ({
|
||||
selectedRowKeysRef,
|
||||
displayDataRef,
|
||||
handleCopyInsert,
|
||||
handleCopyJson,
|
||||
handleCopyCsv,
|
||||
handleExportSelected,
|
||||
copyToClipboard,
|
||||
tableName,
|
||||
enableRowContextMenu: !canModifyData,
|
||||
}), [handleCopyCsv, handleCopyInsert, handleCopyJson, handleExportSelected, copyToClipboard, tableName, canModifyData]);
|
||||
|
||||
const cellContextMenuValue = useMemo(() => ({
|
||||
showMenu: showCellContextMenu,
|
||||
handleBatchFillToSelected,
|
||||
}), [showCellContextMenu, handleBatchFillToSelected]);
|
||||
|
||||
const rowSelectionConfig = useMemo(() => ({
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
columnWidth: selectionColumnWidth,
|
||||
}), [selectedRowKeys, selectionColumnWidth]);
|
||||
|
||||
const rowPropsFactory = useCallback((record: any) => ({ record } as any), []);
|
||||
|
||||
const totalWidth = columns.reduce((sum, col) => sum + (Number(col.width) || 200), 0) + selectionColumnWidth;
|
||||
const enableVirtual = mergedDisplayData.length >= 200;
|
||||
const tableScrollX = useMemo(() => {
|
||||
@@ -2332,6 +2471,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
cellSelectionRafRef.current = null;
|
||||
@@ -2340,6 +2480,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
if (!next) setBatchEditModalOpen(false);
|
||||
message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式');
|
||||
@@ -2403,12 +2547,26 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
onChange={(val) => {
|
||||
const nextMode = String(val) as GridViewMode;
|
||||
if (nextMode === 'json' && cellEditMode) {
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
cellSelectionRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
if (nextMode === 'text') {
|
||||
const selectedKey = selectedRowKeys[0];
|
||||
if (selectedKey !== undefined) {
|
||||
@@ -2655,8 +2813,8 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
{viewMode === 'table' ? (
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName, enableRowContextMenu: !canModifyData }}>
|
||||
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu, handleBatchFillToSelected }}>
|
||||
<DataContext.Provider value={dataContextValue}>
|
||||
<CellContextMenuContext.Provider value={cellContextMenuValue}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
@@ -2668,23 +2826,21 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
scroll={{ x: tableScrollX, y: tableHeight }}
|
||||
sticky={tableStickyConfig}
|
||||
virtual={enableVirtual}
|
||||
loading={loading}
|
||||
loading={loading}
|
||||
rowKey={GONAVI_ROW_KEY}
|
||||
pagination={false}
|
||||
onChange={handleTableChange}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
columnWidth: selectionColumnWidth,
|
||||
}}
|
||||
rowSelection={rowSelectionConfig}
|
||||
rowClassName={(record) => {
|
||||
const k = record?.[GONAVI_ROW_KEY];
|
||||
if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added';
|
||||
if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show
|
||||
if (k === undefined || k === null) return '';
|
||||
const keyStr = rowKeyStr(k);
|
||||
if (addedRowKeySet.has(keyStr)) return 'row-added';
|
||||
if (modifiedRowKeySet.has(keyStr) || deletedRowKeys.has(keyStr)) return 'row-modified'; // deleted won't show
|
||||
return '';
|
||||
}}
|
||||
onRow={(record) => ({ record } as any)}
|
||||
onRow={rowPropsFactory}
|
||||
/>
|
||||
</EditableContext.Provider>
|
||||
</CellContextMenuContext.Provider>
|
||||
|
||||
@@ -31,7 +31,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
|
||||
const currentConnType = (connections.find(c => c.id === tab.connectionId)?.config?.type || '').toLowerCase();
|
||||
const forceReadOnly = currentConnType === 'tdengine';
|
||||
const forceReadOnly = currentConnType === 'tdengine' || currentConnType === 'clickhouse';
|
||||
|
||||
useEffect(() => {
|
||||
setPkColumns([]);
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Modal, Progress, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import {
|
||||
CheckDriverNetworkStatus,
|
||||
DownloadDriverPackage,
|
||||
GetDriverVersionList,
|
||||
GetDriverVersionPackageSize,
|
||||
GetDriverStatusList,
|
||||
InstallLocalDriverPackage,
|
||||
RemoveDriverPackage,
|
||||
SelectDriverPackageFile,
|
||||
} from '../../wailsjs/go/app/App';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
type DriverStatusRow = {
|
||||
type: string;
|
||||
name: string;
|
||||
builtIn: boolean;
|
||||
pinnedVersion?: string;
|
||||
installedVersion?: string;
|
||||
packageSizeText?: string;
|
||||
runtimeAvailable: boolean;
|
||||
packageInstalled: boolean;
|
||||
connectable: boolean;
|
||||
defaultDownloadUrl?: string;
|
||||
installDir?: string;
|
||||
packagePath?: string;
|
||||
executablePath?: string;
|
||||
downloadedAt?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
@@ -35,12 +46,152 @@ type ProgressState = {
|
||||
percent: number;
|
||||
};
|
||||
|
||||
type DriverLogEntry = {
|
||||
time: string;
|
||||
text: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
type DriverNetworkProbe = {
|
||||
name: string;
|
||||
url: string;
|
||||
reachable: boolean;
|
||||
httpStatus?: number;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type DriverNetworkStatus = {
|
||||
reachable: boolean;
|
||||
summary: string;
|
||||
recommendedProxy: boolean;
|
||||
proxyConfigured: boolean;
|
||||
proxyEnv?: Record<string, string>;
|
||||
checks: DriverNetworkProbe[];
|
||||
checkedAt?: string;
|
||||
logPath?: string;
|
||||
};
|
||||
|
||||
type DriverVersionOption = {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
packageSizeText?: string;
|
||||
recommended?: boolean;
|
||||
source?: string;
|
||||
year?: string;
|
||||
displayLabel?: string;
|
||||
};
|
||||
|
||||
const buildVersionOptionKey = (option: DriverVersionOption) => `${option.version}@@${option.downloadUrl}`;
|
||||
const buildVersionSizeLoadingKey = (driverType: string, optionKey: string) => `${driverType}@@${optionKey}`;
|
||||
|
||||
const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
|
||||
type SelectOption = { value: string; label: string };
|
||||
type SelectGroup = { label: string; options: SelectOption[] };
|
||||
|
||||
if (options.length === 0) {
|
||||
return [] as Array<SelectOption | SelectGroup>;
|
||||
}
|
||||
|
||||
const yearGroups = new Map<string, SelectOption[]>();
|
||||
const others: SelectOption[] = [];
|
||||
options.forEach((option) => {
|
||||
const selectOption: SelectOption = {
|
||||
value: buildVersionOptionKey(option),
|
||||
label: option.displayLabel || option.version || '默认版本',
|
||||
};
|
||||
const year = String(option.year || '').trim();
|
||||
if (!year) {
|
||||
others.push(selectOption);
|
||||
return;
|
||||
}
|
||||
const group = yearGroups.get(year) || [];
|
||||
group.push(selectOption);
|
||||
yearGroups.set(year, group);
|
||||
});
|
||||
|
||||
const sortedYears = Array.from(yearGroups.keys()).sort((a, b) => {
|
||||
const left = Number.parseInt(a, 10);
|
||||
const right = Number.parseInt(b, 10);
|
||||
const leftValid = Number.isFinite(left);
|
||||
const rightValid = Number.isFinite(right);
|
||||
if (leftValid && rightValid) {
|
||||
return right - left;
|
||||
}
|
||||
return b.localeCompare(a);
|
||||
});
|
||||
|
||||
const grouped: SelectGroup[] = sortedYears.map((year) => ({
|
||||
label: `${year} 年`,
|
||||
options: yearGroups.get(year) || [],
|
||||
}));
|
||||
if (others.length > 0) {
|
||||
grouped.push({ label: '其他', options: others });
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadDir, setDownloadDir] = useState('');
|
||||
const [networkChecking, setNetworkChecking] = useState(false);
|
||||
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
|
||||
const [rows, setRows] = useState<DriverStatusRow[]>([]);
|
||||
const [actionDriver, setActionDriver] = useState('');
|
||||
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
|
||||
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
|
||||
const [logDriverType, setLogDriverType] = useState('');
|
||||
const [logModalOpen, setLogModalOpen] = useState(false);
|
||||
const [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
|
||||
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
|
||||
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
|
||||
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
const appendOperationLog = useCallback((
|
||||
driverType: string,
|
||||
text: string,
|
||||
signature?: string,
|
||||
mode: 'append' | 'update-last' = 'append',
|
||||
) => {
|
||||
const normalized = String(driverType || '').trim().toLowerCase();
|
||||
const content = String(text || '').trim();
|
||||
if (!normalized || !content) {
|
||||
return;
|
||||
}
|
||||
const sign = String(signature || content).trim() || content;
|
||||
const now = new Date().toLocaleTimeString();
|
||||
setOperationLogMap((prev) => {
|
||||
const history = prev[normalized] || [];
|
||||
if (history.length > 0) {
|
||||
const last = history[history.length - 1];
|
||||
if (last.signature === sign) {
|
||||
if (mode === 'update-last') {
|
||||
if (last.text === content) {
|
||||
return prev;
|
||||
}
|
||||
const nextHistory = [...history];
|
||||
nextHistory[nextHistory.length - 1] = {
|
||||
...last,
|
||||
text: content,
|
||||
time: now,
|
||||
};
|
||||
return { ...prev, [normalized]: nextHistory };
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
const nextHistory = [
|
||||
...history,
|
||||
{
|
||||
time: now,
|
||||
text: content,
|
||||
signature: sign,
|
||||
},
|
||||
];
|
||||
const sliced = nextHistory.length > 200 ? nextHistory.slice(nextHistory.length - 200) : nextHistory;
|
||||
return { ...prev, [normalized]: sliced };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async (toastOnError = true) => {
|
||||
setLoading(true);
|
||||
@@ -65,11 +216,17 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
type: String(item.type || '').trim(),
|
||||
name: String(item.name || item.type || '').trim(),
|
||||
builtIn: !!item.builtIn,
|
||||
pinnedVersion: String(item.pinnedVersion || '').trim() || undefined,
|
||||
installedVersion: String(item.installedVersion || '').trim() || undefined,
|
||||
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
|
||||
runtimeAvailable: !!item.runtimeAvailable,
|
||||
packageInstalled: !!item.packageInstalled,
|
||||
connectable: !!item.connectable,
|
||||
defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined,
|
||||
installDir: String(item.installDir || '').trim() || undefined,
|
||||
packagePath: String(item.packagePath || '').trim() || undefined,
|
||||
executablePath: String(item.executablePath || '').trim() || undefined,
|
||||
downloadedAt: String(item.downloadedAt || '').trim() || undefined,
|
||||
message: String(item.message || '').trim() || undefined,
|
||||
}));
|
||||
setRows(nextRows);
|
||||
@@ -82,12 +239,201 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
}
|
||||
}, [downloadDir]);
|
||||
|
||||
const checkNetworkStatus = useCallback(async (toastOnError = false) => {
|
||||
setNetworkChecking(true);
|
||||
try {
|
||||
const res = await CheckDriverNetworkStatus();
|
||||
if (!res?.success) {
|
||||
if (toastOnError) {
|
||||
message.error(res?.message || '驱动网络检测失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
const checks = Array.isArray(data.checks) ? data.checks : [];
|
||||
const normalizedChecks: DriverNetworkProbe[] = checks.map((item: any) => ({
|
||||
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,
|
||||
error: String(item.error || '').trim() || undefined,
|
||||
}));
|
||||
setNetworkStatus({
|
||||
reachable: !!data.reachable,
|
||||
summary: String(data.summary || '').trim() || '驱动网络检测已完成',
|
||||
recommendedProxy: !!data.recommendedProxy,
|
||||
proxyConfigured: !!data.proxyConfigured,
|
||||
proxyEnv: (data.proxyEnv || {}) as Record<string, string>,
|
||||
checkedAt: String(data.checkedAt || '').trim() || undefined,
|
||||
checks: normalizedChecks,
|
||||
logPath: String(data.logPath || '').trim() || undefined,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (toastOnError) {
|
||||
message.error(`驱动网络检测失败:${err?.message || String(err)}`);
|
||||
}
|
||||
} finally {
|
||||
setNetworkChecking(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => {
|
||||
if (row.builtIn) {
|
||||
return [] as DriverVersionOption[];
|
||||
}
|
||||
const driverType = String(row.type || '').trim();
|
||||
if (!driverType) {
|
||||
return [] as DriverVersionOption[];
|
||||
}
|
||||
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: true }));
|
||||
try {
|
||||
const res = await GetDriverVersionList(driverType, '');
|
||||
if (!res?.success) {
|
||||
if (toastOnError) {
|
||||
message.error(res?.message || `${row.name} 版本列表加载失败`);
|
||||
}
|
||||
return [] as DriverVersionOption[];
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
const rawVersions = Array.isArray(data.versions) ? data.versions : [];
|
||||
const options: DriverVersionOption[] = rawVersions
|
||||
.map((item: any) => {
|
||||
const version = String(item.version || '').trim();
|
||||
const downloadUrl = String(item.downloadUrl || '').trim();
|
||||
if (!version && !downloadUrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version,
|
||||
downloadUrl,
|
||||
packageSizeText: String(item.packageSizeText || '').trim() || undefined,
|
||||
recommended: !!item.recommended,
|
||||
source: String(item.source || '').trim() || undefined,
|
||||
year: String(item.year || '').trim() || undefined,
|
||||
displayLabel: String(item.displayLabel || '').trim() || undefined,
|
||||
} as DriverVersionOption;
|
||||
})
|
||||
.filter((item: DriverVersionOption | null): item is DriverVersionOption => !!item);
|
||||
|
||||
if (options.length === 0) {
|
||||
const fallbackVersion = String(row.pinnedVersion || '').trim();
|
||||
const fallbackURL = String(row.defaultDownloadUrl || '').trim();
|
||||
if (fallbackVersion || fallbackURL) {
|
||||
options.push({
|
||||
version: fallbackVersion,
|
||||
downloadUrl: fallbackURL,
|
||||
recommended: true,
|
||||
source: 'fallback',
|
||||
displayLabel: fallbackVersion || '默认版本',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setVersionMap((prev) => ({ ...prev, [driverType]: options }));
|
||||
setSelectedVersionMap((prev) => {
|
||||
const currentKey = prev[driverType];
|
||||
if (currentKey && options.some((option) => buildVersionOptionKey(option) === currentKey)) {
|
||||
return prev;
|
||||
}
|
||||
const preferred =
|
||||
options.find((option) => option.version === row.installedVersion) ||
|
||||
options.find((option) => option.version === row.pinnedVersion) ||
|
||||
options.find((option) => option.recommended) ||
|
||||
options[0];
|
||||
if (!preferred) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [driverType]: buildVersionOptionKey(preferred) };
|
||||
});
|
||||
return options;
|
||||
} catch (err: any) {
|
||||
if (toastOnError) {
|
||||
message.error(`加载 ${row.name} 版本列表失败:${err?.message || String(err)}`);
|
||||
}
|
||||
return [] as DriverVersionOption[];
|
||||
} finally {
|
||||
setVersionLoadingMap((prev) => ({ ...prev, [driverType]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadVersionPackageSize = useCallback(async (row: DriverStatusRow, optionKey: string) => {
|
||||
if (row.builtIn) {
|
||||
return;
|
||||
}
|
||||
const driverType = String(row.type || '').trim();
|
||||
if (!driverType || !optionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = versionMap[driverType] || [];
|
||||
const selectedOption = options.find((item) => buildVersionOptionKey(item) === optionKey);
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
if (String(selectedOption.packageSizeText || '').trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const versionText = String(selectedOption.version || '').trim();
|
||||
if (!versionText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingKey = buildVersionSizeLoadingKey(driverType, optionKey);
|
||||
if (versionSizeLoadingMap[loadingKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVersionSizeLoadingMap((prev) => ({ ...prev, [loadingKey]: true }));
|
||||
try {
|
||||
const res = await GetDriverVersionPackageSize(driverType, versionText);
|
||||
if (!res?.success) {
|
||||
return;
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
const sizeText = String(data.packageSizeText || '').trim();
|
||||
if (!sizeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVersionMap((prev) => {
|
||||
const current = prev[driverType] || [];
|
||||
let changed = false;
|
||||
const next = current.map((item) => {
|
||||
if (buildVersionOptionKey(item) !== optionKey) {
|
||||
return item;
|
||||
}
|
||||
if (String(item.packageSizeText || '').trim() === sizeText) {
|
||||
return item;
|
||||
}
|
||||
changed = true;
|
||||
return { ...item, packageSizeText: sizeText };
|
||||
});
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [driverType]: next };
|
||||
});
|
||||
} finally {
|
||||
setVersionSizeLoadingMap((prev) => {
|
||||
if (!prev[loadingKey]) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[loadingKey];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [versionMap, versionSizeLoadingMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
refreshStatus(false);
|
||||
}, [open, refreshStatus]);
|
||||
checkNetworkStatus(false);
|
||||
}, [checkNetworkStatus, open, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -112,11 +458,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
percent,
|
||||
},
|
||||
}));
|
||||
const progressText = `${Math.round(percent)}%`;
|
||||
const statusText = String(status || '').toUpperCase();
|
||||
const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`;
|
||||
const lineSignature = `${statusText}|${messageText || '-'}`;
|
||||
appendOperationLog(driverType, lineText, lineSignature, 'update-last');
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
};
|
||||
}, [open]);
|
||||
}, [appendOperationLog, open]);
|
||||
|
||||
const installDriver = useCallback(async (row: DriverStatusRow) => {
|
||||
setActionDriver(row.type);
|
||||
@@ -128,27 +479,97 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
percent: 0,
|
||||
},
|
||||
}));
|
||||
appendOperationLog(row.type, '[START] 开始自动安装');
|
||||
try {
|
||||
const result = await DownloadDriverPackage(row.type, '', downloadDir);
|
||||
let options = versionMap[row.type] || [];
|
||||
if (options.length === 0) {
|
||||
options = await loadVersionOptions(row, true);
|
||||
}
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectedOption =
|
||||
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
options.find((item) => item.recommended) ||
|
||||
options[0];
|
||||
const selectedVersion = selectedOption?.version || row.pinnedVersion || '';
|
||||
const selectedDownloadURL = selectedOption?.downloadUrl || row.defaultDownloadUrl || '';
|
||||
|
||||
const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir);
|
||||
if (!result?.success) {
|
||||
message.error(result?.message || `安装 ${row.name} 失败`);
|
||||
const errText = result?.message || `安装 ${row.name} 失败`;
|
||||
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||
message.error(errText);
|
||||
return;
|
||||
}
|
||||
message.success(`${row.name} 已安装启用`);
|
||||
const versionTip = selectedVersion ? `(${selectedVersion})` : '';
|
||||
appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`);
|
||||
message.success(`${row.name}${versionTip} 已安装启用`);
|
||||
refreshStatus(false);
|
||||
} finally {
|
||||
setActionDriver('');
|
||||
}
|
||||
}, [downloadDir, refreshStatus]);
|
||||
}, [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 || '选择本地驱动包失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const filePath = String((fileRes?.data as any)?.path || '').trim();
|
||||
if (!filePath) {
|
||||
message.error('未选择有效的驱动包文件');
|
||||
return;
|
||||
}
|
||||
|
||||
setActionDriver(row.type);
|
||||
setProgressMap((prev) => ({
|
||||
...prev,
|
||||
[row.type]: {
|
||||
status: 'start',
|
||||
message: '开始导入本地驱动包',
|
||||
percent: 0,
|
||||
},
|
||||
}));
|
||||
appendOperationLog(row.type, `[START] 开始本地导入:${filePath}`);
|
||||
try {
|
||||
const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir);
|
||||
if (!result?.success) {
|
||||
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
|
||||
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||
message.error(errText);
|
||||
return;
|
||||
}
|
||||
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
|
||||
message.success(`${row.name} 本地驱动包已安装启用`);
|
||||
refreshStatus(false);
|
||||
} finally {
|
||||
setActionDriver('');
|
||||
}
|
||||
}, [appendOperationLog, downloadDir, refreshStatus]);
|
||||
|
||||
const openDriverLog = useCallback((driverType: string) => {
|
||||
const normalized = String(driverType || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
setLogDriverType(normalized);
|
||||
setLogModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const removeDriver = useCallback(async (row: DriverStatusRow) => {
|
||||
setActionDriver(row.type);
|
||||
appendOperationLog(row.type, '[START] 开始移除驱动');
|
||||
try {
|
||||
const result = await RemoveDriverPackage(row.type, downloadDir);
|
||||
if (!result?.success) {
|
||||
message.error(result?.message || `移除 ${row.name} 失败`);
|
||||
const errText = result?.message || `移除 ${row.name} 失败`;
|
||||
appendOperationLog(row.type, `[ERROR] ${errText}`);
|
||||
message.error(errText);
|
||||
return;
|
||||
}
|
||||
appendOperationLog(row.type, '[DONE] 驱动移除完成');
|
||||
message.success(`${row.name} 已移除`);
|
||||
setProgressMap((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -159,7 +580,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
} finally {
|
||||
setActionDriver('');
|
||||
}
|
||||
}, [downloadDir, refreshStatus]);
|
||||
}, [appendOperationLog, downloadDir, refreshStatus]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
@@ -169,12 +590,47 @@ 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 <Text type="secondary">内置</Text>;
|
||||
}
|
||||
const installPath = row.executablePath || row.installDir || '-';
|
||||
if (installPath === '-') {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
return (
|
||||
<Text copyable={{ text: installPath }} style={{ fontSize: 12 }}>
|
||||
{installPath}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '安装包大小',
|
||||
dataIndex: 'packageSizeText',
|
||||
key: 'packageSizeText',
|
||||
width: 120,
|
||||
render: (_: string | undefined, row: DriverStatusRow) => row.packageSizeText || '-',
|
||||
render: (_: string | undefined, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return row.packageSizeText || '-';
|
||||
}
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const loadingKey = buildVersionSizeLoadingKey(row.type, selectedKey || '');
|
||||
const selectedOption =
|
||||
options.find((item) => buildVersionOptionKey(item) === selectedKey) ||
|
||||
options.find((item) => item.recommended) ||
|
||||
options[0];
|
||||
const anyKnownSize = options.find((item) => String(item.packageSizeText || '').trim())?.packageSizeText;
|
||||
if (selectedKey && versionSizeLoadingMap[loadingKey]) {
|
||||
return '计算中...';
|
||||
}
|
||||
return selectedOption?.packageSizeText || anyKnownSize || row.packageSizeText || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -224,10 +680,47 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
return <Progress percent={percent} status={status} size="small" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '驱动版本',
|
||||
key: 'driverVersion',
|
||||
width: 230,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
}
|
||||
const options = versionMap[row.type] || [];
|
||||
const selectedKey = selectedVersionMap[row.type];
|
||||
const selectOptions = buildVersionSelectOptions(options);
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
loading={!!versionLoadingMap[row.type]}
|
||||
disabled={actionDriver === row.type}
|
||||
placeholder={options.length > 0 ? '选择驱动版本' : '点击展开加载版本'}
|
||||
value={selectedKey}
|
||||
options={selectOptions as any}
|
||||
onOpenChange={(open) => {
|
||||
if (open && options.length === 0 && !versionLoadingMap[row.type]) {
|
||||
void loadVersionOptions(row, true);
|
||||
return;
|
||||
}
|
||||
if (open && selectedKey) {
|
||||
void loadVersionPackageSize(row, selectedKey);
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setSelectedVersionMap((prev) => ({ ...prev, [row.type]: value }));
|
||||
void loadVersionPackageSize(row, value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 190,
|
||||
width: 320,
|
||||
render: (_: string, row: DriverStatusRow) => {
|
||||
if (row.builtIn) {
|
||||
return <Text type="secondary">-</Text>;
|
||||
@@ -237,19 +730,20 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
if (isSlimBuildUnavailable && !row.packageInstalled) {
|
||||
return <Text type="secondary">需 Full 版</Text>;
|
||||
}
|
||||
if (row.connectable) {
|
||||
return (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => removeDriver(row)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
const logs = operationLogMap[row.type] || [];
|
||||
const hasLogs = logs.length > 0;
|
||||
|
||||
const mainAction = row.connectable ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => removeDriver(row)}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -259,10 +753,41 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
安装启用
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Space size={8} wrap>
|
||||
{mainAction}
|
||||
<Button
|
||||
icon={<FileSearchOutlined />}
|
||||
loading={loadingAction}
|
||||
onClick={() => installDriverFromLocalFile(row)}
|
||||
>
|
||||
本地导入
|
||||
</Button>
|
||||
<Button
|
||||
type={hasLogs ? 'default' : 'text'}
|
||||
disabled={!hasLogs}
|
||||
onClick={() => openDriverLog(row.type)}
|
||||
>
|
||||
日志
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [actionDriver, installDriver, progressMap, removeDriver]);
|
||||
}, [actionDriver, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
|
||||
|
||||
const activeLogRow = useMemo(() => {
|
||||
if (!logDriverType) {
|
||||
return undefined;
|
||||
}
|
||||
return rows.find((item) => item.type === logDriverType);
|
||||
}, [logDriverType, rows]);
|
||||
|
||||
const activeDriverLogs = operationLogMap[logDriverType] || [];
|
||||
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
|
||||
const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -270,11 +795,23 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={980}
|
||||
style={{ top: 24 }}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 18,
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
|
||||
刷新
|
||||
</Button>,
|
||||
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
|
||||
网络检测
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
@@ -282,6 +819,67 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Text type="secondary">除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。</Text>
|
||||
{networkStatus ? (
|
||||
<Alert
|
||||
type={networkStatus.reachable ? 'success' : 'warning'}
|
||||
showIcon
|
||||
message={networkStatus.summary}
|
||||
description={(
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">
|
||||
驱动下载依赖 GitHub 与 Go 模块代理网络。若检测失败,建议先启用 HTTP/HTTPS/SOCKS5 代理后重试。
|
||||
</Text>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'checks',
|
||||
label: '查看网络检测明细',
|
||||
children: (
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
{networkStatus.checks.map((item) => (
|
||||
<Text key={`${item.name}-${item.url}`} type={item.reachable ? 'secondary' : 'danger'}>
|
||||
{item.name}:{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `,HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `,${item.latencyMs}ms` : ''}{item.error ? `,${item.error}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
{proxyEnvEntries.length > 0 ? (
|
||||
<Text type="secondary">
|
||||
检测到代理环境变量:{proxyEnvEntries.map(([key]) => key).join('、')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">未检测到系统代理环境变量。</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="info" showIcon message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'} />
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="驱动目录与复用说明"
|
||||
description={(
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text type="secondary">自动下载和手动导入的驱动都会落盘到以下目录;后续版本升级可重复复用已下载驱动。</Text>
|
||||
<Text type="secondary">手动导入支持单个驱动代理文件(如 `mariadb-driver-agent` / `mariadb-driver-agent.exe`)或驱动总包 `GoNavi-DriverAgents.zip`。</Text>
|
||||
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
|
||||
驱动根目录:{downloadDir || '-'}
|
||||
</Paragraph>
|
||||
{networkStatus?.logPath ? (
|
||||
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
|
||||
运行日志文件:{networkStatus.logPath}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Table
|
||||
rowKey="type"
|
||||
@@ -290,8 +888,40 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
scroll={{ x: 1450 }}
|
||||
/>
|
||||
</Space>
|
||||
<Modal
|
||||
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
|
||||
open={logModalOpen}
|
||||
onCancel={() => setLogModalOpen(false)}
|
||||
footer={[
|
||||
<Button key="close-log" type="primary" onClick={() => setLogModalOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={780}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{activeLogRow?.installDir ? (
|
||||
<Paragraph copyable={{ text: activeLogRow.installDir }} style={{ marginBottom: 0 }}>
|
||||
安装目录:{activeLogRow.installDir}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
{activeLogRow?.executablePath ? (
|
||||
<Paragraph copyable={{ text: activeLogRow.executablePath }} style={{ marginBottom: 0 }}>
|
||||
驱动可执行文件:{activeLogRow.executablePath}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
{activeDriverLogLines.length > 0 ? (
|
||||
<pre style={{ margin: 0, maxHeight: 360, overflow: 'auto', padding: 12, background: '#fafafa', borderRadius: 8, border: '1px solid #f0f0f0', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{activeDriverLogLines.join('\n')}
|
||||
</pre>
|
||||
) : (
|
||||
<Text type="secondary">当前驱动暂无操作日志。</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -922,7 +922,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const applyAutoLimit = (sql: string, dbType: string, maxRows: number): { sql: string; applied: boolean; maxRows: number } => {
|
||||
const normalizedType = (dbType || 'mysql').toLowerCase();
|
||||
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === '';
|
||||
const supportsLimit = normalizedType === 'mysql' || normalizedType === 'mariadb' || normalizedType === 'diros' || normalizedType === 'sphinx' || normalizedType === 'postgres' || normalizedType === 'kingbase' || normalizedType === 'sqlite' || normalizedType === 'duckdb' || normalizedType === 'tdengine' || normalizedType === 'clickhouse' || normalizedType === '';
|
||||
if (!supportsLimit) return { sql, applied: false, maxRows };
|
||||
if (!Number.isFinite(maxRows) || maxRows <= 0) return { sql, applied: false, maxRows };
|
||||
|
||||
@@ -1001,7 +1001,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const maxRows = Number(queryOptions?.maxRows) || 0;
|
||||
const dbType = String((config as any).type || 'mysql');
|
||||
const normalizedDbType = dbType.toLowerCase();
|
||||
const forceReadOnlyResult = normalizedDbType === 'tdengine';
|
||||
const forceReadOnlyResult = normalizedDbType === 'tdengine' || normalizedDbType === 'clickhouse';
|
||||
const wantsLimitProbe = Number.isFinite(maxRows) && maxRows > 0;
|
||||
const probeLimit = wantsLimitProbe ? (maxRows + 1) : 0;
|
||||
let anyTruncated = false;
|
||||
|
||||
@@ -14,6 +14,12 @@ const REDIS_TREE_KEY_TYPE_WIDTH = 92;
|
||||
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
|
||||
const REDIS_TREE_KEY_TTL_WIDTH = 92;
|
||||
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
|
||||
const REDIS_KEY_INITIAL_LOAD_COUNT = 2000;
|
||||
const REDIS_KEY_LOAD_MORE_COUNT = 2000;
|
||||
const REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT = 600;
|
||||
const REDIS_KEY_SEARCH_LOAD_MORE_COUNT = 1000;
|
||||
const REDIS_LARGE_KEYSPACE_THRESHOLD = 10000;
|
||||
const REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS = 200;
|
||||
|
||||
interface RedisViewerProps {
|
||||
connectionId: string;
|
||||
@@ -239,36 +245,62 @@ type RedisKeyTreeGroup = {
|
||||
path: string;
|
||||
children: Map<string, RedisKeyTreeGroup>;
|
||||
leaves: RedisKeyTreeLeaf[];
|
||||
leafCount: number;
|
||||
};
|
||||
|
||||
type RedisKeyTreeResult = {
|
||||
treeData: DataNode[];
|
||||
rawKeyByNodeKey: Map<string, string>;
|
||||
leafNodeKeyByRawKey: Map<string, string>;
|
||||
treeData: RedisTreeDataNode[];
|
||||
groupKeys: string[];
|
||||
};
|
||||
|
||||
type RedisTreeDataNode = DataNode & {
|
||||
nodeType: 'group' | 'leaf';
|
||||
groupName?: string;
|
||||
groupLeafCount?: number;
|
||||
leafLabel?: string;
|
||||
rawKey?: string;
|
||||
keyType?: string;
|
||||
ttl?: number;
|
||||
};
|
||||
|
||||
const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`;
|
||||
|
||||
const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => {
|
||||
const keyText = String(nodeKey);
|
||||
if (!keyText.startsWith('key:')) {
|
||||
return null;
|
||||
}
|
||||
return keyText.slice(4);
|
||||
};
|
||||
|
||||
const getRedisScanLoadCount = (pattern: string, append: boolean): number => {
|
||||
const normalizedPattern = pattern.trim() || '*';
|
||||
if (normalizedPattern === '*') {
|
||||
return append ? REDIS_KEY_LOAD_MORE_COUNT : REDIS_KEY_INITIAL_LOAD_COUNT;
|
||||
}
|
||||
return append ? REDIS_KEY_SEARCH_LOAD_MORE_COUNT : REDIS_KEY_SEARCH_INITIAL_LOAD_COUNT;
|
||||
};
|
||||
|
||||
const normalizeKeySegment = (segment: string): string => {
|
||||
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
|
||||
};
|
||||
|
||||
const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => {
|
||||
return { name, path, children: new Map(), leaves: [] };
|
||||
return { name, path, children: new Map(), leaves: [], leafCount: 0 };
|
||||
};
|
||||
|
||||
const countGroupLeafNodes = (group: RedisKeyTreeGroup): number => {
|
||||
const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => {
|
||||
let count = group.leaves.length;
|
||||
group.children.forEach((child) => {
|
||||
count += countGroupLeafNodes(child);
|
||||
count += calculateGroupLeafCount(child);
|
||||
});
|
||||
group.leafCount = count;
|
||||
return count;
|
||||
};
|
||||
|
||||
const buildRedisKeyTree = (
|
||||
keys: RedisKeyInfo[],
|
||||
formatTTL: (ttl: number) => string,
|
||||
getTypeColor: (type: string) => string,
|
||||
showTTL: boolean
|
||||
sortLeafNodes: boolean
|
||||
): RedisKeyTreeResult => {
|
||||
const root = createTreeGroup('__root__', '__root__');
|
||||
|
||||
@@ -298,105 +330,41 @@ const buildRedisKeyTree = (
|
||||
|
||||
current.leaves.push({ keyInfo, label: leafLabel });
|
||||
});
|
||||
calculateGroupLeafCount(root);
|
||||
|
||||
const rawKeyByNodeKey = new Map<string, string>();
|
||||
const leafNodeKeyByRawKey = new Map<string, string>();
|
||||
const groupKeys: string[] = [];
|
||||
|
||||
const toTreeNodes = (group: RedisKeyTreeGroup): DataNode[] => {
|
||||
const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => {
|
||||
const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const childLeaves = [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key));
|
||||
const childLeaves = sortLeafNodes
|
||||
? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key))
|
||||
: group.leaves;
|
||||
|
||||
const groupNodes: DataNode[] = childGroups.map((child) => {
|
||||
const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => {
|
||||
const groupNodeKey = `group:${child.path}`;
|
||||
groupKeys.push(groupNodeKey);
|
||||
return {
|
||||
key: groupNodeKey,
|
||||
title: (
|
||||
<Space size={6}>
|
||||
<FolderOpenOutlined style={{ color: '#8c8c8c' }} />
|
||||
<span>{child.name}</span>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>({countGroupLeafNodes(child)})</span>
|
||||
</Space>
|
||||
),
|
||||
title: child.name,
|
||||
nodeType: 'group',
|
||||
groupName: child.name,
|
||||
groupLeafCount: child.leafCount,
|
||||
selectable: false,
|
||||
disableCheckbox: true,
|
||||
children: toTreeNodes(child),
|
||||
};
|
||||
});
|
||||
|
||||
const leafNodes: DataNode[] = childLeaves.map((leaf) => {
|
||||
const nodeKey = `key:${leaf.keyInfo.key}`;
|
||||
rawKeyByNodeKey.set(nodeKey, leaf.keyInfo.key);
|
||||
leafNodeKeyByRawKey.set(leaf.keyInfo.key, nodeKey);
|
||||
const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => {
|
||||
return {
|
||||
key: nodeKey,
|
||||
key: buildLeafNodeKey(leaf.keyInfo.key),
|
||||
isLeaf: true,
|
||||
title: (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<KeyOutlined style={{ color: '#1677ff', flexShrink: 0 }} />
|
||||
<Tooltip title={leaf.keyInfo.key}>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{leaf.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tag
|
||||
color={getTypeColor(leaf.keyInfo.type)}
|
||||
style={{
|
||||
marginInlineEnd: 0,
|
||||
width: showTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW,
|
||||
textAlign: 'center',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{leaf.keyInfo.type}
|
||||
</Tag>
|
||||
{showTTL && (
|
||||
<span
|
||||
style={{
|
||||
width: REDIS_TREE_KEY_TTL_WIDTH,
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{formatTTL(leaf.keyInfo.ttl)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
title: leaf.label,
|
||||
nodeType: 'leaf',
|
||||
leafLabel: leaf.label,
|
||||
rawKey: leaf.keyInfo.key,
|
||||
keyType: leaf.keyInfo.type,
|
||||
ttl: leaf.keyInfo.ttl,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -405,8 +373,6 @@ const buildRedisKeyTree = (
|
||||
|
||||
return {
|
||||
treeData: toTreeNodes(root),
|
||||
rawKeyByNodeKey,
|
||||
leafNodeKeyByRawKey,
|
||||
groupKeys,
|
||||
};
|
||||
};
|
||||
@@ -443,11 +409,14 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
onSave: (newValue: string) => Promise<void>;
|
||||
} | null>(null);
|
||||
const jsonEditValueRef = useRef<string>('');
|
||||
const latestLoadRequestIdRef = useRef(0);
|
||||
|
||||
// 面板宽度状态和 ref - 默认占据 50% 宽度
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState<number | string>('50%');
|
||||
const leftPanelRef = useRef<HTMLDivElement>(null);
|
||||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showTreeKeyTTL, setShowTreeKeyTTL] = useState(true);
|
||||
const [treeHeight, setTreeHeight] = useState(500);
|
||||
const [expandedGroupKeys, setExpandedGroupKeys] = useState<string[]>([]);
|
||||
|
||||
const getConfig = useCallback(() => {
|
||||
@@ -462,55 +431,78 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
}, [connection, redisDB]);
|
||||
|
||||
const loadKeys = useCallback(async (pattern: string = '*', fromCursor: number = 0, append: boolean = false) => {
|
||||
const loadKeys = useCallback(async (
|
||||
pattern: string = '*',
|
||||
fromCursor: number = 0,
|
||||
append: boolean = false,
|
||||
targetCount?: number
|
||||
) => {
|
||||
const config = getConfig();
|
||||
if (!config) return;
|
||||
|
||||
const normalizedPattern = pattern.trim() || '*';
|
||||
const effectiveTargetCount = targetCount ?? getRedisScanLoadCount(normalizedPattern, append);
|
||||
const requestId = latestLoadRequestIdRef.current + 1;
|
||||
latestLoadRequestIdRef.current = requestId;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, 100);
|
||||
const res = await (window as any).go.app.App.RedisScanKeys(config, normalizedPattern, fromCursor, effectiveTargetCount);
|
||||
if (requestId !== latestLoadRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (res.success) {
|
||||
const result = res.data;
|
||||
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
|
||||
const nextCursor = Number(result?.cursor || 0);
|
||||
if (append) {
|
||||
setKeys(prev => {
|
||||
const keyMap = new Map<string, RedisKeyInfo>();
|
||||
prev.forEach(item => keyMap.set(item.key, item));
|
||||
result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
|
||||
scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
|
||||
return Array.from(keyMap.values());
|
||||
});
|
||||
} else {
|
||||
setKeys(result.keys);
|
||||
setKeys(scannedKeys);
|
||||
}
|
||||
setCursor(result.cursor);
|
||||
setHasMore(result.cursor !== 0);
|
||||
setCursor(nextCursor);
|
||||
setHasMore(nextCursor !== 0);
|
||||
} else {
|
||||
message.error('加载 Key 失败: ' + res.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (requestId !== latestLoadRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
message.error('加载 Key 失败: ' + (e?.message || String(e)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requestId === latestLoadRequestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [getConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys(searchPattern, 0, 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);
|
||||
loadKeys(pattern, 0, false, getRedisScanLoadCount(pattern, false));
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
loadKeys(searchPattern, cursor, true);
|
||||
if (!hasMore || loading) {
|
||||
return;
|
||||
}
|
||||
loadKeys(searchPattern, cursor, true, getRedisScanLoadCount(searchPattern, true));
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setCursor(0);
|
||||
loadKeys(searchPattern, 0, false);
|
||||
loadKeys(searchPattern, 0, false, getRedisScanLoadCount(searchPattern, false));
|
||||
};
|
||||
|
||||
const loadKeyValue = async (key: string) => {
|
||||
@@ -666,23 +658,51 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const target = treeContainerRef.current;
|
||||
if (!target) return;
|
||||
|
||||
const updateTreeHeight = (nextHeight: number) => {
|
||||
if (nextHeight <= 0) return;
|
||||
setTreeHeight((prev) => (prev === nextHeight ? prev : nextHeight));
|
||||
};
|
||||
|
||||
updateTreeHeight(Math.round(target.getBoundingClientRect().height));
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const nextHeight = Math.round(entries[0]?.contentRect.height || target.getBoundingClientRect().height);
|
||||
updateTreeHeight(nextHeight);
|
||||
});
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
const handleWindowResize = () => {
|
||||
updateTreeHeight(Math.round(target.getBoundingClientRect().height));
|
||||
};
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
const isLargeKeyspace = keys.length >= REDIS_LARGE_KEYSPACE_THRESHOLD;
|
||||
|
||||
const keyTree = useMemo(() => {
|
||||
return buildRedisKeyTree(keys, formatTTL, getTypeColor, showTreeKeyTTL);
|
||||
}, [keys, showTreeKeyTTL]);
|
||||
return buildRedisKeyTree(keys, !isLargeKeyspace);
|
||||
}, [isLargeKeyspace, keys]);
|
||||
|
||||
const groupKeySet = useMemo(() => new Set(keyTree.groupKeys), [keyTree.groupKeys]);
|
||||
|
||||
const selectedTreeNodeKeys = useMemo(() => {
|
||||
if (!selectedKey) {
|
||||
return [] as string[];
|
||||
}
|
||||
const nodeKey = keyTree.leafNodeKeyByRawKey.get(selectedKey);
|
||||
return nodeKey ? [nodeKey] : [];
|
||||
}, [selectedKey, keyTree]);
|
||||
return [buildLeafNodeKey(selectedKey)];
|
||||
}, [selectedKey]);
|
||||
|
||||
const checkedTreeNodeKeys = useMemo(() => {
|
||||
return selectedKeys
|
||||
.map(rawKey => keyTree.leafNodeKeyByRawKey.get(rawKey))
|
||||
.filter((nodeKey): nodeKey is string => Boolean(nodeKey));
|
||||
}, [selectedKeys, keyTree]);
|
||||
return selectedKeys.map(rawKey => buildLeafNodeKey(rawKey));
|
||||
}, [selectedKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
const existingKeySet = new Set(keys.map(item => item.key));
|
||||
@@ -691,16 +711,19 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedGroupKeys((prev) => {
|
||||
const validKeys = prev.filter(nodeKey => keyTree.groupKeys.includes(nodeKey));
|
||||
return validKeys;
|
||||
const validKeys = prev.filter(nodeKey => groupKeySet.has(nodeKey));
|
||||
if (!isLargeKeyspace) {
|
||||
return validKeys;
|
||||
}
|
||||
return validKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS);
|
||||
});
|
||||
}, [keyTree]);
|
||||
}, [groupKeySet, isLargeKeyspace]);
|
||||
|
||||
const handleTreeSelect = (nodeKeys: React.Key[]) => {
|
||||
if (nodeKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
const rawKey = keyTree.rawKeyByNodeKey.get(String(nodeKeys[0]));
|
||||
const rawKey = parseRawKeyFromNodeKey(nodeKeys[0]);
|
||||
if (!rawKey) {
|
||||
return;
|
||||
}
|
||||
@@ -710,11 +733,119 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const handleTreeCheck = (checked: React.Key[] | { checked: React.Key[]; halfChecked: React.Key[] }) => {
|
||||
const checkedNodeKeys = Array.isArray(checked) ? checked : checked.checked;
|
||||
const rawKeys = checkedNodeKeys
|
||||
.map(nodeKey => keyTree.rawKeyByNodeKey.get(String(nodeKey)))
|
||||
.map(nodeKey => parseRawKeyFromNodeKey(nodeKey))
|
||||
.filter((rawKey): rawKey is string => Boolean(rawKey));
|
||||
setSelectedKeys(rawKeys);
|
||||
};
|
||||
|
||||
const renderTreeNodeTitle = useCallback((nodeData: DataNode) => {
|
||||
const treeNode = nodeData as RedisTreeDataNode;
|
||||
|
||||
if (treeNode.nodeType === 'group') {
|
||||
return (
|
||||
<Space size={6}>
|
||||
<FolderOpenOutlined style={{ color: '#8c8c8c' }} />
|
||||
<span>{treeNode.groupName}</span>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>({treeNode.groupLeafCount ?? 0})</span>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const leafLabel = treeNode.leafLabel ?? '';
|
||||
const rawKey = treeNode.rawKey ?? parseRawKeyFromNodeKey(treeNode.key ?? '') ?? '';
|
||||
const keyType = treeNode.keyType ?? 'unknown';
|
||||
const ttl = typeof treeNode.ttl === 'number' ? treeNode.ttl : -1;
|
||||
|
||||
if (isLargeKeyspace) {
|
||||
return (
|
||||
<div style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span>{leafLabel}</span>
|
||||
<span style={{ marginLeft: 8, color: '#999', fontSize: 12 }}>[{keyType}]</span>
|
||||
{showTreeKeyTTL && (
|
||||
<span style={{ marginLeft: 8, color: '#999', fontSize: 12 }}>{formatTTL(ttl)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<KeyOutlined style={{ color: '#1677ff', flexShrink: 0 }} />
|
||||
<Tooltip title={rawKey}>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{leafLabel}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tag
|
||||
color={getTypeColor(keyType)}
|
||||
style={{
|
||||
marginInlineEnd: 0,
|
||||
width: showTreeKeyTTL ? REDIS_TREE_KEY_TYPE_WIDTH : REDIS_TREE_KEY_TYPE_WIDTH_NARROW,
|
||||
textAlign: 'center',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{keyType}
|
||||
</Tag>
|
||||
{showTreeKeyTTL && (
|
||||
<span
|
||||
style={{
|
||||
width: REDIS_TREE_KEY_TTL_WIDTH,
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{formatTTL(ttl)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [formatTTL, getTypeColor, isLargeKeyspace, showTreeKeyTTL]);
|
||||
|
||||
const handleTreeExpand = (nextExpandedKeys: React.Key[]) => {
|
||||
const validGroupKeys = nextExpandedKeys
|
||||
.map(key => String(key))
|
||||
.filter(nodeKey => groupKeySet.has(nodeKey));
|
||||
if (isLargeKeyspace) {
|
||||
setExpandedGroupKeys(validGroupKeys.slice(0, REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS));
|
||||
return;
|
||||
}
|
||||
setExpandedGroupKeys(validGroupKeys);
|
||||
};
|
||||
|
||||
const renderValueEditor = () => {
|
||||
if (!keyValue || !selectedKey) {
|
||||
return <div style={{ padding: 20, textAlign: 'center', color: '#999' }}>选择一个 Key 查看详情</div>;
|
||||
@@ -1757,27 +1888,37 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Spin spinning={loading} size="small">
|
||||
<Tree
|
||||
blockNode
|
||||
showIcon={false}
|
||||
checkable
|
||||
checkStrictly
|
||||
selectable
|
||||
treeData={keyTree.treeData}
|
||||
selectedKeys={selectedTreeNodeKeys}
|
||||
checkedKeys={checkedTreeNodeKeys}
|
||||
expandedKeys={expandedGroupKeys}
|
||||
onExpand={(nextExpandedKeys) => setExpandedGroupKeys(nextExpandedKeys as string[])}
|
||||
onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)}
|
||||
onCheck={(checked) => handleTreeCheck(checked)}
|
||||
style={{ padding: '8px 6px' }}
|
||||
/>
|
||||
</Spin>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{isLargeKeyspace && (
|
||||
<div style={{ padding: '6px 8px', fontSize: 12, color: '#8c8c8c', borderBottom: '1px solid #f0f0f0' }}>
|
||||
已启用大数据量性能模式(简化节点渲染,最多保留 {REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS} 个展开分组)
|
||||
</div>
|
||||
)}
|
||||
<div ref={treeContainerRef} style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<Spin spinning={loading} size="small" style={{ width: '100%' }}>
|
||||
<Tree
|
||||
blockNode
|
||||
showIcon={false}
|
||||
checkable
|
||||
checkStrictly
|
||||
selectable
|
||||
virtual
|
||||
height={Math.max(treeHeight - 8, 220)}
|
||||
treeData={keyTree.treeData}
|
||||
titleRender={renderTreeNodeTitle}
|
||||
selectedKeys={selectedTreeNodeKeys}
|
||||
checkedKeys={checkedTreeNodeKeys}
|
||||
expandedKeys={expandedGroupKeys}
|
||||
onExpand={handleTreeExpand}
|
||||
onSelect={(nodeKeys) => handleTreeSelect(nodeKeys)}
|
||||
onCheck={(checked) => handleTreeCheck(checked)}
|
||||
style={{ padding: '8px 6px' }}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div style={{ padding: 8, textAlign: 'center' }}>
|
||||
<Button onClick={handleLoadMore} loading={loading}>加载更多</Button>
|
||||
<Button onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}>加载更多</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,8 @@ interface TreeNode {
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
type BatchObjectType = 'table' | 'view';
|
||||
type BatchObjectFilterType = 'all' | BatchObjectType;
|
||||
type BatchSelectionScope = 'filtered' | 'all';
|
||||
|
||||
interface BatchObjectItem {
|
||||
title: string;
|
||||
@@ -133,11 +135,47 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [selectedConnection, setSelectedConnection] = useState<string>('');
|
||||
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
||||
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
|
||||
const [batchFilterKeyword, setBatchFilterKeyword] = useState<string>('');
|
||||
const [batchFilterType, setBatchFilterType] = useState<BatchObjectFilterType>('all');
|
||||
const [batchSelectionScope, setBatchSelectionScope] = useState<BatchSelectionScope>('filtered');
|
||||
const filteredBatchObjects = useMemo(() => {
|
||||
const keyword = batchFilterKeyword.trim().toLowerCase();
|
||||
return batchTables.filter((item) => {
|
||||
if (batchFilterType !== 'all' && item.objectType !== batchFilterType) {
|
||||
return false;
|
||||
}
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
return item.title.toLowerCase().includes(keyword) || item.objectName.toLowerCase().includes(keyword);
|
||||
});
|
||||
}, [batchFilterKeyword, batchFilterType, batchTables]);
|
||||
const groupedBatchObjects = useMemo(() => {
|
||||
const tables = batchTables.filter(item => item.objectType === 'table');
|
||||
const views = batchTables.filter(item => item.objectType === 'view');
|
||||
const tables = filteredBatchObjects.filter(item => item.objectType === 'table');
|
||||
const views = filteredBatchObjects.filter(item => item.objectType === 'view');
|
||||
return { tables, views };
|
||||
}, [batchTables]);
|
||||
}, [filteredBatchObjects]);
|
||||
const allBatchObjectKeys = useMemo(() => batchTables.map(item => item.key), [batchTables]);
|
||||
const allBatchObjectKeysByType = useMemo(() => {
|
||||
if (batchFilterType === 'all') {
|
||||
return allBatchObjectKeys;
|
||||
}
|
||||
return batchTables
|
||||
.filter((item) => item.objectType === batchFilterType)
|
||||
.map((item) => item.key);
|
||||
}, [allBatchObjectKeys, batchFilterType, batchTables]);
|
||||
const filteredBatchObjectKeys = useMemo(() => filteredBatchObjects.map(item => item.key), [filteredBatchObjects]);
|
||||
const selectionScopeTargetKeys = useMemo(
|
||||
() => (batchSelectionScope === 'filtered' ? filteredBatchObjectKeys : allBatchObjectKeysByType),
|
||||
[allBatchObjectKeysByType, batchSelectionScope, filteredBatchObjectKeys]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (batchFilterType === 'all') {
|
||||
return;
|
||||
}
|
||||
const allowed = new Set(allBatchObjectKeysByType);
|
||||
setCheckedTableKeys((prev) => prev.filter((key) => allowed.has(key)));
|
||||
}, [allBatchObjectKeysByType, batchFilterType]);
|
||||
|
||||
// Batch Database Operations Modal
|
||||
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
|
||||
@@ -1313,6 +1351,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setBatchTables([]);
|
||||
setCheckedTableKeys([]);
|
||||
setAvailableDatabases([]);
|
||||
setBatchFilterKeyword('');
|
||||
setBatchFilterType('all');
|
||||
setBatchSelectionScope('filtered');
|
||||
|
||||
if (connId) {
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
@@ -1413,6 +1454,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setSelectedDatabase('');
|
||||
setBatchTables([]);
|
||||
setCheckedTableKeys([]);
|
||||
setBatchFilterKeyword('');
|
||||
setBatchFilterType('all');
|
||||
setBatchSelectionScope('filtered');
|
||||
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
@@ -1422,6 +1466,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
const handleDatabaseChange = async (dbName: string) => {
|
||||
setSelectedDatabase(dbName);
|
||||
setBatchFilterKeyword('');
|
||||
setBatchFilterType('all');
|
||||
setBatchSelectionScope('filtered');
|
||||
|
||||
const conn = connections.find(c => c.id === selectedConnection);
|
||||
if (conn && dbName) {
|
||||
@@ -1470,17 +1517,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckedTableKeys(batchTables.map(t => t.key));
|
||||
} else {
|
||||
setCheckedTableKeys([]);
|
||||
if (batchSelectionScope === 'all') {
|
||||
setCheckedTableKeys(checked ? allBatchObjectKeys : []);
|
||||
return;
|
||||
}
|
||||
if (filteredBatchObjectKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (checked) {
|
||||
setCheckedTableKeys(prev => {
|
||||
const nextSet = new Set(prev);
|
||||
filteredBatchObjectKeys.forEach((key) => nextSet.add(key));
|
||||
return allBatchObjectKeys.filter((key) => nextSet.has(key));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const filteredKeySet = new Set(filteredBatchObjectKeys);
|
||||
setCheckedTableKeys(prev => prev.filter((key) => !filteredKeySet.has(key)));
|
||||
};
|
||||
|
||||
const handleInvertSelection = () => {
|
||||
const allKeys = batchTables.map(t => t.key);
|
||||
const newChecked = allKeys.filter(k => !checkedTableKeys.includes(k));
|
||||
setCheckedTableKeys(newChecked);
|
||||
if (batchSelectionScope === 'all') {
|
||||
setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key)));
|
||||
return;
|
||||
}
|
||||
if (filteredBatchObjectKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
setCheckedTableKeys(prev => {
|
||||
const nextSet = new Set(prev);
|
||||
filteredBatchObjectKeys.forEach((key) => {
|
||||
if (nextSet.has(key)) {
|
||||
nextSet.delete(key);
|
||||
} else {
|
||||
nextSet.add(key);
|
||||
}
|
||||
});
|
||||
return allBatchObjectKeys.filter((key) => nextSet.has(key));
|
||||
});
|
||||
};
|
||||
|
||||
const openBatchDatabaseModal = async () => {
|
||||
@@ -2874,6 +2948,43 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchTables.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap size={8} style={{ width: '100%' }}>
|
||||
<Input
|
||||
allowClear
|
||||
value={batchFilterKeyword}
|
||||
onChange={(e) => setBatchFilterKeyword(e.target.value)}
|
||||
placeholder="筛选表/视图名称"
|
||||
prefix={<SearchOutlined />}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Select
|
||||
value={batchFilterType}
|
||||
onChange={(value) => setBatchFilterType(value as BatchObjectFilterType)}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '全部对象', value: 'all' },
|
||||
{ label: '仅表', value: 'table' },
|
||||
{ label: '仅视图', value: 'view' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={batchSelectionScope}
|
||||
onChange={(value) => setBatchSelectionScope(value as BatchSelectionScope)}
|
||||
style={{ width: 220 }}
|
||||
options={[
|
||||
{ label: '勾选作用于:当前筛选结果', value: 'filtered' },
|
||||
{ label: '勾选作用于:全部对象', value: 'all' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ marginTop: 6, color: '#999', fontSize: 12 }}>
|
||||
当前筛选命中 {filteredBatchObjects.length} / {batchTables.length} 个对象
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchTables.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -2881,18 +2992,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAll(true)}
|
||||
disabled={selectionScopeTargetKeys.length === 0}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAll(false)}
|
||||
disabled={selectionScopeTargetKeys.length === 0}
|
||||
>
|
||||
取消全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleInvertSelection}
|
||||
disabled={selectionScopeTargetKeys.length === 0}
|
||||
>
|
||||
反选
|
||||
</Button>
|
||||
@@ -2938,6 +3052,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && (
|
||||
<div style={{ color: '#999', padding: '8px 0' }}>
|
||||
无匹配对象
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
@@ -6,21 +6,7 @@ import App from './App'
|
||||
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
|
||||
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker'
|
||||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker.js?worker'
|
||||
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js'
|
||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution.js'
|
||||
|
||||
(self as any).MonacoEnvironment = {
|
||||
getWorker(_: unknown, label: string) {
|
||||
if (label === 'json') {
|
||||
return new JsonWorker()
|
||||
}
|
||||
return new EditorWorker()
|
||||
},
|
||||
}
|
||||
|
||||
import * as monaco from 'monaco-editor'
|
||||
loader.config({ monaco })
|
||||
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types';
|
||||
import { ConnectionConfig, ProxyConfig, SavedConnection, TabData, SavedQuery } from './types';
|
||||
|
||||
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
|
||||
const DEFAULT_STARTUP_FULLSCREEN = false;
|
||||
const LEGACY_DEFAULT_OPACITY = 0.95;
|
||||
const OPACITY_EPSILON = 1e-6;
|
||||
const MAX_URI_LENGTH = 4096;
|
||||
@@ -10,12 +11,22 @@ const MAX_HOST_ENTRY_LENGTH = 512;
|
||||
const MAX_HOST_ENTRIES = 64;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const PERSIST_VERSION = 4;
|
||||
const DEFAULT_CONNECTION_TYPE = 'mysql';
|
||||
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||||
enabled: false,
|
||||
type: 'socks5',
|
||||
host: '',
|
||||
port: 1080,
|
||||
user: '',
|
||||
password: '',
|
||||
};
|
||||
const SUPPORTED_CONNECTION_TYPES = new Set([
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'diros',
|
||||
'sphinx',
|
||||
'clickhouse',
|
||||
'postgres',
|
||||
'redis',
|
||||
'tdengine',
|
||||
@@ -42,6 +53,8 @@ const getDefaultPortByType = (type: string): number => {
|
||||
return 0;
|
||||
case 'sphinx':
|
||||
return 9306;
|
||||
case 'clickhouse':
|
||||
return 9000;
|
||||
case 'postgres':
|
||||
case 'vastbase':
|
||||
return 5432;
|
||||
@@ -155,6 +168,16 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
password: toTrimmedString(sshRaw.password),
|
||||
keyPath: toTrimmedString(sshRaw.keyPath),
|
||||
};
|
||||
const proxyRaw = (raw.proxy && typeof raw.proxy === 'object') ? raw.proxy as Record<string, unknown> : {};
|
||||
const proxyTypeRaw = toTrimmedString(proxyRaw.type, 'socks5').toLowerCase();
|
||||
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
|
||||
const proxy = {
|
||||
type: proxyType,
|
||||
host: toTrimmedString(proxyRaw.host),
|
||||
port: normalizePort(proxyRaw.port, proxyTypeRaw === 'http' ? 8080 : 1080),
|
||||
user: toTrimmedString(proxyRaw.user),
|
||||
password: toTrimmedString(proxyRaw.password),
|
||||
};
|
||||
|
||||
const safeConfig: ConnectionConfig & Record<string, unknown> = {
|
||||
...raw,
|
||||
@@ -167,6 +190,8 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
database: toTrimmedString(raw.database),
|
||||
useSSH: !!raw.useSSH,
|
||||
ssh,
|
||||
useProxy: !!raw.useProxy,
|
||||
proxy,
|
||||
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
|
||||
hosts: sanitizeAddressList(raw.hosts),
|
||||
topology: raw.topology === 'replica' ? 'replica' : 'single',
|
||||
@@ -194,10 +219,27 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||||
return safeConfig;
|
||||
};
|
||||
|
||||
const resolveConnectionConfigPayload = (raw: Record<string, unknown>): unknown => {
|
||||
if (raw.config && typeof raw.config === 'object') {
|
||||
return raw.config;
|
||||
}
|
||||
// 兼容历史/导入场景:连接对象可能是扁平结构(无 config 包装)。
|
||||
const hasLegacyFlatConfig =
|
||||
raw.type !== undefined ||
|
||||
raw.host !== undefined ||
|
||||
raw.port !== undefined ||
|
||||
raw.user !== undefined ||
|
||||
raw.database !== undefined;
|
||||
if (hasLegacyFlatConfig) {
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const config = sanitizeConnectionConfig(raw.config);
|
||||
const config = sanitizeConnectionConfig(resolveConnectionConfigPayload(raw));
|
||||
const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`;
|
||||
const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`;
|
||||
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
|
||||
@@ -258,6 +300,10 @@ export interface QueryOptions {
|
||||
showColumnType: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalProxyConfig extends ProxyConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
connections: SavedConnection[];
|
||||
tabs: TabData[];
|
||||
@@ -266,6 +312,8 @@ interface AppState {
|
||||
savedQueries: SavedQuery[];
|
||||
theme: 'light' | 'dark';
|
||||
appearance: { opacity: number; blur: number };
|
||||
startupFullscreen: boolean;
|
||||
globalProxy: GlobalProxyConfig;
|
||||
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
|
||||
queryOptions: QueryOptions;
|
||||
sqlLogs: SqlLog[];
|
||||
@@ -292,6 +340,8 @@ interface AppState {
|
||||
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
|
||||
setStartupFullscreen: (enabled: boolean) => void;
|
||||
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||||
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
|
||||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||||
|
||||
@@ -380,6 +430,36 @@ const sanitizeAppearance = (
|
||||
return nextAppearance;
|
||||
};
|
||||
|
||||
const sanitizeStartupFullscreen = (value: unknown): boolean => {
|
||||
return value === true;
|
||||
};
|
||||
|
||||
const sanitizeGlobalProxy = (value: unknown): GlobalProxyConfig => {
|
||||
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
|
||||
const typeRaw = toTrimmedString(raw.type, DEFAULT_GLOBAL_PROXY.type).toLowerCase();
|
||||
const type: 'socks5' | 'http' = typeRaw === 'http' ? 'http' : 'socks5';
|
||||
const fallbackPort = type === 'http' ? 8080 : 1080;
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
type,
|
||||
host: toTrimmedString(raw.host),
|
||||
port: normalizePort(raw.port, fallbackPort),
|
||||
user: toTrimmedString(raw.user),
|
||||
password: toTrimmedString(raw.password),
|
||||
};
|
||||
};
|
||||
|
||||
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return {};
|
||||
}
|
||||
const raw = persistedState as Record<string, unknown>;
|
||||
if (raw.state && typeof raw.state === 'object') {
|
||||
return raw.state as Record<string, unknown>;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
@@ -390,6 +470,8 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: [],
|
||||
theme: 'light',
|
||||
appearance: { ...DEFAULT_APPEARANCE },
|
||||
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
|
||||
globalProxy: { ...DEFAULT_GLOBAL_PROXY },
|
||||
sqlFormatOptions: { keywordCase: 'upper' },
|
||||
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
|
||||
sqlLogs: [],
|
||||
@@ -501,6 +583,8 @@ export const useStore = create<AppState>()(
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
|
||||
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
|
||||
setGlobalProxy: (proxy) => set((state) => ({ globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }) })),
|
||||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||||
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
|
||||
|
||||
@@ -530,17 +614,16 @@ export const useStore = create<AppState>()(
|
||||
}),
|
||||
{
|
||||
name: 'lite-db-storage', // name of the item in the storage (must be unique)
|
||||
version: 3,
|
||||
version: PERSIST_VERSION,
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState as AppState;
|
||||
}
|
||||
const state = persistedState as Partial<AppState>;
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
const nextState: Partial<AppState> = { ...state };
|
||||
nextState.connections = sanitizeConnections(state.connections);
|
||||
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
||||
nextState.theme = sanitizeTheme(state.theme);
|
||||
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
||||
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
|
||||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
|
||||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
|
||||
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
|
||||
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
|
||||
@@ -548,16 +631,16 @@ export const useStore = create<AppState>()(
|
||||
return nextState as AppState;
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
const state = (persistedState && typeof persistedState === 'object')
|
||||
? persistedState as Partial<AppState>
|
||||
: {};
|
||||
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
|
||||
return {
|
||||
...currentState,
|
||||
...state,
|
||||
connections: sanitizeConnections(state.connections),
|
||||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||||
theme: sanitizeTheme(state.theme),
|
||||
appearance: sanitizeAppearance(state.appearance, 3),
|
||||
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
|
||||
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||||
globalProxy: sanitizeGlobalProxy(state.globalProxy),
|
||||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||||
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
|
||||
@@ -569,6 +652,8 @@ export const useStore = create<AppState>()(
|
||||
savedQueries: state.savedQueries,
|
||||
theme: state.theme,
|
||||
appearance: state.appearance,
|
||||
startupFullscreen: state.startupFullscreen,
|
||||
globalProxy: state.globalProxy,
|
||||
sqlFormatOptions: state.sqlFormatOptions,
|
||||
queryOptions: state.queryOptions,
|
||||
tableAccessCount: state.tableAccessCount,
|
||||
|
||||
@@ -6,6 +6,14 @@ export interface SSHConfig {
|
||||
keyPath?: string;
|
||||
}
|
||||
|
||||
export interface ProxyConfig {
|
||||
type: 'socks5' | 'http';
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
type: string;
|
||||
host: string;
|
||||
@@ -16,6 +24,8 @@ export interface ConnectionConfig {
|
||||
database?: string;
|
||||
useSSH?: boolean;
|
||||
ssh?: SSHConfig;
|
||||
useProxy?: boolean;
|
||||
proxy?: ProxyConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
timeout?: number;
|
||||
|
||||
@@ -36,7 +36,7 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
|
||||
if (!raw) return raw;
|
||||
const dbTypeLower = (dbType || '').toLowerCase();
|
||||
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine') {
|
||||
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'diros' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
|
||||
return `\`${raw.replace(/`/g, '``')}\``;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const normalizeModuleId = (id: string): string => id.replace(/\\/g, '/')
|
||||
|
||||
const sanitizeChunkToken = (raw: string): string =>
|
||||
String(raw || '')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'misc'
|
||||
|
||||
const firstSegmentAfter = (id: string, marker: string): string => {
|
||||
const idx = id.indexOf(marker)
|
||||
if (idx < 0) return ''
|
||||
const rest = id.substring(idx + marker.length)
|
||||
const [segment] = rest.split('/')
|
||||
return sanitizeChunkToken(segment)
|
||||
}
|
||||
|
||||
const resolveMonacoChunk = (id: string, prefix: string): string | undefined => {
|
||||
if (!id.includes('/node_modules/monaco-editor/')) return undefined
|
||||
|
||||
if (id.includes('/esm/vs/language/typescript/')) {
|
||||
if (id.includes('typescriptServices')) return `${prefix}-ts-services`
|
||||
return `${prefix}-typescript`
|
||||
}
|
||||
if (id.includes('/esm/vs/language/json/')) return `${prefix}-json`
|
||||
if (id.includes('/esm/vs/language/css/')) return `${prefix}-css`
|
||||
if (id.includes('/esm/vs/language/html/')) return `${prefix}-html`
|
||||
|
||||
if (id.includes('/esm/vs/editor/contrib/')) {
|
||||
return `${prefix}-editor-contrib-${firstSegmentAfter(id, '/esm/vs/editor/contrib/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/browser/')) {
|
||||
return `${prefix}-editor-browser-${firstSegmentAfter(id, '/esm/vs/editor/browser/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/common/')) {
|
||||
return `${prefix}-editor-common-${firstSegmentAfter(id, '/esm/vs/editor/common/')}`
|
||||
}
|
||||
if (id.includes('/esm/vs/editor/')) return `${prefix}-editor`
|
||||
|
||||
if (id.includes('/esm/vs/base/browser/')) return `${prefix}-base-browser`
|
||||
if (id.includes('/esm/vs/base/common/')) return `${prefix}-base-common`
|
||||
if (id.includes('/esm/vs/base/')) return `${prefix}-base`
|
||||
|
||||
if (id.includes('/esm/vs/platform/')) return `${prefix}-platform`
|
||||
|
||||
return `${prefix}-misc`
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -59,61 +11,5 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist', // Standard Wails output directory
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
|
||||
const monacoChunk = resolveMonacoChunk(moduleId, 'vendor-monaco')
|
||||
if (monacoChunk) {
|
||||
return monacoChunk
|
||||
}
|
||||
if (moduleId.includes('/node_modules/@monaco-editor/react/')) return 'vendor-monaco-react'
|
||||
|
||||
if (moduleId.includes('/node_modules/antd/es/')) {
|
||||
return `vendor-antd-${firstSegmentAfter(moduleId, '/node_modules/antd/es/')}`
|
||||
}
|
||||
if (moduleId.includes('/node_modules/antd/')) return 'vendor-antd'
|
||||
if (moduleId.includes('/node_modules/@ant-design/icons/')) return 'vendor-antd-icons'
|
||||
if (moduleId.includes('/node_modules/@ant-design/cssinjs/')) return 'vendor-antd-css'
|
||||
if (moduleId.includes('/node_modules/rc-')) return 'vendor-antd-rc'
|
||||
|
||||
if (moduleId.includes('/node_modules/@dnd-kit/')) return 'vendor-dnd-kit'
|
||||
if (moduleId.includes('/node_modules/sql-formatter/')) return 'vendor-sql-formatter'
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/react/')
|
||||
|| moduleId.includes('/node_modules/react-dom/')
|
||||
|| moduleId.includes('/node_modules/scheduler/')
|
||||
) {
|
||||
return 'vendor-react'
|
||||
}
|
||||
|
||||
if (
|
||||
moduleId.includes('/node_modules/zustand/')
|
||||
|| moduleId.includes('/node_modules/uuid/')
|
||||
|| moduleId.includes('/node_modules/clsx/')
|
||||
|| moduleId.includes('/node_modules/react-resizable/')
|
||||
) {
|
||||
return 'vendor-utils'
|
||||
}
|
||||
|
||||
return 'vendor-misc'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const moduleId = normalizeModuleId(id)
|
||||
if (!moduleId.includes('node_modules')) return undefined
|
||||
return resolveMonacoChunk(moduleId, 'worker-monaco')
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
12
frontend/wailsjs/go/app/App.d.ts
vendored
12
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -6,10 +6,14 @@ import {redis} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
|
||||
|
||||
export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DBConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
|
||||
@@ -38,7 +42,7 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise<connection.QueryRe
|
||||
|
||||
export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
|
||||
|
||||
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function DownloadUpdate():Promise<connection.QueryResult>;
|
||||
|
||||
@@ -66,6 +70,12 @@ export function GetAppInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverVersionList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverVersionPackageSize(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function GetGlobalProxyConfig():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportConfigFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function CheckDriverNetworkStatus() {
|
||||
return window['go']['app']['App']['CheckDriverNetworkStatus']();
|
||||
}
|
||||
|
||||
export function CheckForUpdates() {
|
||||
return window['go']['app']['App']['CheckForUpdates']();
|
||||
}
|
||||
@@ -14,6 +18,10 @@ export function ConfigureDriverRuntimeDirectory(arg1) {
|
||||
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function ConfigureGlobalProxy(arg1, arg2) {
|
||||
return window['go']['app']['App']['ConfigureGlobalProxy'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CreateDatabase(arg1, arg2) {
|
||||
return window['go']['app']['App']['CreateDatabase'](arg1, arg2);
|
||||
}
|
||||
@@ -70,8 +78,8 @@ export function DataSyncPreview(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function DownloadDriverPackage(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3);
|
||||
export function DownloadDriverPackage(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function DownloadUpdate() {
|
||||
@@ -126,6 +134,18 @@ export function GetDriverStatusList(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetDriverVersionList(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverVersionList'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetDriverVersionPackageSize(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverVersionPackageSize'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetGlobalProxyConfig() {
|
||||
return window['go']['app']['App']['GetGlobalProxyConfig']();
|
||||
}
|
||||
|
||||
export function ImportConfigFile() {
|
||||
return window['go']['app']['App']['ImportConfigFile']();
|
||||
}
|
||||
|
||||
@@ -48,6 +48,26 @@ export namespace connection {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ProxyConfig {
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ProxyConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.user = source["user"];
|
||||
this.password = source["password"];
|
||||
}
|
||||
}
|
||||
export class SSHConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -78,6 +98,8 @@ export namespace connection {
|
||||
database: string;
|
||||
useSSH: boolean;
|
||||
ssh: SSHConfig;
|
||||
useProxy?: boolean;
|
||||
proxy?: ProxyConfig;
|
||||
driver?: string;
|
||||
dsn?: string;
|
||||
timeout?: number;
|
||||
@@ -110,6 +132,8 @@ export namespace connection {
|
||||
this.database = source["database"];
|
||||
this.useSSH = source["useSSH"];
|
||||
this.ssh = this.convertValues(source["ssh"], SSHConfig);
|
||||
this.useProxy = source["useProxy"];
|
||||
this.proxy = this.convertValues(source["proxy"], ProxyConfig);
|
||||
this.driver = source["driver"];
|
||||
this.dsn = source["dsn"];
|
||||
this.timeout = source["timeout"];
|
||||
@@ -146,6 +170,7 @@ export namespace connection {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueryResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
||||
16
go.mod
16
go.mod
@@ -5,6 +5,7 @@ go 1.24.3
|
||||
require (
|
||||
gitea.com/kingbase/gokb v0.0.0-20201021123113-29bd62a876c3
|
||||
gitee.com/chunanyong/dm v1.8.22
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0
|
||||
github.com/duckdb/duckdb-go/v2 v2.5.5
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/highgo/pq-sm3 v0.0.0
|
||||
@@ -17,12 +18,16 @@ require (
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/mod v0.32.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/text v0.33.0
|
||||
modernc.org/sqlite v1.44.3
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.5.1 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -34,6 +39,8 @@ require (
|
||||
github.com/duckdb/duckdb-go-bindings/lib/linux-arm64 v0.3.3 // indirect
|
||||
github.com/duckdb/duckdb-go-bindings/lib/windows-amd64 v0.3.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
@@ -44,7 +51,7 @@ require (
|
||||
github.com/google/flatbuffers v25.12.19+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
@@ -60,6 +67,7 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -68,6 +76,7 @@ require (
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
@@ -82,9 +91,10 @@ require (
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260116145544-c6413dc483f5 // indirect
|
||||
|
||||
75
go.sum
75
go.sum
@@ -16,6 +16,10 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuo
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apache/arrow-go/v18 v18.5.1 h1:yaQ6zxMGgf9YCYw4/oaeOU3AULySDlAYDOcnr4LdHdI=
|
||||
@@ -52,6 +56,10 @@ github.com/duckdb/duckdb-go/v2 v2.5.5 h1:TlK8ipnzoKW2aNrjGqRkFWLCDpJDxR/VwH8ezEc
|
||||
github.com/duckdb/duckdb-go/v2 v2.5.5/go.mod h1:6uIbC3gz36NCEygECzboygOo/Z9TeVwox/puG+ohWV0=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
@@ -62,19 +70,23 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs=
|
||||
github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
@@ -83,20 +95,29 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
@@ -134,8 +155,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
@@ -159,6 +186,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg=
|
||||
@@ -169,6 +198,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
@@ -176,6 +206,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/taosdata/driver-go/v3 v3.7.8 h1:N2H6HLLZH2ve2ipcoFgG9BJS+yW0XksqNYwEdSmHaJk=
|
||||
github.com/taosdata/driver-go/v3 v3.7.8/go.mod h1:gSxBEPOueMg0rTmMO1Ug6aeD7AwGdDGvUtLrsDTTpYc=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
|
||||
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
@@ -192,8 +223,10 @@ github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSB
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
@@ -202,38 +235,64 @@ github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstf
|
||||
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -260,15 +319,25 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||||
)
|
||||
|
||||
const dbCachePingInterval = 30 * time.Second
|
||||
@@ -66,6 +67,7 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
logger.Error(err, "关闭数据库连接失败")
|
||||
}
|
||||
}
|
||||
proxytunnel.CloseAllForwarders()
|
||||
// Close all Redis connections
|
||||
CloseAllRedisClients()
|
||||
logger.Infof("资源释放完成,应用已关闭")
|
||||
@@ -77,9 +79,8 @@ func getCacheKey(config connection.ConnectionConfig) string {
|
||||
if !config.UseSSH {
|
||||
config.SSH = connection.SSHConfig{}
|
||||
}
|
||||
// 保持与驱动默认一致,避免同一连接被重复缓存
|
||||
if config.Type == "postgres" && config.Database == "" {
|
||||
config.Database = "postgres"
|
||||
if !config.UseProxy {
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(config)
|
||||
@@ -175,6 +176,12 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
||||
if config.UseSSH {
|
||||
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
|
||||
}
|
||||
if config.UseProxy {
|
||||
b.WriteString(fmt.Sprintf(" 代理=%s://%s:%d", strings.ToLower(strings.TrimSpace(config.Proxy.Type)), config.Proxy.Host, config.Proxy.Port))
|
||||
if strings.TrimSpace(config.Proxy.User) != "" {
|
||||
b.WriteString(" 代理认证=已配置")
|
||||
}
|
||||
}
|
||||
|
||||
if config.Type == "custom" {
|
||||
driver := strings.TrimSpace(config.Driver)
|
||||
@@ -201,15 +208,17 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro
|
||||
}
|
||||
|
||||
func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
|
||||
key := getCacheKey(config)
|
||||
effectiveConfig := applyGlobalProxyToConnection(config)
|
||||
|
||||
key := getCacheKey(effectiveConfig)
|
||||
shortKey := key
|
||||
if len(shortKey) > 12 {
|
||||
shortKey = shortKey[:12]
|
||||
}
|
||||
|
||||
if supported, reason := db.DriverRuntimeSupportStatus(config.Type); !supported {
|
||||
if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(config.Type))
|
||||
reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type))
|
||||
}
|
||||
// Best-effort cleanup: if cached instance exists for this exact config, close it.
|
||||
a.mu.Lock()
|
||||
@@ -247,7 +256,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
a.mu.Unlock()
|
||||
return entry.inst, nil
|
||||
} else {
|
||||
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
}
|
||||
|
||||
// Ping failed: remove cached instance (best effort)
|
||||
@@ -261,17 +270,24 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", config.Type, shortKey)
|
||||
dbInst, err := db.NewDatabase(config.Type)
|
||||
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
|
||||
dbInst, err := db.NewDatabase(effectiveConfig.Type)
|
||||
if err != nil {
|
||||
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", config.Type, shortKey)
|
||||
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, shortKey)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := dbInst.Connect(config); err != nil {
|
||||
wrapped := wrapConnectError(config, err)
|
||||
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
connectConfig, proxyErr := resolveDialConfigWithProxy(effectiveConfig)
|
||||
if proxyErr != nil {
|
||||
wrapped := wrapConnectError(effectiveConfig, proxyErr)
|
||||
logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
if err := dbInst.Connect(connectConfig); err != nil {
|
||||
wrapped := wrapConnectError(effectiveConfig, err)
|
||||
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
@@ -287,6 +303,6 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
|
||||
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
|
||||
a.mu.Unlock()
|
||||
|
||||
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)
|
||||
return dbInst, nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func normalizeRunConfig(config connection.ConnectionConfig, dbName string) conne
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(config.Type)) {
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "mongodb", "tdengine", "clickhouse":
|
||||
// 这些类型的 dbName 表示"数据库",需要写入连接配置以选择目标库。
|
||||
runConfig.Database = name
|
||||
case "dameng":
|
||||
|
||||
204
internal/app/db_proxy.go
Normal file
204
internal/app/db_proxy.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||||
)
|
||||
|
||||
func resolveDialConfigWithProxy(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
|
||||
config := raw
|
||||
if !config.UseProxy {
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
normalizedProxy, err := proxytunnel.NormalizeConfig(config.Proxy)
|
||||
if err != nil {
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
config.Proxy = normalizedProxy
|
||||
|
||||
if config.UseSSH {
|
||||
sshPort := config.SSH.Port
|
||||
if sshPort <= 0 {
|
||||
sshPort = 22
|
||||
}
|
||||
forwardedSSH, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.SSH.Host), sshPort)
|
||||
if err != nil {
|
||||
return connection.ConnectionConfig{}, fmt.Errorf("代理连接 SSH 网关失败:%w", err)
|
||||
}
|
||||
config.SSH.Host = forwardedSSH.host
|
||||
config.SSH.Port = forwardedSSH.port
|
||||
config.UseProxy = false
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
normalizedType := strings.ToLower(strings.TrimSpace(config.Type))
|
||||
if normalizedType == "sqlite" || normalizedType == "duckdb" || normalizedType == "custom" {
|
||||
// 文件型/自定义 DSN 类型不走标准 host:port,不在此层改写。
|
||||
return config, nil
|
||||
}
|
||||
if normalizedType == "mongodb" && config.MongoSRV {
|
||||
// Mongo SRV 由驱动侧 Dialer 处理代理,避免破坏 DNS SRV 拓扑发现。
|
||||
return config, nil
|
||||
}
|
||||
|
||||
targetPort := config.Port
|
||||
if targetPort <= 0 {
|
||||
targetPort = defaultPortByType(normalizedType)
|
||||
}
|
||||
forwardedPrimary, err := buildProxyForwardAddress(normalizedProxy, strings.TrimSpace(config.Host), targetPort)
|
||||
if err != nil {
|
||||
return connection.ConnectionConfig{}, err
|
||||
}
|
||||
config.Host = forwardedPrimary.host
|
||||
config.Port = forwardedPrimary.port
|
||||
|
||||
if len(config.Hosts) > 0 {
|
||||
rewritten := make([]string, 0, len(config.Hosts))
|
||||
seen := make(map[string]struct{}, len(config.Hosts))
|
||||
for _, rawEntry := range config.Hosts {
|
||||
targetHost, targetPort, ok := parseAddressWithDefaultPort(rawEntry, defaultPortByType(normalizedType))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
forwarded, forwardErr := buildProxyForwardAddress(normalizedProxy, targetHost, targetPort)
|
||||
if forwardErr != nil {
|
||||
return connection.ConnectionConfig{}, forwardErr
|
||||
}
|
||||
rewrittenAddress := formatHostPort(forwarded.host, forwarded.port)
|
||||
if _, exists := seen[rewrittenAddress]; exists {
|
||||
continue
|
||||
}
|
||||
seen[rewrittenAddress] = struct{}{}
|
||||
rewritten = append(rewritten, rewrittenAddress)
|
||||
}
|
||||
config.Hosts = rewritten
|
||||
}
|
||||
|
||||
config.UseProxy = false
|
||||
config.Proxy = connection.ProxyConfig{}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type hostPort struct {
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func buildProxyForwardAddress(proxyConfig connection.ProxyConfig, targetHost string, targetPort int) (hostPort, error) {
|
||||
host := strings.TrimSpace(targetHost)
|
||||
if host == "" {
|
||||
host = "localhost"
|
||||
}
|
||||
port := targetPort
|
||||
if port <= 0 {
|
||||
return hostPort{}, fmt.Errorf("目标端口无效:%d", targetPort)
|
||||
}
|
||||
|
||||
forwarder, err := proxytunnel.GetOrCreateLocalForwarder(proxyConfig, host, port)
|
||||
if err != nil {
|
||||
return hostPort{}, err
|
||||
}
|
||||
localHost, localPort, splitOK := parseAddressWithDefaultPort(forwarder.LocalAddr, 0)
|
||||
if !splitOK || localPort <= 0 {
|
||||
return hostPort{}, fmt.Errorf("解析代理本地转发地址失败:%s", forwarder.LocalAddr)
|
||||
}
|
||||
return hostPort{host: localHost, port: localPort}, nil
|
||||
}
|
||||
|
||||
func parseAddressWithDefaultPort(raw string, defaultPort int) (string, int, bool) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(text, "[") {
|
||||
if host, portText, err := net.SplitHostPort(text); err == nil {
|
||||
if port, convErr := strconv.Atoi(portText); convErr == nil && port > 0 && port <= 65535 {
|
||||
return strings.TrimSpace(host), port, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
trimmed := strings.Trim(strings.TrimPrefix(text, "["), "]")
|
||||
if trimmed != "" && defaultPort > 0 {
|
||||
return trimmed, defaultPort, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
if strings.Count(text, ":") == 0 {
|
||||
if defaultPort <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
return text, defaultPort, true
|
||||
}
|
||||
|
||||
if strings.Count(text, ":") == 1 {
|
||||
host, portText, err := net.SplitHostPort(text)
|
||||
if err == nil {
|
||||
port, convErr := strconv.Atoi(portText)
|
||||
if convErr == nil && port > 0 && port <= 65535 {
|
||||
return strings.TrimSpace(host), port, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
if defaultPort > 0 {
|
||||
return strings.TrimSpace(text), defaultPort, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// IPv6 地址未带端口,使用默认端口。
|
||||
if defaultPort > 0 {
|
||||
return text, defaultPort, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
func formatHostPort(host string, port int) string {
|
||||
h := strings.TrimSpace(host)
|
||||
if strings.Contains(h, ":") && !strings.HasPrefix(h, "[") {
|
||||
return fmt.Sprintf("[%s]:%d", h, port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", h, port)
|
||||
}
|
||||
|
||||
func defaultPortByType(driverType string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(driverType)) {
|
||||
case "mysql", "mariadb":
|
||||
return 3306
|
||||
case "diros":
|
||||
return 9030
|
||||
case "sphinx":
|
||||
return 9306
|
||||
case "postgres", "vastbase":
|
||||
return 5432
|
||||
case "redis":
|
||||
return 6379
|
||||
case "tdengine":
|
||||
return 6041
|
||||
case "oracle":
|
||||
return 1521
|
||||
case "dameng":
|
||||
return 5236
|
||||
case "kingbase":
|
||||
return 54321
|
||||
case "sqlserver":
|
||||
return 1433
|
||||
case "mongodb":
|
||||
return 27017
|
||||
case "clickhouse":
|
||||
return 9000
|
||||
case "highgo":
|
||||
return 5866
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
191
internal/app/global_proxy.go
Normal file
191
internal/app/global_proxy.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||||
)
|
||||
|
||||
type globalProxySnapshot struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Proxy connection.ProxyConfig `json:"proxy"`
|
||||
}
|
||||
|
||||
var globalProxyRuntime = struct {
|
||||
mu sync.RWMutex
|
||||
enabled bool
|
||||
proxy connection.ProxyConfig
|
||||
}{}
|
||||
|
||||
func currentGlobalProxyConfig() globalProxySnapshot {
|
||||
globalProxyRuntime.mu.RLock()
|
||||
defer globalProxyRuntime.mu.RUnlock()
|
||||
if !globalProxyRuntime.enabled {
|
||||
return globalProxySnapshot{
|
||||
Enabled: false,
|
||||
Proxy: connection.ProxyConfig{},
|
||||
}
|
||||
}
|
||||
return globalProxySnapshot{
|
||||
Enabled: true,
|
||||
Proxy: globalProxyRuntime.proxy,
|
||||
}
|
||||
}
|
||||
|
||||
func setGlobalProxyConfig(enabled bool, proxyConfig connection.ProxyConfig) (globalProxySnapshot, error) {
|
||||
if !enabled {
|
||||
globalProxyRuntime.mu.Lock()
|
||||
globalProxyRuntime.enabled = false
|
||||
globalProxyRuntime.proxy = connection.ProxyConfig{}
|
||||
globalProxyRuntime.mu.Unlock()
|
||||
return currentGlobalProxyConfig(), nil
|
||||
}
|
||||
|
||||
normalizedProxy, err := proxytunnel.NormalizeConfig(proxyConfig)
|
||||
if err != nil {
|
||||
return globalProxySnapshot{}, err
|
||||
}
|
||||
|
||||
globalProxyRuntime.mu.Lock()
|
||||
globalProxyRuntime.enabled = true
|
||||
globalProxyRuntime.proxy = normalizedProxy
|
||||
globalProxyRuntime.mu.Unlock()
|
||||
return currentGlobalProxyConfig(), nil
|
||||
}
|
||||
|
||||
func (a *App) ConfigureGlobalProxy(enabled bool, proxyConfig connection.ProxyConfig) connection.QueryResult {
|
||||
snapshot, err := setGlobalProxyConfig(enabled, proxyConfig)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
if snapshot.Enabled {
|
||||
authState := ""
|
||||
if strings.TrimSpace(snapshot.Proxy.User) != "" {
|
||||
authState = "(认证:已配置)"
|
||||
}
|
||||
logger.Infof(
|
||||
"全局代理已启用:%s://%s:%d%s",
|
||||
strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)),
|
||||
strings.TrimSpace(snapshot.Proxy.Host),
|
||||
snapshot.Proxy.Port,
|
||||
authState,
|
||||
)
|
||||
} else {
|
||||
logger.Infof("全局代理已关闭")
|
||||
}
|
||||
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: "全局代理配置已生效",
|
||||
Data: snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) GetGlobalProxyConfig() connection.QueryResult {
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: "OK",
|
||||
Data: currentGlobalProxyConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func applyGlobalProxyToConnection(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
effective := config
|
||||
if effective.UseProxy {
|
||||
return effective
|
||||
}
|
||||
if isFileDatabaseType(effective.Type) {
|
||||
effective.Proxy = connection.ProxyConfig{}
|
||||
return effective
|
||||
}
|
||||
|
||||
snapshot := currentGlobalProxyConfig()
|
||||
if !snapshot.Enabled {
|
||||
effective.Proxy = connection.ProxyConfig{}
|
||||
return effective
|
||||
}
|
||||
|
||||
effective.UseProxy = true
|
||||
effective.Proxy = snapshot.Proxy
|
||||
return effective
|
||||
}
|
||||
|
||||
func isFileDatabaseType(driverType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(driverType)) {
|
||||
case "sqlite", "duckdb":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPClientWithGlobalProxy(timeout time.Duration) *http.Client {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
if transport := buildHTTPTransportWithGlobalProxy(); transport != nil {
|
||||
client.Transport = transport
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func buildHTTPTransportWithGlobalProxy() *http.Transport {
|
||||
baseTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok || baseTransport == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
transport := baseTransport.Clone()
|
||||
snapshot := currentGlobalProxyConfig()
|
||||
if !snapshot.Enabled {
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
return transport
|
||||
}
|
||||
|
||||
proxyURL, err := buildProxyURLFromConfig(snapshot.Proxy)
|
||||
if err != nil {
|
||||
logger.Warnf("全局代理配置无效,回退系统代理:%v", err)
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
return transport
|
||||
}
|
||||
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
return transport
|
||||
}
|
||||
|
||||
func buildProxyURLFromConfig(proxyConfig connection.ProxyConfig) (*url.URL, error) {
|
||||
normalizedProxy, err := proxytunnel.NormalizeConfig(proxyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxyType := strings.ToLower(strings.TrimSpace(normalizedProxy.Type))
|
||||
if proxyType != "http" && proxyType != "socks5" {
|
||||
return nil, fmt.Errorf("不支持的代理类型:%s", normalizedProxy.Type)
|
||||
}
|
||||
if strings.TrimSpace(normalizedProxy.Host) == "" {
|
||||
return nil, fmt.Errorf("代理地址不能为空")
|
||||
}
|
||||
if normalizedProxy.Port <= 0 || normalizedProxy.Port > 65535 {
|
||||
return nil, fmt.Errorf("代理端口无效:%d", normalizedProxy.Port)
|
||||
}
|
||||
|
||||
proxyURL := &url.URL{
|
||||
Scheme: proxyType,
|
||||
Host: net.JoinHostPort(strings.TrimSpace(normalizedProxy.Host), strconv.Itoa(normalizedProxy.Port)),
|
||||
}
|
||||
if strings.TrimSpace(normalizedProxy.User) != "" {
|
||||
proxyURL.User = url.UserPassword(strings.TrimSpace(normalizedProxy.User), normalizedProxy.Password)
|
||||
}
|
||||
return proxyURL, nil
|
||||
}
|
||||
@@ -88,6 +88,8 @@ func (a *App) CreateDatabase(config connection.ConnectionConfig, dbName string)
|
||||
query = fmt.Sprintf("CREATE DATABASE \"%s\"", escapedDbName)
|
||||
} else if dbType == "tdengine" {
|
||||
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
|
||||
} else if dbType == "clickhouse" {
|
||||
query = fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", quoteIdentByType(dbType, dbName))
|
||||
} else if dbType == "mariadb" || dbType == "diros" {
|
||||
// MariaDB uses same syntax as MySQL
|
||||
} else if dbType == "sphinx" {
|
||||
@@ -162,7 +164,7 @@ func buildRunConfigForDDL(config connection.ConnectionConfig, dbType string, dbN
|
||||
if strings.EqualFold(strings.TrimSpace(config.Type), "custom") {
|
||||
// custom 连接的 dbName 语义依赖 driver,尽量在常见驱动上对齐内置类型行为。
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "vastbase", "dameng":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "vastbase", "dameng", "clickhouse":
|
||||
if strings.TrimSpace(dbName) != "" {
|
||||
runConfig.Database = strings.TrimSpace(dbName)
|
||||
}
|
||||
@@ -190,9 +192,6 @@ func (a *App) RenameDatabase(config connection.ConnectionConfig, oldName string,
|
||||
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再重命名"}
|
||||
}
|
||||
runConfig := config
|
||||
if strings.TrimSpace(runConfig.Database) == "" {
|
||||
runConfig.Database = "postgres"
|
||||
}
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -219,7 +218,7 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
|
||||
sql string
|
||||
)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "tdengine":
|
||||
case "mysql", "mariadb", "diros", "tdengine", "clickhouse":
|
||||
runConfig = config
|
||||
runConfig.Database = ""
|
||||
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
|
||||
@@ -228,9 +227,6 @@ func (a *App) DropDatabase(config connection.ConnectionConfig, dbName string) co
|
||||
return connection.QueryResult{Success: false, Message: "当前连接正在使用目标数据库,请先连接到其他数据库后再删除"}
|
||||
}
|
||||
runConfig = config
|
||||
if strings.TrimSpace(runConfig.Database) == "" {
|
||||
runConfig.Database = "postgres"
|
||||
}
|
||||
sql = fmt.Sprintf("DROP DATABASE %s", quoteIdentByType(dbType, dbName))
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除数据库", dbType)}
|
||||
@@ -261,7 +257,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "clickhouse":
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持重命名表", dbType)}
|
||||
}
|
||||
@@ -275,7 +271,7 @@ func (a *App) RenameTable(config connection.ConnectionConfig, dbName string, old
|
||||
|
||||
var sql string
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "clickhouse":
|
||||
newQualifiedTable := quoteTableIdentByType(dbType, schemaName, newTableName)
|
||||
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualifiedTable, newQualifiedTable)
|
||||
case "sqlserver":
|
||||
@@ -307,7 +303,7 @@ func (a *App) DropTable(config connection.ConnectionConfig, dbName string, table
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "tdengine", "clickhouse":
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除表", dbType)}
|
||||
}
|
||||
@@ -669,7 +665,7 @@ func (a *App) DropView(config connection.ConnectionConfig, dbName string, viewNa
|
||||
|
||||
dbType := resolveDDLDBType(config)
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "sqlite", "duckdb", "oracle", "dameng", "highgo", "vastbase", "sqlserver", "clickhouse":
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前数据源(%s)暂不支持删除视图", dbType)}
|
||||
}
|
||||
@@ -758,7 +754,7 @@ func (a *App) RenameView(config connection.ConnectionConfig, dbName string, oldN
|
||||
|
||||
var sql string
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "clickhouse":
|
||||
newQualified := quoteTableIdentByType(dbType, schemaName, newName)
|
||||
sql = fmt.Sprintf("RENAME TABLE %s TO %s", oldQualified, newQualified)
|
||||
case "postgres", "kingbase", "highgo", "vastbase":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -701,7 +701,7 @@ func quoteIdentByType(dbType string, ident string) string {
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "mysql", "mariadb", "diros", "sphinx", "tdengine":
|
||||
case "mysql", "mariadb", "diros", "sphinx", "tdengine", "clickhouse":
|
||||
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
|
||||
case "sqlserver":
|
||||
escaped := strings.ReplaceAll(ident, "]", "]]")
|
||||
@@ -950,6 +950,15 @@ func buildListViewQueries(config connection.ConnectionConfig, dbName string) []s
|
||||
return []string{
|
||||
`SELECT table_schema AS schema_name, table_name AS object_name FROM information_schema.views WHERE table_schema NOT IN ('information_schema', 'pg_catalog') ORDER BY table_schema, table_name`,
|
||||
}
|
||||
case "clickhouse":
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []string{
|
||||
`SELECT database AS schema_name, name AS object_name FROM system.tables WHERE engine LIKE '%View%' ORDER BY database, name`,
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf(`SELECT database AS schema_name, name AS object_name FROM system.tables WHERE engine LIKE '%%View%%' AND database='%s' ORDER BY name`, escapedDbName),
|
||||
}
|
||||
default:
|
||||
if strings.TrimSpace(dbName) == "" {
|
||||
return []string{
|
||||
@@ -1070,6 +1079,18 @@ WHERE s.name = '%s' AND v.name = '%s'`,
|
||||
fmt.Sprintf("SELECT sql AS ddl FROM duckdb_views() WHERE view_name = '%s' AND schema_name = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
fmt.Sprintf("SELECT view_definition AS ddl FROM information_schema.views WHERE table_name = '%s' AND table_schema = '%s' LIMIT 1", escapedView, escapedSchema),
|
||||
}
|
||||
case "clickhouse":
|
||||
if safeSchema == "" {
|
||||
safeSchema = strings.TrimSpace(dbName)
|
||||
}
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
fmt.Sprintf("SHOW CREATE TABLE %s.%s", quoteIdentByType("clickhouse", safeSchema), quoteIdentByType("clickhouse", safeView)),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("SHOW CREATE TABLE %s", quoteIdentByType("clickhouse", safeView)),
|
||||
}
|
||||
default:
|
||||
if safeSchema != "" {
|
||||
return []string{
|
||||
|
||||
@@ -374,7 +374,7 @@ func getCurrentAuthor() string {
|
||||
}
|
||||
|
||||
func fetchLatestRelease() (*githubRelease, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
client := newHTTPClientWithGlobalProxy(15 * time.Second)
|
||||
req, err := http.NewRequest(http.MethodGet, updateAPIURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -451,7 +451,7 @@ func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) {
|
||||
return nil, errors.New("Release 未提供 SHA256SUMS")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
client := newHTTPClientWithGlobalProxy(15 * time.Second)
|
||||
req, err := http.NewRequest(http.MethodGet, checksumURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -522,7 +522,7 @@ func (w *downloadProgressWriter) Write(p []byte) (int, error) {
|
||||
}
|
||||
|
||||
func downloadFileWithHash(url, filePath string, onProgress func(downloaded, total int64)) (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
client := newHTTPClientWithGlobalProxy(10 * time.Minute)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -9,33 +9,44 @@ type SSHConfig struct {
|
||||
KeyPath string `json:"keyPath"`
|
||||
}
|
||||
|
||||
// ProxyConfig holds proxy connection details
|
||||
type ProxyConfig struct {
|
||||
Type string `json:"type"` // socks5 | http
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectionConfig holds database connection details including SSH
|
||||
type ConnectionConfig struct {
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
||||
Database string `json:"database"`
|
||||
UseSSH bool `json:"useSSH"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
Driver string `json:"driver,omitempty"` // For custom connection
|
||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
||||
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
||||
Topology string `json:"topology,omitempty"` // single | replica
|
||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
||||
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
||||
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
||||
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
||||
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
||||
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
|
||||
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
|
||||
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
|
||||
Type string `json:"type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
SavePassword bool `json:"savePassword,omitempty"` // Persist password in saved connection
|
||||
Database string `json:"database"`
|
||||
UseSSH bool `json:"useSSH"`
|
||||
SSH SSHConfig `json:"ssh"`
|
||||
UseProxy bool `json:"useProxy,omitempty"`
|
||||
Proxy ProxyConfig `json:"proxy,omitempty"`
|
||||
Driver string `json:"driver,omitempty"` // For custom connection
|
||||
DSN string `json:"dsn,omitempty"` // For custom connection
|
||||
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
|
||||
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
|
||||
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
|
||||
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
|
||||
Topology string `json:"topology,omitempty"` // single | replica
|
||||
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user
|
||||
MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` // MySQL replica auth password
|
||||
ReplicaSet string `json:"replicaSet,omitempty"` // MongoDB replica set name
|
||||
AuthSource string `json:"authSource,omitempty"` // MongoDB authSource
|
||||
ReadPreference string `json:"readPreference,omitempty"` // MongoDB readPreference
|
||||
MongoSRV bool `json:"mongoSrv,omitempty"` // MongoDB use mongodb+srv URI scheme
|
||||
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"` // MongoDB authMechanism
|
||||
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"` // MongoDB replica auth user
|
||||
MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` // MongoDB replica auth password
|
||||
}
|
||||
|
||||
// QueryResult is the standard response format for Wails methods
|
||||
|
||||
603
internal/db/clickhouse_impl.go
Normal file
603
internal/db/clickhouse_impl.go
Normal file
@@ -0,0 +1,603 @@
|
||||
//go:build gonavi_full_drivers || gonavi_clickhouse_driver
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/ssh"
|
||||
"GoNavi-Wails/internal/utils"
|
||||
|
||||
_ "github.com/ClickHouse/clickhouse-go/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClickHousePort = 9000
|
||||
defaultClickHouseUser = "default"
|
||||
defaultClickHouseDatabase = "default"
|
||||
)
|
||||
|
||||
type ClickHouseDB struct {
|
||||
conn *sql.DB
|
||||
pingTimeout time.Duration
|
||||
forwarder *ssh.LocalForwarder
|
||||
database string
|
||||
}
|
||||
|
||||
func normalizeClickHouseConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
normalized := applyClickHouseURI(config)
|
||||
if strings.TrimSpace(normalized.Host) == "" {
|
||||
normalized.Host = "localhost"
|
||||
}
|
||||
if normalized.Port <= 0 {
|
||||
normalized.Port = defaultClickHousePort
|
||||
}
|
||||
if strings.TrimSpace(normalized.User) == "" {
|
||||
normalized.User = defaultClickHouseUser
|
||||
}
|
||||
if strings.TrimSpace(normalized.Database) == "" {
|
||||
normalized.Database = defaultClickHouseDatabase
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func applyClickHouseURI(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
uriText := strings.TrimSpace(config.URI)
|
||||
if uriText == "" {
|
||||
return config
|
||||
}
|
||||
lowerURI := strings.ToLower(uriText)
|
||||
if !strings.HasPrefix(lowerURI, "clickhouse://") {
|
||||
return config
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(uriText)
|
||||
if err != nil {
|
||||
return config
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
if strings.TrimSpace(config.User) == "" {
|
||||
config.User = parsed.User.Username()
|
||||
}
|
||||
if pass, ok := parsed.User.Password(); ok && config.Password == "" {
|
||||
config.Password = pass
|
||||
}
|
||||
}
|
||||
|
||||
if dbName := strings.TrimPrefix(strings.TrimSpace(parsed.Path), "/"); dbName != "" && strings.TrimSpace(config.Database) == "" {
|
||||
config.Database = dbName
|
||||
}
|
||||
if strings.TrimSpace(config.Database) == "" {
|
||||
if dbName := strings.TrimSpace(parsed.Query().Get("database")); dbName != "" {
|
||||
config.Database = dbName
|
||||
}
|
||||
}
|
||||
|
||||
defaultPort := config.Port
|
||||
if defaultPort <= 0 {
|
||||
defaultPort = defaultClickHousePort
|
||||
}
|
||||
if strings.TrimSpace(config.Host) == "" {
|
||||
host, port, ok := parseHostPortWithDefault(parsed.Host, defaultPort)
|
||||
if ok {
|
||||
config.Host = host
|
||||
config.Port = port
|
||||
}
|
||||
}
|
||||
if config.Port <= 0 {
|
||||
config.Port = defaultPort
|
||||
}
|
||||
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), "/"),
|
||||
}
|
||||
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 {
|
||||
if supported, reason := DriverRuntimeSupportStatus("clickhouse"); !supported {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "ClickHouse 纯 Go 驱动未启用,请先在驱动管理中安装启用"
|
||||
}
|
||||
return fmt.Errorf("%s", reason)
|
||||
}
|
||||
|
||||
if c.forwarder != nil {
|
||||
_ = c.forwarder.Close()
|
||||
c.forwarder = nil
|
||||
}
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
|
||||
runConfig := normalizeClickHouseConfig(config)
|
||||
c.pingTimeout = getConnectTimeout(runConfig)
|
||||
c.database = runConfig.Database
|
||||
|
||||
if runConfig.UseSSH {
|
||||
logger.Infof("ClickHouse 使用 SSH 连接:地址=%s:%d 用户=%s", runConfig.Host, runConfig.Port, runConfig.User)
|
||||
forwarder, err := ssh.GetOrCreateLocalForwarder(runConfig.SSH, runConfig.Host, runConfig.Port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 SSH 隧道失败:%w", err)
|
||||
}
|
||||
c.forwarder = forwarder
|
||||
|
||||
host, portText, err := net.SplitHostPort(forwarder.LocalAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析本地转发地址失败:%w", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析本地端口失败:%w", err)
|
||||
}
|
||||
|
||||
runConfig.Host = host
|
||||
runConfig.Port = port
|
||||
runConfig.UseSSH = false
|
||||
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
|
||||
|
||||
if err := c.Ping(); err != nil {
|
||||
_ = c.Close()
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) Close() error {
|
||||
if c.forwarder != nil {
|
||||
if err := c.forwarder.Close(); err != nil {
|
||||
logger.Warnf("关闭 ClickHouse SSH 端口转发失败:%v", err)
|
||||
}
|
||||
c.forwarder = nil
|
||||
}
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) Ping() error {
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
timeout := c.pingTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
ctx, cancel := utils.ContextWithTimeout(timeout)
|
||||
defer cancel()
|
||||
return c.conn.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
if c.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
}
|
||||
rows, err := c.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
if c.conn == nil {
|
||||
return nil, nil, fmt.Errorf("connection not open")
|
||||
}
|
||||
rows, err := c.conn.Query(query)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRows(rows)
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
if c.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
}
|
||||
res, err := c.conn.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) Exec(query string) (int64, error) {
|
||||
if c.conn == nil {
|
||||
return 0, fmt.Errorf("connection not open")
|
||||
}
|
||||
res, err := c.conn.Exec(query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) GetDatabases() ([]string, error) {
|
||||
data, _, err := c.Query("SELECT name FROM system.databases ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(data))
|
||||
for _, row := range data {
|
||||
if val, ok := getClickHouseValueFromRow(row, "name", "database"); ok {
|
||||
result = append(result, fmt.Sprintf("%v", val))
|
||||
continue
|
||||
}
|
||||
for _, value := range row {
|
||||
result = append(result, fmt.Sprintf("%v", value))
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) GetTables(dbName string) ([]string, error) {
|
||||
targetDB := strings.TrimSpace(dbName)
|
||||
if targetDB == "" {
|
||||
targetDB = strings.TrimSpace(c.database)
|
||||
}
|
||||
|
||||
var query string
|
||||
if targetDB != "" {
|
||||
query = fmt.Sprintf(
|
||||
"SELECT name FROM system.tables WHERE database = '%s' ORDER BY name",
|
||||
escapeClickHouseSQLLiteral(targetDB),
|
||||
)
|
||||
} else {
|
||||
query = "SELECT database, name FROM system.tables ORDER BY database, name"
|
||||
}
|
||||
|
||||
data, _, err := c.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(data))
|
||||
for _, row := range data {
|
||||
if targetDB != "" {
|
||||
if val, ok := getClickHouseValueFromRow(row, "name", "table", "table_name"); ok {
|
||||
result = append(result, fmt.Sprintf("%v", val))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
databaseValue, hasDB := getClickHouseValueFromRow(row, "database", "schema_name")
|
||||
tableValue, hasTable := getClickHouseValueFromRow(row, "name", "table", "table_name")
|
||||
if hasDB && hasTable {
|
||||
result = append(result, fmt.Sprintf("%v.%v", databaseValue, tableValue))
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, value := range row {
|
||||
result = append(result, fmt.Sprintf("%v", value))
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) GetCreateStatement(dbName, tableName string) (string, error) {
|
||||
database, table, err := c.resolveDatabaseAndTable(dbName, tableName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SHOW CREATE TABLE %s.%s", quoteClickHouseIdentifier(database), quoteClickHouseIdentifier(table))
|
||||
data, _, err := c.Query(query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
}
|
||||
row := data[0]
|
||||
if val, ok := getClickHouseValueFromRow(row, "statement", "create_statement", "sql", "query"); ok {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", val))
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
|
||||
longest := ""
|
||||
for _, value := range row {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", value))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(text), "CREATE ") && len(text) > len(longest) {
|
||||
longest = text
|
||||
}
|
||||
}
|
||||
if longest != "" {
|
||||
return longest, nil
|
||||
}
|
||||
return "", fmt.Errorf("create statement not found")
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
|
||||
database, table, err := c.resolveDatabaseAndTable(dbName, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
name,
|
||||
type,
|
||||
default_kind,
|
||||
default_expression,
|
||||
is_in_primary_key,
|
||||
is_in_sorting_key,
|
||||
comment
|
||||
FROM system.columns
|
||||
WHERE database = '%s' AND table = '%s'
|
||||
ORDER BY position`,
|
||||
escapeClickHouseSQLLiteral(database),
|
||||
escapeClickHouseSQLLiteral(table),
|
||||
)
|
||||
data, _, err := c.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
columns := make([]connection.ColumnDefinition, 0, len(data))
|
||||
for _, row := range data {
|
||||
nameValue, _ := getClickHouseValueFromRow(row, "name", "column_name")
|
||||
typeValue, _ := getClickHouseValueFromRow(row, "type", "data_type")
|
||||
defaultKind, _ := getClickHouseValueFromRow(row, "default_kind")
|
||||
defaultExpr, hasDefault := getClickHouseValueFromRow(row, "default_expression", "column_default")
|
||||
commentValue, _ := getClickHouseValueFromRow(row, "comment")
|
||||
inPrimary, _ := getClickHouseValueFromRow(row, "is_in_primary_key")
|
||||
inSorting, _ := getClickHouseValueFromRow(row, "is_in_sorting_key")
|
||||
|
||||
colType := strings.TrimSpace(fmt.Sprintf("%v", typeValue))
|
||||
nullable := "NO"
|
||||
if strings.HasPrefix(strings.ToLower(colType), "nullable(") {
|
||||
nullable = "YES"
|
||||
}
|
||||
|
||||
key := ""
|
||||
if isClickHouseTruthy(inPrimary) {
|
||||
key = "PRI"
|
||||
} else if isClickHouseTruthy(inSorting) {
|
||||
key = "MUL"
|
||||
}
|
||||
|
||||
extra := ""
|
||||
kindText := strings.ToUpper(strings.TrimSpace(fmt.Sprintf("%v", defaultKind)))
|
||||
if kindText != "" && kindText != "DEFAULT" {
|
||||
extra = kindText
|
||||
}
|
||||
|
||||
col := connection.ColumnDefinition{
|
||||
Name: strings.TrimSpace(fmt.Sprintf("%v", nameValue)),
|
||||
Type: colType,
|
||||
Nullable: nullable,
|
||||
Key: key,
|
||||
Extra: extra,
|
||||
Comment: strings.TrimSpace(fmt.Sprintf("%v", commentValue)),
|
||||
}
|
||||
if hasDefault && defaultExpr != nil {
|
||||
text := strings.TrimSpace(fmt.Sprintf("%v", defaultExpr))
|
||||
if text != "" {
|
||||
col.Default = &text
|
||||
}
|
||||
}
|
||||
columns = append(columns, col)
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
targetDB := strings.TrimSpace(dbName)
|
||||
if targetDB == "" {
|
||||
targetDB = strings.TrimSpace(c.database)
|
||||
}
|
||||
|
||||
var query string
|
||||
if targetDB != "" {
|
||||
query = fmt.Sprintf(`
|
||||
SELECT
|
||||
database,
|
||||
table,
|
||||
name,
|
||||
type
|
||||
FROM system.columns
|
||||
WHERE database = '%s'
|
||||
ORDER BY table, position`,
|
||||
escapeClickHouseSQLLiteral(targetDB),
|
||||
)
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
database,
|
||||
table,
|
||||
name,
|
||||
type
|
||||
FROM system.columns
|
||||
WHERE database NOT IN ('system', 'information_schema', 'INFORMATION_SCHEMA')
|
||||
ORDER BY database, table, position`
|
||||
}
|
||||
|
||||
data, _, err := c.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]connection.ColumnDefinitionWithTable, 0, len(data))
|
||||
for _, row := range data {
|
||||
databaseValue, _ := getClickHouseValueFromRow(row, "database")
|
||||
tableValue, hasTable := getClickHouseValueFromRow(row, "table", "table_name")
|
||||
nameValue, hasName := getClickHouseValueFromRow(row, "name", "column_name")
|
||||
typeValue, _ := getClickHouseValueFromRow(row, "type", "data_type")
|
||||
if !hasTable || !hasName {
|
||||
continue
|
||||
}
|
||||
|
||||
tableName := strings.TrimSpace(fmt.Sprintf("%v", tableValue))
|
||||
if targetDB == "" {
|
||||
dbText := strings.TrimSpace(fmt.Sprintf("%v", databaseValue))
|
||||
if dbText != "" {
|
||||
tableName = dbText + "." + tableName
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, connection.ColumnDefinitionWithTable{
|
||||
TableName: tableName,
|
||||
Name: strings.TrimSpace(fmt.Sprintf("%v", nameValue)),
|
||||
Type: strings.TrimSpace(fmt.Sprintf("%v", typeValue)),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
|
||||
return []connection.IndexDefinition{}, nil
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
|
||||
return []connection.ForeignKeyDefinition{}, nil
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return []connection.TriggerDefinition{}, nil
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) resolveDatabaseAndTable(dbName, tableName string) (string, string, error) {
|
||||
rawTable := strings.TrimSpace(tableName)
|
||||
if rawTable == "" {
|
||||
return "", "", fmt.Errorf("table name required")
|
||||
}
|
||||
|
||||
resolvedDB := strings.TrimSpace(dbName)
|
||||
resolvedTable := rawTable
|
||||
if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 {
|
||||
if dbPart := normalizeClickHouseIdentifierPart(parts[0]); dbPart != "" {
|
||||
resolvedDB = dbPart
|
||||
}
|
||||
resolvedTable = normalizeClickHouseIdentifierPart(parts[1])
|
||||
} else {
|
||||
resolvedTable = normalizeClickHouseIdentifierPart(rawTable)
|
||||
}
|
||||
|
||||
if resolvedDB == "" {
|
||||
resolvedDB = strings.TrimSpace(c.database)
|
||||
}
|
||||
if resolvedDB == "" {
|
||||
resolvedDB = defaultClickHouseDatabase
|
||||
}
|
||||
if resolvedTable == "" {
|
||||
return "", "", fmt.Errorf("table name required")
|
||||
}
|
||||
return resolvedDB, resolvedTable, nil
|
||||
}
|
||||
|
||||
func normalizeClickHouseIdentifierPart(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
if len(text) >= 2 {
|
||||
first := text[0]
|
||||
last := text[len(text)-1]
|
||||
if (first == '`' && last == '`') || (first == '"' && last == '"') {
|
||||
text = text[1 : len(text)-1]
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func quoteClickHouseIdentifier(raw string) string {
|
||||
return "`" + strings.ReplaceAll(strings.TrimSpace(raw), "`", "``") + "`"
|
||||
}
|
||||
|
||||
func escapeClickHouseSQLLiteral(raw string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(raw), "'", "''")
|
||||
}
|
||||
|
||||
func getClickHouseValueFromRow(row map[string]interface{}, keys ...string) (interface{}, bool) {
|
||||
if len(row) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value, ok := row[key]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
for existingKey, value := range row {
|
||||
for _, key := range keys {
|
||||
if strings.EqualFold(existingKey, key) {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func isClickHouseTruthy(value interface{}) bool {
|
||||
switch val := value.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case int:
|
||||
return val != 0
|
||||
case int8:
|
||||
return val != 0
|
||||
case int16:
|
||||
return val != 0
|
||||
case int32:
|
||||
return val != 0
|
||||
case int64:
|
||||
return val != 0
|
||||
case uint:
|
||||
return val != 0
|
||||
case uint8:
|
||||
return val != 0
|
||||
case uint16:
|
||||
return val != 0
|
||||
case uint32:
|
||||
return val != 0
|
||||
case uint64:
|
||||
return val != 0
|
||||
case string:
|
||||
normalized := strings.ToLower(strings.TrimSpace(val))
|
||||
return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "y"
|
||||
default:
|
||||
normalized := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", value)))
|
||||
return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "y"
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,5 @@ func registerOptionalDatabaseFactories() {
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse")
|
||||
}
|
||||
|
||||
@@ -15,4 +15,5 @@ func registerOptionalDatabaseFactories() {
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine")
|
||||
registerDatabaseFactory(newOptionalDriverAgentDatabase("clickhouse"), "clickhouse")
|
||||
}
|
||||
|
||||
@@ -18,18 +18,19 @@ var coreBuiltinDrivers = map[string]struct{}{
|
||||
// optionalGoDrivers 表示需要用户“安装启用”后才能使用的纯 Go 驱动。
|
||||
// 注意:这是一种运行时门控(installed.json 标记),并不减少主二进制体积。
|
||||
var optionalGoDrivers = map[string]struct{}{
|
||||
"mariadb": {},
|
||||
"diros": {},
|
||||
"sphinx": {},
|
||||
"sqlserver": {},
|
||||
"sqlite": {},
|
||||
"duckdb": {},
|
||||
"dameng": {},
|
||||
"kingbase": {},
|
||||
"highgo": {},
|
||||
"vastbase": {},
|
||||
"mongodb": {},
|
||||
"tdengine": {},
|
||||
"mariadb": {},
|
||||
"diros": {},
|
||||
"sphinx": {},
|
||||
"sqlserver": {},
|
||||
"sqlite": {},
|
||||
"duckdb": {},
|
||||
"dameng": {},
|
||||
"kingbase": {},
|
||||
"highgo": {},
|
||||
"vastbase": {},
|
||||
"mongodb": {},
|
||||
"tdengine": {},
|
||||
"clickhouse": {},
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -83,6 +84,8 @@ func driverDisplayName(driverType string) string {
|
||||
return "MongoDB"
|
||||
case "tdengine":
|
||||
return "TDengine"
|
||||
case "clickhouse":
|
||||
return "ClickHouse"
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(driverType))
|
||||
}
|
||||
|
||||
@@ -114,3 +114,30 @@ func TestTDengineDSN_UsesWebSocketFormat(t *testing.T) {
|
||||
t.Fatalf("tdengine dsn 格式不正确:%s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClickHouseDSN_EscapesPasswordAndSetsTimeout(t *testing.T) {
|
||||
c := &ClickHouseDB{}
|
||||
cfg := normalizeClickHouseConfig(connection.ConnectionConfig{
|
||||
Type: "clickhouse",
|
||||
Host: "127.0.0.1",
|
||||
Port: 9000,
|
||||
User: "default",
|
||||
Password: "p@ss:wo/rd",
|
||||
Database: "analytics",
|
||||
Timeout: 15,
|
||||
})
|
||||
|
||||
dsn := c.getDSN(cfg)
|
||||
if strings.Contains(dsn, cfg.Password) {
|
||||
t.Fatalf("dsn 包含原始密码:%s", dsn)
|
||||
}
|
||||
if !strings.Contains(dsn, "p%40ss%3Awo%2Frd") {
|
||||
t.Fatalf("dsn 未正确转义密码:%s", dsn)
|
||||
}
|
||||
if !strings.Contains(dsn, "dial_timeout=15s") {
|
||||
t.Fatalf("dsn 缺少 dial_timeout 参数:%s", dsn)
|
||||
}
|
||||
if !strings.Contains(dsn, "/analytics") {
|
||||
t.Fatalf("dsn 缺少数据库路径:%s", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
proxytunnel "GoNavi-Wails/internal/proxy"
|
||||
"GoNavi-Wails/internal/ssh"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -29,6 +30,14 @@ type MongoDB struct {
|
||||
forwarder *ssh.LocalForwarder
|
||||
}
|
||||
|
||||
type mongoProxyDialer struct {
|
||||
proxyConfig connection.ProxyConfig
|
||||
}
|
||||
|
||||
func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return proxytunnel.DialContext(ctx, d.proxyConfig, network, address)
|
||||
}
|
||||
|
||||
const defaultMongoPort = 27017
|
||||
|
||||
func normalizeMongoAddress(host string, port int) string {
|
||||
@@ -328,6 +337,9 @@ func (m *MongoDB) Connect(config connection.ConnectionConfig) error {
|
||||
|
||||
uri := m.getURI(attemptConfig)
|
||||
clientOpts := options.Client().ApplyURI(uri)
|
||||
if attemptConfig.UseProxy {
|
||||
clientOpts.SetDialer(&mongoProxyDialer{proxyConfig: attemptConfig.Proxy})
|
||||
}
|
||||
client, err := mongo.Connect(clientOpts)
|
||||
if err != nil {
|
||||
errorDetails = append(errorDetails, fmt.Sprintf("%s连接失败: %v", authLabel, err))
|
||||
|
||||
48
internal/db/postgres_connect_test.go
Normal file
48
internal/db/postgres_connect_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestResolvePostgresConnectDatabases_ExplicitDatabase(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "postgres",
|
||||
Database: "analytics",
|
||||
User: "app_user",
|
||||
}
|
||||
|
||||
got := resolvePostgresConnectDatabases(cfg)
|
||||
want := []string{"analytics"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePostgresConnectDatabases_FallbackOrder(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "postgres",
|
||||
User: "app_user",
|
||||
}
|
||||
|
||||
got := resolvePostgresConnectDatabases(cfg)
|
||||
want := []string{"postgres", "template1", "app_user"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePostgresConnectDatabases_DeduplicateUserDefault(t *testing.T) {
|
||||
cfg := connection.ConnectionConfig{
|
||||
Type: "postgres",
|
||||
User: "postgres",
|
||||
}
|
||||
|
||||
got := resolvePostgresConnectDatabases(cfg)
|
||||
want := []string{"postgres", "template1"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unexpected databases, got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,30 @@ type PostgresDB struct {
|
||||
forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder
|
||||
}
|
||||
|
||||
func resolvePostgresConnectDatabases(config connection.ConnectionConfig) []string {
|
||||
explicit := strings.TrimSpace(config.Database)
|
||||
if explicit != "" {
|
||||
return []string{explicit}
|
||||
}
|
||||
|
||||
candidates := []string{"postgres", "template1", strings.TrimSpace(config.User)}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
result := make([]string, 0, len(candidates))
|
||||
for _, name := range candidates {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
normalized := strings.ToLower(trimmed)
|
||||
if _, exists := seen[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string {
|
||||
// postgres://user:password@host:port/dbname?sslmode=disable
|
||||
dbname := config.Database
|
||||
@@ -53,8 +77,23 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
||||
return fmt.Errorf("%s", reason)
|
||||
}
|
||||
|
||||
var dsn string
|
||||
var err error
|
||||
runConfig := config
|
||||
p.pingTimeout = getConnectTimeout(config)
|
||||
|
||||
cleanupOnFailure := true
|
||||
defer func() {
|
||||
if !cleanupOnFailure {
|
||||
return
|
||||
}
|
||||
if p.conn != nil {
|
||||
_ = p.conn.Close()
|
||||
p.conn = nil
|
||||
}
|
||||
if p.forwarder != nil {
|
||||
_ = p.forwarder.Close()
|
||||
p.forwarder = nil
|
||||
}
|
||||
}()
|
||||
|
||||
if config.UseSSH {
|
||||
// Create SSH tunnel with local port forwarding
|
||||
@@ -83,24 +122,44 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error {
|
||||
localConfig.Port = port
|
||||
localConfig.UseSSH = false // Disable SSH flag for DSN generation
|
||||
|
||||
dsn = p.getDSN(localConfig)
|
||||
runConfig = localConfig
|
||||
logger.Infof("PostgreSQL 通过本地端口转发连接:%s -> %s:%d", forwarder.LocalAddr, config.Host, config.Port)
|
||||
} else {
|
||||
dsn = p.getDSN(config)
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开数据库连接失败:%w", err)
|
||||
}
|
||||
p.conn = db
|
||||
p.pingTimeout = getConnectTimeout(config)
|
||||
attemptDBs := resolvePostgresConnectDatabases(runConfig)
|
||||
var failures []string
|
||||
for _, dbName := range attemptDBs {
|
||||
attemptConfig := runConfig
|
||||
attemptConfig.Database = dbName
|
||||
dsn := p.getDSN(attemptConfig)
|
||||
|
||||
// Force verification
|
||||
if err := p.Ping(); err != nil {
|
||||
return fmt.Errorf("连接建立后验证失败:%w", err)
|
||||
dbConn, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Sprintf("数据库=%s 打开连接失败: %v", dbName, err))
|
||||
continue
|
||||
}
|
||||
p.conn = dbConn
|
||||
|
||||
// Force verification
|
||||
if err := p.Ping(); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("数据库=%s 验证失败: %v", dbName, err))
|
||||
_ = dbConn.Close()
|
||||
p.conn = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(config.Database) == "" && !strings.EqualFold(dbName, "postgres") {
|
||||
logger.Infof("PostgreSQL 自动选择连接数据库:%s", dbName)
|
||||
}
|
||||
|
||||
cleanupOnFailure = false
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
|
||||
if len(failures) == 0 {
|
||||
return fmt.Errorf("连接建立后验证失败:未找到可用的连接数据库")
|
||||
}
|
||||
return fmt.Errorf("连接建立后验证失败:%s", strings.Join(failures, ";"))
|
||||
}
|
||||
|
||||
func (p *PostgresDB) Close() error {
|
||||
|
||||
344
internal/proxy/proxy.go
Normal file
344
internal/proxy/proxy.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
|
||||
xproxy "golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDialTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
type LocalForwarder struct {
|
||||
LocalAddr string
|
||||
RemoteAddr string
|
||||
ProxyAddr string
|
||||
ProxyType string
|
||||
|
||||
cfg connection.ProxyConfig
|
||||
listener net.Listener
|
||||
closeChan chan struct{}
|
||||
closeOnce sync.Once
|
||||
|
||||
closed bool
|
||||
closedMu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
forwarderMu sync.RWMutex
|
||||
localForwarders = make(map[string]*LocalForwarder)
|
||||
)
|
||||
|
||||
func NormalizeConfig(config connection.ProxyConfig) (connection.ProxyConfig, error) {
|
||||
result := connection.ProxyConfig{
|
||||
Type: strings.ToLower(strings.TrimSpace(config.Type)),
|
||||
Host: strings.TrimSpace(config.Host),
|
||||
Port: config.Port,
|
||||
User: strings.TrimSpace(config.User),
|
||||
Password: config.Password,
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case "socks5", "socks5h", "http":
|
||||
default:
|
||||
return result, fmt.Errorf("不支持的代理类型:%s", config.Type)
|
||||
}
|
||||
if result.Type == "socks5h" {
|
||||
result.Type = "socks5"
|
||||
}
|
||||
if result.Host == "" {
|
||||
return result, fmt.Errorf("代理主机为空")
|
||||
}
|
||||
if result.Port <= 0 || result.Port > 65535 {
|
||||
return result, fmt.Errorf("代理端口无效:%d", result.Port)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetOrCreateLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
|
||||
cfg, err := NormalizeConfig(proxyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(remoteHost) == "" || remotePort <= 0 {
|
||||
return nil, fmt.Errorf("无效的远端地址:%s:%d", remoteHost, remotePort)
|
||||
}
|
||||
|
||||
key := forwarderCacheKey(cfg, remoteHost, remotePort)
|
||||
forwarderMu.RLock()
|
||||
forwarder, exists := localForwarders[key]
|
||||
forwarderMu.RUnlock()
|
||||
if exists && forwarder != nil && !forwarder.IsClosed() {
|
||||
return forwarder, nil
|
||||
}
|
||||
|
||||
if exists {
|
||||
forwarderMu.Lock()
|
||||
delete(localForwarders, key)
|
||||
forwarderMu.Unlock()
|
||||
}
|
||||
|
||||
next, err := NewLocalForwarder(cfg, remoteHost, remotePort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
forwarderMu.Lock()
|
||||
localForwarders[key] = next
|
||||
forwarderMu.Unlock()
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func forwarderCacheKey(cfg connection.ProxyConfig, remoteHost string, remotePort int) string {
|
||||
trimmedHost := strings.TrimSpace(remoteHost)
|
||||
credential := cfg.User + "\x00" + cfg.Password
|
||||
credentialHash := sha256.Sum256([]byte(credential))
|
||||
// 仅保留短指纹用于区分不同认证信息,避免在 key 日志中泄露明文口令。
|
||||
fingerprint := hex.EncodeToString(credentialHash[:8])
|
||||
return fmt.Sprintf("%s://%s:%d@%s:%d#%s", cfg.Type, cfg.Host, cfg.Port, trimmedHost, remotePort, fingerprint)
|
||||
}
|
||||
|
||||
func NewLocalForwarder(proxyConfig connection.ProxyConfig, remoteHost string, remotePort int) (*LocalForwarder, error) {
|
||||
cfg, err := NormalizeConfig(proxyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建本地代理监听失败:%w", err)
|
||||
}
|
||||
|
||||
localAddr := listener.Addr().String()
|
||||
remoteAddr := net.JoinHostPort(strings.TrimSpace(remoteHost), fmt.Sprintf("%d", remotePort))
|
||||
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
forwarder := &LocalForwarder{
|
||||
LocalAddr: localAddr,
|
||||
RemoteAddr: remoteAddr,
|
||||
ProxyAddr: proxyAddr,
|
||||
ProxyType: cfg.Type,
|
||||
cfg: cfg,
|
||||
listener: listener,
|
||||
closeChan: make(chan struct{}),
|
||||
}
|
||||
|
||||
go forwarder.forward()
|
||||
logger.Infof("已创建代理端口转发:本地 %s -> 远端 %s(代理 %s://%s)", localAddr, remoteAddr, cfg.Type, proxyAddr)
|
||||
return forwarder, nil
|
||||
}
|
||||
|
||||
func (f *LocalForwarder) forward() {
|
||||
for {
|
||||
localConn, err := f.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-f.closeChan:
|
||||
return
|
||||
default:
|
||||
logger.Warnf("接受本地代理连接失败:%v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
go f.handleConnection(localConn)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *LocalForwarder) handleConnection(localConn net.Conn) {
|
||||
defer localConn.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultDialTimeout)
|
||||
remoteConn, err := dialThroughProxy(ctx, f.cfg, "tcp", f.RemoteAddr)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Warnf("通过代理连接远端失败:远端=%s 代理=%s://%s 错误=%v", f.RemoteAddr, f.ProxyType, f.ProxyAddr, err)
|
||||
return
|
||||
}
|
||||
defer remoteConn.Close()
|
||||
|
||||
errc := make(chan error, 2)
|
||||
var closeOnce sync.Once
|
||||
closeBoth := func() {
|
||||
_ = localConn.Close()
|
||||
_ = remoteConn.Close()
|
||||
}
|
||||
go func() {
|
||||
_, copyErr := io.Copy(remoteConn, localConn)
|
||||
closeOnce.Do(closeBoth)
|
||||
errc <- copyErr
|
||||
}()
|
||||
go func() {
|
||||
_, copyErr := io.Copy(localConn, remoteConn)
|
||||
closeOnce.Do(closeBoth)
|
||||
errc <- copyErr
|
||||
}()
|
||||
<-errc
|
||||
<-errc
|
||||
}
|
||||
|
||||
func (f *LocalForwarder) Close() error {
|
||||
var err error
|
||||
f.closeOnce.Do(func() {
|
||||
f.closedMu.Lock()
|
||||
f.closed = true
|
||||
f.closedMu.Unlock()
|
||||
close(f.closeChan)
|
||||
err = f.listener.Close()
|
||||
if err != nil {
|
||||
logger.Warnf("关闭代理端口转发失败:%v", err)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *LocalForwarder) IsClosed() bool {
|
||||
f.closedMu.RLock()
|
||||
defer f.closedMu.RUnlock()
|
||||
return f.closed
|
||||
}
|
||||
|
||||
func CloseAllForwarders() {
|
||||
forwarderMu.Lock()
|
||||
defer forwarderMu.Unlock()
|
||||
|
||||
for key, forwarder := range localForwarders {
|
||||
if forwarder == nil {
|
||||
continue
|
||||
}
|
||||
_ = forwarder.Close()
|
||||
logger.Infof("已关闭代理端口转发:%s", key)
|
||||
}
|
||||
localForwarders = make(map[string]*LocalForwarder)
|
||||
}
|
||||
|
||||
func DialContext(ctx context.Context, proxyConfig connection.ProxyConfig, network, address string) (net.Conn, error) {
|
||||
cfg, err := NormalizeConfig(proxyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialThroughProxy(ctx, cfg, network, address)
|
||||
}
|
||||
|
||||
func dialThroughProxy(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
|
||||
switch cfg.Type {
|
||||
case "socks5":
|
||||
return dialSOCKS5(ctx, cfg, network, address)
|
||||
case "http":
|
||||
return dialHTTPConnect(ctx, cfg, address)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的代理类型:%s", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func dialSOCKS5(ctx context.Context, cfg connection.ProxyConfig, network, address string) (net.Conn, error) {
|
||||
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
var auth *xproxy.Auth
|
||||
if cfg.User != "" || cfg.Password != "" {
|
||||
auth = &xproxy.Auth{
|
||||
User: cfg.User,
|
||||
Password: cfg.Password,
|
||||
}
|
||||
}
|
||||
dialer, err := xproxy.SOCKS5("tcp", proxyAddr, auth, &net.Dialer{Timeout: defaultDialTimeout})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 SOCKS5 代理拨号器失败:%w", err)
|
||||
}
|
||||
|
||||
type result struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
conn, dialErr := dialer.Dial(network, address)
|
||||
ch <- result{conn: conn, err: dialErr}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
go func() {
|
||||
r := <-ch
|
||||
if r.conn != nil {
|
||||
_ = r.conn.Close()
|
||||
}
|
||||
}()
|
||||
return nil, ctx.Err()
|
||||
case r := <-ch:
|
||||
if r.err != nil {
|
||||
return nil, fmt.Errorf("SOCKS5 代理连接失败:%w", r.err)
|
||||
}
|
||||
return r.conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dialHTTPConnect(ctx context.Context, cfg connection.ProxyConfig, address string) (net.Conn, error) {
|
||||
proxyAddr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||
dialer := &net.Dialer{Timeout: defaultDialTimeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", proxyAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 HTTP 代理失败:%w", err)
|
||||
}
|
||||
|
||||
connectReq := &http.Request{
|
||||
Method: http.MethodConnect,
|
||||
URL: &url.URL{Opaque: address},
|
||||
Host: address,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
if cfg.User != "" || cfg.Password != "" {
|
||||
raw := cfg.User + ":" + cfg.Password
|
||||
connectReq.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(raw)))
|
||||
}
|
||||
if err := connectReq.Write(conn); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("发送 HTTP CONNECT 请求失败:%w", err)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(reader, connectReq)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("读取 HTTP CONNECT 响应失败:%w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("HTTP 代理 CONNECT 失败:%s", strings.TrimSpace(resp.Status))
|
||||
}
|
||||
|
||||
if reader.Buffered() == 0 {
|
||||
return conn, nil
|
||||
}
|
||||
return &bufferedConn{Conn: conn, reader: reader}, nil
|
||||
}
|
||||
|
||||
type bufferedConn struct {
|
||||
net.Conn
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *bufferedConn) Read(p []byte) (int, error) {
|
||||
if c.reader == nil {
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
if c.reader.Buffered() == 0 {
|
||||
c.reader = nil
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
return c.reader.Read(p)
|
||||
}
|
||||
44
internal/proxy/proxy_test.go
Normal file
44
internal/proxy/proxy_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestNormalizeConfigSupportsSocks5hAlias(t *testing.T) {
|
||||
cfg, err := NormalizeConfig(connection.ProxyConfig{
|
||||
Type: "SOCKS5H",
|
||||
Host: "127.0.0.1",
|
||||
Port: 1080,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeConfig returned error: %v", err)
|
||||
}
|
||||
if cfg.Type != "socks5" {
|
||||
t.Fatalf("expected normalized proxy type socks5, got %s", cfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwarderCacheKeyIncludesCredentialFingerprint(t *testing.T) {
|
||||
base := connection.ProxyConfig{
|
||||
Type: "socks5",
|
||||
Host: "127.0.0.1",
|
||||
Port: 1080,
|
||||
User: "tester",
|
||||
Password: "first-password",
|
||||
}
|
||||
other := base
|
||||
other.Password = "second-password"
|
||||
|
||||
keyA := forwarderCacheKey(base, "db.internal", 3306)
|
||||
keyB := forwarderCacheKey(other, "db.internal", 3306)
|
||||
|
||||
if keyA == keyB {
|
||||
t.Fatalf("expected different cache key for different credentials")
|
||||
}
|
||||
if strings.Contains(keyA, base.Password) || strings.Contains(keyB, other.Password) {
|
||||
t.Fatalf("cache key should not contain raw password")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,19 @@ type RedisClientImpl struct {
|
||||
forwarder *ssh.LocalForwarder
|
||||
}
|
||||
|
||||
const (
|
||||
redisScanDefaultTargetCount int64 = 2000
|
||||
redisScanMaxTargetCount int64 = 10000
|
||||
redisScanMinStepCount int64 = 200
|
||||
redisScanMaxStepCount int64 = 2000
|
||||
redisScanMaxRounds = 64
|
||||
redisScanMaxDuration = 12 * time.Second
|
||||
redisSearchMaxTargetCount int64 = 1000
|
||||
redisSearchMaxStepCount int64 = 1000
|
||||
redisSearchMaxRounds = 16
|
||||
redisSearchMaxDuration = 3 * time.Second
|
||||
)
|
||||
|
||||
// NewRedisClient creates a new Redis client instance
|
||||
func NewRedisClient() RedisClient {
|
||||
return &RedisClientImpl{}
|
||||
@@ -102,27 +115,96 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
|
||||
return nil, fmt.Errorf("Redis 客户端未连接")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if pattern == "" {
|
||||
pattern = "*"
|
||||
}
|
||||
|
||||
isSearchPattern := pattern != "*"
|
||||
targetCount := normalizeRedisScanTargetCount(count)
|
||||
scanStepCount := normalizeRedisScanStepCount(targetCount)
|
||||
maxRounds := redisScanMaxRounds
|
||||
maxDuration := redisScanMaxDuration
|
||||
if isSearchPattern {
|
||||
if targetCount > redisSearchMaxTargetCount {
|
||||
targetCount = redisSearchMaxTargetCount
|
||||
}
|
||||
if scanStepCount > redisSearchMaxStepCount {
|
||||
scanStepCount = redisSearchMaxStepCount
|
||||
}
|
||||
maxRounds = redisSearchMaxRounds
|
||||
maxDuration = redisSearchMaxDuration
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), maxDuration+5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
currentCursor := cursor
|
||||
round := 0
|
||||
scanStartedAt := time.Now()
|
||||
|
||||
keys := make([]string, 0, int(targetCount))
|
||||
seen := make(map[string]struct{}, int(targetCount))
|
||||
|
||||
for len(keys) < int(targetCount) {
|
||||
if time.Since(scanStartedAt) >= maxDuration {
|
||||
break
|
||||
}
|
||||
|
||||
batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range batch {
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
keys = append(keys, key)
|
||||
if len(keys) >= int(targetCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
currentCursor = nextCursor
|
||||
round++
|
||||
if currentCursor == 0 || round >= maxRounds {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &RedisScanResult{
|
||||
Keys: r.loadRedisKeyInfos(ctx, keys),
|
||||
Cursor: currentCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeRedisScanTargetCount(count int64) int64 {
|
||||
if count <= 0 {
|
||||
count = 100
|
||||
return redisScanDefaultTargetCount
|
||||
}
|
||||
if count > redisScanMaxTargetCount {
|
||||
return redisScanMaxTargetCount
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func normalizeRedisScanStepCount(targetCount int64) int64 {
|
||||
if targetCount < redisScanMinStepCount {
|
||||
return redisScanMinStepCount
|
||||
}
|
||||
if targetCount > redisScanMaxStepCount {
|
||||
return redisScanMaxStepCount
|
||||
}
|
||||
return targetCount
|
||||
}
|
||||
|
||||
func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) []RedisKeyInfo {
|
||||
result := make([]RedisKeyInfo, 0, len(keys))
|
||||
if len(keys) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, count).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &RedisScanResult{
|
||||
Keys: make([]RedisKeyInfo, 0, len(keys)),
|
||||
Cursor: nextCursor,
|
||||
}
|
||||
|
||||
// Get type and TTL for each key
|
||||
pipe := r.client.Pipeline()
|
||||
typeResults := make([]*redis.StatusCmd, len(keys))
|
||||
ttlResults := make([]*redis.DurationCmd, len(keys))
|
||||
@@ -132,37 +214,44 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
|
||||
ttlResults[i] = pipe.TTL(ctx, key)
|
||||
}
|
||||
|
||||
_, err = pipe.Exec(ctx)
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil && err != redis.Nil {
|
||||
// Fallback: get info one by one
|
||||
for _, key := range keys {
|
||||
keyType, _ := r.GetKeyType(key)
|
||||
ttl, _ := r.GetTTL(key)
|
||||
result.Keys = append(result.Keys, RedisKeyInfo{
|
||||
keyType, typeErr := r.client.Type(ctx, key).Result()
|
||||
if typeErr != nil && typeErr != redis.Nil {
|
||||
keyType = ""
|
||||
}
|
||||
ttlValue, ttlErr := r.client.TTL(ctx, key).Result()
|
||||
if ttlErr != nil && ttlErr != redis.Nil {
|
||||
ttlValue = -2
|
||||
}
|
||||
result = append(result, RedisKeyInfo{
|
||||
Key: key,
|
||||
Type: keyType,
|
||||
TTL: ttl,
|
||||
TTL: toRedisTTLSeconds(ttlValue),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
return result
|
||||
}
|
||||
|
||||
for i, key := range keys {
|
||||
keyType := typeResults[i].Val()
|
||||
ttl := int64(ttlResults[i].Val().Seconds())
|
||||
if ttlResults[i].Val() == -1 {
|
||||
ttl = -1
|
||||
} else if ttlResults[i].Val() == -2 {
|
||||
ttl = -2
|
||||
}
|
||||
result.Keys = append(result.Keys, RedisKeyInfo{
|
||||
result = append(result, RedisKeyInfo{
|
||||
Key: key,
|
||||
Type: keyType,
|
||||
TTL: ttl,
|
||||
Type: typeResults[i].Val(),
|
||||
TTL: toRedisTTLSeconds(ttlResults[i].Val()),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return result, nil
|
||||
func toRedisTTLSeconds(ttl time.Duration) int64 {
|
||||
if ttl == -1 {
|
||||
return -1
|
||||
}
|
||||
if ttl == -2 {
|
||||
return -2
|
||||
}
|
||||
return int64(ttl.Seconds())
|
||||
}
|
||||
|
||||
// GetKeyType returns the type of a key
|
||||
|
||||
1
main.go
1
main.go
@@ -41,6 +41,7 @@ func main() {
|
||||
BackdropType: windows.Acrylic,
|
||||
DisableWindowIcon: false,
|
||||
DisableFramelessWindowDecorations: false,
|
||||
WebviewUserDataPath: resolveWindowsWebviewUserDataPath(),
|
||||
},
|
||||
Mac: &mac.Options{
|
||||
WebviewIsTransparent: true,
|
||||
|
||||
123
main_windows_webview_userdata.go
Normal file
123
main_windows_webview_userdata.go
Normal file
@@ -0,0 +1,123 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func resolveWindowsWebviewUserDataPath() string {
|
||||
appDataDir := strings.TrimSpace(os.Getenv("APPDATA"))
|
||||
if appDataDir == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
targetDir := filepath.Join(appDataDir, "GoNavi", "WebView2")
|
||||
_ = migrateLegacyWindowsWebviewUserData(appDataDir, targetDir)
|
||||
return targetDir
|
||||
}
|
||||
|
||||
func migrateLegacyWindowsWebviewUserData(appDataDir, targetDir string) error {
|
||||
if dirHasContent(targetDir) {
|
||||
return nil
|
||||
}
|
||||
|
||||
exeName := "GoNavi.exe"
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
base := strings.TrimSpace(filepath.Base(exePath))
|
||||
if base != "" {
|
||||
exeName = base
|
||||
}
|
||||
}
|
||||
exeBase := strings.TrimSuffix(exeName, filepath.Ext(exeName))
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(appDataDir, exeName),
|
||||
filepath.Join(appDataDir, exeBase),
|
||||
filepath.Join(appDataDir, "GoNavi.exe"),
|
||||
filepath.Join(appDataDir, "GoNavi"),
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
src := filepath.Clean(strings.TrimSpace(candidate))
|
||||
if src == "" || strings.EqualFold(src, filepath.Clean(targetDir)) {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(src)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
if !dirHasContent(src) {
|
||||
continue
|
||||
}
|
||||
return copyDirTree(src, targetDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dirHasContent(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
return err == nil && len(entries) > 0
|
||||
}
|
||||
|
||||
func copyDirTree(srcDir, dstDir string) error {
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return filepath.WalkDir(srcDir, func(srcPath string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
relPath, err := filepath.Rel(srcDir, srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
dstPath := filepath.Join(dstDir, relPath)
|
||||
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(dstPath, 0o755)
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return copyFileWithMode(srcPath, dstPath, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
func copyFileWithMode(srcPath, dstPath string, mode os.FileMode) error {
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
7
main_windows_webview_userdata_stub.go
Normal file
7
main_windows_webview_userdata_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
func resolveWindowsWebviewUserDataPath() string {
|
||||
return ""
|
||||
}
|
||||
BIN
optional-driver-agent
Executable file
BIN
optional-driver-agent
Executable file
Binary file not shown.
Reference in New Issue
Block a user