🐛 fix(jvm): 修正连接表单模式回填与超时同步

- 保留编辑态 JVM 连接的原始 preferredMode,避免旧配置被静默降级
- 将 JVM 可见超时统一同步到 Endpoint 探测配置
- 抽取 JVM 可编辑模式判定与回填逻辑,统一 ConnectionModal 行为
- 补充 JVM 模式与超时纯函数测试,覆盖 unsupported preferredMode 分支
- 更新需求追踪文档,记录 Task 3 实现、复审与验证结果
This commit is contained in:
Syngnat
2026-04-23 10:20:47 +08:00
parent 9bb7ece2dd
commit 7ddb49a81d
4 changed files with 189 additions and 57 deletions

View File

@@ -26,7 +26,7 @@
- [x] 阶段 2影响分析完成
- [x] 阶段 3方案设计完成已形成正式设计文档
- [x] 阶段 4实施计划完成已形成正式实施计划
- [ ] 阶段 5实现与自检进行中Task 1、Task 2 已完成并通过回归)
- [ ] 阶段 5实现与自检进行中Task 1、Task 2、Task 3 已完成并通过回归)
- [ ] 阶段 6评审与交付
- [ ] 阶段 7发布与观察
@@ -42,10 +42,11 @@
- 已形成 JVM Connector MVP 正式实施计划文档
- 已完成 Task 1JVM 共享契约与配置归一化
- 已完成 Task 2Provider 注册、连接测试与能力探测 API
- 已完成 Task 3JVM 连接表单、图标与展示文案接入
- 进行中:
- Task 3接入 JVM 连接表单与图标
- Task 4只读资源浏览与 JVM Tab
- 待处理:
- Task 4+只读资源浏览、Guard/Audit、AI 结构化计划等后续任务
- Task 5+Guard/Audit、AI 结构化计划等后续任务
## 5. 风险与阻塞
- 风险:
@@ -83,6 +84,8 @@
- Task 1 已完成规格审查与代码质量审查,结论均通过
- 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API
- Task 2 已完成规格审查与代码质量审查,结论均通过
- 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
- 证据(日志/截图/链接):
- `cmd/optional-driver-agent/main.go`
- `internal/db/database.go`
@@ -115,7 +118,16 @@
- `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1`
- `go test ./internal/jvm ./internal/app -count=1`
- `wails build -clean`
- `frontend/src/components/DatabaseIcons.tsx`
- `frontend/src/components/ConnectionModal.tsx`
- `frontend/src/utils/jvmRuntimePresentation.ts`
- `frontend/src/utils/jvmRuntimePresentation.test.ts`
- `frontend/src/utils/jvmConnectionConfig.ts`
- `frontend/src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm run build`
## 8. 下一步
- 下一步行动:进入 Task 3接入 JVM 连接表单、图标与展示文案,并在前端完成最小交互闭环
- 下一步行动:进入 Task 4打通 JVM 只读资源浏览与 Tab 路由,建立首个可打开的 JVM 运行时视图
- 负责人Codex

View File

