feat(jvm): 打通 JVM 只读资源浏览链路

- 后端新增 JVMListResources 与 JVMGetValue 接口并补齐回归测试
- Sidebar 基于能力探测展示 JVM 模式节点并懒加载资源节点
- TabManager 接入 JVMOverview、JVMResourceBrowser 与模式徽标展示
- 补齐 JVM Tab 元数据与连接持久化 sanitize 逻辑
- 更新需求追踪文档并记录 Task 4 验证结果与残余风险
This commit is contained in:
Syngnat
2026-04-23 11:21:36 +08:00
parent 7ddb49a81d
commit 21f2b29d1d
10 changed files with 694 additions and 13 deletions

View File

@@ -26,7 +26,7 @@
- [x] 阶段 2影响分析完成
- [x] 阶段 3方案设计完成已形成正式设计文档
- [x] 阶段 4实施计划完成已形成正式实施计划
- [ ] 阶段 5实现与自检进行中Task 1、Task 2、Task 3 已完成并通过回归)
- [ ] 阶段 5实现与自检进行中Task 1、Task 2、Task 3、Task 4 已完成并通过回归)
- [ ] 阶段 6评审与交付
- [ ] 阶段 7发布与观察
@@ -43,10 +43,11 @@
- 已完成 Task 1JVM 共享契约与配置归一化
- 已完成 Task 2Provider 注册、连接测试与能力探测 API
- 已完成 Task 3JVM 连接表单、图标与展示文案接入
- 已完成 Task 4只读资源浏览与 JVM Tab
- 进行中:
- Task 4只读资源浏览与 JVM Tab
- Task 5Guard/Audit、AI 结构化计划等后续任务准备
- 待处理:
- Task 5+Guard/Audit、AI 结构化计划等后续任务
- Task 5+Guard/Audit、AI 结构化计划等后续任务实现
## 5. 风险与阻塞
- 风险:
@@ -55,6 +56,7 @@
- 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性
- 若目标 JVM 不允许预埋或动态注入 Agent则“通用型”能力边界会明显收缩
- 多接入模式会带来能力不一致问题UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
- 当前 JMX / Endpoint provider 的资源浏览与值读取仍是骨架实现Task 4 已打通接口与 UI 链路,但真实资源展开会返回 `not implemented`
- 阻塞:
- 目标应用技术栈、缓存框架与接入约束尚未明确
- 缓解措施:
@@ -86,6 +88,8 @@
- Task 2 已完成规格审查与代码质量审查,结论均通过
- 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
- 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点
- Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题
- 证据(日志/截图/链接):
- `cmd/optional-driver-agent/main.go`
- `internal/db/database.go`
@@ -127,7 +131,20 @@
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts`
- `cd frontend && npm run build`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/src/components/Sidebar.tsx`
- `frontend/src/components/TabManager.tsx`
- `frontend/src/components/JVMOverview.tsx`
- `frontend/src/components/JVMResourceBrowser.tsx`
- `frontend/src/components/jvm/JVMModeBadge.tsx`
- `frontend/src/store.ts`
- `frontend/src/types.ts`
- `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1`
- `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1`
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm run build`
## 8. 下一步
- 下一步行动:进入 Task 4打通 JVM 只读资源浏览与 Tab 路由,建立首个可打开的 JVM 运行时视图
- 下一步行动:进入 Task 5补齐 Guard/Audit 和 AI 结构化修改计划能力,并收敛真实 provider 的资源浏览实现边界
- 负责人Codex

View File

@@ -0,0 +1,67 @@
import React, { useMemo } from 'react';
import { Card, Descriptions, Empty, Space, Tag, Typography } from 'antd';
import { useStore } from '../store';
import type { SavedConnection, TabData } from '../types';
import JVMModeBadge from './jvm/JVMModeBadge';
const { Paragraph, Text } = Typography;
type JVMOverviewProps = {
tab: TabData;
};
const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId));
const providerMode = tab.providerMode || connection?.config.jvm?.preferredMode || 'jmx';
const readOnly = connection?.config.jvm?.readOnly !== false;
const allowedModes = connection?.config.jvm?.allowedModes || [];
const endpointSummary = useMemo(() => {
if (!connection?.config.jvm?.endpoint) {
return '';
}
const endpoint = connection.config.jvm.endpoint;
if (!endpoint.enabled && !endpoint.baseUrl) {
return '';
}
return endpoint.baseUrl || '已启用';
}, [connection]);
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />;
}
const jmxHost = connection.config.jvm?.jmx?.host || connection.config.host;
const jmxPort = connection.config.jvm?.jmx?.port || connection.config.port;
return (
<div style={{ padding: 20, display: 'grid', gap: 16 }}>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space size={12} wrap>
<JVMModeBadge mode={providerMode} />
<Tag color={readOnly ? 'blue' : 'red'}>{readOnly ? '只读连接' : '可写连接'}</Tag>
<Tag>{connection.config.jvm?.environment || 'dev'}</Tag>
</Space>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
<Text type="secondary"> · {connection.config.host}:{connection.config.port}</Text>
</Paragraph>
</Space>
</Card>
<Card title="连接摘要">
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="当前模式">{providerMode}</Descriptions.Item>
<Descriptions.Item label="允许模式">{allowedModes.length > 0 ? allowedModes.join(', ') : 'jmx'}</Descriptions.Item>
<Descriptions.Item label="JMX 地址">{`${jmxHost}:${jmxPort}`}</Descriptions.Item>
<Descriptions.Item label="Endpoint">{endpointSummary || '未配置'}</Descriptions.Item>
<Descriptions.Item label="资源浏览">{'通过侧边栏展开模式节点后懒加载'}</Descriptions.Item>
</Descriptions>
</Card>
</div>
);
};
export default JVMOverview;

View File

@@ -0,0 +1,168 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Descriptions, Empty, Skeleton, Space, Typography } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import type { JVMValueSnapshot, SavedConnection, TabData } from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import JVMModeBadge from './jvm/JVMModeBadge';
const { Paragraph, Text } = Typography;
type JVMResourceBrowserProps = {
tab: TabData;
};
const buildJVMRuntimeConfig = (connection: SavedConnection, providerMode: string) => {
const sourceJVM = connection.config.jvm || {};
return buildRpcConnectionConfig(connection.config, {
jvm: {
...sourceJVM,
preferredMode: providerMode,
allowedModes: [providerMode],
},
});
};
const formatValue = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId));
const providerMode = tab.providerMode || connection?.config.jvm?.preferredMode || 'jmx';
const [loading, setLoading] = useState(true);
const [snapshot, setSnapshot] = useState<JVMValueSnapshot | null>(null);
const [error, setError] = useState('');
const displayValue = useMemo(() => formatValue(snapshot?.value), [snapshot]);
const loadSnapshot = async () => {
if (!connection) {
setLoading(false);
setSnapshot(null);
setError('连接不存在或已被删除');
return;
}
const resourcePath = String(tab.resourcePath || '').trim();
if (!resourcePath) {
setLoading(false);
setSnapshot(null);
setError('资源路径为空');
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMGetValue !== 'function') {
setLoading(false);
setSnapshot(null);
setError('JVMGetValue 后端方法不可用');
return;
}
setLoading(true);
setError('');
try {
const result = await backendApp.JVMGetValue(
buildJVMRuntimeConfig(connection, providerMode),
resourcePath,
);
if (!result?.success) {
setSnapshot(null);
setError(String(result?.message || '读取 JVM 资源失败'));
return;
}
setSnapshot((result.data || null) as JVMValueSnapshot | null);
} catch (err: any) {
setSnapshot(null);
setError(err?.message || '读取 JVM 资源失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadSnapshot();
}, [connection, providerMode, tab.connectionId, tab.resourcePath]);
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />;
}
return (
<div style={{ padding: 20, display: 'grid', gap: 16 }}>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space size={12} wrap>
<JVMModeBadge mode={providerMode} />
<Button size="small" icon={<ReloadOutlined />} onClick={() => void loadSnapshot()}>
</Button>
</Space>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
</Paragraph>
<Text type="secondary">{tab.resourcePath}</Text>
</Space>
</Card>
<Card title="资源快照">
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
{snapshot ? (
<>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="资源 ID">{snapshot.resourceId || '-'}</Descriptions.Item>
<Descriptions.Item label="资源类型">{snapshot.kind || tab.resourceKind || '-'}</Descriptions.Item>
<Descriptions.Item label="格式">{snapshot.format || '-'}</Descriptions.Item>
<Descriptions.Item label="版本">{snapshot.version || '-'}</Descriptions.Item>
</Descriptions>
<pre
style={{
margin: 0,
padding: 16,
borderRadius: 8,
background: 'rgba(0, 0, 0, 0.04)',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{displayValue}
</pre>
{snapshot.metadata && Object.keys(snapshot.metadata).length > 0 ? (
<pre
style={{
margin: 0,
padding: 16,
borderRadius: 8,
background: 'rgba(0, 0, 0, 0.03)',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{JSON.stringify(snapshot.metadata, null, 2)}
</pre>
) : null}
</>
) : error ? null : <Empty description="暂无资源数据" />}
</Space>
)}
</Card>
</div>
);
};
export default JVMResourceBrowser;

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 } 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 } 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 } 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';
@@ -48,8 +48,10 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
import JVMModeBadge from './jvm/JVMModeBadge';
const { Search } = Input;
@@ -60,7 +62,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag';
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource';
}
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
@@ -968,6 +970,43 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
if (conn.config.type === 'jvm') {
try {
const res = await JVMProbeCapabilities(buildRuntimeConfig(conn) as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const capabilities: JVMCapability[] = Array.isArray(res.data) ? res.data as JVMCapability[] : [];
const modeNodes: TreeNode[] = capabilities.map((capability) => ({
title: capability.displayLabel || capability.mode,
key: `${conn.id}-jvm-mode-${capability.mode}`,
icon: <HddOutlined />,
type: 'jvm-mode',
dataRef: {
...conn,
providerMode: capability.mode,
canBrowse: capability.canBrowse,
canWrite: capability.canWrite,
reason: capability.reason,
displayLabel: capability.displayLabel,
},
isLeaf: capability.canBrowse !== true,
}));
setTreeData(origin => updateTreeData(origin, node.key, modeNodes));
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `conn-${conn.id}-jvm-caps` });
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-jvm-caps` });
} finally {
loadingNodesRef.current.delete(loadKey);
}
return;
}
// Handle Redis connections differently
if (conn.config.type === 'redis') {
try {
@@ -1042,6 +1081,53 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
};
const loadJVMResources = async (node: any) => {
const conn = node.dataRef as SavedConnection & { providerMode?: string; resourcePath?: string };
const providerMode = String(conn.providerMode || '').trim().toLowerCase();
const parentPath = String(conn.resourcePath || '').trim();
const loadKey = `jvm-resources-${conn.id}-${providerMode}-${parentPath}`;
if (loadingNodesRef.current.has(loadKey)) return;
loadingNodesRef.current.add(loadKey);
try {
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListResources !== 'function') {
throw new Error('JVMListResources 后端方法不可用');
}
const res = await backendApp.JVMListResources(buildJVMRuntimeConfig(conn, providerMode), parentPath);
if (res.success) {
const resourceRows: JVMResourceSummary[] = Array.isArray(res.data) ? res.data as JVMResourceSummary[] : [];
const resourceNodes: TreeNode[] = resourceRows.map((item) => ({
title: item.name || item.path || item.id,
key: `${conn.id}-jvm-resource-${providerMode}-${item.path}`,
icon: item.hasChildren ? <FolderOpenOutlined /> : <HddOutlined />,
type: 'jvm-resource',
dataRef: {
...conn,
providerMode: item.providerMode || providerMode,
resourcePath: item.path,
resourceKind: item.kind,
canRead: item.canRead,
canWrite: item.canWrite,
hasChildren: item.hasChildren,
sensitive: item.sensitive,
},
isLeaf: item.hasChildren !== true,
}));
setTreeData(origin => updateTreeData(origin, node.key, resourceNodes));
} else {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: res.message, key: `jvm-resource-${node.key}` });
}
} catch (e: any) {
setLoadedKeys(prev => prev.filter(k => k !== node.key));
message.error({ content: '加载 JVM 资源失败: ' + (e?.message || String(e)), key: `jvm-resource-${node.key}` });
} finally {
loadingNodesRef.current.delete(loadKey);
}
};
const loadTables = async (node: any) => {
const conn = node.dataRef; // has dbName
const dbName = conn.dbName;
@@ -1369,6 +1455,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
if (type === 'connection') {
await loadDatabases({ key, dataRef });
} else if (type === 'jvm-mode' || type === 'jvm-resource') {
await loadJVMResources({ key, dataRef });
} else if (type === 'database') {
await loadTables({ key, dataRef });
} else if (type === 'table') {
@@ -1461,6 +1549,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'table') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'jvm-mode' || type === 'jvm-resource') {
setActiveContext({ connectionId: dataRef.id, dbName: '' });
} else if (type === 'view' || type === 'db-trigger' || type === 'routine') {
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
@@ -1507,6 +1597,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const { type, dataRef, key: nodeKey } = node;
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'jvm-mode' || type === 'jvm-resource') setActiveContext({ connectionId: dataRef.id, dbName: '' });
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
@@ -1585,6 +1676,16 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
routineType
});
return;
} else if (node.type === 'jvm-mode') {
const { providerMode, id } = node.dataRef;
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
openJVMOverviewTab(conn, providerMode);
return;
} else if (node.type === 'jvm-resource') {
const { providerMode, resourcePath, resourceKind, id } = node.dataRef;
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
openJVMResourceTab(conn, providerMode, resourcePath, resourceKind);
return;
}
const key = node.key;
@@ -2380,6 +2481,43 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
};
const buildJVMRuntimeConfig = (conn: SavedConnection & { dbName?: string }, providerMode: string) => {
const sourceJVM = conn.config.jvm || {};
return buildRpcConnectionConfig(conn.config, {
database: '',
jvm: {
...sourceJVM,
preferredMode: providerMode as 'jmx' | 'endpoint' | 'agent',
allowedModes: [providerMode as 'jmx' | 'endpoint' | 'agent'],
},
});
};
const openJVMOverviewTab = (conn: SavedConnection, providerMode: string) => {
addTab({
id: `jvm-overview-${conn.id}-${providerMode}`,
title: buildJVMTabTitle(conn.name, 'overview', providerMode),
type: 'jvm-overview',
connectionId: conn.id,
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
});
};
const openJVMResourceTab = (conn: SavedConnection, providerMode: string, resourcePath: string, resourceKind?: string) => {
const trimmedResourcePath = String(resourcePath || '').trim();
addTab({
id: `jvm-resource-${conn.id}-${providerMode}-${encodeURIComponent(trimmedResourcePath)}`,
title: trimmedResourcePath
? `${buildJVMTabTitle(conn.name, 'resource', providerMode)} · ${trimmedResourcePath}`
: buildJVMTabTitle(conn.name, 'resource', providerMode),
type: 'jvm-resource',
connectionId: conn.id,
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
resourcePath: trimmedResourcePath,
resourceKind,
});
};
const getConnectionNodeRef = (connRef: any) => {
const latestConn = connections.find(c => c.id === connRef.id);
return { key: connRef.id, dataRef: latestConn || connRef };
@@ -3969,6 +4107,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
hoverTitle = String(node?.dataRef?.path || displayTitle);
}
if (node.type === 'jvm-mode') {
return (
<span
title={hoverTitle}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}
>
<JVMModeBadge
mode={String(node?.dataRef?.providerMode || displayTitle)}
label={displayTitle}
reason={String(node?.dataRef?.reason || '').trim() || undefined}
/>
</span>
);
}
if (node.type === 'external-sql-root') {
return (
<span

View File

@@ -16,6 +16,8 @@ import RedisMonitor from './RedisMonitor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import type { TabData } from '../types';
import { buildTabDisplayTitle } from '../utils/tabDisplay';
@@ -203,6 +205,16 @@ const TabManager: React.FC = () => {
content = <DefinitionViewer tab={tab} />;
} else if (tab.type === 'table-overview') {
content = <TableOverview tab={tab} />;
} else if (tab.type === 'jvm-overview') {
content = <JVMOverview tab={tab} />;
} else if (tab.type === 'jvm-resource') {
content = <JVMResourceBrowser tab={tab} />;
} else if (tab.type === 'jvm-audit') {
content = (
<div style={{ padding: 24 }}>
JVM
</div>
);
}
const menuItems: MenuProps['items'] = [

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { Tooltip } from 'antd';
import { resolveJVMModeMeta } from '../../utils/jvmRuntimePresentation';
type JVMModeBadgeProps = {
mode: string;
label?: string;
reason?: string;
};
const JVMModeBadge: React.FC<JVMModeBadgeProps> = ({
mode,
label,
reason,
}) => {
const meta = resolveJVMModeMeta(mode);
const displayLabel = String(label || meta.label || 'Unknown').trim() || 'Unknown';
const content = (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
height: 20,
padding: '0 8px',
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
color: meta.color,
background: meta.backgroundColor,
flexShrink: 0,
}}
>
{displayLabel}
</span>
{reason ? (
<span
style={{
fontSize: 12,
color: '#cf1322',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{reason}
</span>
) : null}
</span>
);
if (!reason) {
return content;
}
return <Tooltip title={reason}>{content}</Tooltip>;
};
export default JVMModeBadge;

View File

@@ -47,6 +47,7 @@ const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 3600;
const PERSIST_VERSION = 8;
const DEFAULT_CONNECTION_TYPE = 'mysql';
const DEFAULT_JVM_PORT = 9010;
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
enabled: false,
type: 'socks5',
@@ -73,6 +74,7 @@ const SUPPORTED_CONNECTION_TYPES = new Set([
'mongodb',
'highgo',
'vastbase',
'jvm',
'sqlite',
'duckdb',
'custom',
@@ -97,6 +99,8 @@ const SSL_SUPPORTED_CONNECTION_TYPES = new Set([
const getDefaultPortByType = (type: string): number => {
switch (type) {
case 'jvm':
return DEFAULT_JVM_PORT;
case 'mysql':
case 'mariadb':
return 3306;
@@ -217,6 +221,68 @@ const normalizeConnectionType = (value: unknown): string => {
return SUPPORTED_CONNECTION_TYPES.has(type) ? type : DEFAULT_CONNECTION_TYPE;
};
const sanitizeJVMModes = (value: unknown): Array<'jmx' | 'endpoint' | 'agent'> => {
if (!Array.isArray(value)) return ['jmx'];
const result: Array<'jmx' | 'endpoint' | 'agent'> = [];
const seen = new Set<'jmx' | 'endpoint' | 'agent'>();
value.forEach((entry) => {
const normalized = toTrimmedString(entry).toLowerCase();
if (normalized !== 'jmx' && normalized !== 'endpoint' && normalized !== 'agent') return;
if (seen.has(normalized)) return;
seen.add(normalized);
result.push(normalized);
});
return result.length > 0 ? result : ['jmx'];
};
const sanitizeJVMConfig = (
value: unknown,
options: {
host: string;
port: number;
timeout: number;
persistSecrets: boolean;
}
): ConnectionConfig['jvm'] => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const allowedModes = sanitizeJVMModes(raw.allowedModes);
const preferredModeRaw = toTrimmedString(raw.preferredMode).toLowerCase();
const preferredMode = allowedModes.includes(preferredModeRaw as 'jmx' | 'endpoint' | 'agent')
? preferredModeRaw as 'jmx' | 'endpoint' | 'agent'
: allowedModes[0];
const environmentRaw = toTrimmedString(raw.environment, 'dev').toLowerCase();
const environment: 'dev' | 'uat' | 'prod' = environmentRaw === 'uat'
? 'uat'
: environmentRaw === 'prod'
? 'prod'
: 'dev';
const jmxRaw = (raw.jmx && typeof raw.jmx === 'object') ? raw.jmx as Record<string, unknown> : {};
const endpointRaw = (raw.endpoint && typeof raw.endpoint === 'object') ? raw.endpoint as Record<string, unknown> : {};
const fallbackPort = options.port > 0 ? options.port : DEFAULT_JVM_PORT;
const fallbackTimeout = options.timeout > 0 ? options.timeout : DEFAULT_TIMEOUT_SECONDS;
return {
environment,
readOnly: typeof raw.readOnly === 'boolean' ? raw.readOnly : true,
allowedModes,
preferredMode,
jmx: {
enabled: jmxRaw.enabled === true || allowedModes.includes('jmx'),
host: toTrimmedString(jmxRaw.host, options.host) || options.host,
port: normalizePort(jmxRaw.port, fallbackPort),
username: toTrimmedString(jmxRaw.username),
password: options.persistSecrets ? toTrimmedString(jmxRaw.password) : '',
domainAllowlist: sanitizeStringArray(jmxRaw.domainAllowlist, 256),
},
endpoint: {
enabled: endpointRaw.enabled === true,
baseUrl: toTrimmedString(endpointRaw.baseUrl),
apiKey: options.persistSecrets ? toTrimmedString(endpointRaw.apiKey) : '',
timeoutSeconds: normalizeIntegerInRange(endpointRaw.timeoutSeconds, fallbackTimeout, 1, MAX_TIMEOUT_SECONDS),
},
};
};
const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
const raw = (value && typeof value === 'object') ? value as Record<string, unknown> : {};
const type = normalizeConnectionType(raw.type);
@@ -309,6 +375,15 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
safeConfig.dsn = toTrimmedString(raw.dsn).slice(0, MAX_URI_LENGTH);
}
if (type === 'jvm') {
safeConfig.jvm = sanitizeJVMConfig(raw.jvm, {
host: safeConfig.host,
port: safeConfig.port,
timeout: safeConfig.timeout || DEFAULT_TIMEOUT_SECONDS,
persistSecrets: savePassword,
});
}
return safeConfig;
};

View File

@@ -201,13 +201,16 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview' | 'jvm-overview' | 'jvm-resource' | 'jvm-audit';
connectionId: string;
dbName?: string;
tableName?: string;
query?: string;
initialTab?: string;
readOnly?: boolean;
providerMode?: 'jmx' | 'endpoint' | 'agent';
resourcePath?: string;
resourceKind?: string;
redisDB?: number; // Redis database index for redis tabs
triggerName?: string; // Trigger name for trigger tabs
viewName?: string; // View name for view definition tabs
@@ -410,4 +413,3 @@ export interface SecurityUpdateStatus {
issues: SecurityUpdateIssue[];
lastError?: string;
}

View File

@@ -7,13 +7,22 @@ import (
var newJVMProvider = jvm.NewProvider
func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult {
func resolveJVMProvider(cfg connection.ConnectionConfig) (connection.ConnectionConfig, jvm.Provider, error) {
normalized, err := jvm.NormalizeConnectionConfig(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.ConnectionConfig{}, nil, err
}
provider, err := newJVMProvider(normalized.JVM.PreferredMode)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
return normalized, provider, nil
}
func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, provider, err := resolveJVMProvider(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
@@ -25,6 +34,34 @@ func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.Quer
return connection.QueryResult{Success: true, Message: "JVM 连接成功"}
}
func (a *App) JVMListResources(cfg connection.ConnectionConfig, parentPath string) connection.QueryResult {
normalized, provider, err := resolveJVMProvider(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
items, err := provider.ListResources(a.ctx, normalized, parentPath)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: items}
}
func (a *App) JVMGetValue(cfg connection.ConnectionConfig, resourcePath string) connection.QueryResult {
normalized, provider, err := resolveJVMProvider(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
value, err := provider.GetValue(a.ctx, normalized, resourcePath)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: value}
}
func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, err := jvm.NormalizeConnectionConfig(cfg)
if err != nil {

View File

@@ -15,7 +15,9 @@ type fakeJVMProvider struct {
probe []jvm.Capability
probeErr error
list []jvm.ResourceSummary
listErr error
value jvm.ValueSnapshot
valueErr error
apply jvm.ApplyResult
}
@@ -27,10 +29,10 @@ func (f fakeJVMProvider) ProbeCapabilities(context.Context, connection.Connectio
return f.probe, f.probeErr
}
func (f fakeJVMProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]jvm.ResourceSummary, error) {
return f.list, nil
return f.list, f.listErr
}
func (f fakeJVMProvider) GetValue(context.Context, connection.ConnectionConfig, string) (jvm.ValueSnapshot, error) {
return f.value, nil
return f.value, f.valueErr
}
func (f fakeJVMProvider) PreviewChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ChangePreview, error) {
return jvm.ChangePreview{Allowed: true, Summary: "preview"}, nil
@@ -238,3 +240,84 @@ func TestJVMProbeCapabilitiesUsesReadableLabelForUnsupportedMode(t *testing.T) {
t.Fatalf("expected unsupported mode error, got %#v", items[0])
}
}
func TestJVMListResourcesReturnsProviderPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
list: []jvm.ResourceSummary{
{
ID: "memory.heap",
Kind: "folder",
Name: "Heap",
Path: "/memory/heap",
ProviderMode: jvm.ModeJMX,
CanRead: true,
HasChildren: true,
},
},
}, nil
})
defer restore()
res := app.JVMListResources(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, "/memory")
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.ResourceSummary)
if !ok || len(items) != 1 {
t.Fatalf("expected one resource summary, got %#v", res.Data)
}
if items[0].Path != "/memory/heap" {
t.Fatalf("expected resource path %q, got %#v", "/memory/heap", items[0])
}
}
func TestJVMGetValueReturnsProviderPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "memory.heap.used",
Kind: "metric",
Format: "number",
Value: 128,
Metadata: map[string]any{
"unit": "MiB",
},
},
}, nil
})
defer restore()
res := app.JVMGetValue(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, "/memory/heap/used")
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
snapshot, ok := res.Data.(jvm.ValueSnapshot)
if !ok {
t.Fatalf("expected value snapshot, got %#v", res.Data)
}
if snapshot.ResourceID != "memory.heap.used" {
t.Fatalf("expected resource id %q, got %#v", "memory.heap.used", snapshot)
}
if snapshot.Metadata["unit"] != "MiB" {
t.Fatalf("expected unit metadata %q, got %#v", "MiB", snapshot.Metadata)
}
}