feat(driver): 提醒重装旧版驱动代理

- optional-driver-agent 新增 metadata 方法返回 driverType、agentRevision 与协议版本
- 安装和本地导入驱动后记录 agentRevision,并在驱动状态中比对是否需要更新
- 驱动管理、连接表单和已有连接加载入口提示重装旧版 agent
- 补充旧 revision 检测和 custom 连接使用统计回归测试
This commit is contained in:
Syngnat
2026-04-29 17:22:49 +08:00
parent 824aafbdea
commit c927e33c8c
8 changed files with 461 additions and 60 deletions

View File

@@ -37,6 +37,7 @@ type agentResponse struct {
const (
agentMethodConnect = "connect"
agentMethodClose = "close"
agentMethodMetadata = "metadata"
agentMethodPing = "ping"
agentMethodQuery = "query"
agentMethodExec = "exec"
@@ -131,6 +132,13 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
*inst = nil
}
return resp
case agentMethodMetadata:
resp.Data = map[string]string{
"driverType": strings.TrimSpace(agentDriverType),
"agentRevision": db.OptionalDriverAgentRevision(agentDriverType),
"protocolSchema": "json-lines-v1",
}
return resp
}
if *inst == nil {

View File

@@ -10,6 +10,7 @@ import (
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
)
type duckMapLike map[any]any
@@ -66,6 +67,33 @@ func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) {
}
}
func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) {
previousDriverType := agentDriverType
previousFactory := agentDatabaseFactory
t.Cleanup(func() {
agentDriverType = previousDriverType
agentDatabaseFactory = previousFactory
})
agentDriverType = "clickhouse"
agentDatabaseFactory = func() db.Database { return nil }
var inst db.Database
resp := handleRequest(&inst, agentRequest{ID: 7, Method: agentMethodMetadata})
if !resp.Success {
t.Fatalf("metadata request failed: %s", resp.Error)
}
data, ok := resp.Data.(map[string]string)
if !ok {
t.Fatalf("metadata response data type = %T", resp.Data)
}
if data["driverType"] != "clickhouse" {
t.Fatalf("unexpected driver type: %q", data["driverType"])
}
if data["agentRevision"] != db.OptionalDriverAgentRevision("clickhouse") {
t.Fatalf("unexpected agent revision: %q", data["agentRevision"])
}
}
type fakeAgentTimeoutDB struct {
queryCalled bool
queryContextCalled bool

View File

@@ -236,6 +236,10 @@ type DriverStatusSnapshot = {
type: string;
name: string;
connectable: boolean;
expectedRevision?: string;
needsUpdate?: boolean;
updateReason?: string;
affectedConnections?: number;
message?: string;
};
@@ -248,6 +252,14 @@ const normalizeDriverType = (value: string): string => {
return normalized;
};
const resolveConnectionDriverType = (type: string, driver?: string): string => {
const normalizedType = normalizeDriverType(type);
if (normalizedType !== "custom") {
return normalizedType;
}
return normalizeDriverType(driver || "");
};
const ConnectionModal: React.FC<{
open: boolean;
onClose: () => void;
@@ -320,6 +332,7 @@ const ConnectionModal: React.FC<{
const redisTopology = Form.useWatch("redisTopology", form) || "single";
const sslMode = Form.useWatch("sslMode", form) || "preferred";
const proxyType = Form.useWatch("proxyType", form) || "socks5";
const customDriver = Form.useWatch("driver", form) || "";
const mongoReadPreference =
Form.useWatch("mongoReadPreference", form) || "primary";
const mongoAuthMechanism = Form.useWatch("mongoAuthMechanism", form) || "";
@@ -851,6 +864,12 @@ const ConnectionModal: React.FC<{
type,
name: String(item.name || item.type || type).trim(),
connectable: !!item.connectable,
expectedRevision: String(item.expectedRevision || "").trim() || undefined,
needsUpdate: !!item.needsUpdate,
updateReason: String(item.updateReason || "").trim() || undefined,
affectedConnections: Number.isFinite(Number(item.affectedConnections))
? Number(item.affectedConnections)
: undefined,
message: String(item.message || "").trim() || undefined,
};
});
@@ -868,8 +887,11 @@ const ConnectionModal: React.FC<{
}
};
const resolveDriverUnavailableReason = async (type: string): Promise<string> => {
const normalized = normalizeDriverType(type);
const resolveDriverUnavailableReason = async (
type: string,
driver?: string,
): Promise<string> => {
const normalized = resolveConnectionDriverType(type, driver);
if (!normalized || normalized === "custom") {
return "";
}
@@ -2382,10 +2404,16 @@ const ConnectionModal: React.FC<{
try {
await form.validateFields();
const values = form.getFieldsValue(true);
const unavailableReason = await resolveDriverUnavailableReason(values.type);
const unavailableReason = await resolveDriverUnavailableReason(
values.type,
values.driver,
);
if (unavailableReason) {
message.warning(unavailableReason);
promptInstallDriver(values.type, unavailableReason);
promptInstallDriver(
resolveConnectionDriverType(values.type, values.driver) || values.type,
unavailableReason,
);
return;
}
setLoading(true);
@@ -2538,7 +2566,10 @@ const ConnectionModal: React.FC<{
try {
await form.validateFields();
const values = form.getFieldsValue(true);
const unavailableReason = await resolveDriverUnavailableReason(values.type);
const unavailableReason = await resolveDriverUnavailableReason(
values.type,
values.driver,
);
if (unavailableReason) {
applyTestFailureFeedback(
resolveConnectionTestFailureFeedback({
@@ -2547,7 +2578,10 @@ const ConnectionModal: React.FC<{
fallback: "驱动未安装启用",
}),
);
promptInstallDriver(values.type, unavailableReason);
promptInstallDriver(
resolveConnectionDriverType(values.type, values.driver) || values.type,
unavailableReason,
);
return;
}
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
@@ -3326,17 +3360,27 @@ const ConnectionModal: React.FC<{
isJVM && hasUnsupportedJvmModeSelection
? "当前连接包含未支持的 JVM 模式。此版本只支持 JMX / Endpoint / Agent请先调整允许模式和首选模式后再继续。"
: "";
const currentDriverType = normalizeDriverType(dbType);
const currentDriverType = resolveConnectionDriverType(dbType, customDriver);
const hasCurrentDriverType =
currentDriverType !== "" && currentDriverType !== "custom";
const currentDriverSnapshot = driverStatusMap[currentDriverType];
const currentDriverUnavailableReason =
currentDriverType !== "custom" &&
hasCurrentDriverType &&
currentDriverSnapshot &&
!currentDriverSnapshot.connectable
? currentDriverSnapshot.message ||
`${currentDriverSnapshot.name || dbType} 驱动未安装启用`
: "";
const currentDriverUpdateReason =
hasCurrentDriverType &&
currentDriverSnapshot?.connectable &&
currentDriverSnapshot.needsUpdate
? currentDriverSnapshot.message ||
currentDriverSnapshot.updateReason ||
`${currentDriverSnapshot.name || dbType} 驱动代理需要重装后才能应用当前版本的驱动侧更新`
: "";
const driverStatusChecking =
currentDriverType !== "custom" && !driverStatusLoaded && step === 2;
hasCurrentDriverType && !driverStatusLoaded && step === 2;
const dbTypeGroups = [
{
@@ -6055,6 +6099,26 @@ const ConnectionModal: React.FC<{
}
/>
)}
{currentDriverUpdateReason && (
<Alert
showIcon
type="warning"
style={{ marginBottom: 12 }}
message="当前数据源驱动代理建议重装"
description={
<Space size={8}>
<span>{currentDriverUpdateReason}</span>
<Button
type="link"
size="small"
onClick={() => onOpenDriverManager?.()}
>
</Button>
</Space>
}
/>
)}
{(() => {
const sectionItems: Array<{
key: "basic" | "network" | "appearance";

View File

@@ -39,6 +39,11 @@ type DriverStatusRow = {
packagePath?: string;
executablePath?: string;
downloadedAt?: string;
agentRevision?: string;
expectedRevision?: string;
needsUpdate?: boolean;
updateReason?: string;
affectedConnections?: number;
message?: string;
};
@@ -360,6 +365,13 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
packagePath: String(item.packagePath || '').trim() || undefined,
executablePath: String(item.executablePath || '').trim() || undefined,
downloadedAt: String(item.downloadedAt || '').trim() || undefined,
agentRevision: String(item.agentRevision || '').trim() || undefined,
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
needsUpdate: !!item.needsUpdate,
updateReason: String(item.updateReason || '').trim() || undefined,
affectedConnections: Number.isFinite(Number(item.affectedConnections))
? Number(item.affectedConnections)
: undefined,
message: String(item.message || '').trim() || undefined,
}));
setRows(nextRows);
@@ -1005,7 +1017,17 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
title: '数据源',
dataIndex: 'name',
key: 'name',
width: 150,
width: 220,
render: (_: string, row: DriverStatusRow) => (
<div style={{ display: 'grid', gap: 4 }}>
<Text strong>{row.name}</Text>
{row.message ? (
<Text type={row.needsUpdate ? 'warning' : 'secondary'} style={{ fontSize: 12 }}>
{row.message}
</Text>
) : null}
</div>
),
},
{
title: '安装包大小',
@@ -1042,6 +1064,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
if (progress && (progress.status === 'start' || progress.status === 'downloading')) {
return <Tag color="processing"> {Math.round(progress.percent)}%</Tag>;
}
if (row.needsUpdate) {
return <Tag color="warning"></Tag>;
}
if (row.connectable) {
return <Tag color="success"></Tag>;
}
@@ -1089,10 +1114,11 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const versionLocked = row.packageInstalled || row.connectable;
if (versionLocked) {
const installedVersion = String(row.installedVersion || '').trim();
const revisionHint = row.needsUpdate ? ',需重装' : '';
if (installedVersion) {
return <Text type="secondary">{installedVersion}</Text>;
return <Text type="secondary">{installedVersion}{revisionHint}</Text>;
}
return <Text type="secondary"></Text>;
return <Text type="secondary">{row.needsUpdate ? '需重装,' : ''}</Text>;
}
const options = versionMap[row.type] || [];
const selectedKey = selectedVersionMap[row.type];
@@ -1148,7 +1174,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const mainAction = row.connectable ? (
const mainAction = row.needsUpdate ? (
<Button
type="primary"
icon={<DownloadOutlined />}
loading={loadingInstallOrRemove}
onClick={() => installDriver(row)}
>
</Button>
) : row.connectable ? (
<Button
danger
icon={<DeleteOutlined />}
@@ -1209,9 +1244,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
row.type,
row.pinnedVersion,
row.installedVersion,
row.updateReason,
row.message,
row.builtIn ? '内置' : '外置',
row.connectable ? '已启用' : row.packageInstalled ? '已安装' : '未启用',
row.needsUpdate ? '强烈建议重装' : row.connectable ? '已启用' : row.packageInstalled ? '已安装' : '未启用',
];
const searchableText = normalizeDriverSearchText(searchableParts.filter(Boolean).join(' '));
return searchableText.includes(normalizedSearchKeyword);

View File

@@ -36,9 +36,9 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
} from '@ant-design/icons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { SavedConnection, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, JVMProbeCapabilities } from '../../wailsjs/go/app/App';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
@@ -81,6 +81,33 @@ interface BatchObjectItem {
dataRef: any;
}
type DriverStatusSnapshot = {
type: string;
name: string;
connectable: boolean;
expectedRevision?: string;
needsUpdate?: boolean;
updateReason?: string;
message?: string;
};
const DRIVER_STATUS_CACHE_TTL_MS = 30_000;
const normalizeDriverType = (value: string): string => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'postgresql') return 'postgres';
if (normalized === 'doris') return 'diros';
return normalized;
};
const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => {
const type = normalizeDriverType(conn?.config?.type || '');
if (type !== 'custom') {
return type;
}
return normalizeDriverType(conn?.config?.driver || '');
};
const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; label: string }> = [
{ value: 'smart', label: '智能' },
{ value: 'object', label: '表对象' },
@@ -211,10 +238,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [autoExpandParent, setAutoExpandParent] = useState(true);
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
const selectedNodesRef = useRef<any[]>([]);
const loadingNodesRef = useRef<Set<string>>(new Set());
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const driverStatusCacheRef = useRef<{ fetchedAt: number; items: Record<string, DriverStatusSnapshot> } | null>(null);
const driverUpdateWarningKeysRef = useRef<Set<string>>(new Set());
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
// Virtual Scroll State
const [treeHeight, setTreeHeight] = useState(500);
@@ -956,13 +985,72 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const typeLabel = normalizedType === 'PROCEDURE' ? 'P' : 'F';
routines.push({ displayName: `${fullName} [${typeLabel}]`, routineName: fullName, routineType: normalizedType });
});
});
return { routines, supported: hasSuccessfulQuery };
};
});
return { routines, supported: hasSuccessfulQuery };
};
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
const loadKey = `dbs-${conn.id}`;
const fetchDriverStatusMap = async (): Promise<Record<string, DriverStatusSnapshot>> => {
const cached = driverStatusCacheRef.current;
if (cached && Date.now() - cached.fetchedAt < DRIVER_STATUS_CACHE_TTL_MS) {
return cached.items;
}
const result: Record<string, DriverStatusSnapshot> = {};
const res = await GetDriverStatusList('', '');
if (!res?.success) {
return result;
}
const data = (res.data || {}) as any;
const drivers = Array.isArray(data.drivers) ? data.drivers : [];
drivers.forEach((item: any) => {
const type = normalizeDriverType(String(item.type || '').trim());
if (!type) return;
result[type] = {
type,
name: String(item.name || item.type || type).trim(),
connectable: !!item.connectable,
expectedRevision: String(item.expectedRevision || '').trim() || undefined,
needsUpdate: !!item.needsUpdate,
updateReason: String(item.updateReason || '').trim() || undefined,
message: String(item.message || '').trim() || undefined,
};
});
driverStatusCacheRef.current = { fetchedAt: Date.now(), items: result };
return result;
};
const warnIfConnectionDriverAgentNeedsUpdate = async (conn: SavedConnection) => {
try {
const driverType = resolveSavedConnectionDriverType(conn);
if (!driverType || driverType === 'custom') {
return;
}
const statusMap = await fetchDriverStatusMap();
const status = statusMap[driverType];
if (!status?.connectable || !status.needsUpdate) {
return;
}
const revisionKey = status.expectedRevision || status.updateReason || status.message || 'unknown';
const warningKey = `${conn.id}:${driverType}:${revisionKey}`;
if (driverUpdateWarningKeysRef.current.has(warningKey)) {
return;
}
driverUpdateWarningKeysRef.current.add(warningKey);
const driverName = status.name || driverType;
const reason = status.message || status.updateReason || `${driverName} driver-agent 与当前 GoNavi 版本要求不一致`;
message.warning({
content: `${driverName} 驱动代理需要重装:${reason}`,
key: `driver-agent-update-${conn.id}`,
duration: 10,
});
} catch (error) {
console.warn('检查驱动代理更新状态失败', error);
}
};
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
void warnIfConnectionDriverAgentNeedsUpdate(conn);
const loadKey = `dbs-${conn.id}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
const config = {
@@ -1845,8 +1933,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setIsBatchModalOpen(true);
};
const loadDatabasesForBatch = async (conn: SavedConnection) => {
const config = {
const loadDatabasesForBatch = async (conn: SavedConnection) => {
void warnIfConnectionDriverAgentNeedsUpdate(conn);
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
@@ -2154,10 +2243,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setIsBatchDbModalOpen(true);
};
const loadDatabasesForDbBatch = async (conn: SavedConnection) => {
setBatchConnContext(conn);
const loadDatabasesForDbBatch = async (conn: SavedConnection) => {
setBatchConnContext(conn);
void warnIfConnectionDriverAgentNeedsUpdate(conn);
const config = {
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",

View File

@@ -127,6 +127,7 @@ type driverDefinition struct {
type installedDriverPackage struct {
DriverType string `json:"driverType"`
Version string `json:"version,omitempty"`
AgentRevision string `json:"agentRevision,omitempty"`
FilePath string `json:"filePath"`
FileName string `json:"fileName"`
ExecutablePath string `json:"executablePath,omitempty"`
@@ -136,23 +137,28 @@ type installedDriverPackage struct {
}
type driverStatusItem struct {
Type string `json:"type"`
Name string `json:"name"`
Engine string `json:"engine,omitempty"`
BuiltIn bool `json:"builtIn"`
PinnedVersion string `json:"pinnedVersion,omitempty"`
InstalledVersion string `json:"installedVersion,omitempty"`
PackageSizeText string `json:"packageSizeText,omitempty"`
RuntimeAvailable bool `json:"runtimeAvailable"`
PackageInstalled bool `json:"packageInstalled"`
Connectable bool `json:"connectable"`
DefaultDownloadURL string `json:"defaultDownloadUrl,omitempty"`
InstallDir string `json:"installDir,omitempty"`
PackagePath string `json:"packagePath,omitempty"`
PackageFileName string `json:"packageFileName,omitempty"`
ExecutablePath string `json:"executablePath,omitempty"`
DownloadedAt string `json:"downloadedAt,omitempty"`
Message string `json:"message,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
Engine string `json:"engine,omitempty"`
BuiltIn bool `json:"builtIn"`
PinnedVersion string `json:"pinnedVersion,omitempty"`
InstalledVersion string `json:"installedVersion,omitempty"`
PackageSizeText string `json:"packageSizeText,omitempty"`
RuntimeAvailable bool `json:"runtimeAvailable"`
PackageInstalled bool `json:"packageInstalled"`
Connectable bool `json:"connectable"`
DefaultDownloadURL string `json:"defaultDownloadUrl,omitempty"`
InstallDir string `json:"installDir,omitempty"`
PackagePath string `json:"packagePath,omitempty"`
PackageFileName string `json:"packageFileName,omitempty"`
ExecutablePath string `json:"executablePath,omitempty"`
DownloadedAt string `json:"downloadedAt,omitempty"`
AgentRevision string `json:"agentRevision,omitempty"`
ExpectedRevision string `json:"expectedRevision,omitempty"`
NeedsUpdate bool `json:"needsUpdate,omitempty"`
UpdateReason string `json:"updateReason,omitempty"`
AffectedConnections int `json:"affectedConnections,omitempty"`
Message string `json:"message,omitempty"`
}
const driverDownloadProgressEvent = "driver:download-progress"
@@ -758,29 +764,36 @@ func (a *App) GetDriverStatusList(downloadDir string, manifestURL string) connec
definitions := allDriverDefinitionsWithPackages(effectivePackages)
triggerDriverVersionMetadataWarmup(definitions)
packageSizeBytesMap := preloadOptionalDriverPackageSizes(definitions)
usageCounts := a.savedConnectionDriverUsageCounts()
items := make([]driverStatusItem, 0, len(definitions))
for _, definition := range definitions {
engine := effectiveDriverEngine(definition)
runtimeAvailable, runtimeReason := db.DriverRuntimeSupportStatus(definition.Type)
pkg, packageMetaExists := readInstalledDriverPackage(resolvedDir, definition.Type)
needsUpdate, updateReason, expectedRevision := optionalDriverAgentRevisionStatus(definition.Type, pkg, packageMetaExists)
packageInstalled := definition.BuiltIn || packageMetaExists
if runtimeAvailable && db.IsOptionalGoDriver(definition.Type) {
packageInstalled = true
}
item := driverStatusItem{
Type: definition.Type,
Name: definition.Name,
Engine: engine,
BuiltIn: definition.BuiltIn,
PinnedVersion: definition.PinnedVersion,
InstalledVersion: strings.TrimSpace(pkg.Version),
PackageSizeText: resolveDriverPackageSizeText(definition, pkg, packageMetaExists, packageSizeBytesMap),
RuntimeAvailable: runtimeAvailable,
PackageInstalled: packageInstalled,
Connectable: runtimeAvailable,
DefaultDownloadURL: definition.DefaultDownloadURL,
InstallDir: driverInstallDir(resolvedDir, definition.Type),
Type: definition.Type,
Name: definition.Name,
Engine: engine,
BuiltIn: definition.BuiltIn,
PinnedVersion: definition.PinnedVersion,
InstalledVersion: strings.TrimSpace(pkg.Version),
PackageSizeText: resolveDriverPackageSizeText(definition, pkg, packageMetaExists, packageSizeBytesMap),
RuntimeAvailable: runtimeAvailable,
PackageInstalled: packageInstalled,
Connectable: runtimeAvailable,
DefaultDownloadURL: definition.DefaultDownloadURL,
InstallDir: driverInstallDir(resolvedDir, definition.Type),
AgentRevision: strings.TrimSpace(pkg.AgentRevision),
ExpectedRevision: expectedRevision,
NeedsUpdate: needsUpdate,
UpdateReason: updateReason,
AffectedConnections: usageCounts[normalizeDriverType(definition.Type)],
}
if packageMetaExists {
item.PackagePath = pkg.FilePath
@@ -792,6 +805,12 @@ func (a *App) GetDriverStatusList(downloadDir string, manifestURL string) connec
switch {
case definition.BuiltIn:
item.Message = "内置驱动,可直接连接"
case needsUpdate:
if item.AffectedConnections > 0 {
item.Message = fmt.Sprintf("%s检测到 %d 个已保存连接正在使用该驱动,请在工具-驱动管理中重装", updateReason, item.AffectedConnections)
} else {
item.Message = updateReason + ",请在工具-驱动管理中重装"
}
case runtimeAvailable:
item.Message = "纯 Go 驱动已启用,可直接连接"
case packageInstalled && strings.TrimSpace(runtimeReason) != "":
@@ -2702,6 +2721,47 @@ func readInstalledDriverPackage(downloadDir string, driverType string) (installe
return meta, true
}
func optionalDriverAgentRevisionStatus(driverType string, pkg installedDriverPackage, packageMetaExists bool) (bool, string, string) {
expected := db.OptionalDriverAgentRevision(driverType)
if strings.TrimSpace(expected) == "" || !packageMetaExists || !db.IsOptionalGoDriver(driverType) {
return false, "", expected
}
actual := strings.TrimSpace(pkg.AgentRevision)
if actual == expected {
return false, "", expected
}
displayName := resolveDriverDisplayName(driverDefinition{Type: driverType})
updateReason := fmt.Sprintf("当前 GoNavi 版本要求更新后的 %s driver-agentrevision: %s", displayName, expected)
impact := "driver-agent 是独立二进制,不会随主程序自动更新;如果不重装,会继续使用旧 agent 逻辑,驱动侧已修复或优化的行为不会生效,可能继续出现旧版本问题。强烈建议重装对应驱动代理"
if actual == "" {
return true, fmt.Sprintf("原因:%s。影响%s", updateReason, impact), expected
}
return true, fmt.Sprintf("原因:%s。影响%s已安装标记%s当前需要%s", updateReason, impact, actual, expected), expected
}
func (a *App) savedConnectionDriverUsageCounts() map[string]int {
counts := map[string]int{}
if a == nil || strings.TrimSpace(a.configDir) == "" {
return counts
}
items, err := a.savedConnectionRepository().List()
if err != nil {
logger.Warnf("统计驱动连接使用数失败:%v", err)
return counts
}
for _, item := range items {
driverType := normalizeDriverType(item.Config.Type)
if driverType == "custom" {
driverType = normalizeDriverType(item.Config.Driver)
}
if driverType == "" || !db.IsOptionalGoDriver(driverType) {
continue
}
counts[driverType]++
}
return counts
}
func writeInstalledDriverPackage(downloadDir string, driverType string, meta installedDriverPackage) error {
driverDir := driverInstallDir(downloadDir, driverType)
if err := os.MkdirAll(driverDir, 0o755); err != nil {
@@ -2765,9 +2825,11 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele
if strings.TrimSpace(downloadSource) == "" {
downloadSource = strings.TrimSpace(downloadURL)
}
agentRevision := probeInstalledOptionalDriverAgentRevision(driverType, runtimePath)
return installedDriverPackage{
DriverType: driverType,
Version: strings.TrimSpace(selectedVersion),
AgentRevision: agentRevision,
FilePath: installPath,
FileName: filepath.Base(installPath),
ExecutablePath: runtimePath,
@@ -2837,9 +2899,11 @@ func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePa
if hashErr != nil {
return installedDriverPackage{}, fmt.Errorf("计算 %s 驱动代理摘要失败:%w", displayName, hashErr)
}
agentRevision := probeInstalledOptionalDriverAgentRevision(driverType, executablePath)
return installedDriverPackage{
DriverType: driverType,
Version: strings.TrimSpace(selectedVersion),
AgentRevision: agentRevision,
FilePath: sourcePath,
FileName: sourceName,
ExecutablePath: executablePath,
@@ -2849,6 +2913,19 @@ func installOptionalDriverAgentFromLocalPath(definition driverDefinition, filePa
}, nil
}
func probeInstalledOptionalDriverAgentRevision(driverType string, executablePath string) string {
expectedRevision := db.OptionalDriverAgentRevision(driverType)
if strings.TrimSpace(expectedRevision) == "" {
return ""
}
metadata, err := db.ProbeOptionalDriverAgentMetadata(driverType, executablePath)
if err != nil {
logger.Warnf("%s 驱动代理未返回版本元数据:%v", resolveDriverDisplayName(driverDefinition{Type: driverType}), err)
return ""
}
return strings.TrimSpace(metadata.AgentRevision)
}
type localDriverCandidate struct {
absPath string
relativePath string

View File

@@ -0,0 +1,72 @@
package app
import (
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestOptionalDriverAgentRevisionStatusDetectsStaleClickHouseAgent(t *testing.T) {
needsUpdate, reason, expected := optionalDriverAgentRevisionStatus("clickhouse", installedDriverPackage{}, true)
if !needsUpdate {
t.Fatal("expected missing ClickHouse agent revision to require update")
}
if expected == "" {
t.Fatal("expected ClickHouse to define an agent revision")
}
if reason == "" {
t.Fatal("expected update reason")
}
if !strings.Contains(reason, "原因:") || !strings.Contains(reason, "影响:") {
t.Fatalf("expected reason to explain cause and impact, got %q", reason)
}
if !strings.Contains(reason, "强烈建议重装") {
t.Fatalf("expected reason to strongly recommend reinstall, got %q", reason)
}
current := installedDriverPackage{AgentRevision: expected}
needsUpdate, reason, _ = optionalDriverAgentRevisionStatus("clickhouse", current, true)
if needsUpdate {
t.Fatalf("expected current ClickHouse agent revision to be accepted, reason=%q", reason)
}
}
func TestSavedConnectionDriverUsageCountsIncludesOptionalAndCustomDrivers(t *testing.T) {
app := &App{configDir: t.TempDir()}
repo := app.savedConnectionRepository()
if err := repo.saveAll([]connection.SavedConnectionView{
{
ID: "conn-clickhouse",
Name: "ClickHouse",
Config: connection.ConnectionConfig{
Type: "clickhouse",
},
},
{
ID: "conn-custom-clickhouse",
Name: "Custom ClickHouse",
Config: connection.ConnectionConfig{
Type: "custom",
Driver: "clickhouse",
},
},
{
ID: "conn-mysql",
Name: "MySQL",
Config: connection.ConnectionConfig{
Type: "mysql",
},
},
}); err != nil {
t.Fatalf("save connections failed: %v", err)
}
counts := app.savedConnectionDriverUsageCounts()
if got := counts["clickhouse"]; got != 2 {
t.Fatalf("expected two ClickHouse usages, got %d", got)
}
if got := counts["mysql"]; got != 0 {
t.Fatalf("expected built-in MySQL to be ignored, got %d", got)
}
}

View File

@@ -23,6 +23,7 @@ import (
const (
optionalAgentMethodConnect = "connect"
optionalAgentMethodClose = "close"
optionalAgentMethodMetadata = "metadata"
optionalAgentMethodPing = "ping"
optionalAgentMethodQuery = "query"
optionalAgentMethodExec = "exec"
@@ -58,6 +59,12 @@ type optionalAgentResponse struct {
RowsAffected int64 `json:"rowsAffected,omitempty"`
}
type OptionalDriverAgentMetadata struct {
DriverType string `json:"driverType,omitempty"`
AgentRevision string `json:"agentRevision,omitempty"`
ProtocolSchema string `json:"protocolSchema,omitempty"`
}
type optionalDriverAgentClient struct {
cmd *exec.Cmd
stdin io.WriteCloser
@@ -69,6 +76,25 @@ type optionalDriverAgentClient struct {
driver string
}
func ProbeOptionalDriverAgentMetadata(driverType string, executablePath string) (OptionalDriverAgentMetadata, error) {
client, err := newOptionalDriverAgentClient(driverType, executablePath)
if err != nil {
return OptionalDriverAgentMetadata{}, err
}
defer func() {
_ = client.close()
}()
var metadata OptionalDriverAgentMetadata
if err := client.call(optionalAgentRequest{Method: optionalAgentMethodMetadata}, &metadata, nil, nil); err != nil {
return OptionalDriverAgentMetadata{}, err
}
metadata.DriverType = normalizeRuntimeDriverType(metadata.DriverType)
metadata.AgentRevision = strings.TrimSpace(metadata.AgentRevision)
metadata.ProtocolSchema = strings.TrimSpace(metadata.ProtocolSchema)
return metadata, nil
}
func newOptionalDriverAgentClient(driverType string, executablePath string) (*optionalDriverAgentClient, error) {
pathText := strings.TrimSpace(executablePath)
if pathText == "" {