mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-14 08:47:36 +08:00
release/0.7.1
This commit is contained in:
73
docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md
Normal file
73
docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md
Normal 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` action,AI 输入框局部消费,不走全局快捷键执行器。
|
||||
- [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. 决策记录
|
||||
- 决策 1:AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。
|
||||
- 决策 2:`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。
|
||||
- 决策 3:AI 发送快捷键允许默认无修饰键 Enter,但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。
|
||||
- 决策 4:输入法 composing 状态始终不发送。
|
||||
- 决策 5:AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。
|
||||
- 决策 6:AI 输入框命中发送快捷键后同时执行 `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 passed,22 tests passed)。
|
||||
- 验证项:浏览器手工验证。
|
||||
- 结果:已通过。
|
||||
- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持;AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送,Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送;Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。
|
||||
- 验证项:前端全量测试。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend test -- --run`(88 files passed,421 tests passed)。
|
||||
- 验证项:diff 空白检查。
|
||||
- 结果:已通过。
|
||||
- 证据:`git diff --check` 无输出。
|
||||
- 验证项:生产构建。
|
||||
- 结果:已通过。
|
||||
- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。
|
||||
- 负责人:Claude Code
|
||||
41
frontend/package-lock.json
generated
41
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
571d014306268cf67665967059cda912
|
||||
0295a42fd931778d85157816d79d29e5
|
||||
@@ -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+Enter,Shift+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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
312
frontend/src/components/DataGrid.ddl.test.tsx
Normal file
312
frontend/src/components/DataGrid.ddl.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
563
frontend/src/components/JVMResourceBrowser.interaction.test.tsx
Normal file
563
frontend/src/components/JVMResourceBrowser.interaction.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
279
frontend/src/components/QueryEditor.external-sql-save.test.tsx
Normal file
279
frontend/src/components/QueryEditor.external-sql-save.test.tsx
Normal 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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 发送');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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]) : [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
89
frontend/src/utils/aiChatSendShortcut.test.ts
Normal file
89
frontend/src/utils/aiChatSendShortcut.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
frontend/src/utils/aiChatSendShortcut.ts
Normal file
50
frontend/src/utils/aiChatSendShortcut.ts
Normal 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;
|
||||
};
|
||||
48
frontend/src/utils/aiSqlLimit.test.ts
Normal file
48
frontend/src/utils/aiSqlLimit.test.ts
Normal 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\'');
|
||||
});
|
||||
});
|
||||
31
frontend/src/utils/aiSqlLimit.ts
Normal file
31
frontend/src/utils/aiSqlLimit.ts
Normal 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);
|
||||
};
|
||||
51
frontend/src/utils/aiTableSchemaTool.test.ts
Normal file
51
frontend/src/utils/aiTableSchemaTool.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
69
frontend/src/utils/aiTableSchemaTool.ts
Normal file
69
frontend/src/utils/aiTableSchemaTool.ts
Normal 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}` };
|
||||
};
|
||||
107
frontend/src/utils/dataGridFind.test.ts
Normal file
107
frontend/src/utils/dataGridFind.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
145
frontend/src/utils/dataGridFind.ts
Normal file
145
frontend/src/utils/dataGridFind.ts
Normal 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;
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
183
internal/db/oracle_applychanges_test.go
Normal file
183
internal/db/oracle_applychanges_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
215
internal/jvm/diagnostic_redaction.go
Normal file
215
internal/jvm/diagnostic_redaction.go
Normal 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
|
||||
}
|
||||
106
internal/jvm/diagnostic_redaction_test.go
Normal file
106
internal/jvm/diagnostic_redaction_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user