@@ -14,7 +14,14 @@ import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn';
import { CUSTOM_CONNECTION_DRIVER_HELP } from '../utils/driverImportGuidance';
import { applyNoAutoCapAttributes, noAutoCapInputProps } from '../utils/inputAutoCap';
import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig } from '../utils/jvmConnectionConfig';
import {
buildDefaultJVMConnectionValues,
buildJVMConnectionConfig,
hasUnsupportedJVMEditableModes,
JVM_EDITABLE_MODES,
normalizeEditableJVMModes,
resolveEditableJVMModeSelection,
} from '../utils/jvmConnectionConfig';
import { resolveJVMModeMeta } from '../utils/jvmRuntimePresentation';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile, TestJVMConnection } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
@@ -27,7 +34,6 @@ const CONNECTION_MODAL_WIDTH = 960;
const CONNECTION_MODAL_BODY_HEIGHT = 620;
const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)';
const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)';
const JVM_FORM_RUNTIME_MODES: Array<'jmx' | 'endpoint'> = ['jmx', 'endpoint'];
type ConnectionSecretKey =
| 'primaryPassword'
| 'sshPassword'
@@ -174,14 +180,11 @@ const ConnectionModal: React.FC<{
const redisTopology = Form.useWatch('redisTopology', form) || 'single';
const jvmAllowedModes = Form.useWatch('jvmAllowedModes', form);
const jvmPreferredMode = Form.useWatch('jvmPreferredMode', form) || 'jmx';
const normalizedJvmAllowedModes = useMemo(() => {
const modes = Array.isArray(jvmAllowedModes)
? jvmAllowedModes
.map((mode) => String(mode || '').trim().toLowerCase())
.filter((mode): mode is typeof JVM_FORM_RUNTIME_MODES[number] => JVM_FORM_RUNTIME_MODES.includes(mode as typeof JVM_FORM_RUNTIME_MODES[number]))
: [];
return modes.length > 0 ? Array.from(new Set(modes)) : ['jmx'];
}, [jvmAllowedModes]);
const normalizedJvmAllowedModes = useMemo(() => normalizeEditableJVMModes(jvmAllowedModes), [jvmAllowedModes]);
const hasUnsupportedJvmModeSelection = useMemo(() => hasUnsupportedJVMEditableModes({
allowedModes: jvmAllowedModes,
preferredMode: jvmPreferredMode,
}), [jvmAllowedModes, jvmPreferredMode]);
const isMySQLLike = dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx';
const isSSLType = supportsSSLForType(dbType);
const sslHintText = isMySQLLike
@@ -1223,18 +1226,13 @@ const ConnectionModal: React.FC<{
const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0;
const mongoIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mongoHosts.length > 0 || !!config.replicaSet;
const redisIsCluster = String(config.topology || '').toLowerCase() === 'cluster' || redisHosts.length > 0;
const jvmAllowedModes = Array.isArray(config.jvm?.allowedModes) && config.jvm.allowedModes.length > 0
? config.jvm.allowedModes.map((mode: string) => String(mode || '').trim().toLowerCase())
: jvmDefaultValues.jvmAllowedModes;
const normalizedJvmAllowedModes = jvmAllowedModes.filter((mode: string) =>
JVM_FORM_RUNTIME_MODES.includes(mode as typeof JVM_FORM_RUNTIME_MODES[number]),
);
const resolvedJvmAllowedModes = normalizedJvmAllowedModes.length > 0
? Array.from(new Set(normalizedJvmAllowedModes))
: jvmDefaultValues.jvmAllowedModes;
const resolvedJvmPreferredMode = resolvedJvmAllowedModes.includes(String(config.jvm?.preferredMode || '').trim().toLowerCase())
? String(config.jvm?.preferredMode || '').trim().toLowerCase()
: resolvedJvmAllowedModes[0];
const { allowedModes: resolvedJvmAllowedModes, preferredMode: resolvedJvmPreferredMode } = resolveEditableJVMModeSelection({
allowedModes: config.jvm?.allowedModes,
preferredMode: config.jvm?.preferredMode,
});
const resolvedJvmTimeout = isJvmConfigType
? Number(config.jvm?.endpoint?.timeoutSeconds || config.timeout || 30)
: Number(config.timeout || 30);
const hasHttpTunnel = !!config.useHttpTunnel;
const hasProxy = !hasHttpTunnel && !!config.useProxy;
form.setFieldsValue({
@@ -1271,7 +1269,7 @@ const ConnectionModal: React.FC<{
httpTunnelPassword: config.httpTunnel?.password,
driver: config.driver,
dsn: config.dsn,
timeout: config.timeout || 30,
timeout: resolvedJvmTimeout,
mysqlTopology: mysqlIsReplica ? 'replica' : 'single',
mysqlReplicaHosts: mysqlReplicaHosts,
mysqlReplicaUser: config.mysqlReplicaUser || '',
@@ -1298,9 +1296,7 @@ const ConnectionModal: React.FC<{
: jvmDefaultValues.jvmEndpointEnabled,
jvmEndpointBaseUrl: isJvmConfigType ? config.jvm?.endpoint?.baseUrl || '' : jvmDefaultValues.jvmEndpointBaseUrl,
jvmEndpointApiKey: isJvmConfigType ? config.jvm?.endpoint?.apiKey || '' : jvmDefaultValues.jvmEndpointApiKey,
jvmEndpointTimeoutSeconds: isJvmConfigType
? Number(config.jvm?.endpoint?.timeoutSeconds || config.timeout || 30)
: Number(config.timeout || 30),
jvmEndpointTimeoutSeconds: resolvedJvmTimeout,
jvmJmxHost: isJvmConfigType && config.jvm?.jmx?.host && config.jvm.jmx.host !== primaryHost
? config.jvm.jmx.host
: '',
@@ -1726,23 +1722,24 @@ const ConnectionModal: React.FC<{
const buildConfig = async (values: any, forPersist: boolean): Promise<ConnectionConfig> => {
const mergedValues = { ...values };
if (String(mergedValues.type || '').trim().toLowerCase() === 'jvm') {
const nextJvmAllowedModes = Array.isArray(mergedValues.jvmAllowedModes)
? mergedValues.jvmAllowedModes
.map((mode: string) => String(mode || '').trim().toLowerCase())
.filter((mode: string) => JVM_FORM_RUNTIME_MODES.includes(mode as typeof JVM_FORM_RUNTIME_MODES[number]))
: [];
const resolvedJvmAllowedModes = nextJvmAllowedModes.length > 0
? Array.from(new Set(nextJvmAllowedModes))
: buildDefaultJVMConnectionValues().jvmAllowedModes;
if (hasUnsupportedJVMEditableModes({
allowedModes: mergedValues.jvmAllowedModes,
preferredMode: mergedValues.jvmPreferredMode,
})) {
throw new Error('当前连接包含未支持的 JVM 模式;请先调整为 JMX 或 Endpoint 后再测试或保存');
}
const resolvedJvmAllowedModes = normalizeEditableJVMModes(mergedValues.jvmAllowedModes);
const resolvedJvmTimeout = Number(mergedValues.timeout || 30);
const preferredJvmMode = String(mergedValues.jvmPreferredMode || '').trim().toLowerCase();
const resolvedJvmPreferredMode = resolvedJvmAllowedModes.find((mode) => mode === preferredJvmMode) || resolvedJvmAllowedModes[0];
return buildJVMConnectionConfig({
...buildDefaultJVMConnectionValues(),
...mergedValues,
jvmAllowedModes: resolvedJvmAllowedModes,
jvmPreferredMode: resolvedJvmAllowedModes.includes(String(mergedValues.jvmPreferredMode || '').trim().toLowerCase())
? String(mergedValues.jvmPreferredMode || '').trim().toLowerCase()
: resolvedJvmAllowedModes[0],
jvmPreferredMode: resolvedJvmPreferredMode,
jvmEndpointEnabled: resolvedJvmAllowedModes.includes('endpoint'),
jvmEndpointTimeoutSeconds: Number(mergedValues.jvmEndpointTimeoutSeconds || mergedValues.timeout || 30),
timeout: resolvedJvmTimeout,
jvmEndpointTimeoutSeconds: resolvedJvmTimeout,
});
}
const parsedUriValues = parseUriToValues(mergedValues.uri, mergedValues.type);
@@ -2138,6 +2135,9 @@ const ConnectionModal: React.FC<{
const isCustom = dbType === 'custom';
const isRedis = dbType === 'redis';
const isJVM = dbType === 'jvm';
const unsupportedJvmModeMessage = isJVM && hasUnsupportedJvmModeSelection
? '当前连接包含未支持的 JVM 模式。此版本只支持 JMX / Endpoint请先调整允许模式和首选模式后再继续。'
: '';
const currentDriverType = normalizeDriverType(dbType);
const currentDriverSnapshot = driverStatusMap[currentDriverType];
const currentDriverUnavailableReason = currentDriverType !== 'custom'
@@ -2311,6 +2311,15 @@ const ConnectionModal: React.FC<{
</>
) : isJVM ? (
<>
{unsupportedJvmModeMessage && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="检测到未支持的 JVM 模式"
description={unsupportedJvmModeMessage}
/>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 120px', gap: 16, alignItems: 'start' }}>
<Form.Item
name="host"
@@ -2340,7 +2349,7 @@ const ConnectionModal: React.FC<{
mode="multiple"
allowClear
placeholder="请选择 JVM 接入模式"
options={JVM_FORM_RUNTIME_MODES.map((mode) => ({
options={JVM_EDITABLE_MODES.map((mode) => ({
value: mode,
label: resolveJVMModeMeta(mode).label,
}))}
@@ -3092,18 +3101,11 @@ const ConnectionModal: React.FC<{
}
if (changed.type !== undefined) setDbType(changed.type);
if (changed.jvmAllowedModes !== undefined) {
const nextModes = Array.isArray(changed.jvmAllowedModes)
? changed.jvmAllowedModes
.map((mode: string) => String(mode || '').trim().toLowerCase())
.filter((mode: string) => JVM_FORM_RUNTIME_MODES.includes(mode as typeof JVM_FORM_RUNTIME_MODES[number]))
: [];
const resolvedModes = nextModes.length > 0 ? Array.from(new Set(nextModes)) : ['jmx'];
const resolvedModes = normalizeEditableJVMModes(changed.jvmAllowedModes);
const currentPreferredMode = String(form.getFieldValue('jvmPreferredMode') || '').trim().toLowerCase();
const resolvedPreferredMode = resolvedModes.find((mode) => mode === currentPreferredMode) || resolvedModes[0];
form.setFieldValue('jvmAllowedModes', resolvedModes);
form.setFieldValue(
'jvmPreferredMode',
resolvedModes.includes(currentPreferredMode) ? currentPreferredMode : resolvedModes[0],
);
form.setFieldValue('jvmPreferredMode', resolvedPreferredMode);
form.setFieldValue('jvmEndpointEnabled', resolvedModes.includes('endpoint'));
}
if (changed.redisTopology !== undefined) {
@@ -3322,7 +3324,7 @@ const ConnectionModal: React.FC<{
}
const isTestSuccess = testResult?.type === 'success';
const hasTestError = !!testResult && !isTestSuccess;
const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking;
const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking || !!unsupportedJvmModeMessage;
return (
<div style={{ display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '4px 2px 0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig } from './jvmConnectionConfig';
import {
buildDefaultJVMConnectionValues,
buildJVMConnectionConfig,
hasUnsupportedJVMEditableModes,
normalizeEditableJVMModes,
resolveEditableJVMModeSelection,
} from './jvmConnectionConfig';
describe('jvmConnectionConfig', () => {
it('defaults to readonly jmx mode', () => {
@@ -58,4 +64,56 @@ describe('jvmConnectionConfig', () => {
expect(config.jvm?.environment).toBe('prod');
expect(config.jvm?.readOnly).toBe(false);
});
it('keeps the visible timeout as the source of truth for endpoint probing', () => {
const config = buildJVMConnectionConfig({
host: 'orders.internal',
port: 9010,
timeout: 45,
jvmEndpointTimeoutSeconds: 30,
jvmAllowedModes: ['endpoint'],
jvmPreferredMode: 'endpoint',
jvmEndpointEnabled: true,
jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm',
});
expect(config.timeout).toBe(45);
expect(config.jvm?.endpoint?.timeoutSeconds).toBe(45);
});
it('normalizes editable JVM modes to the supported form subset', () => {
expect(normalizeEditableJVMModes([' endpoint ', 'agent', 'JMX', 'endpoint'])).toEqual(['endpoint', 'jmx']);
});
it('detects unsupported editable JVM modes without downgrading them silently', () => {
expect(hasUnsupportedJVMEditableModes({
allowedModes: ['agent', 'jmx'],
preferredMode: 'agent',
})).toBe(true);
expect(hasUnsupportedJVMEditableModes({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'agent',
})).toBe(true);
expect(hasUnsupportedJVMEditableModes({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'endpoint',
})).toBe(false);
});
it('preserves preferred mode when rebuilding editable mode selection from stored config', () => {
expect(resolveEditableJVMModeSelection({
allowedModes: [],
preferredMode: 'agent',
})).toEqual({
allowedModes: ['agent'],
preferredMode: 'agent',
});
expect(resolveEditableJVMModeSelection({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'agent',
})).toEqual({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'agent',
});
});
});

View File

@@ -4,12 +4,15 @@ const DEFAULT_JMX_PORT = 9010;
const DEFAULT_TIMEOUT_SECONDS = 30;
const DEFAULT_ENVIRONMENT = 'dev';
const JVM_MODES = ['jmx', 'endpoint', 'agent'] as const;
export const JVM_EDITABLE_MODES = ['jmx', 'endpoint'] as const;
type JVMMode = typeof JVM_MODES[number];
type JVMEditableMode = typeof JVM_EDITABLE_MODES[number];
type JVMEnvironment = 'dev' | 'uat' | 'prod';
type JVMConnectionFormValues = Record<string, unknown>;
const isJVMMode = (value: string): value is JVMMode => JVM_MODES.includes(value as JVMMode);
const isJVMEditableMode = (value: string): value is JVMEditableMode => JVM_EDITABLE_MODES.includes(value as JVMEditableMode);
const toStringValue = (value: unknown): string => {
if (typeof value === 'string') {
@@ -51,6 +54,61 @@ const normalizeModes = (value: unknown): JVMMode[] => {
return result.length > 0 ? result : ['jmx'];
};
export const normalizeEditableJVMModes = (value: unknown): JVMEditableMode[] => {
if (!Array.isArray(value)) {
return ['jmx'];
}
const result: JVMEditableMode[] = [];
const seen = new Set<JVMEditableMode>();
for (const item of value) {
const mode = toStringValue(item).toLowerCase();
if (!isJVMEditableMode(mode) || seen.has(mode)) {
continue;
}
seen.add(mode);
result.push(mode);
}
return result.length > 0 ? result : ['jmx'];
};
export const hasUnsupportedJVMEditableModes = ({
allowedModes,
preferredMode,
}: {
allowedModes: unknown;
preferredMode: unknown;
}): boolean => {
const allowed = Array.isArray(allowedModes)
? allowedModes.map((item) => toStringValue(item).toLowerCase()).filter((item) => item !== '')
: [];
const preferred = toStringValue(preferredMode).toLowerCase();
return allowed.some((mode) => !isJVMEditableMode(mode))
|| (preferred !== '' && !isJVMEditableMode(preferred));
};
export const resolveEditableJVMModeSelection = ({
allowedModes,
preferredMode,
}: {
allowedModes: unknown;
preferredMode: unknown;
}): { allowedModes: string[]; preferredMode: string } => {
const normalizedAllowedModes = Array.isArray(allowedModes)
? allowedModes.map((item) => toStringValue(item).toLowerCase()).filter((item) => item !== '')
: [];
const normalizedPreferredMode = toStringValue(preferredMode).toLowerCase();
const resolvedAllowedModes = normalizedAllowedModes.length > 0
? Array.from(new Set(normalizedAllowedModes))
: (normalizedPreferredMode ? [normalizedPreferredMode] : ['jmx']);
return {
allowedModes: resolvedAllowedModes,
preferredMode: normalizedPreferredMode || resolvedAllowedModes[0],
};
};
const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMode => {
const preferred = toStringValue(value).toLowerCase();
if (isJVMMode(preferred) && allowedModes.includes(preferred)) {
@@ -91,7 +149,9 @@ export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): Conne
const allowedModes = normalizeModes(values.jvmAllowedModes);
const preferredMode = normalizePreferredMode(values.jvmPreferredMode, allowedModes);
const port = toInteger(values.port, DEFAULT_JMX_PORT);
const timeout = toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
const timeout = values.timeout === undefined || values.timeout === null || values.timeout === ''
? toInteger(values.jvmEndpointTimeoutSeconds, DEFAULT_TIMEOUT_SECONDS)
: toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
return {
type: 'jvm',
@@ -116,7 +176,7 @@ export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): Conne
enabled: values.jvmEndpointEnabled === true,
baseUrl: toStringValue(values.jvmEndpointBaseUrl),
apiKey: toStringValue(values.jvmEndpointApiKey),
timeoutSeconds: toInteger(values.jvmEndpointTimeoutSeconds, timeout),
timeoutSeconds: timeout,
},
},
};