release/0.7.1

This commit is contained in:
Syngnat
2026-04-28 19:38:48 +08:00
committed by GitHub
79 changed files with 8040 additions and 302 deletions

View File

@@ -0,0 +1,73 @@
# 需求进度追踪 - AI聊天发送快捷键
## 1. 需求摘要
- 需求名称AI 聊天发送快捷键
- 提出日期2026-04-28
- 负责人Claude Code
- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。
- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。
## 2. 范围与验收
- 范围工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。
- 验收标准工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送Shift+Enter 始终换行;输入法 composing 状态不发送刷新后快捷键保持AI 设置弹窗不再出现独立“聊天输入”快捷键入口。
- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。
## 3. 里程碑与进度
- [x] 阶段 1需求澄清确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。
- [x] 阶段 2影响分析影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。
- [x] 阶段 3方案设计采用共享 `shortcutOptions` actionAI 输入框局部消费,不走全局快捷键执行器。
- [x] 阶段 4实施计划计划已按用户反馈调整为工具中心统一方案。
- [x] 阶段 5实现与自检目标红灯测试已补充新方案核心实现已完成。
- [x] 阶段 6评审与交付已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。
- [ ] 阶段 7发布与观察发布后观察用户输入法场景反馈。
## 4. 变更清单
- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。
- 进行中:无。
- 待处理:发布后观察输入法场景反馈。
## 5. 风险与阻塞
- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。
- 阻塞:无。
- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter普通 Enter 不再触发发送AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。
## 6. 决策记录
- 决策 1AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
- 决策 2`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
- 决策 3AI 发送快捷键允许默认无修饰键 Enter但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
- 决策 4输入法 composing 状态始终不发送。
- 决策 5AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
- 决策 6AI 输入框命中发送快捷键后同时执行 `preventDefault``stopPropagation`,避免事件继续冒泡到全局快捷键处理器。
## 7. 验证记录
- 验证项:初版两档下拉方案红灯测试。
- 结果:已确认旧实现失败。
- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。
- 验证项:工具中心统一方案红灯测试。
- 结果:已确认旧实现失败。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`
- 验证项:工具中心统一方案目标绿灯测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`6 passed`src/components/ai/AIChatInput.notice.test.tsx`2 passed`src/store.test.ts`10 passed
- 验证项:代码审查反馈红灯测试。
- 结果:已确认旧实现失败。
- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。
- 验证项:代码审查反馈修复后目标测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`3 files passed22 tests passed
- 验证项:浏览器手工验证。
- 结果:已通过。
- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
- 验证项:前端全量测试。
- 结果:已通过。
- 证据:`npm --prefix frontend test -- --run`88 files passed421 tests passed
- 验证项diff 空白检查。
- 结果:已通过。
- 证据:`git diff --check` 无输出。
- 验证项:生产构建。
- 结果:已通过。
- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
## 8. 下一步
- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
- 负责人Claude Code

View File

@@ -33,8 +33,10 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-resizable": "^3.0.8",
"@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^3.2.4"
@@ -2037,6 +2039,16 @@
"@types/react": "*"
}
},
"node_modules/@types/react-test-renderer": {
"version": "18.0.7",
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.7.tgz",
"integrity": "sha512-1+ANPOWc6rB3IkSnElhjv6VLlKg2dSv/OWClUyZimbLsQyBn8Js9Vtdsi3UICJ2rIQ3k2la06dkB+C92QfhKmg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -5645,6 +5657,20 @@
"react-dom": ">= 16.3"
}
},
"node_modules/react-shallow-renderer": {
"version": "16.15.0",
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-syntax-highlighter": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
@@ -5665,6 +5691,21 @@
"react": ">= 0.14.0"
}
},
"node_modules/react-test-renderer": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz",
"integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"react-is": "^18.2.0",
"react-shallow-renderer": "^16.15.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",

View File

@@ -35,8 +35,10 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-resizable": "^3.0.8",
"@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^3.2.4"

View File

@@ -1 +1 @@
571d014306268cf67665967059cda912
0295a42fd931778d85157816d79d29e5

View File

@@ -62,9 +62,9 @@ import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
ShortcutAction,
canRecordShortcutForAction,
eventToShortcut,
getShortcutDisplay,
hasModifierKey,
isEditableElement,
isShortcutMatch,
normalizeShortcutCombo,
@@ -2387,11 +2387,15 @@ function App() {
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => {
const meta = SHORTCUT_ACTION_META[action];
if (meta.scope && meta.scope !== 'global') {
return false;
}
const binding = shortcutOptions[action];
if (!binding?.enabled) {
return false;
}
if (isEditableElement(event.target) && !SHORTCUT_ACTION_META[action].allowInEditable) {
if (isEditableElement(event.target) && !meta.allowInEditable) {
return false;
}
return isShortcutMatch(event, binding.combo);
@@ -2455,12 +2459,15 @@ function App() {
if (!combo) {
return;
}
if (!hasModifierKey(combo)) {
void message.warning('快捷键至少包含 Ctrl / Alt / Shift / Meta 之一');
return;
}
const normalizedCombo = normalizeShortcutCombo(combo);
if (!canRecordShortcutForAction(capturingShortcutAction, normalizedCombo)) {
const meta = SHORTCUT_ACTION_META[capturingShortcutAction];
void message.warning(meta.scope === 'aiComposer'
? 'AI 聊天发送快捷键仅支持 Enter / Ctrl+Enter / Cmd+Enter / Alt+EnterShift+Enter 保留换行'
: '快捷键至少包含 Ctrl / Alt / Shift / Meta 之一');
return;
}
const conflictAction = SHORTCUT_ACTION_ORDER.find((action) => {
if (action === capturingShortcutAction) {
return false;
@@ -3482,7 +3489,7 @@ function App() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8 }}>
<div style={utilityPanelStyle}>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
Esc Ctrl/Alt/Shift/Meta
Esc AI Enter Shift+Enter
</div>
</div>
{SHORTCUT_ACTION_ORDER.map((action) => {

View File

@@ -25,6 +25,9 @@ import {
buildMissingProviderNotice,
buildModelFetchFailedNotice,
} from '../utils/aiComposerNotice';
import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
interface AIChatPanelProps {
width?: number;
@@ -254,6 +257,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
const aiChatSendShortcutBinding = useStore(state => state.shortcutOptions.sendAIChatMessage);
const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => {
const state = useStore.getState();
@@ -1145,12 +1149,15 @@ SELECT * FROM users WHERE status = 1;
try {
const safeDbName = args.dbName ? String(args.dbName).trim() : '';
const safeTable = args.tableName ? String(args.tableName).trim() : '';
const { DBShowCreateTable } = await import('../../wailsjs/go/app/App');
const ddlRes = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeTable);
if (ddlRes?.success) {
resStr = typeof ddlRes.data === 'string' ? ddlRes.data : JSON.stringify(ddlRes.data);
success = true;
} else { resStr = ddlRes?.message || 'Failed to fetch DDL'; }
const { DBShowCreateTable, DBGetColumns } = await import('../../wailsjs/go/app/App');
const rpcConfig = buildRpcConnectionConfig(conn.config) as any;
const toolResult = await resolveAITableSchemaToolResult({
tableName: safeTable,
fetchDDL: () => DBShowCreateTable(rpcConfig, safeDbName, safeTable),
fetchColumns: () => DBGetColumns(rpcConfig, safeDbName, safeTable),
});
resStr = toolResult.content;
success = toolResult.success;
} catch (e: any) {
resStr = `获取建表语句失败: ${e?.message || e}`;
}
@@ -1173,14 +1180,8 @@ SELECT * FROM users WHERE status = 1;
}
}
const { DBQuery } = await import('../../wailsjs/go/app/App');
// 只对只读查询自动追加 LIMIT写操作UPDATE/DELETE/INSERT等不追加
const sqlTrimmed = safeSql.replace(/;\s*$/, ''); // 去掉末尾分号防止拼接出 "; LIMIT 50"
const sqlFirstWord = sqlTrimmed.trimStart().split(/\s/)[0]?.toLowerCase() || '';
const isReadQuery = ['select', 'show', 'describe', 'desc', 'explain', 'with'].includes(sqlFirstWord);
const finalSql = (isReadQuery && !sqlTrimmed.toLowerCase().includes('limit'))
? sqlTrimmed + ' LIMIT 50'
: sqlTrimmed;
const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, safeSql + (safeSql.toLowerCase().includes('limit') ? '' : ' LIMIT 50'));
const finalSql = buildAIReadonlyPreviewSQL(conn.config?.type || '', safeSql, 50, conn.config?.driver || '');
const qRes = await DBQuery(buildRpcConnectionConfig(conn.config) as any, safeDbName, finalSql);
if (qRes?.success) {
const rows = Array.isArray(qRes.data) ? qRes.data : [];
const limitedRows = rows.slice(0, 50);
@@ -1471,11 +1472,8 @@ SELECT * FROM users WHERE status = 1;
]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, e, handleSend);
}, [aiChatSendShortcutBinding, handleSend]);
const handleStop = useCallback(async () => {
try {
@@ -1706,6 +1704,7 @@ SELECT * FROM users WHERE status = 1;
activeProvider={activeProvider}
dynamicModels={dynamicModels}
loadingModels={loadingModels}
sendShortcutBinding={aiChatSendShortcutBinding}
composerNotice={composerNotice}
onModelChange={handleModelChange}
onFetchModels={fetchDynamicModels}

View File

@@ -20,7 +20,6 @@ import {
} from '../utils/aiSettingsPresetLayout';
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
interface AISettingsModalProps {

View File

@@ -0,0 +1,312 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DataGrid from './DataGrid';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
showDataTableVerticalBorders: false,
dataTableColumnWidthMode: 'standard',
},
queryOptions: {
showColumnComment: false,
showColumnType: false,
},
setQueryOptions: vi.fn(),
tableColumnOrders: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
setEnableColumnOrderMemory: vi.fn(),
clearTableColumnOrder: vi.fn(),
tableHiddenColumns: {},
enableHiddenColumnMemory: false,
setTableHiddenColumns: vi.fn(),
setEnableHiddenColumnMemory: vi.fn(),
clearTableHiddenColumns: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
ImportData: vi.fn(),
ExportTable: vi.fn(),
ExportData: vi.fn(),
ExportQuery: vi.fn(),
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
loading: vi.fn(() => vi.fn()),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof storeState) => any) => selector(storeState),
}));
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('@monaco-editor/react', () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
}));
vi.mock('./ImportPreviewModal', () => ({
default: () => null,
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
ReloadOutlined: Icon,
ImportOutlined: Icon,
ExportOutlined: Icon,
DownOutlined: Icon,
PlusOutlined: Icon,
DeleteOutlined: Icon,
SaveOutlined: Icon,
UndoOutlined: Icon,
FilterOutlined: Icon,
CloseOutlined: Icon,
ConsoleSqlOutlined: Icon,
FileTextOutlined: Icon,
CopyOutlined: Icon,
ClearOutlined: Icon,
EditOutlined: Icon,
VerticalAlignBottomOutlined: Icon,
LeftOutlined: Icon,
RightOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
};
});
vi.mock('@dnd-kit/core', () => ({
DndContext: ({ children }: any) => <>{children}</>,
PointerSensor: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
closestCenter: vi.fn(),
}));
vi.mock('@dnd-kit/sortable', () => ({
SortableContext: ({ children }: any) => <>{children}</>,
useSortable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: undefined,
isDragging: false,
})),
horizontalListSortingStrategy: vi.fn(),
arrayMove: (items: any[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
},
}));
vi.mock('@dnd-kit/utilities', () => ({
CSS: {
Transform: {
toString: () => '',
},
},
}));
vi.mock('antd', () => {
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button type="button" disabled={disabled || loading} data-button-type={type} onClick={onClick} {...rest}>
{children}
</button>
);
const Input: any = ({ value, onChange, placeholder, ...rest }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} {...rest} />
);
Input.TextArea = ({ value, onChange, placeholder }: any) => (
<textarea value={value} onChange={onChange} placeholder={placeholder} />
);
const createForm = () => ({
resetFields: vi.fn(),
setFieldsValue: vi.fn(),
getFieldsValue: vi.fn(() => ({})),
getFieldValue: vi.fn(),
validateFields: vi.fn(() => Promise.resolve({})),
});
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [createForm()];
const Modal: any = ({ children, footer, open, title }: any) => (
open ? (
<section data-modal-title={title}>
<h2>{title}</h2>
{children}
<div>{footer}</div>
</section>
) : null
);
Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null];
const passthrough = ({ children }: any) => <>{children}</>;
return {
Table: () => <table />,
message: messageApi,
Input,
Button,
Dropdown: passthrough,
Form,
Pagination: () => null,
Select: () => null,
Modal,
Checkbox: ({ checked, onChange }: any) => <input type="checkbox" checked={checked} onChange={onChange} />,
Segmented: () => null,
Tooltip: passthrough,
Popover: passthrough,
DatePicker: () => null,
TimePicker: () => null,
AutoComplete: ({ children }: any) => <>{children}</>,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe('DataGrid DDL interactions', () => {
beforeEach(() => {
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' });
vi.stubGlobal('document', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
activeElement: null,
elementFromPoint: vi.fn(() => null),
createElement: vi.fn(() => ({
style: {},
getContext: vi.fn(() => ({ measureText: vi.fn(() => ({ width: 0 })) })),
})),
body: { style: {} },
});
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
innerHeight: 768,
innerWidth: 1024,
getComputedStyle: vi.fn(() => ({ font: '12px sans-serif' })),
});
vi.stubGlobal('navigator', {
platform: 'MacIntel',
userAgent: '',
clipboard: { writeText: vi.fn(() => Promise.resolve()) },
});
vi.stubGlobal('HTMLElement', class {});
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
afterEach(() => {
backendApp.ImportData.mockReset();
backendApp.ExportTable.mockReset();
backendApp.ExportData.mockReset();
backendApp.ExportQuery.mockReset();
backendApp.ApplyChanges.mockReset();
backendApp.DBGetColumns.mockReset();
backendApp.DBGetIndexes.mockReset();
backendApp.DBShowCreateTable.mockReset();
vi.unstubAllGlobals();
});
it('ignores stale DDL responses after the table context changes', async () => {
let resolveFirstRequest: (value: any) => void = () => {};
backendApp.DBShowCreateTable.mockReturnValueOnce(new Promise((resolve) => {
resolveFirstRequest = resolve;
}));
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await act(async () => {
renderer!.update(
<DataGrid
data={[{ __gonavi_row_key__: 'row-2', id: 2 }]}
columnNames={['id']}
loading={false}
tableName="orders"
dbName="main"
connectionId="conn-1"
/>,
);
resolveFirstRequest({ success: true, data: 'CREATE TABLE users' });
});
await waitForEffects();
expect(textContent(renderer!.root)).not.toContain('CREATE TABLE users');
expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0);
});
});

View File

@@ -44,6 +44,7 @@ vi.mock('../../wailsjs/go/app/App', () => ({
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
vi.mock('@monaco-editor/react', () => ({
@@ -76,6 +77,73 @@ describe('DataGrid layout', () => {
expect(markup).toContain('data-grid-secondary-actions="true"');
expect(markup).toContain('data-grid-view-switcher="true"');
expect(markup).toContain('data-grid-page-find="true"');
expect(markup).toContain('data-grid-page-find-prev="true"');
expect(markup).toContain('data-grid-page-find-next="true"');
expect(markup).toContain('当前页查找...');
});
it('renders a DDL action for table data pages only', () => {
const tableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
expect(tableMarkup).toContain('data-grid-ddl-action="true"');
expect(tableMarkup).toContain('查看 DDL');
const schemaTableMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="public.users"
dbName=""
connectionId="conn-1"
/>,
);
expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"');
expect(schemaTableMarkup).toContain('查看 DDL');
expect(schemaTableMarkup).toContain('data-grid-page-find="true"');
const queryMarkup = renderToStaticMarkup(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
exportScope="queryResult"
/>,
);
expect(queryMarkup).not.toContain('data-grid-ddl-action="true"');
});
it('renders row copy and paste actions in editable table toolbar', () => {

View File

@@ -1,10 +1,10 @@
// cspell:ignore anticon sqls uuidv uuidv4 hscroll
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useRef, useContext, useMemo, useCallback, useDeferredValue } from 'react';
import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker, AutoComplete } from 'antd';
import dayjs from 'dayjs';
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined } from '@ant-design/icons';
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import {
DndContext,
@@ -23,7 +23,7 @@ import {
arrayMove
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes } from '../../wailsjs/go/app/App';
import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
import type { ColumnDefinition, IndexDefinition } from '../types';
@@ -68,6 +68,17 @@ import {
resolveWhereConditionSuggestions,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
import {
attachDataGridFindRenderVersion,
collectDataGridFindMatches,
findDataGridTextRanges,
hasDataGridFindRenderVersionChanged,
normalizeDataGridFindQuery,
resolveDataGridFindNavigationIndex,
summarizeDataGridFindMatches,
type DataGridFindMatch,
type DataGridFindNavigationDirection,
} from '../utils/dataGridFind';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -185,9 +196,9 @@ const normalizeDateTimeString = (val: string) => {
};
// --- Helper: Format Value ---
const formatCellValue = (val: any) => {
const formatCellDisplayText = (val: any): string => {
try {
if (val === null) return <span style={{ color: '#ccc' }}>NULL</span>;
if (val === null) return 'NULL';
if (typeof val === 'object') {
if (!Array.isArray(val) && !isPlainObject(val)) {
return String(val);
@@ -222,6 +233,38 @@ const formatCellValue = (val: any) => {
}
};
const renderHighlightedCellText = (text: string, query: string): React.ReactNode => {
const ranges = findDataGridTextRanges(text, query);
if (ranges.length === 0) return text;
const nodes: React.ReactNode[] = [];
let cursor = 0;
ranges.forEach((range, index) => {
if (range.start > cursor) {
nodes.push(text.slice(cursor, range.start));
}
nodes.push(
<mark key={`${range.start}-${range.end}-${index}`} className="data-grid-find-highlight">
{text.slice(range.start, range.end)}
</mark>,
);
cursor = range.end;
});
if (cursor < text.length) {
nodes.push(text.slice(cursor));
}
return <>{nodes}</>;
};
const renderCellDisplayValue = (val: any, query: string): React.ReactNode => {
const text = formatCellDisplayText(val);
const content = renderHighlightedCellText(text, query);
if (val === null) return <span style={{ color: '#ccc' }}>{content}</span>;
return content;
};
const formatCellValue = (val: any) => renderCellDisplayValue(val, '');
const toEditableText = (val: any): string => {
if (val === null || val === undefined) return '';
if (typeof val === 'string') return val;
@@ -965,6 +1008,15 @@ const DataGrid: React.FC<DataGridProps> = ({
const [displayColumnNames, setDisplayColumnNames] = useState<string[]>([]);
const [localHiddenColumns, setLocalHiddenColumns] = useState<string[]>([]);
const [columnSearchText, setColumnSearchText] = useState('');
const [pageFindText, setPageFindText] = useState('');
const [activePageFindMatchIndex, setActivePageFindMatchIndex] = useState(-1);
const deferredPageFindText = useDeferredValue(pageFindText);
const normalizedPageFindText = useMemo(() => normalizeDataGridFindQuery(deferredPageFindText), [deferredPageFindText]);
useEffect(() => {
setPageFindText('');
setActivePageFindMatchIndex(-1);
}, [connectionId, dbName, tableName]);
// Sync hidden columns from store
useEffect(() => {
@@ -1081,6 +1133,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const isQueryResultExport = exportScope === 'queryResult';
const canImport = exportScope === 'table' && !!tableName;
const canExport = !!connectionId && (isQueryResultExport || !!tableName);
const canViewDdl = exportScope === 'table' && !!connectionId && !!tableName;
const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]);
const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0;
@@ -1192,6 +1245,10 @@ const DataGrid: React.FC<DataGridProps> = ({
const cellEditorApplyRef = useRef<((val: string) => void) | null>(null);
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonEditorValue, setJsonEditorValue] = useState('');
const [ddlModalOpen, setDdlModalOpen] = useState(false);
const [ddlLoading, setDdlLoading] = useState(false);
const [ddlText, setDdlText] = useState('');
const ddlRequestSeqRef = useRef(0);
// --- Data Preview Panel State ---
const [dataPanelOpen, setDataPanelOpen] = useState(false);
@@ -1755,6 +1812,12 @@ const DataGrid: React.FC<DataGridProps> = ({
.${gridId} .ant-table-sticky-scroll {
display: none !important;
}
.${gridId} .data-grid-find-highlight {
padding: 0 1px;
border-radius: 3px;
background: ${darkMode ? 'rgba(246, 196, 83, 0.42)' : 'rgba(255, 193, 7, 0.42)'};
color: inherit;
}
/* 虚拟表列对齐:阻止 header <table> 通过 min-width:100% 拉伸到视口,
使 header 列宽与虚拟 body 单元格宽度精确一致 */
.${gridId} .ant-table-header > table {
@@ -2294,6 +2357,10 @@ const DataGrid: React.FC<DataGridProps> = ({
rowEditorBaseRawRef.current = {};
rowEditorDisplayRef.current = {};
rowEditorNullColsRef.current = new Set();
ddlRequestSeqRef.current += 1;
setDdlModalOpen(false);
setDdlLoading(false);
setDdlText('');
rowEditorForm.resetFields();
closeCellEditor();
form.resetFields();
@@ -3184,6 +3251,43 @@ const DataGrid: React.FC<DataGridProps> = ({
});
}, [displayData, modifiedRows]);
const pageFindMatches = useMemo(() => collectDataGridFindMatches(
mergedDisplayData,
displayColumnNames,
normalizedPageFindText,
(value) => formatCellDisplayText(value),
(row, rowIndex) => String(row?.[GONAVI_ROW_KEY] ?? `row-${rowIndex}`),
), [mergedDisplayData, displayColumnNames, normalizedPageFindText]);
const pageFindSummary = useMemo(() => summarizeDataGridFindMatches(
mergedDisplayData,
displayColumnNames,
normalizedPageFindText,
(value) => formatCellDisplayText(value),
), [mergedDisplayData, displayColumnNames, normalizedPageFindText]);
useEffect(() => {
setActivePageFindMatchIndex(-1);
}, [normalizedPageFindText, mergedDisplayData, displayColumnNames]);
useEffect(() => {
if (normalizedPageFindText) return;
const emptySelection = new Set<string>();
setSelectedCells(emptySelection);
currentSelectionRef.current = emptySelection;
selectionStartRef.current = null;
updateCellSelection(emptySelection);
}, [normalizedPageFindText, updateCellSelection]);
const activePageFindPosition = activePageFindMatchIndex >= 0 && activePageFindMatchIndex < pageFindMatches.length
? activePageFindMatchIndex + 1
: 0;
const tableRenderData = useMemo(
() => attachDataGridFindRenderVersion(mergedDisplayData, normalizedPageFindText),
[mergedDisplayData, normalizedPageFindText]
);
useEffect(() => {
setTextRecordIndex(prev => {
if (mergedDisplayData.length === 0) return 0;
@@ -3532,12 +3636,13 @@ const DataGrid: React.FC<DataGridProps> = ({
editable: canModifyData, // Only editable if table name known and not readonly
render: (text: any) => (
<div style={CELL_ELLIPSIS_STYLE}>
{formatCellValue(text)}
{renderCellDisplayValue(text, normalizedPageFindText)}
</div>
),
shouldCellUpdate: (record: Item, prevRecord: Item) => {
const rowKeyChanged = record?.[GONAVI_ROW_KEY] !== prevRecord?.[GONAVI_ROW_KEY];
if (rowKeyChanged) return true;
if (hasDataGridFindRenderVersionChanged(record, prevRecord)) return true;
return !isCellValueEqualForRender(record?.[key], prevRecord?.[key]);
},
onHeaderCell: (column: any) => ({
@@ -3568,7 +3673,7 @@ const DataGrid: React.FC<DataGridProps> = ({
},
}),
}));
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode]);
}, [displayColumnNames, columnWidths, sortInfo, handleResizeStart, handleResizeAutoFit, canModifyData, onSort, renderColumnTitle, dataTableColumnWidthMode, normalizedPageFindText]);
const mergedColumns = useMemo(() => columns.map((col): ColumnType<any> => {
const dataIndex = String(col.dataIndex);
@@ -3840,6 +3945,43 @@ const DataGrid: React.FC<DataGridProps> = ({
void message.success("Copied to clipboard");
}, []);
const handleOpenTableDdl = useCallback(async () => {
if (!canViewDdl || !currentConnConfig || !tableName) {
void message.error('当前表缺少连接或表名,无法查看 DDL');
return;
}
const requestSeq = ++ddlRequestSeqRef.current;
setDdlModalOpen(true);
setDdlLoading(true);
setDdlText('');
try {
const res = await DBShowCreateTable(buildRpcConnectionConfig(currentConnConfig) as any, dbName || '', tableName);
if (requestSeq !== ddlRequestSeqRef.current) return;
if (res.success) {
setDdlText(String(res.data ?? ''));
return;
}
void message.error(res.message || '获取 DDL 失败');
} catch (error: any) {
if (requestSeq !== ddlRequestSeqRef.current) return;
void message.error(error?.message || '获取 DDL 失败');
} finally {
if (requestSeq === ddlRequestSeqRef.current) {
setDdlLoading(false);
}
}
}, [canViewDdl, currentConnConfig, dbName, tableName]);
const handleCopyDdl = useCallback(() => {
if (!ddlText.trim()) {
void message.info('暂无可复制的 DDL');
return;
}
navigator.clipboard.writeText(ddlText)
.then(() => message.success('DDL 已复制到剪贴板'))
.catch(() => message.error('复制 DDL 失败'));
}, [ddlText]);
const handleCopySelectedCellsToClipboard = useCallback(() => {
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) {
@@ -4564,6 +4706,67 @@ const DataGrid: React.FC<DataGridProps> = ({
return virtualHolder || rcVirtualHolder || body;
}, []);
const focusPageFindMatch = useCallback((match: DataGridFindMatch) => {
if (!match) return;
const nextSelection = new Set([makeCellKey(match.rowKey, match.columnName)]);
setSelectedCells(nextSelection);
currentSelectionRef.current = nextSelection;
selectionStartRef.current = {
rowKey: match.rowKey,
colName: match.columnName,
rowIndex: match.rowIndex,
colIndex: match.columnIndex,
};
const targetRow = mergedDisplayData[match.rowIndex] || mergedDisplayData.find((row) => {
const rowKey = row?.[GONAVI_ROW_KEY];
return rowKey !== undefined && rowKey !== null && rowKeyStr(rowKey) === match.rowKey;
});
if (targetRow && dataPanelOpenRef.current) {
updateFocusedCell(targetRow, match.columnName);
}
const applyVisibleFocus = () => {
const root = containerRef.current;
if (!root) return false;
const cell = Array.from(root.querySelectorAll('.ant-table-cell[data-row-key][data-col-name]')).find((node) => {
const el = node as HTMLElement;
return el.getAttribute('data-row-key') === match.rowKey && el.getAttribute('data-col-name') === match.columnName;
}) as HTMLElement | undefined;
updateCellSelection(nextSelection);
if (!cell) return false;
cell.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
return true;
};
if (applyVisibleFocus()) return;
const tableContainer = tableContainerRef.current;
if (tableContainer instanceof HTMLElement) {
const verticalTarget = pickVerticalScrollTarget(tableContainer);
if (verticalTarget) {
const firstCell = tableContainer.querySelector('.ant-table-cell[data-row-key]') as HTMLElement | null;
const rowHeight = Math.max(24, Math.ceil(firstCell?.getBoundingClientRect().height || 38));
verticalTarget.scrollTop = Math.max(0, (match.rowIndex - 1) * rowHeight);
}
}
requestAnimationFrame(() => {
if (applyVisibleFocus()) return;
requestAnimationFrame(() => {
applyVisibleFocus();
});
});
}, [mergedDisplayData, pickVerticalScrollTarget, rowKeyStr, updateCellSelection, updateFocusedCell]);
const handleNavigatePageFind = useCallback((direction: DataGridFindNavigationDirection) => {
const nextIndex = resolveDataGridFindNavigationIndex(activePageFindMatchIndex, pageFindMatches.length, direction);
if (nextIndex < 0) return;
setActivePageFindMatchIndex(nextIndex);
const match = pageFindMatches[nextIndex];
if (match) focusPageFindMatch(match);
}, [activePageFindMatchIndex, pageFindMatches, focusPageFindMatch]);
const syncExternalScrollFromTargets = useCallback((targets?: HTMLElement[], source?: HTMLElement | null) => {
const externalScroll = externalHorizontalScrollRef.current;
if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') {
@@ -5619,6 +5822,39 @@ const DataGrid: React.FC<DataGridProps> = ({
/>
)}
</Modal>
<Modal
title={tableName ? `DDL - ${tableName}` : 'DDL'}
open={ddlModalOpen}
onCancel={() => setDdlModalOpen(false)}
destroyOnHidden
width={960}
footer={[
<Button key="copy" icon={<CopyOutlined />} onClick={handleCopyDdl} disabled={!ddlText.trim()}>
DDL
</Button>,
<Button key="close" type="primary" onClick={() => setDdlModalOpen(false)}>
</Button>,
]}
>
{ddlModalOpen && (
<Editor
height="56vh"
language="sql"
theme={darkMode ? "transparent-dark" : "transparent-light"}
value={ddlLoading ? '正在加载 DDL...' : ddlText}
options={{
readOnly: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "off",
fontSize: 12,
tabSize: 2,
automaticLayout: true,
}}
/>
)}
</Modal>
{viewMode === 'table' ? (
<div
@@ -5640,7 +5876,7 @@ const DataGrid: React.FC<DataGridProps> = ({
<SortableContext items={displayColumnNames} strategy={horizontalListSortingStrategy}>
<Table
components={tableComponents}
dataSource={mergedDisplayData}
dataSource={tableRenderData}
columns={mergedColumns}
showSorterTooltip={{ target: 'sorter-icon' }}
size="small"
@@ -6110,6 +6346,53 @@ const DataGrid: React.FC<DataGridProps> = ({
>
<Button icon={<FileTextOutlined />}></Button>
</Popover>
{canViewDdl && (
<Button
data-grid-ddl-action="true"
icon={<FileTextOutlined />}
loading={ddlLoading}
onClick={handleOpenTableDdl}
>
DDL
</Button>
)}
<Tooltip title="仅查找当前页已加载数据,不改变 WHERE 条件">
<div data-grid-page-find="true" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Input
{...noAutoCapInputProps}
allowClear
size="small"
prefix={<SearchOutlined />}
placeholder="当前页查找..."
value={pageFindText}
onChange={(event) => setPageFindText(event.target.value)}
style={{ width: 220 }}
/>
<Button
data-grid-page-find-prev="true"
size="small"
icon={<LeftOutlined />}
disabled={pageFindMatches.length === 0}
onClick={() => handleNavigatePageFind('previous')}
>
</Button>
<Button
data-grid-page-find-next="true"
size="small"
icon={<RightOutlined />}
disabled={pageFindMatches.length === 0}
onClick={() => handleNavigatePageFind('next')}
>
</Button>
{normalizedPageFindText && (
<span aria-live="polite" style={{ fontSize: 12, color: darkMode ? '#999' : '#666', whiteSpace: 'nowrap' }}>
{pageFindMatches.length > 0 ? `${activePageFindPosition} / ${pageFindMatches.length} · ` : ''} {pageFindSummary.occurrenceCount} / {pageFindSummary.matchedCellCount}
</span>
)}
</div>
</Tooltip>
</div>
<div data-grid-view-switcher="true" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}></span>

View File

@@ -1,5 +1,7 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create } from "react-test-renderer";
import { message } from "antd";
import { beforeEach, describe, expect, it, vi } from "vitest";
import JVMDiagnosticConsole, {
@@ -33,6 +35,11 @@ const baseState = {
let mockState: any = baseState;
let registeredCompletionProvider: any = null;
let registeredDiagnosticChunkHandler: any = null;
const mockBackendApp = {
JVMListDiagnosticAuditRecords: vi.fn(),
JVMExecuteDiagnosticCommand: vi.fn(),
};
const mockMonaco = {
Range: class {
startLineNumber: number;
@@ -105,6 +112,58 @@ vi.mock("@monaco-editor/react", () => ({
},
}));
vi.mock("../../wailsjs/runtime", () => ({
EventsOn: vi.fn((_eventName: string, handler: any) => {
registeredDiagnosticChunkHandler = handler;
return vi.fn();
}),
}));
vi.mock("@ant-design/icons", () => {
const Icon = () => <span />;
return {
ClearOutlined: Icon,
HistoryOutlined: Icon,
PauseCircleOutlined: Icon,
PlayCircleOutlined: Icon,
ReloadOutlined: Icon,
RocketOutlined: Icon,
ToolOutlined: Icon,
};
});
vi.mock("antd", () => {
const passthrough = ({ children, style }: any) => <div style={style}>{children}</div>;
const Text = ({ children, style }: any) => <span style={style}>{children}</span>;
const Paragraph = ({ children, style }: any) => <p style={style}>{children}</p>;
const Title = ({ children, style }: any) => <h3 style={style}>{children}</h3>;
const Empty = ({ description }: any) => <div>{description}</div>;
Empty.PRESENTED_IMAGE_SIMPLE = "simple";
const List = ({ dataSource = [], renderItem }: any) => (
<div>{dataSource.map((item: any, index: number) => renderItem(item, index))}</div>
);
List.Item = ({ children, style }: any) => <div style={style}>{children}</div>;
const Typography = { Text, Paragraph, Title };
return {
Alert: ({ message: alertMessage, description, style }: any) => (
<div style={style}>{alertMessage}{description}</div>
),
Button: ({ children, onClick, disabled, style }: any) => <button onClick={onClick} disabled={disabled} style={style}>{children}</button>,
Card: ({ children, title, style }: any) => <section style={style}>{title}{children}</section>,
Empty,
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
List,
Space: passthrough,
Tag: ({ children, style }: any) => <span style={style}>{children}</span>,
Typography,
message: {
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
};
});
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) => selector(mockState),
}));
@@ -112,6 +171,27 @@ vi.mock("../store", () => ({
describe("JVMDiagnosticConsole", () => {
beforeEach(() => {
registeredCompletionProvider = null;
registeredDiagnosticChunkHandler = null;
mockState = {
...baseState,
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
mockBackendApp.JVMListDiagnosticAuditRecords.mockResolvedValue({
success: true,
data: [],
});
mockBackendApp.JVMExecuteDiagnosticCommand.mockReset();
vi.mocked(message.success).mockClear();
vi.mocked(message.warning).mockClear();
vi.mocked(message.info).mockClear();
(globalThis as any).window = {
...(globalThis as any).window,
go: { app: { App: mockBackendApp } },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
mockMonaco.editor.setTheme.mockClear();
mockMonaco.languages.register.mockClear();
mockMonaco.languages.registerCompletionItemProvider.mockClear();
@@ -222,9 +302,48 @@ describe("JVMDiagnosticConsole", () => {
expect(markup).toContain('data-language="jvm-diagnostic"');
});
it("uses the same styled editor shell and registers command completion before mount", () => {
it("redacts sensitive diagnostic output in the rendered console", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "watch com.foo.SecretService read '{returnObj}'",
},
},
jvmDiagnosticOutputs: {
"tab-1": [
{
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "password=secret-token\napiKey: api-key-secret",
},
],
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("password=********");
expect(markup).toContain("apiKey: ********");
expect(markup).not.toContain("secret-token");
expect(markup).not.toContain("api-key-secret");
});
it("uses the same styled editor shell and registers command completion before mount", () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
@@ -269,4 +388,559 @@ describe("JVMDiagnosticConsole", () => {
]),
);
});
it("redacts failed diagnostic event content before storing and alerting", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts successful diagnostic warning messages", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
mockBackendApp.JVMExecuteDiagnosticCommand.mockResolvedValue({
success: true,
message: "api_key=query-secret",
});
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
expect(message.warning).toHaveBeenCalledWith("api_key=********");
expect(message.warning).not.toHaveBeenCalledWith(
expect.stringContaining("query-secret"),
);
});
it("redacts successful diagnostic warning messages with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({
success: true,
message: "def456\n-----END PRIVATE KEY-----",
});
});
expect(JSON.stringify((message.warning as any).mock.calls)).not.toContain(
"def456",
);
});
it("keeps diagnostic redaction state after clearing visible output", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIV",
},
});
});
const clearButton = renderer.root
.findAllByType("button")
.find((button: any) => button.children.includes("清空输出"));
await act(async () => {
clearButton.props.onClick();
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "failed",
content: "ATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(mockState.clearJVMDiagnosticOutput).toHaveBeenCalledWith("tab-1");
expect(JSON.stringify(appendedChunks)).not.toContain("ATE KEY");
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
});
it("redacts frontend fallback errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("keeps diagnostic redaction state after local completion fallback", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let resolveCommand: (value: any) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((resolve) => {
resolveCommand = resolve;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
resolveCommand({ success: true });
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
it("redacts terminal-seen execute errors with the active diagnostic stream state", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "completed",
content: "still waiting for execute call",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts execute errors after a real failed terminal event closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
let renderer: any;
await act(async () => {
renderer = create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
});
it("redacts delayed failed terminal events after frontend fallback closes the active PEM stream", async () => {
mockState = {
...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
},
},
};
let rejectCommand: (error: Error) => void = () => {};
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
new Promise((_resolve, reject) => {
rejectCommand = reject;
}),
);
await act(async () => {
create(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
});
await act(async () => {
mockEditor.addCommand.mock.calls[0][1]();
});
const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
});
});
await act(async () => {
rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
});
await act(async () => {
registeredDiagnosticChunkHandler({
tabId: "tab-1",
chunk: {
sessionId: executeRequest.sessionId,
commandId: executeRequest.commandId,
event: "diagnostic",
phase: "failed",
content: "def456\n-----END PRIVATE KEY-----",
},
});
});
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
(call: any[]) => call[1],
);
expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
expect(JSON.stringify(appendedChunks)).not.toContain("def456");
});
});

View File

@@ -33,8 +33,12 @@ import type {
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { resolveJVMDiagnosticCompletionItems } from "../utils/jvmDiagnosticCompletion";
import {
createJVMDiagnosticRedactionState,
formatJVMDiagnosticTransportLabel,
JVM_DIAGNOSTIC_COMMAND_PRESETS,
redactJVMDiagnosticChunkContent,
redactJVMDiagnosticOutput,
type JVMDiagnosticRedactionState,
} from "../utils/jvmDiagnosticPresentation";
import JVMCommandPresetBar from "./jvm/JVMCommandPresetBar";
import JVMDiagnosticHistory from "./jvm/JVMDiagnosticHistory";
@@ -200,6 +204,10 @@ export const createJVMDiagnosticRunningRecord = ({
status: "running",
});
const buildJVMDiagnosticRedactionKey = (
chunk: Pick<JVMDiagnosticEventChunk, "sessionId" | "commandId">,
): string => `${chunk.sessionId || "unknown-session"}::${chunk.commandId || "unknown-command"}`;
const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
@@ -224,6 +232,41 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
const [error, setError] = useState("");
const activeCommandIdRef = useRef("");
const terminalCommandIdsRef = useRef<Set<string>>(new Set());
const redactionStatesRef = useRef<Record<string, JVMDiagnosticRedactionState>>({});
const redactDiagnosticContent = useCallback(
(
content: string,
chunk: Pick<JVMDiagnosticEventChunk, "sessionId" | "commandId">,
) => {
const key = buildJVMDiagnosticRedactionKey(chunk);
const state =
redactionStatesRef.current[key] || createJVMDiagnosticRedactionState();
redactionStatesRef.current[key] = state;
return redactJVMDiagnosticChunkContent(content, state);
},
[],
);
const redactDiagnosticChunk = useCallback(
(chunk: JVMDiagnosticEventChunk, options: { keepState?: boolean } = {}) => {
const key = buildJVMDiagnosticRedactionKey(chunk);
const safeChunk = {
...chunk,
content: redactDiagnosticContent(String(chunk.content || ""), chunk),
};
if (
!options.keepState &&
isJVMDiagnosticTerminalPhase(chunk.phase) &&
!redactionStatesRef.current[key]?.insideSensitivePem &&
!redactionStatesRef.current[key]?.sawSensitivePem
) {
delete redactionStatesRef.current[key];
}
return safeChunk;
},
[redactDiagnosticContent],
);
const finishActiveCommand = useCallback((commandId: string) => {
if (!commandId || activeCommandIdRef.current !== commandId) {
@@ -283,7 +326,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
}
setRecords(Array.isArray(result?.data) ? result.data : []);
} catch (err: any) {
setError(err?.message || "加载诊断历史失败");
setError(redactJVMDiagnosticOutput(err?.message || "加载诊断历史失败"));
} finally {
setHistoryLoading(false);
}
@@ -332,13 +375,14 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
return;
}
appendOutput(tab.id, [payload.chunk]);
if (payload.chunk.phase === "failed") {
setError(payload.chunk.content || "诊断命令执行失败");
const safeChunk = redactDiagnosticChunk(payload.chunk);
appendOutput(tab.id, [safeChunk]);
if (safeChunk.phase === "failed") {
setError(safeChunk.content || "诊断命令执行失败");
}
if (payload.chunk.commandId && isJVMDiagnosticTerminalPhase(payload.chunk.phase)) {
terminalCommandIdsRef.current.add(payload.chunk.commandId);
finishActiveCommand(payload.chunk.commandId);
if (safeChunk.commandId && isJVMDiagnosticTerminalPhase(safeChunk.phase)) {
terminalCommandIdsRef.current.add(safeChunk.commandId);
finishActiveCommand(safeChunk.commandId);
void loadAuditRecords();
}
});
@@ -348,7 +392,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
stopListening();
}
};
}, [appendOutput, finishActiveCommand, loadAuditRecords, tab.id]);
}, [appendOutput, finishActiveCommand, loadAuditRecords, redactDiagnosticChunk, tab.id]);
const handleProbe = async () => {
if (!rpcConnectionConfig) {
@@ -372,7 +416,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
setCapabilities(Array.isArray(result?.data) ? result.data : []);
} catch (err: any) {
setCapabilities([]);
setError(err?.message || "检查诊断能力失败");
setError(redactJVMDiagnosticOutput(err?.message || "检查诊断能力失败"));
} finally {
setLoading(false);
}
@@ -409,7 +453,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
void loadAuditRecords();
} catch (err: any) {
setSession(null);
setError(err?.message || "创建诊断会话失败");
setError(redactJVMDiagnosticOutput(err?.message || "创建诊断会话失败"));
} finally {
setLoading(false);
}
@@ -478,22 +522,27 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
throw new Error(String(result?.message || "执行诊断命令失败"));
}
if (result?.message) {
message.warning(result.message);
message.warning(
redactDiagnosticContent(String(result.message), { sessionId, commandId }),
);
}
const terminalSeen = terminalCommandIdsRef.current.has(commandId);
if (!terminalSeen) {
appendOutput(tab.id, [
{
sessionId,
commandId,
event: "diagnostic",
phase: "completed",
content: "诊断命令调用已返回,但未收到后端终态事件,前端已兜底结束等待状态。",
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
redactDiagnosticChunk(
{
sessionId,
commandId,
event: "diagnostic",
phase: "completed",
content: "诊断命令调用已返回,但未收到后端终态事件,前端已兜底结束等待状态。",
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
},
},
},
{ keepState: true },
),
]);
}
finishActiveCommand(commandId);
@@ -524,21 +573,22 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
});
}
} catch (err: any) {
const messageText = err?.message || "执行诊断命令失败";
const rawMessageText = String(err?.message || "执行诊断命令失败");
let messageText = "";
if (!terminalCommandIdsRef.current.has(commandId)) {
appendOutput(tab.id, [
{
sessionId,
commandId,
event: "diagnostic",
phase: "failed",
content: messageText,
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
},
const safeChunk = redactDiagnosticChunk({
sessionId,
commandId,
event: "diagnostic",
phase: "failed",
content: rawMessageText,
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
},
]);
});
messageText = safeChunk.content;
appendOutput(tab.id, [safeChunk]);
setRecords((current) =>
current.map((record) =>
record.commandId === commandId
@@ -546,6 +596,8 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
: record,
),
);
} else {
messageText = redactDiagnosticContent(rawMessageText, { sessionId, commandId });
}
finishActiveCommand(commandId);
setError(messageText);
@@ -576,7 +628,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
}
message.info("已发送取消请求");
} catch (err: any) {
setError(err?.message || "取消诊断命令失败");
setError(redactJVMDiagnosticOutput(err?.message || "取消诊断命令失败"));
} finally {
setLoading(false);
}

View File

@@ -0,0 +1,563 @@
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import JVMResourceBrowser from "./JVMResourceBrowser";
import type { JVMValueSnapshot } from "../types";
const storeState = vi.hoisted(() => ({
connections: [
{
id: "conn-jvm-writable",
name: "orders-jvm",
config: {
host: "127.0.0.1",
user: "jmx-user",
port: 9010,
type: "jvm",
jvm: {
preferredMode: "jmx",
readOnly: false,
jmx: {
password: "initial-jmx-secret",
},
},
},
},
],
addTab: vi.fn(),
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
theme: "light",
}));
const backendApp = vi.hoisted(() => ({
JVMGetValue: vi.fn(),
JVMPreviewChange: vi.fn(),
JVMApplyChange: vi.fn(),
}));
vi.mock("@monaco-editor/react", () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
}));
vi.mock("@ant-design/icons", () => ({
FileSearchOutlined: () => <span />,
ReloadOutlined: () => <span />,
RobotOutlined: () => <span />,
}));
vi.mock("antd", () => {
const Text = ({ children }: any) => <span>{children}</span>;
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button
type="button"
data-button-type={type}
disabled={disabled || loading}
onClick={onClick}
{...rest}
>
{children}
</button>
);
const Card = ({ children, title }: any) => (
<section>
<h2>{title}</h2>
{children}
</section>
);
const Descriptions: any = ({ children }: any) => <dl>{children}</dl>;
Descriptions.Item = ({ children, label }: any) => (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
const Input: any = ({ value, onChange, placeholder }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} />
);
Input.TextArea = ({ value, onChange }: any) => (
<textarea value={value} onChange={onChange} />
);
return {
Alert: ({ message }: any) => <div role="alert">{message}</div>,
Button,
Card,
Descriptions,
Empty: ({ description }: any) => <div>{description}</div>,
Input,
Skeleton: () => <div>loading</div>,
Space: ({ children }: any) => <div>{children}</div>,
Tag: ({ children }: any) => <span>{children}</span>,
Typography: { Text },
};
});
vi.mock("../store", () => {
const useStore = (selector: (state: typeof storeState) => any) => selector(storeState);
useStore.getState = () => storeState;
return { useStore };
});
vi.mock("./jvm/JVMModeBadge", () => ({
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
}));
vi.mock("./jvm/JVMWorkspaceLayout", () => ({
getJVMWorkspaceCardStyle: () => ({}),
JVMWorkspaceHero: ({ actions, badges, description, title }: any) => (
<header>
<h1>{title}</h1>
{description}
{badges}
{actions}
</header>
),
JVMWorkspaceShell: ({ children }: any) => <main>{children}</main>,
}));
vi.mock("./jvm/JVMChangePreviewModal", () => ({
default: ({ open, onConfirm }: any) =>
open ? <button type="button" onClick={onConfirm}></button> : null,
}));
const writableTab = {
id: "tab-jvm-resource",
type: "jvm-resource",
title: "[orders-jvm] JVM 资源",
connectionId: "conn-jvm-writable",
providerMode: "jmx",
resourcePath: "jmx:/attribute/app/Mode",
resourceKind: "attribute",
} as any;
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === "string" ? item : textContent(item)))
.join("");
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === "button" && textContent(node).includes(text))[0];
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe("JVMResourceBrowser interactions", () => {
beforeEach(() => {
storeState.connections = [
{
id: "conn-jvm-writable",
name: "orders-jvm",
config: {
host: "127.0.0.1",
user: "jmx-user",
port: 9010,
type: "jvm",
jvm: {
preferredMode: "jmx",
readOnly: false,
jmx: {
password: "initial-jmx-secret",
},
},
},
},
];
const snapshot: JVMValueSnapshot = {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
version: "v1",
value: "cold",
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "warm" },
},
],
};
backendApp.JVMGetValue.mockResolvedValue({ success: true, data: snapshot });
backendApp.JVMPreviewChange.mockResolvedValue({
allowed: true,
requiresConfirmation: true,
confirmationToken: "token-from-preview",
summary: "设置 Mode",
riskLevel: "high",
before: snapshot,
after: { ...snapshot, value: "warm", version: "v2" },
});
backendApp.JVMApplyChange.mockResolvedValue({
success: true,
data: {
status: "applied",
updatedValue: { ...snapshot, value: "warm", version: "v2" },
},
});
vi.stubGlobal("window", {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
go: {
app: {
App: backendApp,
},
},
});
});
afterEach(() => {
backendApp.JVMGetValue.mockReset();
backendApp.JVMPreviewChange.mockReset();
backendApp.JVMApplyChange.mockReset();
vi.unstubAllGlobals();
});
it("applies the latest successful preview request even when the draft is edited afterward", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
});
const payloadEditor = () => renderer!.root.findByType("textarea");
await act(async () => {
payloadEditor().props.onChange({ target: { value: '{"value":"previewed"}' } });
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
await act(async () => {
payloadEditor().props.onChange({ target: { value: '{"value":"edited-after-preview"}' } });
});
await act(async () => {
findButton(renderer!, "确认执行").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMApplyChange).toHaveBeenCalledTimes(1);
expect(backendApp.JVMApplyChange.mock.calls[0][0]).toBe(
backendApp.JVMPreviewChange.mock.calls[0][0],
);
expect(backendApp.JVMApplyChange.mock.calls[0][1]).toMatchObject({
action: "set",
confirmationToken: "token-from-preview",
payload: { value: "previewed" },
});
});
it("does not let a stale snapshot resource id override the current resource preview", async () => {
backendApp.JVMGetValue.mockResolvedValueOnce({
success: true,
data: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
version: "v1",
value: "cold",
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "warm" },
},
],
} as JVMValueSnapshot,
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
});
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMPreviewChange.mock.calls[backendApp.JVMPreviewChange.mock.calls.length - 1]?.[1]).toMatchObject({
resourceId: "jmx:/attribute/app/OtherMode",
});
});
it("ignores stale preview responses after the resource context changes", async () => {
let resolvePreview: (value: any) => void = () => {};
backendApp.JVMPreviewChange.mockReturnValueOnce(
new Promise((resolve) => {
resolvePreview = resolve;
}),
);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
resolvePreview({
allowed: true,
requiresConfirmation: true,
confirmationToken: "stale-token",
summary: "旧预览",
riskLevel: "high",
before: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "cold",
},
after: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "warm",
},
});
});
await waitForEffects();
expect(findButton(renderer!, "确认执行")).toBeUndefined();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after the resource context changes", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
findButton(renderer!, "确认执行").props.onClick();
});
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after the connection config changes", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
storeState.connections = [
{
...storeState.connections[0],
config: {
...storeState.connections[0].config,
jvm: {
...storeState.connections[0].config.jvm,
readOnly: true,
},
},
},
];
await act(async () => {
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
});
const confirmButton = findButton(renderer!, "确认执行");
if (confirmButton) {
await act(async () => {
confirmButton.props.onClick();
});
}
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("rejects confirming a preview after JVM credentials change", async () => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
const reasonInput = renderer!.root
.findAllByType("input")
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
await act(async () => {
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
renderer!.root.findByType("textarea").props.onChange({
target: { value: '{"value":"previewed"}' },
});
});
await act(async () => {
findButton(renderer!, "预览变更").props.onClick();
});
await waitForEffects();
storeState.connections = [
{
...storeState.connections[0],
config: {
...storeState.connections[0].config,
jvm: {
...storeState.connections[0].config.jvm,
jmx: {
...storeState.connections[0].config.jvm.jmx,
password: "rotated-jmx-secret",
},
},
},
},
];
await act(async () => {
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
});
const confirmButton = findButton(renderer!, "确认执行");
if (confirmButton) {
await act(async () => {
confirmButton.props.onClick();
});
}
await waitForEffects();
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
});
it("does not seed sensitive payload examples into the draft editor", async () => {
backendApp.JVMGetValue.mockResolvedValueOnce({
success: true,
data: {
resourceId: "jmx:/attribute/app/Password",
kind: "attribute",
format: "string",
version: "v1",
value: "secret-token",
sensitive: true,
supportedActions: [
{
action: "set",
label: "设置属性",
payloadExample: { value: "secret-token" },
},
],
} as JVMValueSnapshot,
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/Password",
}}
/>,
);
});
await waitForEffects();
expect(renderer!.root.findByType("textarea").props.value).not.toContain("secret-token");
});
});

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import Editor from "@monaco-editor/react";
import {
Alert,
@@ -38,9 +38,14 @@ import {
type JVMAIChangePlan,
} from "../utils/jvmAiPlan";
import {
buildJVMActionPayloadTemplate,
buildJVMPreviewApplyRequest,
estimateJVMResourceEditorHeight,
formatJVMActionDisplayText,
formatJVMActionSummary,
formatJVMMetadataForDisplay,
formatJVMValueForDisplay,
JVM_DEFAULT_PAYLOAD_TEMPLATE,
resolveJVMActionDisplay,
resolveJVMValueEditorLanguage,
} from "../utils/jvmResourcePresentation";
@@ -56,7 +61,7 @@ import {
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
const { TextArea } = Input;
const DEFAULT_PAYLOAD_TEXT = "{\n \n}";
const DEFAULT_PAYLOAD_TEXT = JVM_DEFAULT_PAYLOAD_TEMPLATE;
type JVMResourceBrowserProps = {
tab: TabData;
@@ -76,6 +81,66 @@ const buildJVMRuntimeConfig = (
});
};
const buildJVMPreviewConfigRevision = (value: unknown): string => {
let text = "";
try {
text = JSON.stringify(value ?? null);
} catch {
return "unserializable";
}
let hash = 2166136261;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16);
};
const buildJVMPreviewRuntimeFingerprint = (
connection: SavedConnection | undefined,
providerMode: string,
): string => {
const config = connection?.config;
const jvm = config?.jvm || {};
return JSON.stringify({
configRevision: buildJVMPreviewConfigRevision(config),
type: config?.type || "",
host: config?.host || "",
port: config?.port || 0,
user: config?.user || "",
providerMode,
environment: jvm.environment || "",
readOnly: jvm.readOnly !== false,
allowedModes: jvm.allowedModes || [],
preferredMode: jvm.preferredMode || "",
jmx: {
enabled: jvm.jmx?.enabled || false,
host: jvm.jmx?.host || "",
port: jvm.jmx?.port || 0,
username: jvm.jmx?.username || "",
domainAllowlist: jvm.jmx?.domainAllowlist || [],
},
endpoint: {
enabled: jvm.endpoint?.enabled || false,
baseUrl: jvm.endpoint?.baseUrl || "",
timeoutSeconds: jvm.endpoint?.timeoutSeconds || 0,
},
agent: {
enabled: jvm.agent?.enabled || false,
baseUrl: jvm.agent?.baseUrl || "",
timeoutSeconds: jvm.agent?.timeoutSeconds || 0,
},
});
};
const buildJVMPreviewContextKey = (
connectionId: string,
mode: string,
path: string,
runtimeFingerprint: string,
): string => `${connectionId}::${mode}::${path}::${runtimeFingerprint}`;
const snapshotBlockStyle = (background: string): React.CSSProperties => ({
margin: 0,
borderRadius: 8,
@@ -83,17 +148,6 @@ const snapshotBlockStyle = (background: string): React.CSSProperties => ({
overflow: "auto",
});
const formatValue = (value: unknown): string => {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const formatDraftPayload = (draft: JVMAIChangeDraft): string => {
try {
return JSON.stringify(draft.payload ?? {}, null, 2);
@@ -102,19 +156,6 @@ const formatDraftPayload = (draft: JVMAIChangeDraft): string => {
}
};
const buildActionPayloadTemplate = (
definition?: JVMActionDefinition | null,
): string => {
if (definition?.payloadExample) {
try {
return JSON.stringify(definition.payloadExample, null, 2);
} catch {
return DEFAULT_PAYLOAD_TEXT;
}
}
return DEFAULT_PAYLOAD_TEXT;
};
const resolveDefaultAction = (
actions: JVMActionDefinition[] | undefined,
providerMode: "jmx" | "endpoint" | "agent",
@@ -164,6 +205,10 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
"jmx") as "jmx" | "endpoint" | "agent";
const resourcePath = String(tab.resourcePath || "").trim();
const readOnly = connection?.config.jvm?.readOnly !== false;
const runtimeFingerprint = useMemo(
() => buildJVMPreviewRuntimeFingerprint(connection, providerMode),
[connection, providerMode],
);
const [loading, setLoading] = useState(true);
const [snapshot, setSnapshot] = useState<JVMValueSnapshot | null>(null);
const [error, setError] = useState("");
@@ -181,24 +226,50 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
const [previewResult, setPreviewResult] = useState<JVMChangePreview | null>(
null,
);
const [previewRequest, setPreviewRequest] = useState<JVMChangeRequest | null>(
null,
);
const [previewRuntimeConfig, setPreviewRuntimeConfig] = useState<any | null>(
null,
);
const [previewContextKey, setPreviewContextKey] = useState("");
const [applyLoading, setApplyLoading] = useState(false);
const previewSequenceRef = useRef(0);
const currentPreviewContextKey = buildJVMPreviewContextKey(
tab.connectionId,
providerMode,
resourcePath,
runtimeFingerprint,
);
const previewContextKeyRef = useRef(currentPreviewContextKey);
previewContextKeyRef.current = currentPreviewContextKey;
const displayValue = useMemo(() => formatValue(snapshot?.value), [snapshot]);
const clearPreviewState = () => {
setPreviewOpen(false);
setPreviewResult(null);
setPreviewRequest(null);
setPreviewRuntimeConfig(null);
setPreviewContextKey("");
};
const displayValue = useMemo(() => formatJVMValueForDisplay(snapshot), [snapshot]);
const displayLanguage = useMemo(
() =>
resolveJVMValueEditorLanguage(snapshot?.format || "", snapshot?.value),
[snapshot?.format, snapshot?.value],
snapshot?.sensitive
? "plaintext"
: resolveJVMValueEditorLanguage(snapshot?.format || "", snapshot?.value),
[snapshot?.format, snapshot?.sensitive, snapshot?.value],
);
const metadataText = useMemo(
() =>
snapshot?.metadata && Object.keys(snapshot.metadata).length > 0
? JSON.stringify(snapshot.metadata, null, 2)
: "",
[snapshot?.metadata],
() => formatJVMMetadataForDisplay(snapshot),
[snapshot],
);
const metadataLanguage = useMemo(
() => resolveJVMValueEditorLanguage("json", snapshot?.metadata),
[snapshot?.metadata],
() =>
snapshot?.sensitive
? "plaintext"
: resolveJVMValueEditorLanguage("json", snapshot?.metadata),
[snapshot?.metadata, snapshot?.sensitive],
);
const supportedActions = useMemo(() => {
if (!Array.isArray(snapshot?.supportedActions)) {
@@ -218,6 +289,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
);
const loadSnapshot = async () => {
const loadContextKey = currentPreviewContextKey;
if (!connection) {
setLoading(false);
setSnapshot(null);
@@ -247,6 +319,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
buildJVMRuntimeConfig(connection, providerMode),
resourcePath,
);
if (loadContextKey !== previewContextKeyRef.current) {
return;
}
if (!result?.success) {
setSnapshot(null);
setError(String(result?.message || "读取 JVM 资源失败"));
@@ -263,9 +338,10 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
useEffect(() => {
void loadSnapshot();
}, [connection, providerMode, resourcePath, tab.connectionId]);
}, [connection, providerMode, resourcePath, runtimeFingerprint, tab.connectionId]);
useEffect(() => {
setSnapshot(null);
setAction("");
setReason("");
setPayloadText(DEFAULT_PAYLOAD_TEXT);
@@ -273,9 +349,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
setDraftResourceId("");
setDraftError("");
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
}, [providerMode, resourcePath, tab.connectionId]);
previewSequenceRef.current += 1;
clearPreviewState();
}, [currentPreviewContextKey]);
useEffect(() => {
if (action.trim()) {
@@ -290,7 +366,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
String(payloadText || "").trim() === "" ||
payloadText === DEFAULT_PAYLOAD_TEXT
) {
setPayloadText(buildActionPayloadTemplate(nextDefinition));
setPayloadText(buildJVMActionPayloadTemplate(nextDefinition, snapshot?.sensitive));
}
}, [action, payloadText, providerMode, supportedActions]);
@@ -328,8 +404,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
"AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。",
);
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
return;
}
@@ -338,8 +413,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
"当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。",
);
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
return;
}
@@ -349,8 +423,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
} catch (err: any) {
setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿");
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
return;
}
@@ -363,8 +436,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
setApplyMessage(
`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`,
);
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
};
window.addEventListener(
@@ -393,7 +465,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
currentPayload === "{}" ||
payloadText === DEFAULT_PAYLOAD_TEXT
) {
setPayloadText(buildActionPayloadTemplate(definition));
setPayloadText(buildJVMActionPayloadTemplate(definition, snapshot?.sensitive));
}
};
@@ -414,9 +486,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
payload = parsed as Record<string, any>;
}
const resourceId = String(
draftResourceId || snapshot?.resourceId || resourcePath,
).trim();
const resourceId = String(draftResourceId || resourcePath).trim();
if (!resourceId) {
throw new Error("资源 ID 为空,无法生成变更草稿");
}
@@ -497,34 +567,45 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
return;
}
const previewSequence = ++previewSequenceRef.current;
const previewContextKey = currentPreviewContextKey;
const runtimeConfig = buildJVMRuntimeConfig(connection, providerMode);
setPreviewLoading(true);
setDraftError("");
setApplyMessage("");
try {
const result = await backendApp.JVMPreviewChange(
buildJVMRuntimeConfig(connection, providerMode),
runtimeConfig,
draftPlan,
);
if (
previewSequence !== previewSequenceRef.current ||
previewContextKey !== previewContextKeyRef.current
) {
return;
}
if (result?.success === false) {
setPreviewResult(null);
setPreviewOpen(false);
clearPreviewState();
setDraftError(String(result?.message || "预览 JVM 变更失败"));
return;
}
const preview = normalizePreviewResult(result);
if (!preview) {
setPreviewResult(null);
setPreviewOpen(false);
clearPreviewState();
setDraftError("预览结果格式不正确");
return;
}
setPreviewResult(preview);
setPreviewRequest(draftPlan);
setPreviewRuntimeConfig(runtimeConfig);
setPreviewContextKey(previewContextKey);
setPreviewOpen(true);
} catch (err: any) {
setPreviewResult(null);
setPreviewOpen(false);
clearPreviewState();
setDraftError(err?.message || "预览 JVM 变更失败");
} finally {
setPreviewLoading(false);
@@ -532,6 +613,8 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
};
const handleApply = async () => {
await Promise.resolve();
if (!connection) {
setDraftError("连接不存在或已被删除");
return;
@@ -543,11 +626,21 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
return;
}
let draftPlan: JVMChangeRequest;
if (!previewResult || !previewRequest || !previewRuntimeConfig) {
setDraftError("请先预览变更,再确认执行");
return;
}
if (previewContextKey !== previewContextKeyRef.current) {
clearPreviewState();
setDraftError("资源上下文已变化,请重新预览后再执行");
return;
}
let applyRequest: JVMChangeRequest;
try {
draftPlan = buildDraftPlan();
applyRequest = buildJVMPreviewApplyRequest(previewRequest, previewResult);
} catch (err: any) {
setDraftError(err?.message || "变更草稿不合法");
setDraftError(err?.message || "确认令牌缺失,请重新预览后再执行");
return;
}
@@ -556,8 +649,8 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
setApplyMessage("");
try {
const result = await backendApp.JVMApplyChange(
buildJVMRuntimeConfig(connection, providerMode),
draftPlan,
previewRuntimeConfig,
applyRequest,
);
if (result?.success === false) {
setDraftError(String(result?.message || "执行 JVM 变更失败"));
@@ -569,8 +662,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
setSnapshot(applyResult.updatedValue);
}
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
setApplyMessage(
applyResult?.message || result?.message || "JVM 变更已执行",
);
@@ -897,8 +989,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text strong>Payload(JSON)</Text>
<Text type="secondary">
JSON 使 payload
{selectedActionDefinition?.payloadExample
使稿使
request稿
{selectedActionDefinition?.payloadExample && !snapshot?.sensitive
? " 已按当前动作填充推荐模板。"
: ""}
</Text>

View File

@@ -0,0 +1,279 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedQuery, TabData } from '../types';
import QueryEditor from './QueryEditor';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
addTab: vi.fn(),
savedQueries: [] as SavedQuery[],
saveQuery: vi.fn(),
theme: 'light',
sqlFormatOptions: { keywordCase: 'upper' as const },
setSqlFormatOptions: vi.fn(),
queryOptions: { maxRows: 5000 },
setQueryOptions: vi.fn(),
shortcutOptions: {
runQuery: { enabled: false, combo: '' },
},
activeTabId: 'tab-1',
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBQueryWithCancel: vi.fn(),
DBQueryMulti: vi.fn(),
DBGetTables: vi.fn(),
DBGetAllColumns: vi.fn(),
DBGetDatabases: vi.fn(),
DBGetColumns: vi.fn(),
CancelQuery: vi.fn(),
GenerateQueryID: vi.fn(),
WriteSQLFile: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}));
const editorState = vi.hoisted(() => {
const state = {
value: '',
editor: null as any,
};
state.editor = {
getValue: vi.fn(() => state.value),
setValue: vi.fn((value: string) => {
state.value = value;
}),
getModel: vi.fn(() => ({
getValue: () => state.value,
setValue: (value: string) => {
state.value = value;
},
getValueInRange: () => '',
getLineContent: () => '',
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }),
})),
getSelection: vi.fn(() => null),
addAction: vi.fn(),
onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
hasTextFocus: vi.fn(() => true),
};
return state;
});
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('../utils/autoFetchVisibility', () => ({
useAutoFetchVisibility: () => false,
}));
vi.mock('@monaco-editor/react', () => ({
default: ({ defaultValue, onMount }: any) => {
React.useEffect(() => {
editorState.value = String(defaultValue || '');
onMount?.(editorState.editor, {
editor: { setTheme: vi.fn() },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
registerCompletionItemProvider: vi.fn(),
},
});
}, []);
return <textarea data-editor value={editorState.value} readOnly />;
},
}));
vi.mock('./DataGrid', () => ({
default: () => null,
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
SettingOutlined: Icon,
CloseOutlined: Icon,
StopOutlined: Icon,
RobotOutlined: Icon,
};
});
vi.mock('antd', () => {
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>
{children}
</button>
);
Button.Group = ({ children }: any) => <div>{children}</div>;
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
return {
Button,
message: messageApi,
Modal: ({ children, open }: any) => (open ? <section>{children}</section> : null),
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
Form,
Dropdown: ({ children }: any) => <>{children}</>,
Tooltip: ({ children }: any) => <>{children}</>,
Select: () => null,
Tabs: () => null,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'query.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1;',
...overrides,
});
describe('QueryEditor external SQL save', () => {
beforeEach(() => {
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
storeState.addTab.mockReset();
storeState.saveQuery.mockReset();
storeState.savedQueries = [];
storeState.activeTabId = 'tab-1';
messageApi.success.mockReset();
messageApi.error.mockReset();
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
editorState.value = '';
editorState.editor.getValue.mockClear();
editorState.editor.setValue.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
let renderer: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 2;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 2;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
filePath,
query: 'select 2;',
savedQueryId: undefined,
}));
expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
});
it('does not create saved queries when external SQL file writes fail', async () => {
let renderer: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 4;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 4;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).not.toHaveBeenCalled();
expect(messageApi.error).toHaveBeenCalledWith('保存 SQL 文件失败: 磁盘只读');
});
it('keeps saved query quick-save behavior for non-file tabs', async () => {
storeState.savedQueries = [
{
id: 'saved-1',
name: '常用查询',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
},
];
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
editorState.value = 'select 3;';
await act(async () => {
findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).not.toHaveBeenCalled();
expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'saved-1',
name: '常用查询',
sql: 'select 3;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
}));
});
});

View File

@@ -6,7 +6,7 @@ import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
@@ -2204,7 +2204,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return saved;
};
const handleQuickSave = () => {
const handleQuickSave = async () => {
const filePath = String(tab.filePath || '').trim();
if (filePath) {
const sql = getCurrentQuery();
try {
const res = await WriteSQLFile(filePath, sql);
if (!res.success) {
message.error('保存 SQL 文件失败: ' + (res.message || '未知错误'));
return;
}
addTab({
...tab,
query: sql,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
filePath,
savedQueryId: undefined,
});
message.success('SQL 文件已保存!');
} catch (error) {
message.error('保存 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error)));
}
return;
}
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const saveId = existed?.id || fallbackSavedId || '';

View File

@@ -1852,7 +1852,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<Search
{...noAutoCapInputProps}
style={{ flex: 1 }}
placeholder={searchMode === 'exact' ? '输入完整 Key 精确搜索' : '搜索 Key模糊匹配'}
placeholder={searchMode === 'exact' ? '输入完整 Key / 命名空间精确搜索' : '搜索 Key模糊匹配'}
value={searchInput}
onChange={handleSearchInputChange}
onSearch={handleSearch}

View File

@@ -2411,6 +2411,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
connectionId,
dbName,
query: String(data || ''),
filePath,
});
};
@@ -4211,6 +4212,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
size="small"
type="text"
icon={<PlusOutlined />}
title="添加外部 SQL 目录"
aria-label="添加外部 SQL 目录"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();

View File

@@ -36,6 +36,7 @@ describe('AIChatInput notice layout', () => {
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={{
tone: 'error',
title: '模型列表加载失败',
@@ -58,4 +59,35 @@ describe('AIChatInput notice layout', () => {
expect(inputIndex).toBeGreaterThanOrEqual(0);
expect(noticeIndex).toBeLessThan(inputIndex);
});
it('renders the selected send shortcut in the composer placeholder', () => {
const markup = renderToStaticMarkup(
<AIChatInput
input=""
setInput={() => {}}
draftImages={[]}
setDraftImages={() => {}}
sending={false}
onSend={() => {}}
onStop={() => {}}
handleKeyDown={() => {}}
activeConnName=""
activeContext={null}
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
composerNotice={null}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
/>
);
expect(markup).toContain('Meta+Enter 发送');
});
});

View File

@@ -2,10 +2,13 @@ import React from 'react';
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { useStore } from '../../store';
import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
import type { ShortcutBinding } from '../../utils/shortcuts';
interface AIChatInputProps {
input: string;
@@ -21,6 +24,7 @@ interface AIChatInputProps {
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
sendShortcutBinding: ShortcutBinding;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
@@ -36,7 +40,7 @@ interface AIChatInputProps {
export const AIChatInput: React.FC<AIChatInputProps> = ({
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
composerNotice,
sendShortcutBinding, composerNotice,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars
}) => {
@@ -202,24 +206,21 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
continue;
}
const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName);
let createSql = '';
if (res.success && res.data) {
if (typeof res.data === 'string') {
createSql = res.data;
} else if (Array.isArray(res.data) && res.data.length > 0) {
const row = res.data[0];
createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string;
}
} else {
message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误'));
const rpcConfig = buildRpcConnectionConfig(conn.config) as any;
const schemaResult = await resolveAITableSchemaToolResult({
tableName,
fetchDDL: () => DBShowCreateTable(rpcConfig, dbName, tableName),
fetchColumns: () => DBGetColumns(rpcConfig, dbName, tableName),
});
if (!schemaResult.success) {
message.error(`获取表 ${dbName}.${tableName} 结构失败: ${schemaResult.content}`);
}
if (createSql) {
if (schemaResult.success && schemaResult.content) {
addAIContext(connectionKey, {
dbName: dbName,
tableName: tableName,
ddl: createSql
ddl: schemaResult.content
});
addedCount++;
}
@@ -381,7 +382,7 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
}
}}
onKeyDown={handleKeyDown as any}
placeholder="输入消息... (Enter 发送Shift+Enter 换行,/ 快捷命令)"
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding)}Shift+Enter 换行,/ 快捷命令)`}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}

View File

@@ -15,6 +15,7 @@ import {
parseJVMDiagnosticPlan,
resolveJVMDiagnosticPlanTargetTabId,
} from '../../utils/jvmDiagnosticPlan';
import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
@@ -260,7 +261,13 @@ const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConn
setPreviewData(null);
try {
const { DBQuery } = await import('../../../wailsjs/go/app/App');
const res = await DBQuery(activeConnectionConfig, activeDbName || '', displayText + ' LIMIT 50');
const previewSql = buildAIReadonlyPreviewSQL(
activeConnectionConfig?.type || '',
displayText,
50,
activeConnectionConfig?.driver || '',
);
const res = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql);
if (res.success && Array.isArray(res.data)) {
const rows = res.data as any[];
const cols = rows.length > 0 ? Object.keys(rows[0]) : [];

View File

@@ -2,7 +2,10 @@ import React, { useMemo } from "react";
import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd";
import type { JVMChangePreview } from "../../types";
import { formatJVMRiskLevelText } from "../../utils/jvmResourcePresentation";
import {
formatJVMRiskLevelText,
formatJVMValueForDisplay,
} from "../../utils/jvmResourcePresentation";
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
@@ -21,17 +24,6 @@ const riskColorMap: Record<string, string> = {
high: "red",
};
const formatValue = (value: unknown): string => {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const previewBlockStyle: React.CSSProperties = {
margin: 0,
padding: 12,
@@ -135,7 +127,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>
{formatValue(preview.before?.value)}
{formatJVMValueForDisplay(preview.before)}
</pre>
</div>
@@ -160,7 +152,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>
{formatValue(preview.after?.value)}
{formatJVMValueForDisplay(preview.after)}
</pre>
</div>
</Space>

View File

@@ -3,7 +3,7 @@ import { Empty, List, Tag, Typography } from "antd";
import type { JVMDiagnosticEventChunk } from "../../types";
import {
formatJVMDiagnosticChunkText,
formatJVMDiagnosticChunksForDisplay,
formatJVMDiagnosticEventLabel,
formatJVMDiagnosticPhaseLabel,
} from "../../utils/jvmDiagnosticPresentation";
@@ -28,6 +28,8 @@ const JVMDiagnosticOutput: React.FC<JVMDiagnosticOutputProps> = ({
);
}
const chunkTexts = formatJVMDiagnosticChunksForDisplay(chunks);
return (
<div style={{ maxHeight, overflow: "auto", paddingRight: 4 }}>
<List
@@ -45,7 +47,7 @@ const JVMDiagnosticOutput: React.FC<JVMDiagnosticOutputProps> = ({
fontFamily: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
}}
>
{formatJVMDiagnosticChunkText(chunk)}
{chunkTexts[index]}
</Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{chunk.phase ? (

View File

@@ -134,6 +134,7 @@ if (typeof window !== 'undefined' && !(window as any).go) {
SelectSQLDirectory: async (currentPath: string) => ({ success: false, message: currentPath ? '已取消' : '已取消' }),
ListSQLDirectory: async () => ({ success: true, data: [] }),
ReadSQLFile: async () => ({ success: false, message: '已取消' }),
WriteSQLFile: async (_filePath: string, _content: string) => ({ success: true }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
ImportConnectionsPayload: async (raw: string, _password?: string) => {

View File

@@ -255,4 +255,132 @@ describe('store appearance persistence', () => {
},
]);
});
it('defaults AI chat send shortcut to Enter in shared shortcut options', async () => {
const { useStore } = await importStore();
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Enter',
enabled: true,
});
});
it('persists recorded AI chat send shortcut and restores it after reload', async () => {
const { useStore } = await importStore();
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Meta+Enter',
enabled: true,
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
});
it('falls back to Enter when persisted AI chat send shortcut is invalid', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
shortcutOptions: {
sendAIChatMessage: {
combo: 'A',
enabled: true,
},
},
},
version: 8,
}));
const { useStore } = await importStore();
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Enter',
enabled: true,
});
});
it('does not overwrite recorded AI chat send shortcut during startup config refresh', async () => {
const { useStore } = await importStore();
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Ctrl+Enter',
enabled: true,
});
useStore.getState().replaceConnections([]);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Ctrl+Enter',
enabled: true,
});
});
it('keeps persisted AI chat send shortcut when startup refresh runs before shortcut hydration catches up', async () => {
const { useStore } = await importStore();
const shortcutOptions = useStore.getState().shortcutOptions;
storage.setItem('lite-db-storage', JSON.stringify({
state: {
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
combo: 'Meta+Enter',
enabled: true,
},
},
},
version: 8,
}));
useStore.setState({
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
combo: 'Enter',
enabled: true,
},
},
});
useStore.getState().replaceConnections([]);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
});
it('does not let a stale default shortcut state overwrite an explicitly recorded AI chat shortcut', async () => {
const { useStore } = await importStore();
const shortcutOptions = useStore.getState().shortcutOptions;
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Meta+Enter',
enabled: true,
});
useStore.setState({
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
combo: 'Enter',
enabled: true,
},
},
});
useStore.getState().replaceGlobalProxy({});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
combo: 'Meta+Enter',
enabled: true,
});
});
});

View File

@@ -61,6 +61,7 @@ const MAX_TIMEOUT_SECONDS = 3600;
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
const PERSIST_VERSION = 8;
const PERSIST_STORAGE_KEY = "lite-db-storage";
const DEFAULT_CONNECTION_TYPE = "mysql";
const DEFAULT_JVM_PORT = 9010;
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
@@ -1156,6 +1157,46 @@ const unwrapPersistedAppState = (
return raw;
};
let shortcutOptionsExplicitlySet = false;
const readPersistedShortcutOptions = (): ShortcutOptions | null => {
if (typeof localStorage === "undefined") {
return null;
}
try {
const payload = localStorage.getItem(PERSIST_STORAGE_KEY);
if (!payload) {
return null;
}
const state = unwrapPersistedAppState(JSON.parse(payload));
if (state.shortcutOptions === undefined) {
return null;
}
return sanitizeShortcutOptions(state.shortcutOptions);
} catch {
return null;
}
};
const resolveShortcutOptionsForPersistence = (
shortcutOptions: ShortcutOptions,
): ShortcutOptions => {
const safeOptions = sanitizeShortcutOptions(shortcutOptions);
if (shortcutOptionsExplicitlySet) {
return safeOptions;
}
return readPersistedShortcutOptions() ?? safeOptions;
};
const runWithExplicitShortcutPersistence = (callback: () => void): void => {
shortcutOptionsExplicitlySet = true;
try {
callback();
} finally {
shortcutOptionsExplicitlySet = false;
}
};
// --- AI 会话文件持久化辅助函数 ---
/** 每个 session 独立防抖定时器2秒 */
@@ -1294,7 +1335,10 @@ export const useStore = create<AppState>()(
})),
})),
replaceConnections: (connections) =>
set({ connections: sanitizeConnections(connections) }),
set((state) => ({
connections: sanitizeConnections(connections),
shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions,
})),
addConnectionTag: (tag) =>
set((state) => ({ connectionTags: [...state.connectionTags, tag] })),
@@ -1606,31 +1650,38 @@ export const useStore = create<AppState>()(
globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }),
})),
replaceGlobalProxy: (proxy) =>
set({
set((state) => ({
globalProxy: sanitizeGlobalProxy({
...DEFAULT_GLOBAL_PROXY,
...proxy,
}),
}),
shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions,
})),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
setQueryOptions: (options) =>
set((state) => ({
queryOptions: { ...state.queryOptions, ...options },
})),
updateShortcut: (action, binding) =>
set((state) => ({
shortcutOptions: {
...state.shortcutOptions,
[action]: {
...state.shortcutOptions[action],
...binding,
updateShortcut: (action, binding) => {
runWithExplicitShortcutPersistence(() => {
set((state) => ({
shortcutOptions: {
...state.shortcutOptions,
[action]: {
...state.shortcutOptions[action],
...binding,
},
},
},
})),
resetShortcutOptions: () =>
set({
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
}),
}));
});
},
resetShortcutOptions: () => {
runWithExplicitShortcutPersistence(() => {
set({
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
});
});
},
addSqlLog: (log) =>
set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
@@ -1931,7 +1982,7 @@ export const useStore = create<AppState>()(
})),
}),
{
name: "lite-db-storage", // name of the item in the storage (must be unique)
name: PERSIST_STORAGE_KEY, // name of the item in the storage (must be unique)
version: PERSIST_VERSION,
migrate: (persistedState: unknown, version: number) => {
const state = unwrapPersistedAppState(
@@ -2054,7 +2105,7 @@ export const useStore = create<AppState>()(
: toPersistedGlobalProxy(state.globalProxy),
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
shortcutOptions: state.shortcutOptions,
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,

View File

@@ -237,6 +237,7 @@ export interface JVMValueSnapshot {
export interface JVMChangePreview {
allowed: boolean;
requiresConfirmation?: boolean;
confirmationToken?: string;
summary: string;
riskLevel: "low" | "medium" | "high";
blockingReason?: string;
@@ -251,6 +252,7 @@ export interface JVMChangeRequest {
reason: string;
source?: "manual" | "ai-plan";
expectedVersion?: string;
confirmationToken?: string;
payload?: Record<string, any>;
}
@@ -405,6 +407,7 @@ export interface TabData {
dbName?: string;
tableName?: string;
query?: string;
filePath?: string;
initialTab?: string;
readOnly?: boolean;
providerMode?: "jmx" | "endpoint" | "agent";

View File

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import {
canRecordShortcutForAction,
DEFAULT_SHORTCUT_OPTIONS,
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
type ShortcutBinding,
} from './shortcuts';
import {
consumeAIChatSendShortcutOnKeyDown,
getAIChatSendShortcutLabel,
shouldSendAIChatOnKeyDown,
} from './aiChatSendShortcut';
const binding = (combo: string, enabled = true): ShortcutBinding => ({ combo, enabled });
describe('aiChatSendShortcut', () => {
it('registers AI chat send in the shared shortcut center with Enter default', () => {
expect(SHORTCUT_ACTION_ORDER).toContain('sendAIChatMessage');
expect(DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage).toEqual({ combo: 'Enter', enabled: true });
expect(SHORTCUT_ACTION_META.sendAIChatMessage).toMatchObject({
label: 'AI 聊天发送',
allowInEditable: true,
allowWithoutModifier: true,
scope: 'aiComposer',
requiredKey: 'Enter',
disallowShift: true,
});
});
it('allows recording only single-modifier Enter-based AI send shortcuts', () => {
expect(canRecordShortcutForAction('sendAIChatMessage', 'Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Meta+Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Alt+Enter')).toBe(true);
expect(canRecordShortcutForAction('sendAIChatMessage', 'A')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Shift+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Shift+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Alt+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Ctrl+Meta+Enter')).toBe(false);
expect(canRecordShortcutForAction('sendAIChatMessage', 'Meta+Alt+Enter')).toBe(false);
});
it('keeps modifier requirements for global shortcuts', () => {
expect(canRecordShortcutForAction('runQuery', 'Enter')).toBe(false);
expect(canRecordShortcutForAction('runQuery', 'Ctrl+Enter')).toBe(true);
});
it('sends on the configured Enter shortcut but never during composition or Shift+Enter', () => {
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter' })).toBe(true);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', shiftKey: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', isComposing: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'Enter', nativeEvent: { isComposing: true } })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter'), { key: 'a' })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Enter', false), { key: 'Enter' })).toBe(false);
});
it('matches recorded Cmd or Ctrl Enter shortcuts', () => {
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter' })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter', metaKey: true })).toBe(true);
expect(shouldSendAIChatOnKeyDown(binding('Meta+Enter'), { key: 'Enter', ctrlKey: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', ctrlKey: true })).toBe(true);
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', metaKey: true })).toBe(false);
expect(shouldSendAIChatOnKeyDown(binding('Ctrl+Enter'), { key: 'Enter', ctrlKey: true, isComposing: true })).toBe(false);
});
it('does not allow Shift to become an AI send shortcut even if a stale binding exists', () => {
expect(shouldSendAIChatOnKeyDown(binding('Shift+Enter'), { key: 'Enter', shiftKey: true })).toBe(false);
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'))).toBe('Meta+Enter 发送');
expect(getAIChatSendShortcutLabel(binding('Enter', false))).toBe('快捷键发送已关闭');
});
it('stops propagation after consuming the configured AI send shortcut', () => {
const event = {
key: 'Enter',
metaKey: true,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
const onSend = vi.fn();
expect(consumeAIChatSendShortcutOnKeyDown(binding('Meta+Enter'), event, onSend)).toBe(true);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(event.stopPropagation).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,50 @@
import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplay, isShortcutMatch, type ShortcutBinding } from './shortcuts';
export interface AIChatSendShortcutKeyEventLike {
key?: string;
shiftKey?: boolean;
metaKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
isComposing?: boolean;
nativeEvent?: {
isComposing?: boolean;
};
preventDefault?: () => void;
stopPropagation?: () => void;
}
export const getAIChatSendShortcutLabel = (binding: ShortcutBinding | undefined): string => {
if (binding?.enabled === false) {
return '快捷键发送已关闭';
}
const combo = binding?.combo || DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage.combo;
return `${getShortcutDisplay(combo)} 发送`;
};
export const shouldSendAIChatOnKeyDown = (
binding: ShortcutBinding | undefined,
event: AIChatSendShortcutKeyEventLike,
): boolean => {
if (!binding?.enabled) {
return false;
}
if (event.shiftKey || event.isComposing || event.nativeEvent?.isComposing) {
return false;
}
return isShortcutMatch(event as KeyboardEvent, binding.combo);
};
export const consumeAIChatSendShortcutOnKeyDown = (
binding: ShortcutBinding | undefined,
event: AIChatSendShortcutKeyEventLike,
onSend: () => void,
): boolean => {
if (!shouldSendAIChatOnKeyDown(binding, event)) {
return false;
}
event.preventDefault?.();
event.stopPropagation?.();
onSend();
return true;
};

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { buildAIReadonlyPreviewSQL } from './aiSqlLimit';
describe('buildAIReadonlyPreviewSQL', () => {
it('limits Oracle readonly SQL with ROWNUM instead of MySQL LIMIT', () => {
const sql = buildAIReadonlyPreviewSQL('oracle', 'SELECT 1 FROM DUAL;', 50);
expect(sql).toBe('SELECT * FROM (SELECT 1 FROM DUAL) WHERE ROWNUM <= 50');
expect(sql.toLowerCase()).not.toContain('limit');
});
it('does not add another limit when Oracle SQL already limits rows', () => {
expect(buildAIReadonlyPreviewSQL('oracle', 'SELECT * FROM users WHERE ROWNUM <= 10', 50))
.toBe('SELECT * FROM users WHERE ROWNUM <= 10');
expect(buildAIReadonlyPreviewSQL('oracle', 'SELECT * FROM users FETCH FIRST 10 ROWS ONLY', 50))
.toBe('SELECT * FROM users FETCH FIRST 10 ROWS ONLY');
});
it('resolves custom Oracle drivers from the driver alias', () => {
expect(buildAIReadonlyPreviewSQL('custom', 'SELECT 1 FROM DUAL;', 50, 'oracle'))
.toBe('SELECT * FROM (SELECT 1 FROM DUAL) WHERE ROWNUM <= 50');
});
it('keeps MySQL-family SQL on LIMIT syntax', () => {
expect(buildAIReadonlyPreviewSQL('mysql', 'SELECT * FROM users', 50))
.toBe('SELECT * FROM users LIMIT 50 OFFSET 0');
});
it('keeps PostgreSQL-compatible and ClickHouse SQL on LIMIT syntax', () => {
expect(buildAIReadonlyPreviewSQL('postgres', 'SELECT * FROM users', 50))
.toBe('SELECT * FROM users LIMIT 50 OFFSET 0');
expect(buildAIReadonlyPreviewSQL('kingbase', 'SELECT * FROM users', 50))
.toBe('SELECT * FROM users LIMIT 50 OFFSET 0');
expect(buildAIReadonlyPreviewSQL('clickhouse', 'SELECT * FROM events', 50))
.toBe('SELECT * FROM events LIMIT 50 OFFSET 0');
});
it('limits Dameng readonly SQL with Oracle-compatible ROWNUM syntax', () => {
expect(buildAIReadonlyPreviewSQL('dameng', 'SELECT 1 FROM DUAL;', 50))
.toBe('SELECT * FROM (SELECT 1 FROM DUAL) WHERE ROWNUM <= 50');
});
it('does not limit non-readonly SQL', () => {
expect(buildAIReadonlyPreviewSQL('oracle', 'UPDATE users SET name = \'a\';', 50))
.toBe('UPDATE users SET name = \'a\'');
});
});

View File

@@ -0,0 +1,31 @@
import { buildPaginatedSelectSQL } from './sql';
import { resolveSqlDialect } from './sqlDialect';
const AI_READONLY_SQL_KEYWORDS = new Set(['select', 'show', 'describe', 'desc', 'explain', 'with', 'pragma', 'values']);
const trimSQLStatement = (sql: string): string => String(sql || '').trim().replace(/;\s*$/, '').trim();
const isAIReadonlySQL = (sql: string): boolean => {
const firstWord = trimSQLStatement(sql).trimStart().split(/\s+/)[0]?.toLowerCase() || '';
return AI_READONLY_SQL_KEYWORDS.has(firstWord);
};
const hasExistingRowLimit = (dialect: string, sql: string): boolean => {
const text = trimSQLStatement(sql).toLowerCase();
if (!text) return false;
if (/\blimit\s+\d+\b/.test(text)) return true;
if (/\bfetch\s+(first|next)\s+\d+\s+rows?\b/.test(text)) return true;
if (/\btop\s*\(?\s*\d+\s*\)?\b/.test(text)) return true;
return (dialect === 'oracle' || dialect === 'dameng') && /\brownum\b/.test(text);
};
export const buildAIReadonlyPreviewSQL = (dbType: string, sql: string, limit = 50, driver = ''): string => {
const baseSQL = trimSQLStatement(sql);
const safeLimit = Math.max(0, Math.floor(Number(limit) || 0));
const dialect = resolveSqlDialect(dbType, driver);
if (!baseSQL || safeLimit <= 0 || !isAIReadonlySQL(baseSQL) || hasExistingRowLimit(dialect, baseSQL)) {
return baseSQL;
}
return buildPaginatedSelectSQL(dialect, baseSQL, '', safeLimit, 0);
};

View File

@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveAITableSchemaToolResult } from './aiTableSchemaTool';
describe('resolveAITableSchemaToolResult', () => {
it('returns DDL directly when DDL fetch succeeds', async () => {
const fetchColumns = vi.fn();
const result = await resolveAITableSchemaToolResult({
tableName: 'USERS',
fetchDDL: vi.fn().mockResolvedValue({ success: true, data: 'CREATE TABLE USERS (ID NUMBER)' }),
fetchColumns,
});
expect(result).toEqual({ success: true, content: 'CREATE TABLE USERS (ID NUMBER)' });
expect(fetchColumns).not.toHaveBeenCalled();
});
it('falls back to column metadata when DDL fetch fails due to permissions', async () => {
const result = await resolveAITableSchemaToolResult({
tableName: 'USERS',
fetchDDL: vi.fn().mockResolvedValue({ success: false, message: 'ORA-31603: object not found or insufficient privileges' }),
fetchColumns: vi.fn().mockResolvedValue({
success: true,
data: [
{ Name: 'ID', Type: 'NUMBER', Nullable: 'NO', Default: null, Comment: '主键' },
{ Name: 'NAME', Type: 'VARCHAR2(64)', Nullable: 'YES' },
],
}),
});
expect(result.success).toBe(true);
expect(result.content).toContain('DDL 获取失败,已降级为字段元数据摘要');
expect(result.content).toContain('ORA-31603');
expect(result.content).toContain('可用字段ID, NAME');
expect(result.content).toContain('"field":"ID"');
expect(result.content).toContain('"type":"NUMBER"');
});
it('returns a combined failure when both DDL and column metadata fail', async () => {
const result = await resolveAITableSchemaToolResult({
tableName: 'USERS',
fetchDDL: vi.fn().mockResolvedValue({ success: false, message: 'DDL permission denied' }),
fetchColumns: vi.fn().mockResolvedValue({ success: false, message: 'columns permission denied' }),
});
expect(result.success).toBe(false);
expect(result.content).toContain('DDL permission denied');
expect(result.content).toContain('columns permission denied');
});
});

View File

@@ -0,0 +1,69 @@
type ToolQueryResult = {
success?: boolean;
data?: unknown;
message?: string;
};
type ResolveAITableSchemaToolResultParams = {
tableName: string;
fetchDDL: () => Promise<ToolQueryResult>;
fetchColumns: () => Promise<ToolQueryResult>;
};
const stringifyToolData = (data: unknown): string => (
typeof data === 'string' ? data : JSON.stringify(data)
);
const firstStringValue = (row: Record<string, unknown>, keys: string[]): string => {
for (const key of keys) {
const value = row[key];
if (value !== undefined && value !== null) {
return String(value);
}
}
return '';
};
const normalizeAIColumn = (raw: unknown) => {
const row = (raw && typeof raw === 'object') ? raw as Record<string, unknown> : {};
const keys = Object.keys(row);
return {
field: firstStringValue(row, ['Field', 'field', 'COLUMN_NAME', 'column_name', 'Name', 'name']) || (keys.length > 0 ? String(row[keys[0]] ?? '') : ''),
type: firstStringValue(row, ['Type', 'type', 'DATA_TYPE', 'data_type']) || (keys.length > 1 ? String(row[keys[1]] ?? '') : ''),
nullable: firstStringValue(row, ['Null', 'null', 'IS_NULLABLE', 'is_nullable', 'Nullable', 'nullable']),
default: firstStringValue(row, ['Default', 'default', 'COLUMN_DEFAULT', 'column_default', 'DefaultValue']),
comment: firstStringValue(row, ['Comment', 'comment', 'COLUMN_COMMENT', 'column_comment', 'Description']),
};
};
const buildColumnFallbackContent = (tableName: string, ddlError: string, columns: unknown[]): string => {
const normalizedColumns = columns.map(normalizeAIColumn).filter((column) => column.field.trim());
const fieldNames = normalizedColumns.map((column) => column.field).join(', ');
return [
`⚠️ 表 ${tableName} 的 DDL 获取失败,已降级为字段元数据摘要。`,
`DDL 错误:${ddlError || '未知错误'}`,
'该结果不包含完整索引、约束、触发器等 DDL 信息;请基于字段列表继续分析,不要因为 DDL 权限失败而停止。',
`可用字段:${fieldNames || '无'}`,
`详细信息:${JSON.stringify(normalizedColumns)}`,
].join('\n');
};
export const resolveAITableSchemaToolResult = async ({
tableName,
fetchDDL,
fetchColumns,
}: ResolveAITableSchemaToolResultParams): Promise<{ success: boolean; content: string }> => {
const ddlResult = await fetchDDL();
if (ddlResult?.success) {
return { success: true, content: stringifyToolData(ddlResult.data) };
}
const ddlError = ddlResult?.message || 'Failed to fetch DDL';
const columnResult = await fetchColumns();
if (columnResult?.success && Array.isArray(columnResult.data)) {
return { success: true, content: buildColumnFallbackContent(tableName, ddlError, columnResult.data) };
}
const columnError = columnResult?.message || 'Failed to fetch columns';
return { success: false, content: `获取建表语句失败:${ddlError};降级获取字段列表也失败:${columnError}` };
};

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import {
attachDataGridFindRenderVersion,
collectDataGridFindMatches,
findDataGridTextRanges,
hasDataGridFindRenderVersionChanged,
normalizeDataGridFindQuery,
resolveDataGridFindNavigationIndex,
summarizeDataGridFindMatches,
} from './dataGridFind';
describe('dataGridFind', () => {
it('normalizes blank queries to an empty search value without changing non-blank text', () => {
expect(normalizeDataGridFindQuery(' alpha ')).toBe(' alpha ');
expect(normalizeDataGridFindQuery(' ')).toBe('');
expect(normalizeDataGridFindQuery(null)).toBe('');
});
it('finds case-insensitive non-overlapping text ranges', () => {
expect(findDataGridTextRanges('Alpha beta ALPHA', 'alpha')).toEqual([
{ start: 0, end: 5 },
{ start: 11, end: 16 },
]);
});
it('treats special characters as plain text', () => {
expect(findDataGridTextRanges('a+b a.b a+b', 'a+b')).toEqual([
{ start: 0, end: 3 },
{ start: 8, end: 11 },
]);
});
it('preserves whitespace in non-blank plain text queries', () => {
expect(findDataGridTextRanges(' alpha alpha ', ' alpha')).toEqual([
{ start: 0, end: 6 },
{ start: 6, end: 12 },
]);
expect(findDataGridTextRanges('alpha beta alphabeta', 'alpha ')).toEqual([
{ start: 0, end: 6 },
]);
});
it('returns no ranges for empty query or empty text', () => {
expect(findDataGridTextRanges('alpha', '')).toEqual([]);
expect(findDataGridTextRanges('', 'alpha')).toEqual([]);
});
it('summarizes matches across selected columns only', () => {
const rows = [
{ id: 1, name: 'Alpha', note: 'alpha beta', hidden: 'alpha' },
{ id: 2, name: 'Gamma', note: 'none', hidden: 'alpha' },
];
expect(
summarizeDataGridFindMatches(rows, ['name', 'note'], 'alpha', (value) => String(value ?? '')),
).toEqual({ matchedCellCount: 2, occurrenceCount: 2 });
});
it('collects ordered cell matches with row and column coordinates', () => {
const rows = [
{ __gonavi_row_key__: 'row-1', name: 'Alpha alpha', note: 'beta Alpha' },
{ __gonavi_row_key__: 'row-2', name: 'none', note: 'alpha' },
];
expect(
collectDataGridFindMatches(
rows,
['name', 'note'],
'alpha',
(value) => String(value ?? ''),
(row) => String(row.__gonavi_row_key__),
),
).toEqual([
{ rowIndex: 0, rowKey: 'row-1', columnName: 'name', columnIndex: 0, occurrenceIndex: 0, start: 0, end: 5 },
{ rowIndex: 0, rowKey: 'row-1', columnName: 'name', columnIndex: 0, occurrenceIndex: 1, start: 6, end: 11 },
{ rowIndex: 0, rowKey: 'row-1', columnName: 'note', columnIndex: 1, occurrenceIndex: 0, start: 5, end: 10 },
{ rowIndex: 1, rowKey: 'row-2', columnName: 'note', columnIndex: 1, occurrenceIndex: 0, start: 0, end: 5 },
]);
});
it('resolves previous and next navigation indexes with wrapping', () => {
expect(resolveDataGridFindNavigationIndex(-1, 4, 'next')).toBe(0);
expect(resolveDataGridFindNavigationIndex(0, 4, 'next')).toBe(1);
expect(resolveDataGridFindNavigationIndex(3, 4, 'next')).toBe(0);
expect(resolveDataGridFindNavigationIndex(-1, 4, 'previous')).toBe(3);
expect(resolveDataGridFindNavigationIndex(0, 4, 'previous')).toBe(3);
expect(resolveDataGridFindNavigationIndex(2, 4, 'previous')).toBe(1);
expect(resolveDataGridFindNavigationIndex(0, 0, 'next')).toBe(-1);
});
it('tracks render version changes without exposing metadata as row data', () => {
const rows = [{ id: 1, name: 'Alpha' }];
expect(attachDataGridFindRenderVersion(rows, '')).toBe(rows);
const alphaRows = attachDataGridFindRenderVersion(rows, 'alpha');
const betaRows = attachDataGridFindRenderVersion(rows, 'beta');
expect(alphaRows).not.toBe(rows);
expect(alphaRows[0]).not.toBe(rows[0]);
expect(Object.keys(alphaRows[0])).toEqual(['id', 'name']);
expect(hasDataGridFindRenderVersionChanged(alphaRows[0], rows[0])).toBe(true);
expect(hasDataGridFindRenderVersionChanged(betaRows[0], alphaRows[0])).toBe(true);
expect(hasDataGridFindRenderVersionChanged(rows[0], alphaRows[0])).toBe(true);
});
});

View File

@@ -0,0 +1,145 @@
export interface DataGridTextRange {
start: number;
end: number;
}
export interface DataGridFindSummary {
matchedCellCount: number;
occurrenceCount: number;
}
export interface DataGridFindMatch extends DataGridTextRange {
rowIndex: number;
rowKey: string;
columnName: string;
columnIndex: number;
occurrenceIndex: number;
}
export type DataGridFindNavigationDirection = 'previous' | 'next';
export const DATA_GRID_FIND_RENDER_VERSION = Symbol('DATA_GRID_FIND_RENDER_VERSION');
export const normalizeDataGridFindQuery = (value: unknown): string => {
const text = String(value ?? '');
return text.trim().length === 0 ? '' : text;
};
export const findDataGridTextRanges = (text: string, query: string): DataGridTextRange[] => {
const normalizedQuery = normalizeDataGridFindQuery(query);
if (!text || !normalizedQuery) return [];
const source = String(text);
const lowerSource = source.toLocaleLowerCase();
const lowerQuery = normalizedQuery.toLocaleLowerCase();
const ranges: DataGridTextRange[] = [];
let startIndex = 0;
while (startIndex < source.length) {
const matchIndex = lowerSource.indexOf(lowerQuery, startIndex);
if (matchIndex === -1) break;
const end = matchIndex + normalizedQuery.length;
ranges.push({ start: matchIndex, end });
startIndex = end;
}
return ranges;
};
export const attachDataGridFindRenderVersion = <T>(rows: T[], query: string): T[] => {
const normalizedQuery = normalizeDataGridFindQuery(query);
if (!normalizedQuery) return rows;
return rows.map((row) => {
if (!row || typeof row !== 'object') return row;
const nextRow = { ...(row as object) } as T;
Object.defineProperty(nextRow, DATA_GRID_FIND_RENDER_VERSION, {
value: normalizedQuery,
enumerable: false,
});
return nextRow;
});
};
export const hasDataGridFindRenderVersionChanged = (nextRecord: unknown, previousRecord: unknown): boolean => {
const nextVersion = nextRecord && typeof nextRecord === 'object'
? (nextRecord as Record<symbol, unknown>)[DATA_GRID_FIND_RENDER_VERSION]
: undefined;
const previousVersion = previousRecord && typeof previousRecord === 'object'
? (previousRecord as Record<symbol, unknown>)[DATA_GRID_FIND_RENDER_VERSION]
: undefined;
return nextVersion !== previousVersion;
};
export const summarizeDataGridFindMatches = <T>(
rows: T[],
columnNames: string[],
query: string,
getCellText: (value: unknown, row: T, columnName: string) => string,
): DataGridFindSummary => {
const normalizedQuery = normalizeDataGridFindQuery(query);
if (!normalizedQuery) {
return { matchedCellCount: 0, occurrenceCount: 0 };
}
let matchedCellCount = 0;
let occurrenceCount = 0;
rows.forEach((row) => {
columnNames.forEach((columnName) => {
const record = row as Record<string, unknown>;
const ranges = findDataGridTextRanges(getCellText(record[columnName], row, columnName), normalizedQuery);
if (ranges.length > 0) {
matchedCellCount += 1;
occurrenceCount += ranges.length;
}
});
});
return { matchedCellCount, occurrenceCount };
};
export const collectDataGridFindMatches = <T>(
rows: T[],
columnNames: string[],
query: string,
getCellText: (value: unknown, row: T, columnName: string) => string,
getRowKey: (row: T, rowIndex: number) => string,
): DataGridFindMatch[] => {
const normalizedQuery = normalizeDataGridFindQuery(query);
if (!normalizedQuery) return [];
const matches: DataGridFindMatch[] = [];
rows.forEach((row, rowIndex) => {
const record = row as Record<string, unknown>;
const rowKey = getRowKey(row, rowIndex);
columnNames.forEach((columnName, columnIndex) => {
findDataGridTextRanges(getCellText(record[columnName], row, columnName), normalizedQuery).forEach((range, occurrenceIndex) => {
matches.push({
rowIndex,
rowKey,
columnName,
columnIndex,
occurrenceIndex,
start: range.start,
end: range.end,
});
});
});
});
return matches;
};
export const resolveDataGridFindNavigationIndex = (
currentIndex: number,
matchCount: number,
direction: DataGridFindNavigationDirection,
): number => {
if (matchCount <= 0) return -1;
if (direction === 'previous') {
return currentIndex <= 0 ? matchCount - 1 : currentIndex - 1;
}
return currentIndex < 0 || currentIndex >= matchCount - 1 ? 0 : currentIndex + 1;
};

View File

@@ -41,6 +41,7 @@ describe('externalSqlTree helpers', () => {
});
expect(node.type).toBe('external-sql-root');
expect(node.title).toBe('外部 SQL 目录 (1)');
expect(node.children).toHaveLength(1);
expect(node.children?.[0]).toMatchObject({
title: 'scripts',

View File

@@ -117,7 +117,7 @@ export const buildExternalSQLRootNode = ({
});
return {
title: children.length > 0 ? `外部 SQL 文件 (${children.length})` : '外部 SQL 文件',
title: children.length > 0 ? `外部 SQL 目录 (${children.length})` : '外部 SQL 目录',
key: `${dbNodeKey}-external-sql`,
type: 'external-sql-root',
isLeaf: children.length === 0,

View File

@@ -11,7 +11,7 @@ describe('inputAutoCap', () => {
});
});
it('applies lowercase DOM attributes to inputs and textareas', () => {
it('applies no-auto-cap attributes to inputs and textareas', () => {
const inputAttributes: Record<string, string> = {};
const textareaAttributes: Record<string, string> = {};
const input = {

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { buildJVMChangeDraftFromAIPlan, extractJVMChangePlan, resolveJVMAIPlanResourceId, resolveJVMAIPlanTargetTabId } from './jvmAiPlan';
import {
buildJVMChangeDraftFromAIPlan,
buildJVMAIPlanPrompt,
extractJVMChangePlan,
resolveJVMAIPlanResourceId,
resolveJVMAIPlanTargetTabId,
} from './jvmAiPlan';
describe('extractJVMChangePlan', () => {
it('parses fenced json plan with namespace and key selector', () => {
@@ -102,6 +108,34 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
});
});
describe('buildJVMAIPlanPrompt', () => {
it('masks sensitive snapshot values before injecting the AI prompt', () => {
const prompt = buildJVMAIPlanPrompt({
connectionName: 'orders-jvm',
host: '127.0.0.1',
providerMode: 'jmx',
resourcePath: 'jmx:/attribute/app/Password',
readOnly: false,
snapshot: {
resourceId: 'jmx:/attribute/app/Password',
kind: 'attribute',
format: 'string',
value: 'secret-token',
sensitive: true,
supportedActions: [
{
action: 'set',
payloadExample: { value: 'secret-token' },
},
],
},
});
expect(prompt).toContain('********');
expect(prompt).not.toContain('secret-token');
});
});
describe('resolveJVMAIPlanTargetTabId', () => {
it('prefers the original tab when message context still matches', () => {
expect(

View File

@@ -1,4 +1,5 @@
import type { JVMActionDefinition, JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
import { JVM_SENSITIVE_VALUE_MASK } from './jvmResourcePresentation';
export type JVMAIChangePlan = {
targetType: 'cacheEntry' | 'managedBean' | 'attribute' | 'operation';
@@ -106,6 +107,9 @@ const formatSnapshotValue = (snapshot?: JVMValueSnapshot | null): string => {
if (!snapshot) {
return '当前资源快照尚未加载成功。';
}
if (snapshot.sensitive) {
return JVM_SENSITIVE_VALUE_MASK;
}
if (typeof snapshot.value === 'string') {
return snapshot.value;
}

View File

@@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
import {
formatJVMDiagnosticChunkText,
formatJVMDiagnosticChunksForDisplay,
formatJVMDiagnosticCommandTypeLabel,
formatJVMDiagnosticPhaseLabel,
formatJVMDiagnosticRiskLabel,
formatJVMDiagnosticSourceLabel,
formatJVMDiagnosticTransportLabel,
groupJVMDiagnosticPresets,
redactJVMDiagnosticOutput,
resolveJVMDiagnosticRiskColor,
} from "./jvmDiagnosticPresentation";
@@ -32,6 +34,204 @@ describe("jvmDiagnosticPresentation", () => {
).toBe("执行中thread -n 5");
});
it("redacts sensitive values in diagnostic output chunks", () => {
const text = formatJVMDiagnosticChunkText({
sessionId: "sess-1",
phase: "running",
content:
"password=secret-token\napiKey: api-key-secret\naccessToken = bearer-secret\nPRIVATE_KEY=-----BEGIN PRIVATE KEY-----raw-key",
});
expect(text).toContain("password=********");
expect(text).toContain("apiKey: ********");
expect(text).toContain("accessToken = ********");
expect(text).toContain("PRIVATE_KEY=********");
expect(text).not.toContain("secret-token");
expect(text).not.toContain("api-key-secret");
expect(text).not.toContain("bearer-secret");
expect(text).not.toContain("raw-key");
});
it("redacts JSON, environment, separator and partial PEM sensitive output", () => {
const text = redactJVMDiagnosticOutput([
'{"password":"json-secret","api_key":"api-json-secret","accessToken":"access-json-secret"}',
"DB_PASSWORD=hunter2",
"SPRING_DATASOURCE_PASSWORD=spring-secret",
"AWS_SECRET_ACCESS_KEY=aws-secret",
"api-key: kebab-secret",
"api key = spaced-secret",
"private.key: dot-secret",
"refresh_token=refresh-secret",
"secret=foo;bar",
"PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nraw-key-line",
].join("\n"));
expect(text).toContain('"password":"********"');
expect(text).toContain('"api_key":"********"');
expect(text).toContain('"accessToken":"********"');
expect(text).toContain("DB_PASSWORD=********");
expect(text).toContain("SPRING_DATASOURCE_PASSWORD=********");
expect(text).toContain("AWS_SECRET_ACCESS_KEY=********");
expect(text).toContain("api-key: ********");
expect(text).toContain("api key = ********");
expect(text).toContain("private.key: ********");
expect(text).toContain("refresh_token=********");
expect(text).toContain("secret=********");
expect(text).toContain("PRIVATE_KEY=********");
expect(text).not.toContain("json-secret");
expect(text).not.toContain("api-json-secret");
expect(text).not.toContain("access-json-secret");
expect(text).not.toContain("hunter2");
expect(text).not.toContain("spring-secret");
expect(text).not.toContain("aws-secret");
expect(text).not.toContain("kebab-secret");
expect(text).not.toContain("spaced-secret");
expect(text).not.toContain("dot-secret");
expect(text).not.toContain("refresh-secret");
expect(text).not.toContain("foo;bar");
expect(text).not.toContain("raw-key-line");
});
it("redacts PEM continuation across diagnostic chunks", () => {
const texts = formatJVMDiagnosticChunksForDisplay([
{
sessionId: "sess-1",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
},
{
sessionId: "sess-1",
phase: "running",
content: "def456\n-----END PRIVATE KEY-----",
},
{
sessionId: "sess-1",
phase: "running",
content: "thread_name=main",
},
]);
expect(texts.join("\n")).not.toContain("abc123");
expect(texts.join("\n")).not.toContain("def456");
expect(texts.join("\n")).not.toContain("PRIVATE KEY");
expect(texts[2]).toContain("thread_name=main");
});
it("redacts PEM begin marker split across diagnostic chunks", () => {
const texts = formatJVMDiagnosticChunksForDisplay([
{
sessionId: "sess-1",
phase: "running",
content: "PRIVATE_KEY=-----BEGIN PRIV",
},
{
sessionId: "sess-1",
phase: "running",
content: "ATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
},
{
sessionId: "sess-1",
phase: "running",
content: "thread_name=main",
},
]);
expect(texts.join("\n")).not.toContain("BEGIN PRIV");
expect(texts.join("\n")).not.toContain("ATE KEY");
expect(texts.join("\n")).not.toContain("abc123");
expect(texts[2]).toContain("thread_name=main");
});
it("redacts algorithm-prefixed PEM begin marker split across chunks", () => {
const texts = formatJVMDiagnosticChunksForDisplay([
{
sessionId: "sess-1",
phase: "running",
content: "-----BEGIN RSA PRIV",
},
{
sessionId: "sess-1",
phase: "running",
content: "ATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----",
},
{
sessionId: "sess-1",
phase: "running",
content: "thread_name=main",
},
]);
expect(texts.join("\n")).not.toContain("RSA PRIV");
expect(texts.join("\n")).not.toContain("ATE KEY");
expect(texts.join("\n")).not.toContain("abc123");
expect(texts[2]).toContain("thread_name=main");
});
it("redacts algorithm-prefixed PEM markers split after the algorithm and inside key labels", () => {
const cases = [
["-----BEGIN RSA", " PRIVATE KEY-----\nabc123\n-----END RSA PRIVATE KEY-----"],
["-----BEGIN RSA PRIVATE K", "EY-----\nabc123\n-----END RSA PRIVATE KEY-----"],
["-----BEGIN OPENSSH", " PRIVATE KEY-----\nabc123\n-----END OPENSSH PRIVATE KEY-----"],
["-----BEGIN EC PRIVATE KE", "Y-----\nabc123\n-----END EC PRIVATE KEY-----"],
];
for (const [firstChunk, secondChunk] of cases) {
const texts = formatJVMDiagnosticChunksForDisplay([
{
sessionId: "sess-1",
phase: "running",
content: firstChunk,
},
{
sessionId: "sess-1",
phase: "running",
content: secondChunk,
},
]);
expect(texts.join("\n")).not.toContain("PRIVATE K");
expect(texts.join("\n")).not.toContain("EY-----");
expect(texts.join("\n")).not.toContain("abc123");
}
});
it("redacts JSON scalar values and URL query parameters", () => {
const text = redactJVMDiagnosticOutput(
'{"password":123456,"token":true,"credential":null}\nhttps://svc.local/callback?access_token=url-secret&x=1&api_key=query-secret',
);
expect(text).toContain('"password":********');
expect(text).toContain('"token":********');
expect(text).toContain('"credential":********');
expect(text).toContain("access_token=********");
expect(text).toContain("api_key=********");
expect(text).not.toContain("123456");
expect(text).not.toContain("true");
expect(text).not.toContain("url-secret");
expect(text).not.toContain("query-secret");
});
it("redacts authorization values across text, JSON and query parameters", () => {
const text = redactJVMDiagnosticOutput(
'Authorization: Bearer header-secret\n{"authorization":"Bearer json-secret"}\nhttps://svc.local/callback?authorization=Bearer%20query-secret',
);
expect(text).toContain("Authorization: ********");
expect(text).toContain('"authorization":"********"');
expect(text).toContain("authorization=********");
expect(text).not.toContain("header-secret");
expect(text).not.toContain("json-secret");
expect(text).not.toContain("query-secret");
});
it("keeps non-sensitive diagnostic output unchanged", () => {
expect(
redactJVMDiagnosticOutput(
"thread_name=main\nmethod: com.foo.OrderService.submit\ncost=42ms",
),
).toBe("thread_name=main\nmethod: com.foo.OrderService.submit\ncost=42ms");
});
it("localizes diagnostic status, transport, risk and source labels", () => {
expect(formatJVMDiagnosticPhaseLabel("completed")).toBe("已完成");
expect(formatJVMDiagnosticTransportLabel("arthas-tunnel")).toBe("Arthas Tunnel");

View File

@@ -103,6 +103,160 @@ const SOURCE_LABELS: Record<string, string> = {
"ai-plan": "AI 计划",
};
const JVM_DIAGNOSTIC_REDACTION_MASK = "********";
const JVM_DIAGNOSTIC_SENSITIVE_KEY_PATTERN =
"(?:password|passwd|pwd|secret|token|credential|authorization|api[_.\\- \\t]*key|access[_.\\- \\t]*key|private[_.\\- \\t]*key|secret[_.\\- \\t]*key|auth[_.\\- \\t]*key|access[_.\\- \\t]*token|refresh[_.\\- \\t]*token)";
const JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY =
`[A-Za-z0-9_.\\- \\t]*${JVM_DIAGNOSTIC_SENSITIVE_KEY_PATTERN}[A-Za-z0-9_.\\- \\t]*`;
const JVM_DIAGNOSTIC_PEM_BEGIN_PATTERN =
/-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----/i;
const JVM_DIAGNOSTIC_PEM_END_PATTERN =
/-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----/i;
const JVM_DIAGNOSTIC_PEM_BEGIN_PREFIX_PATTERN = /-----BEGIN[\s\S]*$/i;
const JVM_DIAGNOSTIC_PEM_END_CONTINUATION_PATTERN =
/^[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----/i;
const JVM_DIAGNOSTIC_COMPLETE_PEM_PATTERN =
/-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----/gi;
const JVM_DIAGNOSTIC_PARTIAL_PEM_PATTERN =
/-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*$/gi;
const JVM_DIAGNOSTIC_SENSITIVE_PEM_LABELS = [
"PRIVATE KEY",
"RSA PRIVATE KEY",
"DSA PRIVATE KEY",
"EC PRIVATE KEY",
"OPENSSH PRIVATE KEY",
"ENCRYPTED PRIVATE KEY",
"SECRET",
"TOKEN",
"CREDENTIAL",
];
const JVM_DIAGNOSTIC_DOUBLE_QUOTED_VALUE_PATTERN = new RegExp(
`(")(${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY})(")([ \\t]*:[ \\t]*)(")((?:\\\\.|[^"\\\\])*)(")`,
"gi",
);
const JVM_DIAGNOSTIC_SINGLE_QUOTED_VALUE_PATTERN = new RegExp(
`(')(${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY})(')([ \\t]*:[ \\t]*)(')((?:\\\\.|[^'\\\\])*)(')`,
"gi",
);
const JVM_DIAGNOSTIC_UNQUOTED_SCALAR_PATTERN = new RegExp(
`(["']?)(${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY})(\\1)([ \\t]*:[ \\t]*)(true|false|null|-?\\d+(?:\\.\\d+)?)`,
"gi",
);
const JVM_DIAGNOSTIC_UNQUOTED_KEY_VALUE_PATTERN = new RegExp(
`(^|[\\r\\n,;{\\[?&]|\\s)(${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY})([ \\t]*[:=][ \\t]*)([^\\r\\n&]*)`,
"gi",
);
const redactJVMDiagnosticKeyValues = (value: string): string =>
value
.replace(
JVM_DIAGNOSTIC_DOUBLE_QUOTED_VALUE_PATTERN,
(_match, keyOpen: string, key: string, keyClose: string, separator: string, valueOpen: string, _rawValue: string, valueClose: string) =>
`${keyOpen}${key}${keyClose}${separator}${valueOpen}${JVM_DIAGNOSTIC_REDACTION_MASK}${valueClose}`,
)
.replace(
JVM_DIAGNOSTIC_SINGLE_QUOTED_VALUE_PATTERN,
(_match, keyOpen: string, key: string, keyClose: string, separator: string, valueOpen: string, _rawValue: string, valueClose: string) =>
`${keyOpen}${key}${keyClose}${separator}${valueOpen}${JVM_DIAGNOSTIC_REDACTION_MASK}${valueClose}`,
)
.replace(
JVM_DIAGNOSTIC_UNQUOTED_SCALAR_PATTERN,
(_match, keyOpen: string, key: string, keyClose: string, separator: string) =>
`${keyOpen}${key}${keyClose}${separator}${JVM_DIAGNOSTIC_REDACTION_MASK}`,
)
.replace(
JVM_DIAGNOSTIC_UNQUOTED_KEY_VALUE_PATTERN,
(_match, prefix: string, key: string, separator: string) =>
`${prefix}${key}${separator}${JVM_DIAGNOSTIC_REDACTION_MASK}`,
);
export type JVMDiagnosticRedactionState = {
insideSensitivePem: boolean;
sawSensitivePem: boolean;
};
export const createJVMDiagnosticRedactionState = (): JVMDiagnosticRedactionState => ({
insideSensitivePem: false,
sawSensitivePem: false,
});
const hasSensitivePemBeginPrefix = (value: string): boolean => {
const match = value.match(JVM_DIAGNOSTIC_PEM_BEGIN_PREFIX_PATTERN);
if (!match) {
return false;
}
const prefix = match[0];
const label = prefix
.replace(/^-----BEGIN\s*/i, "")
.replace(/-+$/g, "")
.trim()
.replace(/\s+/g, " ")
.toUpperCase();
if (
!label ||
JVM_DIAGNOSTIC_SENSITIVE_PEM_LABELS.some(
(item) => item.startsWith(label) || label.startsWith(item),
)
) {
return true;
}
return new RegExp(
`${JVM_DIAGNOSTIC_SENSITIVE_KEY_BODY}[ \t]*[:=][ \t]*-----BEGIN[\\s\\S]*$`,
"i",
).test(value);
};
const redactJVMDiagnosticOutputWithState = (
value: string,
state: JVMDiagnosticRedactionState,
): string => {
let text = value;
if (state.insideSensitivePem) {
const pemEnd = text.search(JVM_DIAGNOSTIC_PEM_END_PATTERN);
if (pemEnd < 0) {
return JVM_DIAGNOSTIC_REDACTION_MASK;
}
state.insideSensitivePem = false;
state.sawSensitivePem = true;
text = `${JVM_DIAGNOSTIC_REDACTION_MASK}${text.slice(pemEnd).replace(JVM_DIAGNOSTIC_PEM_END_PATTERN, "")}`;
} else if (state.sawSensitivePem && JVM_DIAGNOSTIC_PEM_END_PATTERN.test(text)) {
text = text.replace(
JVM_DIAGNOSTIC_PEM_END_CONTINUATION_PATTERN,
JVM_DIAGNOSTIC_REDACTION_MASK,
);
}
text = text
.replace(JVM_DIAGNOSTIC_COMPLETE_PEM_PATTERN, () => {
state.sawSensitivePem = true;
return JVM_DIAGNOSTIC_REDACTION_MASK;
})
.replace(JVM_DIAGNOSTIC_PARTIAL_PEM_PATTERN, (match) => {
state.sawSensitivePem = true;
state.insideSensitivePem = !JVM_DIAGNOSTIC_PEM_END_PATTERN.test(match);
return JVM_DIAGNOSTIC_REDACTION_MASK;
});
if (!state.insideSensitivePem && hasSensitivePemBeginPrefix(text)) {
state.insideSensitivePem = true;
state.sawSensitivePem = true;
text = text.replace(
JVM_DIAGNOSTIC_PEM_BEGIN_PREFIX_PATTERN,
JVM_DIAGNOSTIC_REDACTION_MASK,
);
}
return redactJVMDiagnosticKeyValues(text);
};
export const redactJVMDiagnosticChunkContent = (
value?: string | null,
state: JVMDiagnosticRedactionState = createJVMDiagnosticRedactionState(),
): string => redactJVMDiagnosticOutputWithState(String(value || ""), state);
export const redactJVMDiagnosticOutput = (value?: string | null): string =>
redactJVMDiagnosticChunkContent(value);
export const formatJVMDiagnosticPresetCategory = (
category: JVMDiagnosticPresetCategory,
): string => CATEGORY_LABELS[category];
@@ -159,14 +313,14 @@ export const groupJVMDiagnosticPresets = (
items: presets.filter((item) => item.category === category),
}));
export const formatJVMDiagnosticChunkText = (
const formatJVMDiagnosticChunkTextWithContent = (
chunk: JVMDiagnosticEventChunk,
content: string,
): string => {
const rawPhase = String(chunk.phase || chunk.event || "").trim();
const phase = chunk.phase
? formatJVMDiagnosticPhaseLabel(chunk.phase)
: formatJVMDiagnosticEventLabel(chunk.event);
const content = String(chunk.content || "").trim();
if (!rawPhase && !content) {
return "空事件";
}
@@ -178,3 +332,23 @@ export const formatJVMDiagnosticChunkText = (
}
return `${phase}${content}`;
};
export const formatJVMDiagnosticChunkText = (
chunk: JVMDiagnosticEventChunk,
): string =>
formatJVMDiagnosticChunkTextWithContent(
chunk,
redactJVMDiagnosticOutput(chunk.content).trim(),
);
export const formatJVMDiagnosticChunksForDisplay = (
chunks: JVMDiagnosticEventChunk[],
): string[] => {
const state = createJVMDiagnosticRedactionState();
return chunks.map((chunk) =>
formatJVMDiagnosticChunkTextWithContent(
chunk,
redactJVMDiagnosticChunkContent(chunk.content, state).trim(),
),
);
};

View File

@@ -1,10 +1,14 @@
import { describe, expect, it } from "vitest";
import {
buildJVMActionPayloadTemplate,
buildJVMPreviewApplyRequest,
estimateJVMResourceEditorHeight,
formatJVMAuditResultLabel,
formatJVMActionSummary,
formatJVMMetadataForDisplay,
formatJVMRiskLevelText,
formatJVMValueForDisplay,
resolveJVMAuditResultColor,
resolveJVMActionDisplay,
resolveJVMValueEditorLanguage,
@@ -68,6 +72,121 @@ describe("jvmResourcePresentation", () => {
);
});
it("masks sensitive JVM snapshot values for display", () => {
expect(
formatJVMValueForDisplay({
resourceId: "jmx:/attribute/app/Password",
kind: "attribute",
format: "string",
value: "secret-token",
sensitive: true,
}),
).toBe("********");
expect(
formatJVMValueForDisplay({
resourceId: "jmx:/attribute/app/State",
kind: "attribute",
format: "json",
value: { state: "READY" },
}),
).toBe(JSON.stringify({ state: "READY" }, null, 2));
});
it("masks sensitive JVM snapshot metadata for display", () => {
expect(
formatJVMMetadataForDisplay({
metadata: { token: "secret-token" },
sensitive: true,
}),
).toBe("********");
expect(
formatJVMMetadataForDisplay({
metadata: { owner: "orders" },
}),
).toBe(JSON.stringify({ owner: "orders" }, null, 2));
});
it("masks sensitive action payload examples", () => {
expect(
buildJVMActionPayloadTemplate(
{
action: "set",
payloadExample: { value: "secret-token" },
},
true,
),
).toBe("{\n \n}");
});
it("builds apply requests from the previewed request and confirmation token", () => {
const previewedRequest = {
providerMode: "jmx" as const,
resourceId: "jmx:/attribute/app/Mode",
action: "set",
reason: "修复运行模式",
source: "manual" as const,
expectedVersion: "v1",
payload: { value: "warm" },
};
expect(
buildJVMPreviewApplyRequest(previewedRequest, {
allowed: true,
requiresConfirmation: true,
confirmationToken: "token-from-preview",
summary: "设置 Mode",
riskLevel: "high",
before: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "cold",
},
after: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "warm",
},
}),
).toEqual({
...previewedRequest,
confirmationToken: "token-from-preview",
});
});
it("rejects confirmed apply requests when preview token is missing", () => {
expect(() =>
buildJVMPreviewApplyRequest(
{
providerMode: "jmx",
resourceId: "jmx:/attribute/app/Mode",
action: "set",
reason: "修复运行模式",
payload: { value: "warm" },
},
{
allowed: true,
requiresConfirmation: true,
summary: "设置 Mode",
riskLevel: "high",
before: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "cold",
},
after: {
resourceId: "jmx:/attribute/app/Mode",
kind: "attribute",
format: "string",
value: "warm",
},
},
),
).toThrow("确认令牌缺失");
});
it("caps editor height for very long payloads while keeping short content compact", () => {
expect(estimateJVMResourceEditorHeight("line-1")).toBe(180);
expect(

View File

@@ -1,4 +1,9 @@
import type { JVMActionDefinition } from "../types";
import type {
JVMActionDefinition,
JVMChangePreview,
JVMChangeRequest,
JVMValueSnapshot,
} from "../types";
type JVMActionDisplay = {
action: string;
@@ -191,6 +196,69 @@ export const formatJVMAuditResultLabel = (value?: string | null): string => {
return normalizeText(value);
};
export const JVM_SENSITIVE_VALUE_MASK = "********";
export const JVM_DEFAULT_PAYLOAD_TEMPLATE = "{\n \n}";
const formatRawJVMValue = (value: unknown): string => {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
export const formatJVMValueForDisplay = (
snapshot?: JVMValueSnapshot | null,
): string => {
if (snapshot?.sensitive) {
return JVM_SENSITIVE_VALUE_MASK;
}
return formatRawJVMValue(snapshot?.value);
};
export const formatJVMMetadataForDisplay = (
snapshot?: Pick<JVMValueSnapshot, "metadata" | "sensitive"> | null,
): string => {
if (!snapshot?.metadata || Object.keys(snapshot.metadata).length === 0) {
return "";
}
if (snapshot.sensitive) {
return JVM_SENSITIVE_VALUE_MASK;
}
return formatRawJVMValue(snapshot.metadata);
};
export const buildJVMActionPayloadTemplate = (
definition?: JVMActionDefinition | null,
sensitive = false,
): string => {
if (sensitive || !definition?.payloadExample) {
return JVM_DEFAULT_PAYLOAD_TEMPLATE;
}
try {
return JSON.stringify(definition.payloadExample, null, 2);
} catch {
return JVM_DEFAULT_PAYLOAD_TEMPLATE;
}
};
export const buildJVMPreviewApplyRequest = (
previewRequest: JVMChangeRequest,
preview: JVMChangePreview,
): JVMChangeRequest => {
const confirmationToken = String(preview.confirmationToken || "").trim();
if (preview.requiresConfirmation && !confirmationToken) {
throw new Error("确认令牌缺失,请重新预览后再执行");
}
return {
...previewRequest,
confirmationToken: confirmationToken || undefined,
};
};
export const resolveJVMValueEditorLanguage = (
format: string,
value: unknown,

View File

@@ -2,6 +2,7 @@ import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
export type ShortcutAction =
| 'runQuery'
| 'sendAIChatMessage'
| 'focusSidebarSearch'
| 'newQueryTab'
| 'toggleLogPanel'
@@ -20,6 +21,10 @@ export interface ShortcutActionMeta {
label: string;
description: string;
allowInEditable?: boolean;
allowWithoutModifier?: boolean;
scope?: 'global' | 'aiComposer';
requiredKey?: string;
disallowShift?: boolean;
platformOnly?: 'mac';
}
@@ -73,6 +78,7 @@ const KEY_ALIASES: Record<string, string> = {
export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'runQuery',
'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
'toggleLogPanel',
@@ -86,6 +92,15 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
label: '执行 SQL',
description: '在当前查询页执行 SQL',
},
sendAIChatMessage: {
label: 'AI 聊天发送',
description: '在 AI 输入框中发送当前消息Shift+Enter 始终换行',
allowInEditable: true,
allowWithoutModifier: true,
scope: 'aiComposer',
requiredKey: 'Enter',
disallowShift: true,
},
focusSidebarSearch: {
label: '聚焦侧边栏搜索',
description: '定位到左侧连接树搜索框',
@@ -117,6 +132,7 @@ export const SHORTCUT_ACTION_META: Record<ShortcutAction, ShortcutActionMeta> =
export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
runQuery: { combo: 'Ctrl+Shift+R', enabled: true },
sendAIChatMessage: { combo: 'Enter', enabled: true },
focusSidebarSearch: { combo: 'Ctrl+F', enabled: true },
newQueryTab: { combo: 'Ctrl+Shift+N', enabled: true },
toggleLogPanel: { combo: 'Ctrl+Shift+L', enabled: true },
@@ -213,6 +229,37 @@ export const hasModifierKey = (combo: string): boolean => {
return normalized.split('+').some(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]));
};
const getShortcutKeyToken = (combo: string): string => {
const parts = normalizeShortcutCombo(combo).split('+').filter(Boolean);
const key = parts[parts.length - 1] || '';
return MODIFIER_SET.has(key as typeof MODIFIER_ORDER[number]) ? '' : key;
};
const getShortcutModifierTokens = (combo: string): string[] => (
normalizeShortcutCombo(combo)
.split('+')
.filter(part => MODIFIER_SET.has(part as typeof MODIFIER_ORDER[number]))
);
export const canRecordShortcutForAction = (action: ShortcutAction, combo: string): boolean => {
const normalized = normalizeShortcutCombo(combo);
if (!normalized || !getShortcutKeyToken(normalized)) {
return false;
}
const meta = SHORTCUT_ACTION_META[action];
if (meta.requiredKey && getShortcutKeyToken(normalized) !== normalizeShortcutCombo(meta.requiredKey)) {
return false;
}
if (meta.disallowShift && normalized.split('+').includes('Shift')) {
return false;
}
if (meta.allowWithoutModifier) {
return getShortcutModifierTokens(normalized).length <= 1;
}
return hasModifierKey(normalized);
};
export const cloneShortcutOptions = (value: ShortcutOptions): ShortcutOptions => {
return SHORTCUT_ACTION_ORDER.reduce((acc, action) => {
acc[action] = {
@@ -235,7 +282,7 @@ export const sanitizeShortcutOptions = (value: unknown): ShortcutOptions => {
const binding = actionRaw as Record<string, unknown>;
const combo = normalizeShortcutCombo(String(binding.combo || defaults[action].combo));
defaults[action] = {
combo: combo || defaults[action].combo,
combo: combo && canRecordShortcutForAction(action, combo) ? combo : defaults[action].combo,
enabled: binding.enabled === false ? false : true,
};
});

View File

@@ -192,7 +192,8 @@ export const buildPaginatedSelectSQL = (
}
switch (normalizedType) {
case 'oracle': {
case 'oracle':
case 'dameng': {
const orderedSql = `${base}${orderBy}`;
const upperBound = safeOffset + safeLimit;
if (safeOffset <= 0) {

View File

@@ -279,3 +279,5 @@ export function TestConnection(arg1:connection.ConnectionConfig):Promise<connect
export function TestJVMConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function WriteSQLFile(arg1:string,arg2:string):Promise<connection.QueryResult>;

View File

@@ -549,3 +549,7 @@ export function TestJVMConnection(arg1) {
export function TruncateTables(arg1, arg2, arg3) {
return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3);
}
export function WriteSQLFile(arg1, arg2) {
return window['go']['app']['App']['WriteSQLFile'](arg1, arg2);
}

View File

@@ -948,6 +948,7 @@ export namespace jvm {
reason: string;
source?: string;
expectedVersion?: string;
confirmationToken?: string;
payload?: Record<string, any>;
static createFrom(source: any = {}) {
@@ -962,6 +963,7 @@ export namespace jvm {
this.reason = source["reason"];
this.source = source["source"];
this.expectedVersion = source["expectedVersion"];
this.confirmationToken = source["confirmationToken"];
this.payload = source["payload"];
}
}

View File

@@ -55,17 +55,20 @@ type queryContext struct {
// App struct
type App struct {
ctx context.Context
startedAt time.Time
dbCache map[string]cachedDatabase // Cache for DB connections
connectFailures map[string]cachedConnectFailure
mu sync.RWMutex // Mutex for cache access
updateMu sync.Mutex
updateState updateState
queryMu sync.RWMutex
configDir string
secretStore secretstore.SecretStore
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
ctx context.Context
startedAt time.Time
dbCache map[string]cachedDatabase // Cache for DB connections
connectFailures map[string]cachedConnectFailure
mu sync.RWMutex // Mutex for cache access
updateMu sync.Mutex
updateState updateState
queryMu sync.RWMutex
configDir string
secretStore secretstore.SecretStore
runningQueries map[string]queryContext // queryID -> cancelFunc and start time
jvmPreviewTokenMu sync.Mutex
jvmPreviewTokens map[string]jvmPreviewConfirmationToken
jvmPreviewTokenTTL time.Duration
}
// NewApp creates a new App application struct
@@ -78,11 +81,13 @@ func NewAppWithSecretStore(store secretstore.SecretStore) *App {
store = secretstore.NewUnavailableStore("secret store unavailable")
}
return &App{
dbCache: make(map[string]cachedDatabase),
connectFailures: make(map[string]cachedConnectFailure),
runningQueries: make(map[string]queryContext),
configDir: resolveAppConfigDir(),
secretStore: store,
dbCache: make(map[string]cachedDatabase),
connectFailures: make(map[string]cachedConnectFailure),
runningQueries: make(map[string]queryContext),
configDir: resolveAppConfigDir(),
secretStore: store,
jvmPreviewTokens: make(map[string]jvmPreviewConfirmationToken),
jvmPreviewTokenTTL: defaultJVMPreviewConfirmationTokenTTL,
}
}

View File

@@ -49,3 +49,16 @@ func TestNormalizeSchemaAndTable_PostgresStillSplitsQualifiedName(t *testing.T)
t.Fatalf("expected postgres qualified split to public.orders, got %q.%q", schema, table)
}
}
func TestQuoteTableIdentByType_KingbaseNormalizesQuotedQualifiedTable(t *testing.T) {
t.Parallel()
schema, table := normalizeSchemaAndTableByType("kingbase", "", `\"Idf_server\".\"mes_bip_wip_finished\"`)
if schema != "Idf_server" || table != "mes_bip_wip_finished" {
t.Fatalf("expected kingbase qualified split to Idf_server.mes_bip_wip_finished, got %q.%q", schema, table)
}
if got := quoteTableIdentByType("kingbase", schema, table); got != `"Idf_server"."mes_bip_wip_finished"` {
t.Fatalf("unexpected kingbase table identifier: %s", got)
}
}

View File

@@ -174,6 +174,26 @@ func normalizeSchemaAndTableByType(dbType string, dbName string, tableName strin
return rawDB, rawTable
}
if dbType == "kingbase" {
schema, table := db.SplitKingbaseQualifiedName(rawTable)
if schema != "" && table != "" {
return schema, table
}
if table != "" {
return "public", table
}
}
if dbType == "postgres" || dbType == "highgo" || dbType == "vastbase" {
schema, table := db.SplitSQLQualifiedName(rawTable)
if schema != "" && table != "" {
return schema, table
}
if table != "" {
return "public", table
}
}
if parts := strings.SplitN(rawTable, ".", 2); len(parts) == 2 {
schema := strings.TrimSpace(parts[0])
table := strings.TrimSpace(parts[1])
@@ -204,7 +224,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", "clickhouse":
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "dameng", "clickhouse":
if strings.TrimSpace(dbName) != "" {
runConfig.Database = strings.TrimSpace(dbName)
}
@@ -918,6 +938,12 @@ func resolveCreateStatementWithFallback(dbInst db.Database, config connection.Co
return sqlStr, nil
}
if supportsViewCreateStatementLookup(dbType) {
if viewDDL, ok := tryGetViewCreateStatement(dbInst, config, dbName, schemaName, pureTableName); ok {
return viewDDL, nil
}
}
if !supportsCreateStatementFallback(dbType) {
if sourceErr != nil {
return "", sourceErr
@@ -952,6 +978,15 @@ func supportsCreateStatementFallback(dbType string) bool {
}
}
func supportsViewCreateStatementLookup(dbType string) bool {
switch dbType {
case "mysql", "mariadb", "diros", "sphinx", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "oracle", "dameng", "sqlite", "duckdb", "clickhouse":
return true
default:
return false
}
}
func shouldFallbackCreateStatement(dbType string, ddl string) bool {
if !supportsCreateStatementFallback(dbType) {
return false
@@ -961,7 +996,7 @@ func shouldFallbackCreateStatement(dbType string, ddl string) bool {
if trimmed == "" {
return true
}
if hasCreateTableHead(trimmed) {
if hasCreateTableOrViewHead(trimmed) {
return false
}
@@ -974,7 +1009,7 @@ func shouldFallbackCreateStatement(dbType string, ddl string) bool {
return true
}
func hasCreateTableHead(sqlText string) bool {
func hasCreateTableOrViewHead(sqlText string) bool {
lines := strings.Split(sqlText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
@@ -984,7 +1019,10 @@ func hasCreateTableHead(sqlText string) bool {
if strings.HasPrefix(line, "--") || strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") {
continue
}
return strings.HasPrefix(strings.ToLower(line), "create table")
lower := strings.ToLower(line)
return strings.HasPrefix(lower, "create table") ||
strings.HasPrefix(lower, "create view") ||
strings.HasPrefix(lower, "create or replace view")
}
return false
}

View File

@@ -13,18 +13,23 @@ type fakeCreateStatementDB struct {
createErr error
columns []connection.ColumnDefinition
columnsErr error
queryRows []map[string]interface{}
queryErr error
createSchema string
createTable string
colsSchema string
colsTable string
columnsCalls int
queries []string
}
func (f *fakeCreateStatementDB) Connect(config connection.ConnectionConfig) error { return nil }
func (f *fakeCreateStatementDB) Close() error { return nil }
func (f *fakeCreateStatementDB) Ping() error { return nil }
func (f *fakeCreateStatementDB) Query(query string) ([]map[string]interface{}, []string, error) {
return nil, nil, nil
f.queries = append(f.queries, query)
return f.queryRows, []string{"ddl"}, f.queryErr
}
func (f *fakeCreateStatementDB) Exec(query string) (int64, error) { return 0, nil }
func (f *fakeCreateStatementDB) GetDatabases() ([]string, error) { return nil, nil }
@@ -35,6 +40,7 @@ func (f *fakeCreateStatementDB) GetCreateStatement(dbName, tableName string) (st
return f.createSQL, f.createErr
}
func (f *fakeCreateStatementDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
f.columnsCalls++
f.colsSchema = dbName
f.colsTable = tableName
return f.columns, f.columnsErr
@@ -80,6 +86,46 @@ func TestResolveDDLDBType_CustomDriverAlias(t *testing.T) {
}
}
func TestNormalizeSchemaAndTableByType_PGLikeQuotedQualifiedName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dbType string
tableName string
wantSchema string
wantTable string
}{
{name: "postgres quoted dots", dbType: "postgres", tableName: `"sales.schema"."order.items"`, wantSchema: "sales.schema", wantTable: "order.items"},
{name: "highgo escaped quoted", dbType: "highgo", tableName: `\"sales\".\"orders\"`, wantSchema: "sales", wantTable: "orders"},
{name: "vastbase quoted table only", dbType: "vastbase", tableName: `"order.items"`, wantSchema: "public", wantTable: "order.items"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotSchema, gotTable := normalizeSchemaAndTableByType(tt.dbType, "", tt.tableName)
if gotSchema != tt.wantSchema || gotTable != tt.wantTable {
t.Fatalf("normalizeSchemaAndTableByType(%q,%q)=(%q,%q),want=(%q,%q)", tt.dbType, tt.tableName, gotSchema, gotTable, tt.wantSchema, tt.wantTable)
}
})
}
}
func TestBuildRunConfigForDDL_CustomHighGoUsesDatabase(t *testing.T) {
t.Parallel()
got := buildRunConfigForDDL(connection.ConnectionConfig{
Type: "custom",
Driver: "highgo",
Database: "default_db",
}, "highgo", "target_db")
if got.Database != "target_db" {
t.Fatalf("expected custom highgo DDL database target_db, got %q", got.Database)
}
}
func TestResolveCreateStatementWithFallback_CustomKingbaseUsesPublicSchema(t *testing.T) {
t.Parallel()
@@ -130,6 +176,124 @@ func TestResolveCreateStatementWithFallback_KeepQualifiedSchema(t *testing.T) {
}
}
func TestResolveCreateStatementWithFallback_PGLikeQuotedQualifiedName(t *testing.T) {
t.Parallel()
dbInst := &fakeCreateStatementDB{
createSQL: "-- SHOW CREATE TABLE not fully supported for PostgreSQL in this MVP.",
columns: []connection.ColumnDefinition{
{Name: "id", Type: "integer", Nullable: "NO", Key: "PRI"},
},
}
ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{
Type: "postgres",
}, "", `"sales.schema"."order.items"`)
if err != nil {
t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err)
}
if dbInst.createSchema != "sales.schema" || dbInst.createTable != "order.items" {
t.Fatalf("expected create target sales.schema.order.items, got %q.%q", dbInst.createSchema, dbInst.createTable)
}
if dbInst.colsSchema != "sales.schema" || dbInst.colsTable != "order.items" {
t.Fatalf("expected column target sales.schema.order.items, got %q.%q", dbInst.colsSchema, dbInst.colsTable)
}
if !strings.Contains(ddl, `CREATE TABLE "sales.schema"."order.items"`) {
t.Fatalf("expected fallback DDL with quoted dotted identifiers, got: %s", ddl)
}
}
func TestResolveCreateStatementWithFallback_ReturnsCreateViewDirectly(t *testing.T) {
t.Parallel()
dbInst := &fakeCreateStatementDB{
createSQL: "CREATE VIEW sales.orders_v AS SELECT 1;",
columnsErr: errors.New("should not be called"),
}
ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{Type: "postgres"}, "", "sales.orders_v")
if err != nil {
t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err)
}
if ddl != dbInst.createSQL {
t.Fatalf("expected original create view DDL, got: %s", ddl)
}
if dbInst.columnsCalls != 0 {
t.Fatalf("CREATE VIEW path should not call GetColumns, calls=%d", dbInst.columnsCalls)
}
}
func TestResolveCreateStatementWithFallback_PGLikeViewHelperBeforeColumnFallback(t *testing.T) {
t.Parallel()
dbInst := &fakeCreateStatementDB{
createSQL: "SHOW CREATE TABLE not directly supported in PostgreSQL",
columnsErr: errors.New("should not be called"),
queryRows: []map[string]interface{}{
{"ddl": "SELECT id FROM sales.orders"},
},
}
ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{Type: "postgres"}, "", "sales.orders_v")
if err != nil {
t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err)
}
if !strings.Contains(ddl, `CREATE VIEW "sales"."orders_v" AS SELECT id FROM sales.orders`) {
t.Fatalf("expected CREATE VIEW DDL from view helper, got: %s", ddl)
}
if dbInst.columnsCalls != 0 {
t.Fatalf("view helper path should not call GetColumns, calls=%d", dbInst.columnsCalls)
}
if len(dbInst.queries) == 0 || !strings.Contains(dbInst.queries[0], "pg_get_viewdef") {
t.Fatalf("expected pg_get_viewdef query, got: %v", dbInst.queries)
}
}
func TestResolveCreateStatementWithFallback_PGLikeViewHelperKeepsQuotedDottedName(t *testing.T) {
t.Parallel()
dbInst := &fakeCreateStatementDB{
createSQL: "SHOW CREATE TABLE not directly supported in PostgreSQL",
columnsErr: errors.New("should not be called"),
queryRows: []map[string]interface{}{
{"ddl": "SELECT 1"},
},
}
ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{Type: "postgres"}, "", `"sales.schema"."order.items"`)
if err != nil {
t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err)
}
if !strings.Contains(ddl, `CREATE VIEW "sales.schema"."order.items" AS SELECT 1`) {
t.Fatalf("expected CREATE VIEW DDL to keep quoted dotted identifiers, got: %s", ddl)
}
if dbInst.columnsCalls != 0 {
t.Fatalf("view helper path should not call GetColumns, calls=%d", dbInst.columnsCalls)
}
}
func TestResolveCreateStatementWithFallback_PGLikeViewHelperMissFallsBackToColumns(t *testing.T) {
t.Parallel()
dbInst := &fakeCreateStatementDB{
createSQL: "SHOW CREATE TABLE not directly supported in PostgreSQL",
columns: []connection.ColumnDefinition{
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
},
}
ddl, err := resolveCreateStatementWithFallback(dbInst, connection.ConnectionConfig{Type: "postgres"}, "", "sales.orders")
if err != nil {
t.Fatalf("resolveCreateStatementWithFallback() unexpected error: %v", err)
}
if !strings.Contains(ddl, `CREATE TABLE "sales"."orders"`) {
t.Fatalf("expected CREATE TABLE fallback after view helper miss, got: %s", ddl)
}
if dbInst.columnsCalls != 1 {
t.Fatalf("expected one GetColumns call after view helper miss, calls=%d", dbInst.columnsCalls)
}
}
func TestResolveCreateStatementWithFallback_NoFallbackForMySQL(t *testing.T) {
t.Parallel()

View File

@@ -96,6 +96,29 @@ func readSQLFileByPath(filePath string) connection.QueryResult {
return connection.QueryResult{Success: true, Data: string(content)}
}
func writeSQLFileByPath(filePath string, content string) connection.QueryResult {
target := strings.TrimSpace(filePath)
if target == "" {
return connection.QueryResult{Success: false, Message: "文件路径不能为空"}
}
if abs, err := filepath.Abs(target); err == nil {
target = abs
}
info, err := os.Stat(target)
if err != nil {
return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)}
}
if info.IsDir() {
return connection.QueryResult{Success: false, Message: "所选路径不是 SQL 文件"}
}
if err := os.WriteFile(target, []byte(content), info.Mode().Perm()); err != nil {
return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法写入 SQL 文件: %v", err)}
}
return connection.QueryResult{Success: true, Data: map[string]interface{}{"filePath": target}}
}
func buildSQLDirectoryEntries(directory string) ([]SQLDirectoryEntry, error) {
entries, err := os.ReadDir(directory)
if err != nil {
@@ -215,6 +238,10 @@ func (a *App) ReadSQLFile(filePath string) connection.QueryResult {
return readSQLFileByPath(filePath)
}
func (a *App) WriteSQLFile(filePath string, content string) connection.QueryResult {
return writeSQLFileByPath(filePath, content)
}
// ExecuteSQLFile 在后端流式读取并执行大 SQL 文件,通过事件推送进度。
// 前端通过 EventsOn("sqlfile:progress", ...) 监听进度。
func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string, filePath string, jobID string) connection.QueryResult {
@@ -1328,6 +1355,12 @@ func quoteIdentByType(dbType string, ident string) string {
switch dbType {
case "mysql", "mariadb", "diros", "sphinx", "tdengine", "clickhouse":
return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
case "kingbase":
cleaned := db.NormalizeKingbaseIdentifier(ident)
if cleaned == "" {
return `""`
}
return `"` + strings.ReplaceAll(cleaned, `"`, `""`) + `"`
case "sqlserver":
escaped := strings.ReplaceAll(ident, "]", "]]")
return "[" + escaped + "]"
@@ -1342,6 +1375,17 @@ func quoteQualifiedIdentByType(dbType string, ident string) string {
return raw
}
if dbType == "kingbase" {
schema, table := db.SplitKingbaseQualifiedName(raw)
if table == "" {
return quoteIdentByType(dbType, raw)
}
if schema == "" {
return quoteIdentByType(dbType, table)
}
return quoteIdentByType(dbType, schema) + "." + quoteIdentByType(dbType, table)
}
parts := strings.Split(raw, ".")
if len(parts) <= 1 {
return quoteIdentByType(dbType, raw)
@@ -1617,8 +1661,8 @@ func tryGetViewCreateStatement(
continue
}
if looksLikeSelectOrWith(createSQL) {
qualifiedView := qualifyTable(schemaName, viewName)
createSQL = fmt.Sprintf("CREATE VIEW %s AS %s", quoteQualifiedIdentByType(config.Type, qualifiedView), strings.TrimSuffix(strings.TrimSpace(createSQL), ";"))
dbType := resolveDDLDBType(config)
createSQL = fmt.Sprintf("CREATE VIEW %s AS %s", quoteTableIdentByType(dbType, schemaName, viewName), strings.TrimSuffix(strings.TrimSpace(createSQL), ";"))
}
return ensureSQLTerminator(createSQL), true
}

View File

@@ -46,6 +46,32 @@ func TestBuildTableDataClearSQL_ClearUsesMongoDeleteCommand(t *testing.T) {
}
}
func TestBuildTableDataClearSQL_KingbaseTruncateNormalizesQuotedQualifiedTable(t *testing.T) {
t.Parallel()
sql, err := buildTableDataClearSQL(connection.ConnectionConfig{Type: "kingbase"}, `\"Idf_server\".\"mes_bip_wip_finished\"`, tableDataClearModeTruncate)
if err != nil {
t.Fatalf("buildTableDataClearSQL() unexpected error: %v", err)
}
if sql != `TRUNCATE TABLE "Idf_server"."mes_bip_wip_finished"` {
t.Fatalf("unexpected kingbase truncate sql: %s", sql)
}
}
func TestBuildTableDataClearSQL_KingbaseClearNormalizesQuotedQualifiedTable(t *testing.T) {
t.Parallel()
sql, err := buildTableDataClearSQL(connection.ConnectionConfig{Type: "kingbase"}, `\"Idf_server\".\"mes_bip_wip_finished\"`, tableDataClearModeDeleteAll)
if err != nil {
t.Fatalf("buildTableDataClearSQL() unexpected error: %v", err)
}
if sql != `DELETE FROM "Idf_server"."mes_bip_wip_finished"` {
t.Fatalf("unexpected kingbase clear sql: %s", sql)
}
}
func TestBuildTableDataClearSQL_TruncateRejectsUnsupportedDialect(t *testing.T) {
t.Parallel()

View File

@@ -41,6 +41,40 @@ func TestBuildSQLDirectoryEntriesKeepsOnlySQLFilesAndNestedFolders(t *testing.T)
}
}
func TestWriteSQLFileByPathOverwritesExistingSQLFile(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "report.sql")
if err := os.WriteFile(filePath, []byte("select 1;"), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
result := writeSQLFileByPath(filePath, "select 2;\n")
if !result.Success {
t.Fatalf("expected sql file write to succeed, got %#v", result)
}
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if string(content) != "select 2;\n" {
t.Fatalf("expected file content to be overwritten, got %q", string(content))
}
}
func TestWriteSQLFileByPathRejectsDirectories(t *testing.T) {
result := writeSQLFileByPath(t.TempDir(), "select 1;")
if result.Success {
t.Fatalf("expected directory write to fail, got %#v", result)
}
}
func TestWriteSQLFileByPathRejectsEmptyPath(t *testing.T) {
result := writeSQLFileByPath(" ", "select 1;")
if result.Success {
t.Fatalf("expected empty path write to fail, got %#v", result)
}
}
func TestReadSQLFileByPathReturnsLargeFileMetadata(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "big.sql")
file, err := os.Create(filePath)

View File

@@ -1,15 +1,44 @@
package app
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
"github.com/google/uuid"
)
var newJVMProvider = jvm.NewProvider
const defaultJVMPreviewConfirmationTokenTTL = 10 * time.Minute
type jvmPreviewConfirmationToken struct {
contextHash string
expiresAt time.Time
}
type jvmPreviewConfirmationContext struct {
ConfigHash string `json:"configHash"`
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source"`
ExpectedVersion string `json:"expectedVersion"`
PayloadHash string `json:"payloadHash"`
PreviewChecksum string `json:"previewChecksum"`
RiskLevel string `json:"riskLevel"`
BeforeVersion string `json:"beforeVersion"`
AfterVersion string `json:"afterVersion"`
}
func buildJVMCapabilityError(mode string, cfg connection.ConnectionConfig, err error) jvm.Capability {
probeCfg := cfg
probeCfg.JVM.PreferredMode = mode
@@ -40,6 +69,119 @@ func resolveJVMProviderForMode(cfg connection.ConnectionConfig, mode string) (co
return normalized, provider, nil
}
func (a *App) issueJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
contextHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
if err != nil {
return "", err
}
token := uuid.NewString()
now := time.Now()
ttl := a.jvmPreviewTokenTTL
if ttl <= 0 {
ttl = defaultJVMPreviewConfirmationTokenTTL
}
a.jvmPreviewTokenMu.Lock()
defer a.jvmPreviewTokenMu.Unlock()
if a.jvmPreviewTokens == nil {
a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken)
}
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
a.jvmPreviewTokens[token] = jvmPreviewConfirmationToken{
contextHash: contextHash,
expiresAt: now.Add(ttl),
}
return token, nil
}
func (a *App) consumeJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) error {
if !preview.RequiresConfirmation {
return nil
}
if strings.TrimSpace(preview.ConfirmationToken) == "" {
return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交")
}
token := strings.TrimSpace(req.ConfirmationToken)
if token == "" {
return fmt.Errorf("缺少确认令牌,请先完成预览确认")
}
expectedHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
if err != nil {
return err
}
now := time.Now()
a.jvmPreviewTokenMu.Lock()
if a.jvmPreviewTokens == nil {
a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken)
}
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
entry, ok := a.jvmPreviewTokens[token]
if ok {
delete(a.jvmPreviewTokens, token)
}
a.jvmPreviewTokenMu.Unlock()
if !ok {
return fmt.Errorf("确认令牌已失效,请重新预览并确认")
}
if !entry.expiresAt.After(now) {
return fmt.Errorf("确认令牌已过期,请重新预览并确认")
}
if subtle.ConstantTimeCompare([]byte(entry.contextHash), []byte(expectedHash)) != 1 {
return fmt.Errorf("确认令牌不匹配,请重新预览并确认")
}
return nil
}
func (a *App) pruneExpiredJVMPreviewConfirmationTokensLocked(now time.Time) {
for token, entry := range a.jvmPreviewTokens {
if !entry.expiresAt.After(now) {
delete(a.jvmPreviewTokens, token)
}
}
}
func buildJVMPreviewConfirmationContextHash(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
configHash, err := hashJSONValue(cfg)
if err != nil {
return "", fmt.Errorf("生成 JVM 预览上下文失败: %w", err)
}
payloadHash, err := hashJSONValue(req.Payload)
if err != nil {
return "", fmt.Errorf("生成 JVM 预览载荷摘要失败: %w", err)
}
input := jvmPreviewConfirmationContext{
ConfigHash: configHash,
ProviderMode: strings.TrimSpace(cfg.JVM.PreferredMode),
ResourceID: strings.TrimSpace(req.ResourceID),
Action: strings.TrimSpace(req.Action),
Reason: strings.TrimSpace(req.Reason),
Source: strings.TrimSpace(req.Source),
ExpectedVersion: strings.TrimSpace(req.ExpectedVersion),
PayloadHash: payloadHash,
PreviewChecksum: strings.TrimSpace(preview.ConfirmationToken),
RiskLevel: strings.TrimSpace(preview.RiskLevel),
BeforeVersion: strings.TrimSpace(preview.Before.Version),
AfterVersion: strings.TrimSpace(preview.After.Version),
}
return hashJSONValue(input)
}
func hashJSONValue(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, provider, err := resolveJVMProvider(cfg)
if err != nil {
@@ -97,6 +239,13 @@ func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRe
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if preview.Allowed && preview.RequiresConfirmation {
token, err := a.issueJVMPreviewConfirmationToken(normalized, req, preview)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
preview.ConfirmationToken = token
}
return connection.QueryResult{Success: true, Data: preview}
}
@@ -124,26 +273,66 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
}
return connection.QueryResult{Success: false, Message: message}
}
result, err := provider.ApplyChange(a.ctx, normalized, req)
if err != nil {
if err := a.consumeJVMPreviewConfirmationToken(normalized, req, preview); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")).Append(jvm.AuditRecord{
ConnectionID: normalized.ID,
ProviderMode: normalized.JVM.PreferredMode,
ResourceID: req.ResourceID,
Action: req.Action,
Reason: req.Reason,
Source: req.Source,
Result: result.Status,
}); err != nil {
if strings.TrimSpace(result.Message) == "" {
result.Message = "变更已执行,但审计记录写入失败: " + err.Error()
} else {
result.Message += ";审计记录写入失败: " + err.Error()
auditStore := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl"))
appendAuditRecord := func(record jvm.AuditRecord) error {
return auditStore.Append(record)
}
appendAudit := func(result string, timestamp int64) error {
return appendAuditRecord(jvm.AuditRecord{
Timestamp: timestamp,
ConnectionID: normalized.ID,
ProviderMode: normalized.JVM.PreferredMode,
ResourceID: req.ResourceID,
Action: req.Action,
Reason: req.Reason,
Source: req.Source,
Result: result,
})
}
appendWarning := func(message string, warning string) string {
message = strings.TrimSpace(message)
warning = strings.TrimSpace(warning)
if warning == "" {
return message
}
if message == "" {
return warning
}
return message + "" + warning
}
pendingTimestamp := time.Now().UnixMilli()
terminalAuditTimestamp := func() int64 {
ts := time.Now().UnixMilli()
if ts <= pendingTimestamp {
return pendingTimestamp + 1
}
return ts
}
if err := appendAudit("pending", pendingTimestamp); err != nil {
return connection.QueryResult{Success: false, Message: "审计记录写入失败,已阻止 JVM 变更: " + err.Error()}
}
result, err := provider.ApplyChange(a.ctx, normalized, req)
if err != nil {
if auditErr := appendAudit("failed", terminalAuditTimestamp()); auditErr != nil {
return connection.QueryResult{Success: false, Message: err.Error() + ";失败审计写入失败: " + auditErr.Error()}
}
return connection.QueryResult{Success: false, Message: err.Error()}
}
terminalResult := strings.TrimSpace(result.Status)
if terminalResult == "" {
terminalResult = "applied"
}
if err := appendAudit(terminalResult, terminalAuditTimestamp()); err != nil {
result.Message = appendWarning(result.Message, "终态审计写入失败: "+err.Error())
return connection.QueryResult{Success: true, Message: result.Message, Data: result}
}
return connection.QueryResult{Success: true, Data: result}

View File

@@ -13,6 +13,7 @@ import (
)
var newJVMDiagnosticTransport = jvm.NewDiagnosticTransport
var emitJVMDiagnosticRuntimeEvent = runtime.EventsEmit
const diagnosticChunkEvent = "jvm:diagnostic:chunk"
@@ -81,6 +82,8 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
return connection.QueryResult{Success: false, Message: err.Error()}
}
redactor := jvm.NewDiagnosticOutputRedactor()
req.SessionID = strings.TrimSpace(req.SessionID)
req.CommandID = strings.TrimSpace(req.CommandID)
req.Command = strings.TrimSpace(req.Command)
@@ -100,9 +103,10 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
req.Source = "manual"
}
commandType, err := jvm.ValidateDiagnosticCommandPolicy(normalized.JVM.Diagnostic, req.Command)
commandType, err := jvm.ValidateDiagnosticExecutionPolicy(normalized, req.Command)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
message := redactor.RedactContent(req.SessionID, req.CommandID, err.Error())
return connection.QueryResult{Success: false, Message: message}
}
riskLevel := diagnosticRiskLevel(commandType)
auditStore := jvm.NewDiagnosticAuditStore(filepath.Join(a.auditRootDir(), "jvm_diag_audit.jsonl"))
@@ -120,7 +124,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
RiskLevel: riskLevel,
Status: "running",
}); err != nil {
auditWarnings = append(auditWarnings, "审计记录写入失败: "+err.Error())
return connection.QueryResult{Success: false, Message: "诊断审计记录写入失败,已阻止命令执行: " + err.Error()}
}
terminalSeen := false
@@ -150,12 +154,9 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
if chunk.Timestamp == 0 {
chunk.Timestamp = time.Now().UnixMilli()
}
if strings.TrimSpace(chunk.SessionID) == "" {
chunk.SessionID = req.SessionID
}
if strings.TrimSpace(chunk.CommandID) == "" {
chunk.CommandID = req.CommandID
}
chunk.SessionID = req.SessionID
chunk.CommandID = req.CommandID
chunk = redactor.RedactChunk(chunk)
a.emitDiagnosticChunk(tabID, chunk)
if isDiagnosticTerminalPhase(chunk.Phase) {
appendTerminalAudit(chunk.Phase)
@@ -168,19 +169,20 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
if strings.Contains(strings.ToLower(err.Error()), "canceled") {
phase = "canceled"
}
redactedError := redactor.RedactContent(req.SessionID, req.CommandID, err.Error())
if !terminalSeen {
chunk := jvm.DiagnosticEventChunk{
SessionID: req.SessionID,
CommandID: req.CommandID,
Event: "diagnostic",
Phase: phase,
Content: err.Error(),
Content: redactedError,
Timestamp: time.Now().UnixMilli(),
}
a.emitDiagnosticChunk(tabID, chunk)
appendTerminalAudit(phase)
}
return connection.QueryResult{Success: false, Message: joinDiagnosticMessages(err.Error(), auditWarnings)}
return connection.QueryResult{Success: false, Message: joinDiagnosticMessages(redactedError, auditWarnings)}
}
if !terminalSeen {
@@ -253,7 +255,7 @@ func (a *App) emitDiagnosticChunk(tabID string, chunk jvm.DiagnosticEventChunk)
if a.ctx == nil {
return
}
runtime.EventsEmit(a.ctx, diagnosticChunkEvent, diagnosticChunkEventPayload{
emitJVMDiagnosticRuntimeEvent(a.ctx, diagnosticChunkEvent, diagnosticChunkEventPayload{
TabID: strings.TrimSpace(tabID),
Chunk: chunk,
})

View File

@@ -2,7 +2,10 @@ package app
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
@@ -10,16 +13,17 @@ import (
)
type fakeDiagnosticTransport struct {
testErr error
caps []jvm.DiagnosticCapability
capsErr error
handle jvm.DiagnosticSessionHandle
startErr error
executeReq jvm.DiagnosticCommandRequest
executeErr error
cancelSession string
cancelCommand string
cancelErr error
testErr error
caps []jvm.DiagnosticCapability
capsErr error
handle jvm.DiagnosticSessionHandle
startErr error
executeReq jvm.DiagnosticCommandRequest
executeErr error
executeCalls int
cancelSession string
cancelCommand string
cancelErr error
}
func (f fakeDiagnosticTransport) Mode() string { return jvm.DiagnosticTransportAgentBridge }
@@ -48,6 +52,55 @@ func (f fakeDiagnosticTransport) CloseSession(context.Context, connection.Connec
return nil
}
type fakeStreamingDiagnosticTransport struct {
sink jvm.DiagnosticEventSink
chunks []jvm.DiagnosticEventChunk
executeErr error
}
func (f *fakeStreamingDiagnosticTransport) Mode() string { return jvm.DiagnosticTransportAgentBridge }
func (f *fakeStreamingDiagnosticTransport) TestConnection(context.Context, connection.ConnectionConfig) error {
return nil
}
func (f *fakeStreamingDiagnosticTransport) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.DiagnosticCapability, error) {
return nil, nil
}
func (f *fakeStreamingDiagnosticTransport) StartSession(context.Context, connection.ConnectionConfig, jvm.DiagnosticSessionRequest) (jvm.DiagnosticSessionHandle, error) {
return jvm.DiagnosticSessionHandle{}, nil
}
func (f *fakeStreamingDiagnosticTransport) SetEventSink(sink jvm.DiagnosticEventSink) {
f.sink = sink
}
func (f *fakeStreamingDiagnosticTransport) ExecuteCommand(context.Context, connection.ConnectionConfig, jvm.DiagnosticCommandRequest) error {
if f.sink != nil {
chunks := f.chunks
if len(chunks) == 0 {
chunks = []jvm.DiagnosticEventChunk{{
Event: "diagnostic",
Phase: "running",
Content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
}}
}
for _, chunk := range chunks {
f.sink(chunk)
}
}
return f.executeErr
}
func (f *fakeStreamingDiagnosticTransport) CancelCommand(context.Context, connection.ConnectionConfig, string, string) error {
return nil
}
func (f *fakeStreamingDiagnosticTransport) CloseSession(context.Context, connection.ConnectionConfig, string) error {
return nil
}
func TestJVMProbeDiagnosticCapabilitiesReturnsTransportPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
@@ -161,6 +214,417 @@ func TestJVMExecuteDiagnosticCommandReturnsAccepted(t *testing.T) {
}
}
func TestJVMExecuteDiagnosticCommandBlocksTraceWhenConnectionReadOnly(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
readOnly := true
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowTraceCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-trace-1",
Command: "watch com.foo.OrderService submitOrder '{params,returnObj}' -x 2",
Source: "manual",
Reason: "定位慢调用",
})
if res.Success {
t.Fatalf("expected trace command to be blocked in read-only mode, got %+v", res)
}
if !strings.Contains(res.Message, "只读") {
t.Fatalf("expected read-only message, got %+v", res)
}
if recorder.executeCalls != 0 {
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
}
}
func TestJVMExecuteDiagnosticCommandBlocksMutatingWhenConnectionReadOnly(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
readOnly := true
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowMutatingCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-mutating-1",
Command: "ognl '@java.lang.System@getProperty(\"user.dir\")'",
Source: "manual",
Reason: "读取系统属性",
})
if res.Success {
t.Fatalf("expected mutating command to be blocked in read-only mode, got %+v", res)
}
if !strings.Contains(res.Message, "只读") {
t.Fatalf("expected read-only message, got %+v", res)
}
if recorder.executeCalls != 0 {
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
}
}
func TestJVMExecuteDiagnosticCommandBlocksMultilineCommandWhenConnectionReadOnly(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
readOnly := true
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
AllowTraceCommands: true,
AllowMutatingCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-multiline-1",
Command: "thread -n 1\nognl '@java.lang.System@setProperty(\"x\",\"y\")'",
Source: "manual",
Reason: "观察线程",
})
if res.Success {
t.Fatalf("expected multiline command to be blocked in read-only mode, got %+v", res)
}
if recorder.executeCalls != 0 {
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
}
}
func TestJVMExecuteDiagnosticCommandAllowsObserveWhenConnectionReadOnly(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
readOnly := true
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-observe-1",
Command: "thread -n 5",
Source: "manual",
Reason: "观察线程",
})
if !res.Success {
t.Fatalf("expected observe command to be allowed in read-only mode, got %+v", res)
}
if recorder.executeCalls != 1 {
t.Fatalf("expected transport ExecuteCommand called once, got %d", recorder.executeCalls)
}
}
func TestJVMExecuteDiagnosticCommandRedactsExecuteErrorMessage(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{executeErr: errors.New("Authorization: Bearer header-secret")}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-observe-secret",
Command: "thread -n 5",
Source: "manual",
Reason: "观察线程",
})
if res.Success {
t.Fatalf("expected execute failure, got %+v", res)
}
if strings.Contains(res.Message, "header-secret") {
t.Fatalf("expected execute error message to be redacted, got %q", res.Message)
}
if !strings.Contains(res.Message, "Authorization: ********") {
t.Fatalf("expected redacted authorization message, got %q", res.Message)
}
}
func TestJVMExecuteDiagnosticCommandRedactsExecuteErrorWithStreamingPEMState(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return &fakeStreamingDiagnosticTransport{executeErr: errors.New("def456\n-----END PRIVATE KEY-----")}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-observe-pem",
Command: "thread -n 5",
Source: "manual",
Reason: "观察线程",
})
if res.Success {
t.Fatalf("expected execute failure, got %+v", res)
}
if strings.Contains(res.Message, "def456") || strings.Contains(res.Message, "PRIVATE KEY") {
t.Fatalf("expected execute error PEM continuation to be redacted, got %q", res.Message)
}
}
func TestJVMExecuteDiagnosticCommandRedactsPolicyErrorMessage(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-policy-secret",
Command: "watch com.foo.OrderService submitOrder '{params}' password=plain-secret",
Source: "manual",
Reason: "观察线程",
})
if res.Success {
t.Fatalf("expected policy failure, got %+v", res)
}
if strings.Contains(res.Message, "plain-secret") {
t.Fatalf("expected policy error message to be redacted, got %q", res.Message)
}
if recorder.executeCalls != 0 {
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
}
}
func TestJVMExecuteDiagnosticCommandEmitsRedactedChunksWithRequestIDs(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
app.ctx = context.Background()
var emitted []diagnosticChunkEventPayload
prevEmitter := emitJVMDiagnosticRuntimeEvent
emitJVMDiagnosticRuntimeEvent = func(ctx context.Context, eventName string, optionalData ...interface{}) {
if eventName != diagnosticChunkEvent {
return
}
payload, ok := optionalData[0].(diagnosticChunkEventPayload)
if !ok {
t.Fatalf("expected diagnostic chunk event payload, got %#v", optionalData[0])
}
emitted = append(emitted, payload)
}
defer func() { emitJVMDiagnosticRuntimeEvent = prevEmitter }()
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return &fakeStreamingDiagnosticTransport{
chunks: []jvm.DiagnosticEventChunk{
{
SessionID: "remote-sess",
CommandID: "remote-cmd-1",
Event: "diagnostic",
Phase: "running",
Content: "PRIVATE_KEY=-----BEG",
},
{
SessionID: "remote-sess",
CommandID: "remote-cmd-2",
Event: "diagnostic",
Phase: "failed",
Content: "IN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
},
},
}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-event-secret",
Command: "thread -n 5",
Source: "manual",
Reason: "观察线程",
})
if !res.Success {
t.Fatalf("expected accepted command, got %+v", res)
}
if len(emitted) != 2 {
t.Fatalf("expected 2 emitted chunks, got %#v", emitted)
}
combined := ""
for _, payload := range emitted {
if payload.TabID != "tab-diag-1" {
t.Fatalf("unexpected tab id in emitted payload: %#v", payload)
}
if payload.Chunk.SessionID != "sess-1" || payload.Chunk.CommandID != "cmd-event-secret" {
t.Fatalf("expected emitted chunk to use request ids, got %#v", payload.Chunk)
}
combined += payload.Chunk.Content
}
for _, leaked := range []string{"PRIVATE KEY", "abc123", "-----END"} {
if strings.Contains(combined, leaked) {
t.Fatalf("expected emitted chunks to be redacted, leaked %q in %q", leaked, combined)
}
}
}
func TestJVMExecuteDiagnosticCommandFailsClosedWhenAuditWriteFails(t *testing.T) {
app := NewAppWithSecretStore(nil)
tempDir := t.TempDir()
blockerPath := filepath.Join(tempDir, "audit-blocker")
if err := os.WriteFile(blockerPath, []byte("blocker"), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
app.configDir = blockerPath
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
readOnly := true
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-observe-audit",
Command: "thread -n 5",
Source: "manual",
Reason: "观察线程",
})
if res.Success {
t.Fatalf("expected command to fail closed when initial audit write fails, got %+v", res)
}
if !strings.Contains(res.Message, "审计") {
t.Fatalf("expected audit failure message, got %+v", res)
}
if recorder.executeCalls != 0 {
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
}
}
func TestJVMCancelDiagnosticCommandDelegatesToTransport(t *testing.T) {
app := NewAppWithSecretStore(nil)
recorder := &fakeDiagnosticTransport{}
@@ -241,6 +705,7 @@ func (d diagnosticTransportRecorder) StartSession(ctx context.Context, cfg conne
func (d diagnosticTransportRecorder) ExecuteCommand(ctx context.Context, cfg connection.ConnectionConfig, req jvm.DiagnosticCommandRequest) error {
d.recorder.executeReq = req
d.recorder.executeCalls++
return d.recorder.ExecuteCommand(ctx, cfg, req)
}

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,21 @@ func normalizeKingbaseIdentCommon(raw string) string {
return strings.TrimSpace(value)
}
// NormalizeKingbaseIdentifier removes nested client-side quoting from a Kingbase identifier.
func NormalizeKingbaseIdentifier(raw string) string {
return normalizeKingbaseIdentCommon(raw)
}
// SplitKingbaseQualifiedName splits a Kingbase schema-qualified identifier safely.
func SplitKingbaseQualifiedName(raw string) (schema string, table string) {
return splitKingbaseQualifiedNameCommon(raw)
}
// SplitSQLQualifiedName splits a schema-qualified SQL identifier without splitting dots inside quotes.
func SplitSQLQualifiedName(raw string) (schema string, table string) {
return splitSQLQualifiedNameCommon(raw)
}
func splitKingbaseQualifiedNameCommon(raw string) (schema string, table string) {
text := strings.TrimSpace(raw)
if text == "" {
@@ -104,6 +119,124 @@ func splitKingbaseQualifiedNameCommon(raw string) (schema string, table string)
return schemaPart, tablePart
}
func splitSQLQualifiedNameCommon(raw string) (schema string, table string) {
text := normalizeSQLIdentifierEscapes(strings.TrimSpace(raw))
if text == "" {
return "", ""
}
sep := findSQLQualifiedSeparator(text)
if sep < 0 {
return "", normalizeSQLIdentPartCommon(text)
}
schemaPart := normalizeSQLIdentPartCommon(text[:sep])
tablePart := normalizeSQLIdentPartCommon(text[sep+1:])
if tablePart == "" {
if schemaPart == "" {
return "", normalizeSQLIdentPartCommon(text)
}
return "", schemaPart
}
if schemaPart == "" {
return "", tablePart
}
return schemaPart, tablePart
}
func normalizeSQLIdentifierEscapes(raw string) string {
value := strings.TrimSpace(raw)
for i := 0; i < 4; i++ {
next := strings.TrimSpace(value)
next = strings.ReplaceAll(next, `\\\"`, `\"`)
next = strings.ReplaceAll(next, `\"`, `"`)
if next == value {
break
}
value = next
}
return strings.TrimSpace(value)
}
func normalizeSQLIdentPartCommon(raw string) string {
value := normalizeSQLIdentifierEscapes(strings.TrimSpace(raw))
if value == "" {
return ""
}
if len(value) >= 2 {
first := value[0]
last := value[len(value)-1]
switch {
case first == '"' && last == '"':
return strings.TrimSpace(strings.ReplaceAll(value[1:len(value)-1], `""`, `"`))
case first == '`' && last == '`':
return strings.TrimSpace(strings.ReplaceAll(value[1:len(value)-1], "``", "`"))
case first == '[' && last == ']':
return strings.TrimSpace(strings.ReplaceAll(value[1:len(value)-1], "]]", "]"))
}
}
return value
}
func findSQLQualifiedSeparator(raw string) int {
inDouble := false
inBacktick := false
inBracket := false
for i := 0; i < len(raw); i++ {
ch := raw[i]
if inDouble {
if ch == '\\' && i+1 < len(raw) && raw[i+1] == '"' {
inDouble = false
i++
continue
}
if ch == '"' {
if i+1 < len(raw) && raw[i+1] == '"' {
i++
continue
}
inDouble = false
}
continue
}
if inBacktick {
if ch == '`' {
inBacktick = false
}
continue
}
if inBracket {
if ch == ']' {
inBracket = false
}
continue
}
switch ch {
case '\\':
if i+1 < len(raw) && raw[i+1] == '"' {
inDouble = true
i++
}
case '"':
inDouble = true
case '`':
inBacktick = true
case '[':
inBracket = true
case '.':
return i
}
}
return -1
}
func findKingbaseQualifiedSeparator(raw string) int {
inDouble := false
inBacktick := false

View File

@@ -51,6 +51,30 @@ func TestSplitKingbaseQualifiedNameCommon(t *testing.T) {
}
}
func TestSplitSQLQualifiedName(t *testing.T) {
tests := []struct {
name string
in string
wantSchema string
wantTable string
}{
{name: "plain", in: "sales.orders", wantSchema: "sales", wantTable: "orders"},
{name: "quoted dots", in: `"sales.schema"."order.items"`, wantSchema: "sales.schema", wantTable: "order.items"},
{name: "escaped quoted dots", in: `\"sales.schema\".\"order.items\"`, wantSchema: "sales.schema", wantTable: "order.items"},
{name: "quoted table only with dot", in: `"order.items"`, wantSchema: "", wantTable: "order.items"},
{name: "escaped quoted", in: `\"sales\".\"orders\"`, wantSchema: "sales", wantTable: "orders"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotSchema, gotTable := SplitSQLQualifiedName(tt.in)
if gotSchema != tt.wantSchema || gotTable != tt.wantTable {
t.Fatalf("SplitSQLQualifiedName(%q)=(%q,%q),want=(%q,%q)", tt.in, gotSchema, gotTable, tt.wantSchema, tt.wantTable)
}
})
}
}
func TestBuildKingbaseSearchPathCommon(t *testing.T) {
tests := []struct {
name string

View File

@@ -0,0 +1,183 @@
package db
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"strings"
"sync"
"testing"
"time"
"GoNavi-Wails/internal/connection"
)
const oracleRecordingDriverName = "gonavi_oracle_recording"
var (
registerOracleRecordingDriverOnce sync.Once
oracleRecordingDriverMu sync.Mutex
oracleRecordingDriverSeq int
oracleRecordingDriverStates = map[string]*oracleRecordingState{}
)
type oracleRecordingState struct {
mu sync.Mutex
execArgs [][]driver.NamedValue
}
func (s *oracleRecordingState) snapshotExecArgs() [][]driver.NamedValue {
s.mu.Lock()
defer s.mu.Unlock()
result := make([][]driver.NamedValue, len(s.execArgs))
for i, args := range s.execArgs {
result[i] = append([]driver.NamedValue(nil), args...)
}
return result
}
type oracleRecordingDriver struct{}
func (oracleRecordingDriver) Open(name string) (driver.Conn, error) {
oracleRecordingDriverMu.Lock()
state := oracleRecordingDriverStates[name]
oracleRecordingDriverMu.Unlock()
if state == nil {
return nil, fmt.Errorf("recording state not found: %s", name)
}
return &oracleRecordingConn{state: state}, nil
}
type oracleRecordingConn struct {
state *oracleRecordingState
}
func (c *oracleRecordingConn) Prepare(query string) (driver.Stmt, error) {
return nil, fmt.Errorf("prepare not supported in oracle recording driver: %s", query)
}
func (c *oracleRecordingConn) Close() error { return nil }
func (c *oracleRecordingConn) Begin() (driver.Tx, error) { return oracleRecordingTx{}, nil }
func (c *oracleRecordingConn) ExecContext(_ context.Context, _ string, args []driver.NamedValue) (driver.Result, error) {
c.state.mu.Lock()
defer c.state.mu.Unlock()
c.state.execArgs = append(c.state.execArgs, append([]driver.NamedValue(nil), args...))
return driver.RowsAffected(1), nil
}
func (c *oracleRecordingConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
if strings.Contains(strings.ToLower(query), "tab_columns") {
return &oracleRecordingRows{
columns: []string{"COLUMN_NAME", "DATA_TYPE", "NULLABLE", "DATA_DEFAULT"},
rows: [][]driver.Value{
{"UPDATED_AT", "TIMESTAMP", "YES", nil},
{"CREATED_AT", "DATE", "NO", nil},
},
}, nil
}
return &oracleRecordingRows{}, nil
}
var _ driver.ExecerContext = (*oracleRecordingConn)(nil)
var _ driver.QueryerContext = (*oracleRecordingConn)(nil)
type oracleRecordingTx struct{}
func (oracleRecordingTx) Commit() error { return nil }
func (oracleRecordingTx) Rollback() error { return nil }
type oracleRecordingRows struct {
columns []string
rows [][]driver.Value
index int
}
func (r *oracleRecordingRows) Columns() []string {
return append([]string(nil), r.columns...)
}
func (r *oracleRecordingRows) Close() error { return nil }
func (r *oracleRecordingRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
return io.EOF
}
row := r.rows[r.index]
for idx := range dest {
if idx < len(row) {
dest[idx] = row[idx]
}
}
r.index++
return nil
}
func openOracleRecordingDB(t *testing.T) (*sql.DB, *oracleRecordingState) {
t.Helper()
registerOracleRecordingDriverOnce.Do(func() {
sql.Register(oracleRecordingDriverName, oracleRecordingDriver{})
})
oracleRecordingDriverMu.Lock()
oracleRecordingDriverSeq++
dsn := fmt.Sprintf("oracle-recording-%d", oracleRecordingDriverSeq)
state := &oracleRecordingState{}
oracleRecordingDriverStates[dsn] = state
oracleRecordingDriverMu.Unlock()
dbConn, err := sql.Open(oracleRecordingDriverName, dsn)
if err != nil {
t.Fatalf("打开 recording db 失败: %v", err)
}
t.Cleanup(func() {
_ = dbConn.Close()
oracleRecordingDriverMu.Lock()
delete(oracleRecordingDriverStates, dsn)
oracleRecordingDriverMu.Unlock()
})
return dbConn, state
}
func TestOracleApplyChangesNormalizesTemporalStringsForUpdate(t *testing.T) {
t.Parallel()
dbConn, state := openOracleRecordingDB(t)
oracleDB := &OracleDB{conn: dbConn}
changes := connection.ChangeSet{
Updates: []connection.UpdateRow{{
Keys: map[string]interface{}{
"CREATED_AT": "2026-03-05T10:30:00Z",
},
Values: map[string]interface{}{
"UPDATED_AT": "2026-04-01T12:13:14.123456789Z",
},
}},
}
if err := oracleDB.ApplyChanges("EVENTS", changes); err != nil {
t.Fatalf("ApplyChanges 返回错误: %v", err)
}
executions := state.snapshotExecArgs()
if len(executions) != 1 {
t.Fatalf("期望执行 1 条更新,实际 %d 条", len(executions))
}
args := executions[0]
if len(args) != 2 {
t.Fatalf("期望 2 个绑定参数,实际 %d 个: %#v", len(args), args)
}
if _, ok := args[0].Value.(time.Time); !ok {
t.Fatalf("更新时间字段应绑定为 time.Time实际=%#v(%T)", args[0].Value, args[0].Value)
}
if _, ok := args[1].Value.(time.Time); !ok {
t.Fatalf("日期主键字段应绑定为 time.Time实际=%#v(%T)", args[1].Value, args[1].Value)
}
}

View File

@@ -389,11 +389,118 @@ func (o *OracleDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
return triggers, nil
}
func splitOracleQualifiedTableName(raw string) (string, string) {
table := strings.TrimSpace(raw)
schema := ""
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
schema = strings.Trim(strings.TrimSpace(parts[0]), "\"")
table = strings.TrimSpace(parts[1])
}
table = strings.Trim(strings.TrimSpace(table), "\"")
return schema, table
}
func (o *OracleDB) loadColumnTypeMap(tableName string) map[string]string {
result := map[string]string{}
schema, table := splitOracleQualifiedTableName(tableName)
if table == "" {
return result
}
columns, err := o.GetColumns(schema, table)
if err != nil {
logger.Warnf("加载 Oracle 列元数据失败(不影响提交):表=%s err=%v", tableName, err)
return result
}
for _, col := range columns {
name := strings.ToLower(strings.TrimSpace(col.Name))
if name == "" {
continue
}
result[name] = strings.TrimSpace(col.Type)
}
return result
}
func normalizeOracleValueForWrite(columnName string, value interface{}, columnTypeMap map[string]string) interface{} {
columnType := columnTypeMap[strings.ToLower(strings.TrimSpace(columnName))]
if !isOracleTemporalColumnType(columnType) {
return value
}
if value == nil {
return nil
}
text, ok := value.(string)
if !ok {
return value
}
raw := strings.TrimSpace(text)
if raw == "" {
return nil
}
if parsed, ok := parseOracleTemporalString(raw); ok {
return parsed
}
return value
}
func isOracleTemporalColumnType(columnType string) bool {
typ := strings.ToUpper(strings.TrimSpace(columnType))
return strings.Contains(typ, "DATE") || strings.Contains(typ, "TIMESTAMP")
}
func parseOracleTemporalString(raw string) (time.Time, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return time.Time{}, false
}
text = strings.ReplaceAll(text, "+ ", "+")
text = strings.ReplaceAll(text, "- ", "-")
candidates := []string{text}
if len(text) >= 19 && text[10] == ' ' && (strings.HasSuffix(text, "Z") || hasTimezoneOffset(text)) {
candidates = append(candidates, strings.Replace(text, " ", "T", 1))
}
layoutsWithZone := []string{
"2006-01-02 15:04:05.999999999 -0700 MST",
"2006-01-02 15:04:05 -0700 MST",
"2006-01-02 15:04:05.999999999 -0700",
"2006-01-02 15:04:05 -0700",
time.RFC3339Nano,
time.RFC3339,
}
for _, candidate := range candidates {
for _, layout := range layoutsWithZone {
if parsed, err := time.Parse(layout, candidate); err == nil {
return parsed, true
}
}
}
layoutsWithoutZone := []string{
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layoutsWithoutZone {
if parsed, err := time.ParseInLocation(layout, text, time.Local); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if o.conn == nil {
return fmt.Errorf("连接未打开")
}
columnTypeMap := o.loadColumnTypeMap(tableName)
tx, err := o.conn.Begin()
if err != nil {
return err
@@ -432,7 +539,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
for k, v := range pk {
idx++
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
args = append(args, v)
args = append(args, normalizeOracleValueForWrite(k, v, columnTypeMap))
}
if len(wheres) == 0 {
continue
@@ -452,7 +559,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
for k, v := range update.Values {
idx++
sets = append(sets, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
args = append(args, v)
args = append(args, normalizeOracleValueForWrite(k, v, columnTypeMap))
}
if len(sets) == 0 {
@@ -463,7 +570,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
for k, v := range update.Keys {
idx++
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
args = append(args, v)
args = append(args, normalizeOracleValueForWrite(k, v, columnTypeMap))
}
if len(wheres) == 0 {
@@ -487,7 +594,7 @@ func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet)
idx++
cols = append(cols, quoteIdent(k))
placeholders = append(placeholders, fmt.Sprintf(":%d", idx))
args = append(args, v)
args = append(args, normalizeOracleValueForWrite(k, v, columnTypeMap))
}
if len(cols) == 0 {

View File

@@ -80,11 +80,35 @@ func ValidateDiagnosticCommandPolicy(cfg connection.JVMDiagnosticConfig, command
return category, nil
}
func ValidateDiagnosticExecutionPolicy(cfg connection.ConnectionConfig, command string) (string, error) {
diagnosticCfg, err := NormalizeDiagnosticConfig(cfg)
if err != nil {
return "", err
}
category, err := ValidateDiagnosticCommandPolicy(diagnosticCfg, command)
if err != nil {
return "", err
}
if cfg.JVM.ReadOnly != nil && *cfg.JVM.ReadOnly {
switch category {
case DiagnosticCommandCategoryTrace, DiagnosticCommandCategoryMutating:
return "", fmt.Errorf("当前连接为只读模式,仅允许观察类诊断命令")
}
}
return category, nil
}
func classifyDiagnosticCommand(command string) (string, string, error) {
normalizedCommand := strings.TrimSpace(command)
if normalizedCommand == "" {
return "", "", fmt.Errorf("诊断命令不能为空")
}
if strings.ContainsAny(normalizedCommand, "\r\n") {
return "", "", fmt.Errorf("诊断命令不支持换行或多命令输入")
}
fields := strings.Fields(strings.ToLower(normalizedCommand))
head := fields[0]

View File

@@ -29,6 +29,35 @@ func TestNormalizeDiagnosticConfigDefaultsToDisabledObserveOnly(t *testing.T) {
}
}
func TestValidateDiagnosticCommandPolicyRejectsMultilineCommand(t *testing.T) {
cfg, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
AllowTraceCommands: true,
AllowMutatingCommands: true,
},
},
})
if err != nil {
t.Fatalf("NormalizeDiagnosticConfig returned error: %v", err)
}
for _, command := range []string{
"thread -n 1\nognl '@java.lang.System@setProperty(\"x\",\"y\")'",
"thread -n 1\rwatch com.foo.OrderService submitOrder '{params}'",
} {
if _, err := ValidateDiagnosticCommandPolicy(cfg, command); err == nil {
t.Fatalf("expected multiline command to be rejected: %q", command)
}
}
}
func TestClassifyDiagnosticCommandRejectsMutatingCommandWhenDisabled(t *testing.T) {
cfg, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
Type: "jvm",

View File

@@ -0,0 +1,215 @@
package jvm
import (
"regexp"
"strings"
"sync"
)
const diagnosticRedactionMask = "********"
const diagnosticSensitiveKeyPattern = `(?:password|passwd|pwd|secret|token|credential|authorization|api[_.\- \t]*key|access[_.\- \t]*key|private[_.\- \t]*key|secret[_.\- \t]*key|auth[_.\- \t]*key|access[_.\- \t]*token|refresh[_.\- \t]*token)`
const diagnosticSensitiveKeyBody = `[A-Za-z0-9_.\- \t]*` + diagnosticSensitiveKeyPattern + `[A-Za-z0-9_.\- \t]*`
var (
diagnosticPEMEndPattern = regexp.MustCompile(`(?i)-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----`)
diagnosticPEMBeginPrefixPattern = regexp.MustCompile(`(?is)-----BEGIN[\s\S]*$`)
diagnosticPEMEndContinuationPattern = regexp.MustCompile(`(?is)^[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----`)
diagnosticCompletePEMPattern = regexp.MustCompile(`(?is)-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[^-]*-----`)
diagnosticPartialPEMPattern = regexp.MustCompile(`(?is)-----BEGIN [^-]*(?:PRIVATE KEY|SECRET|TOKEN|CREDENTIAL)[\s\S]*$`)
diagnosticSensitivePEMLabels = []string{
"PRIVATE KEY",
"RSA PRIVATE KEY",
"DSA PRIVATE KEY",
"EC PRIVATE KEY",
"OPENSSH PRIVATE KEY",
"ENCRYPTED PRIVATE KEY",
"SECRET",
"TOKEN",
"CREDENTIAL",
}
diagnosticDoubleQuotedValuePattern = regexp.MustCompile(`(?i)(")(` + diagnosticSensitiveKeyBody + `)(")([ \t]*:[ \t]*)(")((?:\\.|[^"\\])*)(")`)
diagnosticSingleQuotedValuePattern = regexp.MustCompile(`(?i)(')(` + diagnosticSensitiveKeyBody + `)(')([ \t]*:[ \t]*)(')((?:\\.|[^'\\])*)(')`)
diagnosticDoubleQuotedScalarPattern = regexp.MustCompile(`(?i)(")(` + diagnosticSensitiveKeyBody + `)(")([ \t]*:[ \t]*)(true|false|null|-?\d+(?:\.\d+)?)`)
diagnosticSingleQuotedScalarPattern = regexp.MustCompile(`(?i)(')(` + diagnosticSensitiveKeyBody + `)(')([ \t]*:[ \t]*)(true|false|null|-?\d+(?:\.\d+)?)`)
diagnosticUnquotedKeyValuePattern = regexp.MustCompile(`(?i)(^|[\r\n,;{\[?&]|\s)(` + diagnosticSensitiveKeyBody + `)([ \t]*[:=][ \t]*)([^\r\n&]*)`)
diagnosticSensitivePEMBeginWithKeyPattern = regexp.MustCompile(`(?is)` + diagnosticSensitiveKeyBody + `[ \t]*[:=][ \t]*-----BEGIN[\s\S]*$`)
diagnosticSensitiveKeyAssignmentTailPattern = regexp.MustCompile(`(?is)(^|[\r\n,;{\[?&]|\s)` + diagnosticSensitiveKeyBody + `[ \t]*[:=][ \t]*([^\r\n&]*)$`)
)
type DiagnosticRedactionState struct {
InsideSensitivePEM bool
SawSensitivePEM bool
PendingPEMBeginFragment string
}
type DiagnosticOutputRedactor struct {
mu sync.Mutex
states map[string]*DiagnosticRedactionState
}
func NewDiagnosticOutputRedactor() *DiagnosticOutputRedactor {
return &DiagnosticOutputRedactor{states: map[string]*DiagnosticRedactionState{}}
}
func (r *DiagnosticOutputRedactor) RedactChunk(chunk DiagnosticEventChunk) DiagnosticEventChunk {
chunk.Content = r.RedactContent(chunk.SessionID, chunk.CommandID, chunk.Content)
return chunk
}
func (r *DiagnosticOutputRedactor) RedactContent(sessionID string, commandID string, content string) string {
if r == nil {
return RedactDiagnosticOutput(content)
}
r.mu.Lock()
defer r.mu.Unlock()
key := diagnosticRedactionStateKey(sessionID, commandID)
state := r.states[key]
if state == nil {
state = &DiagnosticRedactionState{}
r.states[key] = state
}
return redactDiagnosticOutputWithState(content, state)
}
func RedactDiagnosticOutput(content string) string {
state := DiagnosticRedactionState{}
return redactDiagnosticOutputWithState(content, &state)
}
func diagnosticRedactionStateKey(sessionID string, commandID string) string {
return strings.TrimSpace(sessionID) + "::" + strings.TrimSpace(commandID)
}
func redactDiagnosticOutputWithState(content string, state *DiagnosticRedactionState) string {
text := content
if state.PendingPEMBeginFragment != "" {
pending := state.PendingPEMBeginFragment
state.PendingPEMBeginFragment = ""
if isSensitivePEMBeginFragment(pending + content) {
state.InsideSensitivePEM = true
state.SawSensitivePEM = true
}
}
if state.InsideSensitivePEM {
pemEnd := diagnosticPEMEndPattern.FindStringIndex(text)
if pemEnd == nil {
return diagnosticRedactionMask
}
state.InsideSensitivePEM = false
state.SawSensitivePEM = true
text = diagnosticRedactionMask + diagnosticPEMEndPattern.ReplaceAllString(text[pemEnd[0]:], "")
} else if state.SawSensitivePEM && diagnosticPEMEndPattern.MatchString(text) {
text = diagnosticPEMEndContinuationPattern.ReplaceAllString(text, diagnosticRedactionMask)
}
text = diagnosticCompletePEMPattern.ReplaceAllStringFunc(text, func(string) string {
state.SawSensitivePEM = true
return diagnosticRedactionMask
})
text = diagnosticPartialPEMPattern.ReplaceAllStringFunc(text, func(match string) string {
state.SawSensitivePEM = true
state.InsideSensitivePEM = !diagnosticPEMEndPattern.MatchString(match)
return diagnosticRedactionMask
})
if !state.InsideSensitivePEM && !diagnosticPEMEndPattern.MatchString(content) && hasSensitivePEMPartialBeginWithKey(content) {
state.InsideSensitivePEM = true
state.SawSensitivePEM = true
}
if !state.InsideSensitivePEM && hasSensitivePEMBeginPrefix(text) {
state.InsideSensitivePEM = true
state.SawSensitivePEM = true
text = diagnosticPEMBeginPrefixPattern.ReplaceAllString(text, diagnosticRedactionMask)
}
if !state.InsideSensitivePEM && !diagnosticPEMEndPattern.MatchString(content) {
if fragment := sensitivePEMBeginTailFragment(content); fragment != "" {
state.PendingPEMBeginFragment = fragment
state.SawSensitivePEM = true
text = redactTrailingPEMBeginFragment(text, fragment)
}
}
return redactDiagnosticKeyValues(text)
}
func hasSensitivePEMBeginPrefix(value string) bool {
prefix := diagnosticPEMBeginPrefixPattern.FindString(value)
if prefix == "" {
return false
}
if isSensitivePEMBeginFragment(prefix) {
return true
}
return diagnosticSensitivePEMBeginWithKeyPattern.MatchString(value)
}
func hasSensitivePEMPartialBeginWithKey(value string) bool {
matches := diagnosticSensitiveKeyAssignmentTailPattern.FindAllStringSubmatch(value, -1)
for _, match := range matches {
if len(match) >= 3 && isSensitivePEMBeginFragment(match[2]) {
return true
}
}
return false
}
func isSensitivePEMBeginFragment(value string) bool {
fragment := strings.ToUpper(strings.TrimSpace(value))
if fragment == "" {
return false
}
marker := "-----BEGIN"
if len(fragment) <= len(marker) {
return strings.HasPrefix(marker, fragment) && strings.HasPrefix(fragment, "-")
}
if !strings.HasPrefix(fragment, marker) {
return false
}
label := strings.TrimSpace(strings.TrimRight(strings.TrimPrefix(fragment, marker), "-"))
label = strings.Join(strings.Fields(label), " ")
if label == "" {
return true
}
for _, item := range diagnosticSensitivePEMLabels {
if strings.HasPrefix(item, label) || strings.HasPrefix(label, item) {
return true
}
}
return false
}
func sensitivePEMBeginTailFragment(value string) string {
line := value
if idx := strings.LastIndexAny(line, "\r\n"); idx >= 0 {
line = line[idx+1:]
}
for start := 0; start < len(line); start++ {
fragment := line[start:]
if isSensitivePEMBeginFragment(fragment) {
return fragment
}
}
return ""
}
func redactTrailingPEMBeginFragment(value string, fragment string) string {
if fragment == "" {
return value
}
idx := strings.LastIndex(value, fragment)
if idx < 0 {
return value
}
return value[:idx] + diagnosticRedactionMask
}
func redactDiagnosticKeyValues(value string) string {
text := diagnosticDoubleQuotedValuePattern.ReplaceAllString(value, `${1}${2}${3}${4}${5}`+diagnosticRedactionMask+`${7}`)
text = diagnosticSingleQuotedValuePattern.ReplaceAllString(text, `${1}${2}${3}${4}${5}`+diagnosticRedactionMask+`${7}`)
text = diagnosticDoubleQuotedScalarPattern.ReplaceAllString(text, `${1}${2}${3}${4}`+diagnosticRedactionMask)
text = diagnosticSingleQuotedScalarPattern.ReplaceAllString(text, `${1}${2}${3}${4}`+diagnosticRedactionMask)
text = diagnosticUnquotedKeyValuePattern.ReplaceAllString(text, `${1}${2}${3}`+diagnosticRedactionMask)
return text
}

View File

@@ -0,0 +1,106 @@
package jvm
import (
"strings"
"testing"
)
func TestDiagnosticOutputRedactorRedactsSensitiveKeyValues(t *testing.T) {
redactor := NewDiagnosticOutputRedactor()
chunk := redactor.RedactChunk(DiagnosticEventChunk{
SessionID: "sess-1",
CommandID: "cmd-1",
Content: strings.Join([]string{
"password=secret-token",
"api_key: api-secret",
"Authorization: Bearer header-secret",
`{"refresh_token":"json-secret"}`,
"https://svc.local/callback?access_token=query-secret&x=1",
}, "\n"),
})
for _, leaked := range []string{"secret-token", "api-secret", "header-secret", "json-secret", "query-secret"} {
if strings.Contains(chunk.Content, leaked) {
t.Fatalf("redacted chunk leaked %q: %q", leaked, chunk.Content)
}
}
for _, masked := range []string{"password=********", "api_key: ********", "Authorization: ********", `"refresh_token":"********"`, "access_token=********"} {
if !strings.Contains(chunk.Content, masked) {
t.Fatalf("expected redacted chunk to contain %q, got %q", masked, chunk.Content)
}
}
}
func TestDiagnosticOutputRedactorRedactsPEMAcrossChunksAndRepeatedContinuation(t *testing.T) {
redactor := NewDiagnosticOutputRedactor()
first := redactor.RedactChunk(DiagnosticEventChunk{
SessionID: "sess-1",
CommandID: "cmd-1",
Content: "PRIVATE_KEY=-----BEGIN RSA PRIVATE K",
})
second := redactor.RedactChunk(DiagnosticEventChunk{
SessionID: "sess-1",
CommandID: "cmd-1",
Content: "EY-----\nabc123\n-----END RSA PRIVATE KEY-----",
})
third := redactor.RedactContent("sess-1", "cmd-1", "abc123\n-----END RSA PRIVATE KEY-----")
combined := strings.Join([]string{first.Content, second.Content, third}, "\n")
for _, leaked := range []string{"RSA PRIVATE K", "EY-----", "abc123"} {
if strings.Contains(combined, leaked) {
t.Fatalf("redacted PEM stream leaked %q: %q", leaked, combined)
}
}
}
func TestDiagnosticOutputRedactorRedactsPEMWhenBeginMarkerIsSplit(t *testing.T) {
stream := "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----"
beginIndex := strings.Index(stream, "-----BEGIN")
if beginIndex < 0 {
t.Fatal("test stream missing PEM begin marker")
}
for split := beginIndex + 1; split < beginIndex+len("-----BEGIN PRIVATE KEY"); split++ {
redactor := NewDiagnosticOutputRedactor()
combined := redactor.RedactContent("sess-1", "cmd-1", stream[:split]) + redactor.RedactContent("sess-1", "cmd-1", stream[split:])
for _, leaked := range []string{"PRIVATE KEY", "abc123", "-----END"} {
if strings.Contains(combined, leaked) {
t.Fatalf("split at %d leaked %q: %q", split, leaked, combined)
}
}
}
}
func TestDiagnosticOutputRedactorRedactsRawPEMWhenBeginMarkerIsSplit(t *testing.T) {
stream := "-----BEGIN PRIVATE KEY-----\nabc123\n-----END PRIVATE KEY-----"
for split := 1; split < len("-----BEGIN PRIVATE KEY"); split++ {
redactor := NewDiagnosticOutputRedactor()
combined := redactor.RedactContent("sess-1", "cmd-1", stream[:split]) + redactor.RedactContent("sess-1", "cmd-1", stream[split:])
for _, leaked := range []string{"-----BEG", "PRIVATE KEY", "abc123", "-----END"} {
if strings.Contains(combined, leaked) {
t.Fatalf("split at %d leaked %q: %q", split, leaked, combined)
}
}
}
}
func TestDiagnosticOutputRedactorDoesNotMaskUnrelatedCommandOutput(t *testing.T) {
redactor := NewDiagnosticOutputRedactor()
_ = redactor.RedactChunk(DiagnosticEventChunk{
SessionID: "sess-1",
CommandID: "cmd-1",
Content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
})
other := redactor.RedactChunk(DiagnosticEventChunk{
SessionID: "sess-1",
CommandID: "cmd-2",
Content: "thread_name=main",
})
if other.Content != "thread_name=main" {
t.Fatalf("expected unrelated command output unchanged, got %q", other.Content)
}
}

View File

@@ -2,6 +2,9 @@ package jvm
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
@@ -76,7 +79,17 @@ func BuildChangePreview(
}
}
if !preview.Allowed || provider == nil {
if !preview.Allowed {
return preview, nil
}
if provider == nil {
if preview.RequiresConfirmation {
confirmationToken, tokenErr := buildChangeConfirmationToken(normalized, req, preview)
if tokenErr != nil {
return ChangePreview{}, tokenErr
}
preview.ConfirmationToken = confirmationToken
}
return preview, nil
}
@@ -106,6 +119,16 @@ func BuildChangePreview(
if hasSnapshotOverride(providerPreview.After) {
preview.After = mergeValueSnapshot(preview.After, providerPreview.After)
}
if strings.EqualFold(strings.TrimSpace(preview.RiskLevel), "high") {
preview.RequiresConfirmation = true
}
if preview.Allowed && preview.RequiresConfirmation {
confirmationToken, tokenErr := buildChangeConfirmationToken(normalized, req, preview)
if tokenErr != nil {
return ChangePreview{}, tokenErr
}
preview.ConfirmationToken = confirmationToken
}
return preview, nil
}
@@ -118,6 +141,7 @@ func NormalizeChangeRequest(req ChangeRequest) (ChangeRequest, error) {
normalized.Reason = strings.TrimSpace(normalized.Reason)
normalized.Source = strings.TrimSpace(normalized.Source)
normalized.ExpectedVersion = strings.TrimSpace(normalized.ExpectedVersion)
normalized.ConfirmationToken = strings.TrimSpace(normalized.ConfirmationToken)
if normalized.ResourceID == "" {
return ChangeRequest{}, fmt.Errorf("resource id is required")
@@ -138,7 +162,8 @@ func hasSnapshotOverride(snapshot ValueSnapshot) bool {
strings.TrimSpace(snapshot.Format) != "" ||
strings.TrimSpace(snapshot.Version) != "" ||
snapshot.Value != nil ||
snapshot.Metadata != nil
snapshot.Metadata != nil ||
snapshot.Sensitive
}
func mergeValueSnapshot(base ValueSnapshot, override ValueSnapshot) ValueSnapshot {
@@ -161,5 +186,67 @@ func mergeValueSnapshot(base ValueSnapshot, override ValueSnapshot) ValueSnapsho
if override.Metadata != nil {
merged.Metadata = override.Metadata
}
if override.Sensitive {
merged.Sensitive = true
}
return merged
}
func ValidateChangeConfirmation(preview ChangePreview, req ChangeRequest) error {
if !preview.RequiresConfirmation {
return nil
}
previewToken := strings.TrimSpace(preview.ConfirmationToken)
requestToken := strings.TrimSpace(req.ConfirmationToken)
if previewToken == "" {
return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交")
}
if requestToken == "" {
return fmt.Errorf("缺少确认令牌,请先完成预览确认")
}
if previewToken != requestToken {
return fmt.Errorf("确认令牌不匹配,请重新预览并确认")
}
return nil
}
type confirmationTokenInput struct {
ConnectionID string `json:"connectionId"`
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source"`
ExpectedVersion string `json:"expectedVersion"`
Payload map[string]any `json:"payload"`
Summary string `json:"summary"`
RiskLevel string `json:"riskLevel"`
BeforeVersion string `json:"beforeVersion"`
AfterVersion string `json:"afterVersion"`
}
func buildChangeConfirmationToken(cfg connection.ConnectionConfig, req ChangeRequest, preview ChangePreview) (string, error) {
input := confirmationTokenInput{
ConnectionID: strings.TrimSpace(cfg.ID),
ProviderMode: strings.TrimSpace(cfg.JVM.PreferredMode),
ResourceID: strings.TrimSpace(req.ResourceID),
Action: strings.TrimSpace(req.Action),
Reason: strings.TrimSpace(req.Reason),
Source: strings.TrimSpace(req.Source),
ExpectedVersion: strings.TrimSpace(req.ExpectedVersion),
Payload: req.Payload,
Summary: strings.TrimSpace(preview.Summary),
RiskLevel: strings.TrimSpace(preview.RiskLevel),
BeforeVersion: strings.TrimSpace(preview.Before.Version),
AfterVersion: strings.TrimSpace(preview.After.Version),
}
encoded, err := json.Marshal(input)
if err != nil {
return "", fmt.Errorf("生成 JVM 变更确认令牌失败: %w", err)
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}

View File

@@ -68,6 +68,9 @@ func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) {
if preview.BlockingReason == "" || !strings.Contains(preview.BlockingReason, "只读") {
t.Fatalf("expected readonly blocking reason, got %#v", preview)
}
if strings.TrimSpace(preview.ConfirmationToken) != "" {
t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview)
}
if preview.Before.ResourceID != "/cache/orders" {
t.Fatalf("expected before snapshot resource id to be preserved, got %#v", preview.Before)
}
@@ -169,6 +172,93 @@ func TestPreviewChangeMarksProdWritesAsConfirmationRequired(t *testing.T) {
}
}
func TestPreviewChangeMarksHighRiskWritesAsConfirmationRequired(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "provider high risk preview",
RiskLevel: "high",
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-writable",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if !preview.RequiresConfirmation {
t.Fatalf("expected high risk preview to require confirmation, got %#v", preview)
}
if strings.TrimSpace(preview.ConfirmationToken) == "" {
t.Fatalf("expected high risk preview to include confirmation token, got %#v", preview)
}
}
func TestPreviewChangeMergesProviderSensitiveFlag(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
before: ValueSnapshot{
ResourceID: "/cache/orders/password",
Kind: "attribute",
Format: "string",
Value: "old-secret",
},
preview: ChangePreview{
Allowed: true,
Summary: "provider preview",
RiskLevel: "high",
Before: ValueSnapshot{
Value: "old-secret",
Sensitive: true,
},
After: ValueSnapshot{
Value: "new-secret",
Sensitive: true,
},
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-writable",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/cache/orders/password",
Action: "set",
Reason: "rotate secret",
Payload: map[string]any{
"value": "new-secret",
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if !preview.Before.Sensitive || !preview.After.Sensitive {
t.Fatalf("expected merged preview snapshots to preserve sensitive flag, got %#v", preview)
}
}
func TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults(t *testing.T) {
readOnly := false
@@ -224,3 +314,202 @@ func TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults(t *testing.
t.Fatalf("expected after snapshot defaults to be preserved, got %#v", preview.After)
}
}
func TestBuildChangePreviewAddsConfirmationTokenWhenRequired(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "invoke resize",
RiskLevel: "high",
RequiresConfirmation: true,
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-prod",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Environment: EnvPROD,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if !preview.RequiresConfirmation {
t.Fatalf("expected confirmation requirement, got %#v", preview)
}
if strings.TrimSpace(preview.ConfirmationToken) == "" {
t.Fatalf("expected confirmation token, got %#v", preview)
}
}
func TestBuildChangePreviewUsesNormalizedProviderModeForConfirmationToken(t *testing.T) {
readOnly := false
cfg := connection.ConnectionConfig{
Type: "jvm",
ID: "conn-prod",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Environment: EnvPROD,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}
previewWithoutRequestedMode, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "invoke resize",
RiskLevel: "high",
RequiresConfirmation: true,
},
}, cfg, ChangeRequest{
ProviderMode: "",
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error for empty provider mode: %v", err)
}
if strings.TrimSpace(previewWithoutRequestedMode.ConfirmationToken) == "" {
t.Fatalf("expected confirmation token for empty requested provider mode, got %#v", previewWithoutRequestedMode)
}
previewWithRequestedMode, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "invoke resize",
RiskLevel: "high",
RequiresConfirmation: true,
},
}, cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error for explicit provider mode: %v", err)
}
if strings.TrimSpace(previewWithRequestedMode.ConfirmationToken) == "" {
t.Fatalf("expected confirmation token for explicit requested provider mode, got %#v", previewWithRequestedMode)
}
if previewWithoutRequestedMode.ConfirmationToken != previewWithRequestedMode.ConfirmationToken {
t.Fatalf("expected tokens to match when normalized mode is the same, got %q vs %q", previewWithoutRequestedMode.ConfirmationToken, previewWithRequestedMode.ConfirmationToken)
}
}
func TestBuildChangePreviewBlockedByProviderDoesNotGenerateConfirmationToken(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: false,
RequiresConfirmation: true,
BlockingReason: "provider denied write",
Summary: "blocked by provider",
RiskLevel: "high",
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-prod",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Environment: EnvPROD,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"args": []any{},
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if preview.Allowed {
t.Fatalf("expected provider-blocked preview, got %#v", preview)
}
if strings.TrimSpace(preview.ConfirmationToken) != "" {
t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview)
}
}
func TestBuildChangePreviewFailsClosedWhenTokenMarshalFails(t *testing.T) {
readOnly := false
_, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "invoke resize",
RiskLevel: "high",
RequiresConfirmation: true,
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-prod",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Environment: EnvPROD,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/mbean/java.lang:type=Memory/operation/gc",
Action: "invoke",
Reason: "manual maintenance",
Payload: map[string]any{
"invalid": func() {},
},
})
if err == nil {
t.Fatal("expected BuildChangePreview to fail when confirmation token marshal fails")
}
if !strings.Contains(err.Error(), "确认令牌") {
t.Fatalf("expected error to mention confirmation token, got %v", err)
}
}
func TestValidateChangeConfirmationRejectsMissingOrMismatchedToken(t *testing.T) {
preview := ChangePreview{
Allowed: true,
RequiresConfirmation: true,
ConfirmationToken: "token-a",
}
if err := ValidateChangeConfirmation(preview, ChangeRequest{}); err == nil {
t.Fatal("expected missing confirmation token to be rejected")
}
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-b"}); err == nil {
t.Fatal("expected mismatched confirmation token to be rejected")
}
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-a"}); err != nil {
t.Fatalf("expected matching confirmation token to pass, got %v", err)
}
}

View File

@@ -287,6 +287,10 @@ func TestJMXProviderRealJMXRoundTrip(t *testing.T) {
}
provider := NewJMXProvider()
monitoringProvider, ok := provider.(MonitoringCapableProvider)
if !ok {
t.Fatal("expected JMX provider to implement monitoring snapshots")
}
fixture := startJMXFixture(t)
readOnly := false
cfg := connection.ConnectionConfig{
@@ -335,14 +339,310 @@ func TestJMXProviderRealJMXRoundTrip(t *testing.T) {
}
mbean := mbeans[0]
blockedDomainPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindDomain,
Domain: "java.lang",
})
_, err = provider.ListResources(context.Background(), cfg, blockedDomainPath)
if err == nil {
t.Fatal("expected list on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain list context, got %v", err)
}
_, err = provider.GetValue(context.Background(), cfg, blockedDomainPath)
if err == nil {
t.Fatal("expected get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain get context, got %v", err)
}
blockedMBeanPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindMBean,
ObjectName: "java.lang:type=Memory",
})
_, err = provider.ListResources(context.Background(), cfg, blockedMBeanPath)
if err == nil {
t.Fatal("expected list on blocked domain mbean to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain mbean list context, got %v", err)
}
_, err = provider.GetValue(context.Background(), cfg, blockedMBeanPath)
if err == nil {
t.Fatal("expected direct mbean get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain mbean get context, got %v", err)
}
blockedAttributePath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindAttribute,
ObjectName: "java.lang:type=Memory",
Attribute: "HeapMemoryUsage",
})
_, err = provider.GetValue(context.Background(), cfg, blockedAttributePath)
if err == nil {
t.Fatal("expected direct attribute get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain attribute get context, got %v", err)
}
blockedOperationPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindOperation,
ObjectName: "java.lang:type=Memory",
Operation: "gc",
})
_, err = provider.GetValue(context.Background(), cfg, blockedOperationPath)
if err == nil {
t.Fatal("expected direct operation get on blocked domain to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain operation get context, got %v", err)
}
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedOperationPath,
Action: "invoke",
Reason: "尝试跨域操作预览",
})
if err == nil {
t.Fatal("expected preview on blocked domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain operation preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedOperationPath,
Action: "invoke",
Reason: "尝试跨域操作调用",
})
if err == nil {
t.Fatal("expected apply on blocked domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain operation apply context, got %v", err)
}
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedAttributePath,
Action: "update",
Reason: "尝试跨域属性预览",
Payload: map[string]any{
"value": "blocked",
},
})
if err == nil {
t.Fatal("expected preview on blocked domain attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain attribute preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: blockedAttributePath,
Action: "update",
Reason: "尝试跨域属性修改",
Payload: map[string]any{
"value": "blocked",
},
})
if err == nil {
t.Fatal("expected apply on blocked domain attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain attribute apply context, got %v", err)
}
defaultDomainMBeanPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindMBean,
ObjectName: ":type=CacheSettings,name=DefaultDomainCache",
})
_, err = provider.ListResources(context.Background(), cfg, defaultDomainMBeanPath)
if err == nil {
t.Fatal("expected list on default domain alias mbean to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias mbean list context, got %v", err)
}
defaultDomainAttributePath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindAttribute,
ObjectName: ":type=CacheSettings,name=DefaultDomainCache",
Attribute: "Mode",
})
_, err = provider.GetValue(context.Background(), cfg, defaultDomainAttributePath)
if err == nil {
t.Fatal("expected get on default domain alias attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias attribute get context, got %v", err)
}
defaultDomainOperationPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindOperation,
ObjectName: ":type=CacheSettings,name=DefaultDomainCache",
Operation: "resize",
Signature: []string{"int", "boolean"},
})
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: defaultDomainOperationPath,
Action: "invoke",
Reason: "尝试默认域别名操作预览",
Payload: map[string]any{
"args": []any{3, true},
},
})
if err == nil {
t.Fatal("expected preview on default domain alias operation to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias operation preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: defaultDomainOperationPath,
Action: "invoke",
Reason: "尝试默认域别名操作调用",
Payload: map[string]any{
"args": []any{3, true},
},
})
if err == nil {
t.Fatal("expected apply on default domain alias operation to fail")
}
if got := err.Error(); !containsAll(got, "domain") {
t.Fatalf("expected default domain alias operation apply context, got %v", err)
}
whitespaceDomainMBeanPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindMBean,
ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache",
})
_, err = provider.ListResources(context.Background(), cfg, whitespaceDomainMBeanPath)
if err == nil {
t.Fatal("expected list on whitespace-suffixed domain mbean to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain mbean list context, got %v", err)
}
whitespaceDomainAttributePath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindAttribute,
ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache",
Attribute: "Mode",
})
_, err = provider.GetValue(context.Background(), cfg, whitespaceDomainAttributePath)
if err == nil {
t.Fatal("expected get on whitespace-suffixed domain attribute to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain attribute get context, got %v", err)
}
whitespaceDomainOperationPath := buildJMXResourcePath(jmxResourceTarget{
Kind: jmxResourceKindOperation,
ObjectName: "com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache",
Operation: "resize",
Signature: []string{"int", "boolean"},
})
_, err = provider.PreviewChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: whitespaceDomainOperationPath,
Action: "invoke",
Reason: "尝试空白后缀域操作预览",
Payload: map[string]any{
"args": []any{4, true},
},
})
if err == nil {
t.Fatal("expected preview on whitespace-suffixed domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain operation preview context, got %v", err)
}
_, err = provider.ApplyChange(context.Background(), cfg, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: whitespaceDomainOperationPath,
Action: "invoke",
Reason: "尝试空白后缀域操作调用",
Payload: map[string]any{
"args": []any{4, true},
},
})
if err == nil {
t.Fatal("expected apply on whitespace-suffixed domain operation to fail")
}
if got := err.Error(); !containsAll(got, "domain", "com.gonavi.fixture") {
t.Fatalf("expected whitespace-suffixed domain operation apply context, got %v", err)
}
_, err = monitoringProvider.GetMonitoringSnapshot(context.Background(), cfg, nil)
if err == nil {
t.Fatal("expected monitor on blocked domain allowlist to fail")
}
if got := err.Error(); !containsAll(got, "domain", "java.lang") {
t.Fatalf("expected blocked domain monitor context, got %v", err)
}
monitoringCfg := cfg
monitoringCfg.JVM.JMX.DomainAllowlist = []string{"com.gonavi.fixture", "java.lang"}
monitoringSnapshot, err := monitoringProvider.GetMonitoringSnapshot(context.Background(), monitoringCfg, nil)
if err != nil {
t.Fatalf("expected monitor with java.lang allowlist to succeed: %v", err)
}
if monitoringSnapshot.Point.Timestamp <= 0 {
t.Fatalf("unexpected monitor snapshot point: %#v", monitoringSnapshot.Point)
}
children, err := provider.ListResources(context.Background(), cfg, mbean.Path)
if err != nil {
t.Fatalf("ListResources(mbean) returned error: %v", err)
}
modeAttr := findResourceByName(t, children, "Mode")
passwordAttr := findResourceByName(t, children, "Password")
apiKeyAttr := findResourceByName(t, children, "ApiKey")
lastInvocationAttr := findResourceByName(t, children, "LastInvocation")
resizeOp := findResourceByName(t, children, "resize(int,boolean)")
passwordSnapshot, err := provider.GetValue(context.Background(), cfg, passwordAttr.Path)
if err != nil {
t.Fatalf("GetValue(password) returned error: %v", err)
}
if !passwordSnapshot.Sensitive {
t.Fatalf("expected password snapshot to be sensitive: %#v", passwordSnapshot)
}
for _, action := range passwordSnapshot.SupportedActions {
if payloadValue, ok := action.PayloadExample["value"]; ok && payloadValue == "secret-token" {
t.Fatalf("sensitive payload example leaked raw password: %#v", action.PayloadExample)
}
}
apiKeySnapshot, err := provider.GetValue(context.Background(), cfg, apiKeyAttr.Path)
if err != nil {
t.Fatalf("GetValue(api key) returned error: %v", err)
}
if !apiKeySnapshot.Sensitive {
t.Fatalf("expected api key snapshot to be sensitive: %#v", apiKeySnapshot)
}
for _, action := range apiKeySnapshot.SupportedActions {
if payloadValue, ok := action.PayloadExample["value"]; ok && payloadValue == "api-key-secret" {
t.Fatalf("sensitive payload example leaked raw api key: %#v", action.PayloadExample)
}
}
modeBefore, err := provider.GetValue(context.Background(), cfg, modeAttr.Path)
if err != nil {
t.Fatalf("GetValue(mode before) returned error: %v", err)

View File

@@ -2,6 +2,8 @@ package com.gonavi.fixture;
public final class CacheSettings implements CacheSettingsMBean {
private volatile String mode = "warm";
private volatile String password = "secret-token";
private volatile String apiKey = "api-key-secret";
private final int hitCount = 7;
private volatile String lastInvocation = "none";
@@ -15,6 +17,26 @@ public final class CacheSettings implements CacheSettingsMBean {
this.mode = mode;
}
@Override
public String getPassword() {
return password;
}
@Override
public void setPassword(String password) {
this.password = password;
}
@Override
public String getApiKey() {
return apiKey;
}
@Override
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
@Override
public int getHitCount() {
return hitCount;

View File

@@ -4,6 +4,12 @@ public interface CacheSettingsMBean {
String getMode();
void setMode(String mode);
String getPassword();
void setPassword(String password);
String getApiKey();
void setApiKey(String apiKey);
int getHitCount();
String getLastInvocation();

View File

@@ -15,6 +15,14 @@ public final class JMXTestServer {
if (!server.isRegistered(objectName)) {
server.registerMBean(new CacheSettings(), objectName);
}
ObjectName defaultDomainObjectName = new ObjectName(":type=CacheSettings,name=DefaultDomainCache");
if (!server.isRegistered(defaultDomainObjectName)) {
server.registerMBean(new CacheSettings(), defaultDomainObjectName);
}
ObjectName whitespaceDomainObjectName = new ObjectName("com.gonavi.fixture :type=CacheSettings,name=WhitespaceDomainCache");
if (!server.isRegistered(whitespaceDomainObjectName)) {
server.registerMBean(new CacheSettings(), whitespaceDomainObjectName);
}
System.out.println("READY");
System.out.flush();

View File

@@ -58,18 +58,20 @@ type ValueSnapshot struct {
}
type ChangeRequest struct {
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source,omitempty"`
ExpectedVersion string `json:"expectedVersion,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
ProviderMode string `json:"providerMode"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Reason string `json:"reason"`
Source string `json:"source,omitempty"`
ExpectedVersion string `json:"expectedVersion,omitempty"`
ConfirmationToken string `json:"confirmationToken,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
type ChangePreview struct {
Allowed bool `json:"allowed"`
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
ConfirmationToken string `json:"confirmationToken,omitempty"`
Summary string `json:"summary"`
RiskLevel string `json:"riskLevel"`
BlockingReason string `json:"blockingReason,omitempty"`

View File

@@ -155,6 +155,46 @@ func (r *RedisClientImpl) toPhysicalPattern(pattern string) string {
return prefix + normalized
}
func redisGlobPatternLiteralKey(pattern string) (string, bool) {
if pattern == "" {
return "", false
}
var builder strings.Builder
for i := 0; i < len(pattern); i++ {
char := pattern[i]
if char == '\\' {
if i+1 >= len(pattern) {
return "", false
}
i++
builder.WriteByte(pattern[i])
continue
}
if char == '*' || char == '?' || char == '[' {
return "", false
}
builder.WriteByte(char)
}
return builder.String(), true
}
func escapeRedisGlobLiteral(value string) string {
var builder strings.Builder
for i := 0; i < len(value); i++ {
char := value[i]
if char == '*' || char == '?' || char == '[' || char == ']' || char == '\\' {
builder.WriteByte('\\')
}
builder.WriteByte(char)
}
return builder.String()
}
func redisExactSearchPattern(literalKey string) (string, string) {
return literalKey, escapeRedisGlobLiteral(literalKey) + ":*"
}
func (r *RedisClientImpl) toPhysicalKeys(keys []string) []string {
if len(keys) == 0 {
return nil
@@ -367,6 +407,15 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
if pattern == "" {
pattern = "*"
}
exactPhysicalKey := ""
if literalKey, ok := redisGlobPatternLiteralKey(pattern); ok {
exactKey, namespacePattern := redisExactSearchPattern(literalKey)
exactPhysicalKey = r.toPhysicalKey(exactKey)
if exactPhysicalKey == "" {
return &RedisScanResult{Keys: []RedisKeyInfo{}, Cursor: "0"}, nil
}
pattern = namespacePattern
}
physicalPattern := r.toPhysicalPattern(pattern)
isSearchPattern := pattern != "*"
@@ -393,6 +442,10 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
keys := make([]string, 0, int(targetCount))
seen := make(map[string]struct{}, int(targetCount))
var mu sync.Mutex
if exactPhysicalKey != "" {
keys = append(keys, exactPhysicalKey)
seen[exactPhysicalKey] = struct{}{}
}
err := r.clusterClient.ForEachMaster(ctx, func(nodeCtx context.Context, node *redis.Client) error {
var nodeCursor uint64
@@ -453,6 +506,10 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
keys := make([]string, 0, int(targetCount))
seen := make(map[string]struct{}, int(targetCount))
if exactPhysicalKey != "" && currentCursor == 0 {
keys = append(keys, exactPhysicalKey)
seen[exactPhysicalKey] = struct{}{}
}
for len(keys) < int(targetCount) {
if time.Since(scanStartedAt) >= maxDuration {

View File

@@ -120,6 +120,69 @@ func TestNormalizeRedisGetValueError(t *testing.T) {
}
}
func TestRedisGlobPatternLiteralKey(t *testing.T) {
tests := []struct {
name string
pattern string
wantKey string
wantExact bool
}{
{name: "plain exact key", pattern: "Agent", wantKey: "Agent", wantExact: true},
{name: "escaped glob characters stay literal", pattern: `user:\*:\[id\]\?\\raw`, wantKey: `user:*:[id]?\raw`, wantExact: true},
{name: "fuzzy wildcard is not exact", pattern: "*[aA][gG][eE][nN][tT]*", wantExact: false},
{name: "unescaped suffix wildcard is not exact", pattern: "Agent*", wantExact: false},
{name: "unescaped single character wildcard is not exact", pattern: "Agent?", wantExact: false},
{name: "unescaped character class is not exact", pattern: "Agent[0-9]", wantExact: false},
{name: "empty pattern is not exact", pattern: "", wantExact: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotKey, gotExact := redisGlobPatternLiteralKey(tt.pattern)
if gotExact != tt.wantExact {
t.Fatalf("redisGlobPatternLiteralKey(%q) exact=%v, want %v", tt.pattern, gotExact, tt.wantExact)
}
if gotKey != tt.wantKey {
t.Fatalf("redisGlobPatternLiteralKey(%q) key=%q, want %q", tt.pattern, gotKey, tt.wantKey)
}
})
}
}
func TestRedisExactSearchPattern(t *testing.T) {
tests := []struct {
name string
literalKey string
wantExactKey string
wantNamespace string
}{
{
name: "plain namespace folder",
literalKey: "Agent",
wantExactKey: "Agent",
wantNamespace: "Agent:*",
},
{
name: "escaped namespace keeps glob chars literal",
literalKey: `user:*:[id]?\raw`,
wantExactKey: `user:*:[id]?\raw`,
wantNamespace: `user:\*:\[id\]\?\\raw:*`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExactKey, gotNamespace := redisExactSearchPattern(tt.literalKey)
if gotExactKey != tt.wantExactKey {
t.Fatalf("redisExactSearchPattern(%q) exactKey=%q, want %q", tt.literalKey, gotExactKey, tt.wantExactKey)
}
if gotNamespace != tt.wantNamespace {
t.Fatalf("redisExactSearchPattern(%q) namespace=%q, want %q", tt.literalKey, gotNamespace, tt.wantNamespace)
}
})
}
}
func TestReadRedisHashEntriesWithFallbackUsesHScanWhenHGetAllForbidden(t *testing.T) {
scanCalls := 0
values, length, err := readRedisHashEntriesWithFallback(

View File

@@ -52,13 +52,13 @@ final class JmxRuntime {
case "list":
return listResources(server, connection, target);
case "get":
return singleton("snapshot", getValue(server, target));
return singleton("snapshot", getValue(server, connection, target));
case "monitor":
return singleton("monitoringSnapshot", getMonitoringSnapshot(server));
return singleton("monitoringSnapshot", getMonitoringSnapshot(server, connection));
case "preview":
return singleton("preview", previewChange(server, target, change));
return singleton("preview", previewChange(server, connection, target, change));
case "apply":
return singleton("applyResult", applyChange(server, target, change));
return singleton("applyResult", applyChange(server, connection, target, change));
default:
throw new IllegalArgumentException("unsupported helper command: " + command);
}
@@ -100,6 +100,7 @@ final class JmxRuntime {
}
if (target.isDomain()) {
requireDomainAllowed(connection, target.domain);
Set<ObjectName> names = server.queryNames(new ObjectName(target.domain + ":*"), null);
List<ObjectName> sortedNames = new ArrayList<>(names);
Collections.sort(sortedNames, Comparator.comparing(ObjectName::getCanonicalName));
@@ -123,6 +124,7 @@ final class JmxRuntime {
if (target.isMBean()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanInfo info = server.getMBeanInfo(objectName);
MBeanAttributeInfo[] attributes = info.getAttributes();
@@ -170,10 +172,15 @@ final class JmxRuntime {
throw new IllegalArgumentException("target kind " + target.kind + " does not support list");
}
private static Map<String, Object> getValue(MBeanServerConnection server, TargetSpec target) throws Exception {
private static Map<String, Object> getValue(
MBeanServerConnection server,
ConnectionSpec connection,
TargetSpec target
) throws Exception {
requireTarget(target);
if (target.isDomain()) {
requireDomainAllowed(connection, target.domain);
Set<ObjectName> names = server.queryNames(new ObjectName(target.domain + ":*"), null);
Map<String, Object> value = new LinkedHashMap<>();
value.put("domain", target.domain);
@@ -182,6 +189,7 @@ final class JmxRuntime {
}
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
if (target.isMBean()) {
MBeanInfo info = server.getMBeanInfo(objectName);
List<Map<String, Object>> attributes = new ArrayList<>();
@@ -219,7 +227,9 @@ final class JmxRuntime {
throw new IllegalArgumentException("unsupported target kind: " + target.kind);
}
private static Map<String, Object> getMonitoringSnapshot(MBeanServerConnection server) throws Exception {
private static Map<String, Object> getMonitoringSnapshot(MBeanServerConnection server, ConnectionSpec connection) throws Exception {
requireDomainAllowed(connection, "java.lang");
LinkedHashMap<String, Object> result = new LinkedHashMap<>();
LinkedHashMap<String, Object> point = new LinkedHashMap<>();
List<String> availableMetrics = new ArrayList<>();
@@ -423,6 +433,7 @@ final class JmxRuntime {
private static Map<String, Object> previewChange(
MBeanServerConnection server,
ConnectionSpec connection,
TargetSpec target,
Map<String, Object> change
) throws Exception {
@@ -431,6 +442,7 @@ final class JmxRuntime {
if (target.isAttribute()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanAttributeInfo attributeInfo = requireAttributeInfo(server, objectName, target.attribute);
Map<String, Object> before = attributeSnapshot(objectName, attributeInfo, server.getAttribute(objectName, target.attribute));
if (!attributeInfo.isWritable()) {
@@ -457,6 +469,7 @@ final class JmxRuntime {
if (target.isOperation()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanOperationInfo operationInfo = requireOperationInfo(server, objectName, target.operation, target.signature);
List<Object> args = argumentList(payload);
String[] signature = effectiveSignature(target, payload, operationInfo);
@@ -486,6 +499,7 @@ final class JmxRuntime {
private static Map<String, Object> applyChange(
MBeanServerConnection server,
ConnectionSpec connection,
TargetSpec target,
Map<String, Object> change
) throws Exception {
@@ -494,6 +508,7 @@ final class JmxRuntime {
if (target.isAttribute()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanAttributeInfo attributeInfo = requireAttributeInfo(server, objectName, target.attribute);
if (!attributeInfo.isWritable()) {
throw new IllegalArgumentException("attribute " + target.attribute + " is not writable");
@@ -512,6 +527,7 @@ final class JmxRuntime {
if (target.isOperation()) {
ObjectName objectName = new ObjectName(target.objectName);
requireDomainAllowed(connection, objectName);
MBeanOperationInfo operationInfo = requireOperationInfo(server, objectName, target.operation, target.signature);
List<Object> args = argumentList(payload);
String[] signature = effectiveSignature(target, payload, operationInfo);
@@ -590,17 +606,18 @@ final class JmxRuntime {
Object value
) {
Object jsonValue = toJsonCompatible(value);
boolean sensitive = isSensitiveName(attributeInfo.getName());
List<Map<String, Object>> supportedActions = attributeInfo.isWritable()
? Collections.singletonList(actionDefinition(
"set",
"设置属性",
"更新 JMX 属性 " + attributeInfo.getName(),
isSensitiveName(attributeInfo.getName()),
sensitive,
Collections.singletonList(payloadField("value", attributeInfo.getType(), true, "目标属性值")),
metadata("value", jsonValue)
sensitive ? Collections.<String, Object>emptyMap() : metadata("value", jsonValue)
))
: Collections.emptyList();
return snapshot("attribute", inferFormat(jsonValue), jsonValue, attributeInfo.getDescription(), isSensitiveName(attributeInfo.getName()), supportedActions, metadata(
return snapshot("attribute", inferFormat(jsonValue), jsonValue, attributeInfo.getDescription(), sensitive, supportedActions, metadata(
"objectName", objectName.toString(),
"attribute", attributeInfo.getName(),
"type", attributeInfo.getType(),
@@ -898,13 +915,47 @@ final class JmxRuntime {
return lowered.contains("password")
|| lowered.contains("secret")
|| lowered.contains("token")
|| lowered.contains("credential");
|| lowered.contains("credential")
|| lowered.contains("apikey")
|| lowered.contains("api_key")
|| lowered.contains("accesskey")
|| lowered.contains("access_key")
|| lowered.contains("privatekey")
|| lowered.contains("private_key")
|| lowered.contains("secretkey")
|| lowered.contains("secret_key")
|| lowered.contains("authkey")
|| lowered.contains("auth_key");
}
private static String domainOf(ObjectName objectName) {
return objectName.getDomain();
}
private static void requireDomainAllowed(ConnectionSpec connection, String domain) {
if (connection == null) {
return;
}
String rawDomain = domain == null ? "" : domain;
String normalizedDomain = rawDomain.trim();
if (normalizedDomain.isEmpty()) {
if (connection.hasDomainAllowlist()) {
throw new IllegalArgumentException("domain is not allowed: <default>");
}
return;
}
if (!rawDomain.equals(normalizedDomain) || !connection.isDomainAllowed(rawDomain)) {
throw new IllegalArgumentException("domain is not allowed: " + normalizedDomain);
}
}
private static void requireDomainAllowed(ConnectionSpec connection, ObjectName objectName) {
if (objectName == null) {
return;
}
requireDomainAllowed(connection, objectName.getDomain());
}
private static void requireTarget(TargetSpec target) {
if (target == null || target.isRoot()) {
throw new IllegalArgumentException("change target is required");
@@ -1263,6 +1314,10 @@ final class JmxRuntime {
return new ConnectionSpec(host, port, username, password, allowlist);
}
private boolean hasDomainAllowlist() {
return !domainAllowlist.isEmpty();
}
private boolean isDomainAllowed(String domain) {
return domainAllowlist.isEmpty() || domainAllowlist.contains(domain);
}