mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-01 09:51:23 +08:00
✨ feat(i18n): 推进多语言剩余切片闭环
- 补齐 DataGrid、DataViewer、DefinitionViewer、JVM 等模块多语言文案与回归测试 - 收口 JVM 前后端展示、诊断、监控和资源呈现相关多语言路径 - 更新六语言共享词典并保留 raw 边界
This commit is contained in:
@@ -467,9 +467,12 @@ describe('DataGrid commit change set', () => {
|
||||
rowKeyToString,
|
||||
normalizeCommitCellValue: normalizeValue,
|
||||
shouldCommitColumn: commitColumnGuard,
|
||||
});
|
||||
rowLocatorMessages: {
|
||||
noSafeLocator: () => 'No safe row locator is available for this result set.',
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toEqual({ ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' });
|
||||
expect(result).toEqual({ ok: false, error: 'No safe row locator is available for this result set.' });
|
||||
});
|
||||
|
||||
it('rejects delete rows when unique locator value is null', () => {
|
||||
@@ -490,7 +493,38 @@ describe('DataGrid commit change set', () => {
|
||||
shouldCommitColumn: commitColumnGuard,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, error: '定位列 EMAIL 的值为空,无法安全提交修改。' });
|
||||
expect(result).toEqual({ ok: false, error: 'Locator column EMAIL is empty, so changes cannot be submitted safely.' });
|
||||
});
|
||||
|
||||
it('keeps DataGrid safe locator fallback messages out of source Chinese literals', () => {
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
const rowLocatorSource = readFileSync(new URL('../utils/rowLocator.ts', import.meta.url), 'utf8');
|
||||
|
||||
expect(`${dataGridSource}\n${rowLocatorSource}`).not.toMatch(/当前结果没有可用的安全行定位方式|定位列 .* 的值为空,无法安全提交修改/);
|
||||
expect(dataGridSource).toContain('data_grid.message.no_safe_locator');
|
||||
expect(dataGridSource).toContain('data_grid.message.locator_column_value_empty');
|
||||
});
|
||||
|
||||
it('keeps DataGrid column quick-find warning messages localized', () => {
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(dataGridSource).not.toMatch(/未找到字段列|当前未渲染,无法定位/);
|
||||
expect(dataGridSource).toContain('data_grid.message.column_quick_find_not_found');
|
||||
expect(dataGridSource).toContain('data_grid.message.column_quick_find_not_rendered');
|
||||
});
|
||||
|
||||
it('keeps DataGrid datetime picker now footer localized', () => {
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(dataGridSource).not.toContain('>此刻</a>');
|
||||
expect(dataGridSource).toContain('data_grid.datetime_picker.now');
|
||||
});
|
||||
|
||||
it('keeps DataGrid AI insight prompt wrapper localized', () => {
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(dataGridSource).not.toMatch(/请帮我分析以下查询结果数据|请分析数据特征|业务上的洞察/);
|
||||
expect(dataGridSource).toContain('data_grid.ai_insight.prompt');
|
||||
});
|
||||
|
||||
it('marks the active virtual editing row so shouldCellUpdate can reopen inline editors', () => {
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
resolveWritableColumnName,
|
||||
resolveRowLocatorValues,
|
||||
type EditRowLocator,
|
||||
type RowLocatorMessages,
|
||||
} from '../utils/rowLocator';
|
||||
import {
|
||||
getColumnDefinitionComment,
|
||||
@@ -883,6 +884,8 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null);
|
||||
const form = useContext(EditableContext);
|
||||
const cellContextMenuContext = useContext(CellContextMenuContext);
|
||||
const i18nLanguage = useDataGridI18nLanguage();
|
||||
const dateTimePickerNowLabel = t('data_grid.datetime_picker.now', undefined, i18nLanguage);
|
||||
|
||||
/** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */
|
||||
const lockTableScroll = useCallback((lock: boolean) => {
|
||||
@@ -1000,7 +1003,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
const fieldName = getCellFieldName(record, dataIndex);
|
||||
setCellFieldValue(form, fieldName, dayjs());
|
||||
}}
|
||||
>此刻</a>
|
||||
>{dateTimePickerNowLabel}</a>
|
||||
)}
|
||||
onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)}
|
||||
onOpenChange={(open) => {
|
||||
@@ -1384,6 +1387,7 @@ export const buildDataGridCommitChangeSet = ({
|
||||
rowKeyToString,
|
||||
normalizeCommitCellValue,
|
||||
shouldCommitColumn,
|
||||
rowLocatorMessages,
|
||||
}: {
|
||||
addedRows: any[];
|
||||
modifiedRows: Record<string, any>;
|
||||
@@ -1394,9 +1398,10 @@ export const buildDataGridCommitChangeSet = ({
|
||||
rowKeyToString: (key: any) => string;
|
||||
normalizeCommitCellValue: NormalizeCommitCellValue;
|
||||
shouldCommitColumn: (columnName: string) => boolean;
|
||||
rowLocatorMessages?: RowLocatorMessages;
|
||||
}): { ok: true; changes: DataGridCommitChangeSet } | { ok: false; error: string } => {
|
||||
if (!editLocator || editLocator.readOnly || editLocator.strategy === 'none') {
|
||||
return { ok: false, error: editLocator?.reason || '当前结果没有可用的安全行定位方式,无法提交修改。' };
|
||||
return { ok: false, error: editLocator?.reason || rowLocatorMessages?.noSafeLocator?.() || 'No safe row locator is available for this result set.' };
|
||||
}
|
||||
|
||||
const normalizeValues = (values: Record<string, any>, mode: 'insert' | 'update') => {
|
||||
@@ -1433,7 +1438,7 @@ export const buildDataGridCommitChangeSet = ({
|
||||
for (const keyStr of deletedRowKeys) {
|
||||
const originalRow = originalRowsByKey.get(keyStr);
|
||||
if (!originalRow) continue;
|
||||
const locatorValues = resolveRowLocatorValues(editLocator, originalRow);
|
||||
const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages);
|
||||
if (!locatorValues.ok) return { ok: false, error: locatorValues.error };
|
||||
deletes.push(locatorValues.values);
|
||||
}
|
||||
@@ -1443,7 +1448,7 @@ export const buildDataGridCommitChangeSet = ({
|
||||
const originalRow = originalRowsByKey.get(keyStr);
|
||||
if (!originalRow) continue;
|
||||
|
||||
const locatorValues = resolveRowLocatorValues(editLocator, originalRow);
|
||||
const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages);
|
||||
if (!locatorValues.ok) return { ok: false, error: locatorValues.error };
|
||||
|
||||
const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY);
|
||||
@@ -1527,6 +1532,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
},
|
||||
[language]
|
||||
);
|
||||
const rowLocatorMessages = useMemo<RowLocatorMessages>(() => ({
|
||||
noSafeLocator: () => translateDataGrid('data_grid.message.no_safe_locator'),
|
||||
emptyLocatorValue: (column: string) => translateDataGrid('data_grid.message.locator_column_value_empty', { column }),
|
||||
}), [translateDataGrid]);
|
||||
|
||||
const isMacLike = useMemo(() => isMacLikePlatform(), []);
|
||||
const isV2Ui = appearance?.uiVersion === 'v2';
|
||||
@@ -5028,7 +5037,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
onClick={() => {
|
||||
setCellFieldValue(form, getCellFieldName(record, dataIndex), dayjs());
|
||||
}}
|
||||
>此刻</a>
|
||||
>{translateDataGrid('data_grid.datetime_picker.now')}</a>
|
||||
)}
|
||||
onOk={(value) => setTimeout(() => { void saveVirtualInlineEditor((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)}
|
||||
onOpenChange={(open) => {
|
||||
@@ -5218,6 +5227,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
rowKeyToString: rowKeyStr,
|
||||
normalizeCommitCellValue,
|
||||
shouldCommitColumn,
|
||||
rowLocatorMessages,
|
||||
});
|
||||
if (!changeSetResult.ok) {
|
||||
void message.error(changeSetResult.error
|
||||
@@ -5260,7 +5270,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}
|
||||
}, [addedRows, modifiedRows, deletedRowKeys, data, effectiveEditLocator,
|
||||
visibleColumnNames, rowKeyStr, normalizeCommitCellValue, shouldCommitColumn,
|
||||
connectionId, tableName, connections, translateDataGrid]);
|
||||
connectionId, tableName, connections, rowLocatorMessages, translateDataGrid]);
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!connectionId || !tableName) return;
|
||||
@@ -5276,6 +5286,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
rowKeyToString: rowKeyStr,
|
||||
normalizeCommitCellValue,
|
||||
shouldCommitColumn,
|
||||
rowLocatorMessages,
|
||||
});
|
||||
if (!changeSetResult.ok) {
|
||||
void message.error(changeSetResult.error
|
||||
@@ -6777,7 +6788,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const targetColumnName = resolveColumnQuickFindTarget(effectiveQuery);
|
||||
if (!targetColumnName) {
|
||||
if (effectiveQuery.trim()) {
|
||||
void message.warning(`未找到字段列:${effectiveQuery.trim()}`);
|
||||
void message.warning(translateDataGrid('data_grid.message.column_quick_find_not_found', { query: effectiveQuery.trim() }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -6788,10 +6799,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
if (tryFocus()) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (tryFocus()) return;
|
||||
void message.warning(`字段列“${targetColumnName}”当前未渲染,无法定位`);
|
||||
void message.warning(translateDataGrid('data_grid.message.column_quick_find_not_rendered', { column: targetColumnName }));
|
||||
});
|
||||
});
|
||||
}, [columnQuickFindText, focusColumnQuickFindTarget, resolveColumnQuickFindTarget]);
|
||||
}, [columnQuickFindText, focusColumnQuickFindTarget, resolveColumnQuickFindTarget, translateDataGrid]);
|
||||
|
||||
// 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效)
|
||||
useEffect(() => {
|
||||
@@ -7369,14 +7380,17 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
const handleRequestAiInsight = useCallback(() => {
|
||||
const sampleData = mergedDisplayData.slice(0, 10);
|
||||
const prompt = `请帮我分析以下查询结果数据(取前 ${sampleData.length} 条示例):\n\`\`\`json\n${JSON.stringify(sampleData, null, 2)}\n\`\`\`\n\n请分析数据特征、发现规律,或者给出一些业务上的洞察。`;
|
||||
const prompt = translateDataGrid('data_grid.ai_insight.prompt', {
|
||||
count: sampleData.length,
|
||||
json: JSON.stringify(sampleData, null, 2),
|
||||
});
|
||||
const store = useStore.getState();
|
||||
const wasClosed = !store.aiPanelVisible;
|
||||
if (wasClosed) store.setAIPanelVisible(true);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
|
||||
}, wasClosed ? 350 : 0);
|
||||
}, [mergedDisplayData]);
|
||||
}, [mergedDisplayData, translateDataGrid]);
|
||||
|
||||
const handleToggleTotalCount = useCallback(() => {
|
||||
if (!onRequestTotalCount) return;
|
||||
|
||||
@@ -22,6 +22,7 @@ const storeState = vi.hoisted(() => ({
|
||||
},
|
||||
},
|
||||
],
|
||||
languagePreference: 'zh-CN',
|
||||
addSqlLog: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -107,6 +108,21 @@ describe('DataViewer safe editing locator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dataGridState.latestProps = null;
|
||||
storeState.connections = [
|
||||
{
|
||||
id: 'conn-1',
|
||||
name: 'oracle',
|
||||
config: {
|
||||
type: 'oracle',
|
||||
host: '127.0.0.1',
|
||||
port: 1521,
|
||||
user: 'scott',
|
||||
password: '',
|
||||
database: 'ORCLPDB1',
|
||||
},
|
||||
},
|
||||
];
|
||||
storeState.languagePreference = 'zh-CN';
|
||||
storeState.connections[0].config.type = 'oracle';
|
||||
storeState.connections[0].config.database = 'ORCLPDB1';
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
@@ -117,6 +133,37 @@ describe('DataViewer safe editing locator', () => {
|
||||
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
|
||||
});
|
||||
|
||||
it('localizes the missing connection message through DataViewer catalog keys', async () => {
|
||||
storeState.connections = [];
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<DataViewer tab={createTab({ connectionId: 'missing-conn' })} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(messageApi.error).toHaveBeenCalledWith('未找到连接');
|
||||
renderer!.unmount();
|
||||
});
|
||||
|
||||
it('keeps DataViewer message wrappers and SQL log phase labels keyed', () => {
|
||||
const source = readFileSync(new URL('./DataViewer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/当前结果集尚未就绪|统计失败|统计总数失败|统计结果解析失败|Mongo 筛选条件无效|解析失败|主查询|复杂类型降级重试|重试\(32MB sort_buffer\)|重试\(128MB sort_buffer\)|已自动提升排序缓冲并重试成功|查询失败|查询超过连接超时时间|DuckDB 查询超过连接超时时间|超时|MongoDB 结果集中缺少 _id|加载索引失败|无法加载主键\/唯一索引元数据|无法加载唯一索引元数据|保持只读|当前结果没有可用的安全行定位方式/);
|
||||
expect(source).toContain('data_viewer.message.connection_not_found');
|
||||
expect(source).toContain('data_viewer.message.result_not_ready');
|
||||
expect(source).toContain('data_viewer.message.query_failed');
|
||||
expect(source).toContain('data_viewer.message.query_timeout');
|
||||
expect(source).toContain('data_viewer.message.duckdb_query_timeout');
|
||||
expect(source).toContain('data_viewer.read_only.reason.mongo_id_missing');
|
||||
expect(source).toContain('data_viewer.read_only.reason.no_safe_locator');
|
||||
expect(source).toContain('data_viewer.read_only.warning.table');
|
||||
expect(source).toContain('data_viewer.read_only.warning.collection');
|
||||
expect(source).toContain('data_viewer.sql_log.phase.main_query');
|
||||
expect(source).toContain('data_viewer.sql_log.phase.sort_buffer_retry');
|
||||
});
|
||||
|
||||
it('enables table preview editing after primary keys are loaded', async () => {
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
success: true,
|
||||
@@ -204,6 +251,7 @@ describe('DataViewer safe editing locator', () => {
|
||||
});
|
||||
|
||||
it('keeps MongoDB results read-only when _id is missing', async () => {
|
||||
storeState.languagePreference = 'en-US';
|
||||
storeState.connections[0].config.type = 'mongodb';
|
||||
storeState.connections[0].config.database = 'app';
|
||||
backendApp.DBQuery.mockResolvedValue({
|
||||
@@ -218,10 +266,10 @@ describe('DataViewer safe editing locator', () => {
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'none',
|
||||
readOnly: true,
|
||||
reason: 'MongoDB 结果集中缺少 _id,无法安全提交修改。',
|
||||
reason: 'MongoDB result set is missing _id, so changes cannot be submitted safely.',
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(true);
|
||||
expect(messageApi.warning).toHaveBeenCalledWith('集合 app.users 保持只读:MongoDB 结果集中缺少 _id,无法安全提交修改。');
|
||||
expect(messageApi.warning).toHaveBeenCalledWith('Collection app.users remains read-only: MongoDB result set is missing _id, so changes cannot be submitted safely.');
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
@@ -324,6 +372,7 @@ describe('DataViewer safe editing locator', () => {
|
||||
});
|
||||
|
||||
it('shows an actionable message for DuckDB timeout interruption errors', async () => {
|
||||
storeState.languagePreference = 'en-US';
|
||||
storeState.connections[0].config.type = 'duckdb';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
@@ -339,12 +388,13 @@ describe('DataViewer safe editing locator', () => {
|
||||
|
||||
const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-timeout', dbName: 'main', tableName: 'events', title: 'events' }));
|
||||
|
||||
expect(messageApi.error).toHaveBeenCalledWith('DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。');
|
||||
expect(messageApi.error).toHaveBeenCalledWith('DuckDB query exceeded the connection timeout and was interrupted. Increase the connection timeout, or reduce the sort/filter scope and retry.');
|
||||
expect(storeState.addSqlLog.mock.calls.some((call: any[]) => String(call[0]?.message || '').includes('context deadline exceeded'))).toBe(true);
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
it('keeps non-Oracle table preview read-only when no safe locator exists', async () => {
|
||||
storeState.languagePreference = 'en-US';
|
||||
storeState.connections[0].config.type = 'mysql';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBGetColumns.mockResolvedValue({
|
||||
@@ -358,10 +408,10 @@ describe('DataViewer safe editing locator', () => {
|
||||
expect(dataGridState.latestProps?.editLocator).toMatchObject({
|
||||
strategy: 'none',
|
||||
readOnly: true,
|
||||
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
|
||||
reason: 'No primary key or usable unique index was found, so changes cannot be submitted safely.',
|
||||
});
|
||||
expect(dataGridState.latestProps?.readOnly).toBe(true);
|
||||
expect(messageApi.warning).toHaveBeenCalledWith('表 main.users 保持只读:未检测到主键或可用唯一索引,无法安全提交修改。');
|
||||
expect(messageApi.warning).toHaveBeenCalledWith('Table main.users remains read-only: No primary key or usable unique index was found, so changes cannot be submitted safely.');
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveA
|
||||
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { resolveLanguage, t as translate, type I18nParams } from '../i18n';
|
||||
import {
|
||||
buildEffectiveFilterConditions,
|
||||
normalizeQuickWhereCondition,
|
||||
@@ -38,6 +39,8 @@ type ViewerPaginationState = {
|
||||
totalCountCancelled: boolean;
|
||||
};
|
||||
|
||||
type DataViewerTranslator = (key: string, params?: I18nParams) => string;
|
||||
|
||||
const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
const isIntegerText = (text: string): boolean => /^[+-]?\d+$/.test(text);
|
||||
@@ -103,6 +106,47 @@ const buildDataViewerReadOnlyLocator = (reason: string): EditRowLocator => ({
|
||||
reason,
|
||||
});
|
||||
|
||||
const READ_ONLY_REASON_NO_SAFE_LOCATOR = '\u672a\u68c0\u6d4b\u5230\u4e3b\u952e\u6216\u53ef\u7528\u552f\u4e00\u7d22\u5f15\uff0c\u65e0\u6cd5\u5b89\u5168\u63d0\u4ea4\u4fee\u6539\u3002';
|
||||
const READ_ONLY_REASON_ORACLE_ROWID_MISSING = '\u672a\u68c0\u6d4b\u5230\u4e3b\u952e\u6216\u53ef\u7528\u552f\u4e00\u7d22\u5f15\uff0c\u4e14\u7ed3\u679c\u4e2d\u7f3a\u5c11 Oracle ROWID\uff0c\u65e0\u6cd5\u5b89\u5168\u63d0\u4ea4\u4fee\u6539\u3002';
|
||||
const READ_ONLY_REASON_PRIMARY_KEY_MISSING_PREFIX = '\u7ed3\u679c\u96c6\u4e2d\u7f3a\u5c11\u4e3b\u952e\u5217 ';
|
||||
const READ_ONLY_REASON_SAFE_SUBMIT_SUFFIX = '\uff0c\u65e0\u6cd5\u5b89\u5168\u63d0\u4ea4\u4fee\u6539\u3002';
|
||||
|
||||
const localizeDataViewerReadOnlyReason = (reason: string | undefined, tr: DataViewerTranslator): string => {
|
||||
const text = String(reason || '').trim();
|
||||
if (!text) return tr('data_viewer.read_only.reason.no_safe_locator');
|
||||
if (text === READ_ONLY_REASON_NO_SAFE_LOCATOR) {
|
||||
return tr('data_viewer.read_only.reason.no_safe_locator');
|
||||
}
|
||||
if (text === READ_ONLY_REASON_ORACLE_ROWID_MISSING) {
|
||||
return tr('data_viewer.read_only.reason.oracle_rowid_missing');
|
||||
}
|
||||
if (text.startsWith(READ_ONLY_REASON_PRIMARY_KEY_MISSING_PREFIX) && text.endsWith(READ_ONLY_REASON_SAFE_SUBMIT_SUFFIX)) {
|
||||
const columns = text.slice(READ_ONLY_REASON_PRIMARY_KEY_MISSING_PREFIX.length, -READ_ONLY_REASON_SAFE_SUBMIT_SUFFIX.length);
|
||||
return tr('data_viewer.read_only.reason.primary_key_column_missing', { columns });
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const localizeDataViewerReadOnlyLocator = (locator: EditRowLocator, tr: DataViewerTranslator): EditRowLocator => {
|
||||
if (!locator.readOnly) return locator;
|
||||
return { ...locator, reason: localizeDataViewerReadOnlyReason(locator.reason, tr) };
|
||||
};
|
||||
|
||||
const warnDataViewerReadOnly = (
|
||||
kind: 'table' | 'collection',
|
||||
target: string,
|
||||
reason: string | undefined,
|
||||
tr: DataViewerTranslator,
|
||||
) => {
|
||||
const key = kind === 'table'
|
||||
? 'data_viewer.read_only.warning.table'
|
||||
: 'data_viewer.read_only.warning.collection';
|
||||
message.warning(tr(key, {
|
||||
target,
|
||||
reason: localizeDataViewerReadOnlyReason(reason, tr),
|
||||
}));
|
||||
};
|
||||
|
||||
const formatDataViewerTableName = (dbName: string, tableName: string): string => (
|
||||
dbName ? `${dbName}.${tableName}` : tableName
|
||||
);
|
||||
@@ -116,13 +160,13 @@ const getTableColumnNames = (columns: ColumnDefinition[] | undefined): string[]
|
||||
const MONGODB_ID_COLUMN = '_id';
|
||||
const MONGODB_ID_LOCATOR_COLUMN = '__gonavi_mongodb_id_locator__';
|
||||
|
||||
const buildMongoDataViewerEditLocator = (resultColumns: string[]): EditRowLocator => {
|
||||
const buildMongoDataViewerEditLocator = (resultColumns: string[], tr: DataViewerTranslator): EditRowLocator => {
|
||||
const columns = (resultColumns || [])
|
||||
.map((column) => String(column || '').trim())
|
||||
.filter(Boolean);
|
||||
const idColumn = columns.find((column) => column.toLowerCase() === MONGODB_ID_COLUMN);
|
||||
if (!idColumn) {
|
||||
return buildDataViewerReadOnlyLocator('MongoDB 结果集中缺少 _id,无法安全提交修改。');
|
||||
return buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.mongo_id_missing'));
|
||||
}
|
||||
|
||||
const locatorValueColumn = columns.find((column) => column === MONGODB_ID_LOCATOR_COLUMN) || idColumn;
|
||||
@@ -210,16 +254,16 @@ const isDuckDBComplexColumnType = (columnType?: string): boolean => {
|
||||
return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list');
|
||||
};
|
||||
|
||||
const formatDataViewerQueryError = (dbType: string, messageText: unknown): string => {
|
||||
const rawMessage = String(messageText || '查询失败').trim() || '查询失败';
|
||||
const formatDataViewerQueryError = (dbType: string, messageText: unknown, tr: DataViewerTranslator): string => {
|
||||
const rawMessage = String(messageText || tr('data_viewer.message.query_failed')).trim() || tr('data_viewer.message.query_failed');
|
||||
const lower = rawMessage.toLowerCase();
|
||||
const isTimeout = lower.includes('context deadline exceeded') || lower.includes('deadline exceeded') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('超时');
|
||||
const isTimeout = lower.includes('context deadline exceeded') || lower.includes('deadline exceeded') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('\u8d85\u65f6');
|
||||
const isDuckDBInterrupted = String(dbType || '').trim().toLowerCase() === 'duckdb' && (lower.includes('interrupt error') || lower.includes('interrupted'));
|
||||
if (isTimeout || isDuckDBInterrupted) {
|
||||
if (String(dbType || '').trim().toLowerCase() === 'duckdb') {
|
||||
return 'DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。';
|
||||
return tr('data_viewer.message.duckdb_query_timeout');
|
||||
}
|
||||
return '查询超过连接超时时间,已中断。请调大连接超时时间,或减少查询范围后重试。';
|
||||
return tr('data_viewer.message.query_timeout');
|
||||
}
|
||||
return rawMessage;
|
||||
};
|
||||
@@ -286,6 +330,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
const connections = useStore(state => state.connections);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const languagePreference = useStore(state => state.languagePreference);
|
||||
const language = resolveLanguage(languagePreference);
|
||||
const tr = useCallback((key: string, params?: I18nParams) => translate(key, params, language), [language]);
|
||||
const isV2Ui = appearance?.uiVersion === 'v2';
|
||||
const fetchSeqRef = useRef(0);
|
||||
const countSeqRef = useRef(0);
|
||||
@@ -433,7 +480,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
const countKey = latestCountKeyRef.current;
|
||||
|
||||
if (!config || !countSql || !countKey) {
|
||||
message.warning('当前结果集尚未就绪,请先执行一次加载');
|
||||
message.warning(tr('data_viewer.message.result_not_ready'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -452,7 +499,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
sql: countSql,
|
||||
status: resCount?.success ? 'success' : 'error',
|
||||
duration: countDuration,
|
||||
message: resCount?.success ? '' : String(resCount?.message || '统计失败'),
|
||||
message: resCount?.success ? '' : String(resCount?.message || tr('data_viewer.message.total_count_failed')),
|
||||
dbName
|
||||
});
|
||||
|
||||
@@ -461,7 +508,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
|
||||
if (!resCount?.success) {
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||
message.error(String(resCount?.message || '统计总数失败'));
|
||||
message.error(String(resCount?.message || tr('data_viewer.message.total_count_failed')));
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(resCount.data) || resCount.data.length === 0) {
|
||||
@@ -472,7 +519,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
const total = parseTotalFromCountRow(resCount.data[0]);
|
||||
if (total === null) {
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||
message.error('统计结果解析失败');
|
||||
message.error(tr('data_viewer.message.total_count_parse_failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -489,9 +536,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
if (manualCountSeqRef.current !== countSeq) return;
|
||||
if (manualCountKeyRef.current !== countKey) return;
|
||||
setPagination(prev => ({ ...prev, totalCountLoading: false }));
|
||||
message.error(`统计总数失败: ${String(e?.message || e)}`);
|
||||
message.error(tr('data_viewer.message.total_count_failed_detail', { detail: String(e?.message || e) }));
|
||||
}
|
||||
}, [addSqlLog]);
|
||||
}, [addSqlLog, tr]);
|
||||
|
||||
const handleCancelManualTotalCount = useCallback(() => {
|
||||
manualCountSeqRef.current++;
|
||||
@@ -503,7 +550,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
setLoading(true);
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
message.error("Connection not found");
|
||||
message.error(tr('data_viewer.message.connection_not_found'));
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -537,7 +584,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
try {
|
||||
mongoFilter = buildMongoFilter(effectiveFilterConditions);
|
||||
} catch (e: any) {
|
||||
message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`);
|
||||
const detail = String(e?.message || e || tr('data_viewer.message.mongo_filter_parse_failed'));
|
||||
message.error(tr('data_viewer.message.mongo_filter_invalid_detail', { detail }));
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -561,19 +609,19 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
const [resCols, resIndexes] = await Promise.all([
|
||||
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName),
|
||||
DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName)
|
||||
.catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })),
|
||||
.catch((error: any) => ({ success: false, message: String(error?.message || error || 'Failed to load indexes'), data: [] })),
|
||||
]);
|
||||
if (fetchSeqRef.current !== seq) return;
|
||||
if (pkSeqRef.current !== locatorSeq) return;
|
||||
if (pkKeyRef.current !== locatorKey) return;
|
||||
|
||||
if (!resCols?.success || !Array.isArray(resCols.data)) {
|
||||
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
|
||||
const nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.metadata_unavailable'));
|
||||
pkColumnsForQuery = [];
|
||||
editLocatorForQuery = nextLocator;
|
||||
setPkColumns([]);
|
||||
setEditLocator(nextLocator);
|
||||
message.warning(`表 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
|
||||
warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
|
||||
} else {
|
||||
const columnDefs = resCols.data as ColumnDefinition[];
|
||||
const primaryKeys = columnDefs
|
||||
@@ -587,16 +635,16 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
const locatorColumns = isOracleLikeDialect(dbType)
|
||||
? [...resultColumns, ORACLE_ROWID_LOCATOR_COLUMN]
|
||||
: resultColumns;
|
||||
let nextLocator = resolveEditRowLocator({
|
||||
let nextLocator = localizeDataViewerReadOnlyLocator(resolveEditRowLocator({
|
||||
dbType,
|
||||
resultColumns: locatorColumns,
|
||||
primaryKeys,
|
||||
indexes,
|
||||
allowOracleRowID: true,
|
||||
});
|
||||
}), tr);
|
||||
|
||||
if (nextLocator.readOnly && primaryKeys.length === 0 && !resIndexes?.success && !isOracleLikeDialect(dbType)) {
|
||||
nextLocator = buildDataViewerReadOnlyLocator('无法加载唯一索引元数据,无法安全提交修改。');
|
||||
nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.index_metadata_unavailable'));
|
||||
}
|
||||
|
||||
pkColumnsForQuery = primaryKeys;
|
||||
@@ -604,19 +652,19 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
setPkColumns(primaryKeys);
|
||||
setEditLocator(nextLocator);
|
||||
if (nextLocator.readOnly) {
|
||||
message.warning(`表 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`);
|
||||
warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (fetchSeqRef.current !== seq) return;
|
||||
if (pkSeqRef.current !== locatorSeq) return;
|
||||
if (pkKeyRef.current !== locatorKey) return;
|
||||
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
|
||||
const nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.metadata_unavailable'));
|
||||
pkColumnsForQuery = [];
|
||||
editLocatorForQuery = nextLocator;
|
||||
setPkColumns([]);
|
||||
setEditLocator(nextLocator);
|
||||
message.warning(`表 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
|
||||
warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -660,8 +708,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
} else {
|
||||
const baseSql = buildDataViewerBaseSelectSQL(dbType, tableName, whereSQL, editLocatorForQuery);
|
||||
sql = `${baseSql}${orderBySQL}`;
|
||||
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
|
||||
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET,并在前端翻转结果。
|
||||
// ClickHouse deep pagination with very large OFFSET can be slow. When the tail offset is smaller,
|
||||
// query in reverse ORDER BY with a smaller OFFSET, then reverse rows in the frontend.
|
||||
if (isClickHouse && totalKnown && offset > 0 && reverseOrderSQL) {
|
||||
const pageRowCount = Math.max(0, Math.min(size, totalRows - offset));
|
||||
if (pageRowCount > 0) {
|
||||
@@ -715,7 +763,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
|
||||
const hasSort = hasExplicitSort(sortInfo);
|
||||
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
|
||||
let resData = await executeDataQuery(sql, '主查询');
|
||||
let resData = await executeDataQuery(sql, tr('data_viewer.sql_log.phase.main_query'));
|
||||
|
||||
if (!resData.success && dbTypeLower === 'duckdb' && isDuckDBUnsupportedTypeError(String(resData.message || ''))) {
|
||||
const cacheKey = `${tab.connectionId}|${dbName}|${tableName}`;
|
||||
@@ -748,7 +796,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
|
||||
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery)), size + 1, offset);
|
||||
executedSql = fallbackSql;
|
||||
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
|
||||
resData = await executeDataQuery(fallbackSql, tr('data_viewer.sql_log.phase.complex_type_fallback_retry'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,17 +804,17 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
|
||||
if (retrySql32MB !== sql) {
|
||||
executedSql = retrySql32MB;
|
||||
resData = await executeDataQuery(retrySql32MB, '重试(32MB sort_buffer)');
|
||||
resData = await executeDataQuery(retrySql32MB, tr('data_viewer.sql_log.phase.sort_buffer_retry', { size: '32MB' }));
|
||||
}
|
||||
if (!resData.success && isSortMemoryErr(resData.message)) {
|
||||
const retrySql128MB = withSortBufferTuningSQL(dbType, sql, 128 * 1024 * 1024);
|
||||
if (retrySql128MB !== executedSql) {
|
||||
executedSql = retrySql128MB;
|
||||
resData = await executeDataQuery(retrySql128MB, '重试(128MB sort_buffer)');
|
||||
resData = await executeDataQuery(retrySql128MB, tr('data_viewer.sql_log.phase.sort_buffer_retry', { size: '128MB' }));
|
||||
}
|
||||
}
|
||||
if (resData.success) {
|
||||
message.warning('已自动提升排序缓冲并重试成功。');
|
||||
message.warning(tr('data_viewer.message.sort_buffer_retry_succeeded'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -788,13 +836,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
}
|
||||
if (fetchSeqRef.current !== seq) return;
|
||||
if (isMongoDB && !forceReadOnly && tableName) {
|
||||
const nextLocator = buildMongoDataViewerEditLocator(fieldNames);
|
||||
const nextLocator = buildMongoDataViewerEditLocator(fieldNames, tr);
|
||||
pkColumnsForQuery = nextLocator.readOnly ? [] : [MONGODB_ID_COLUMN];
|
||||
editLocatorForQuery = nextLocator;
|
||||
setPkColumns(pkColumnsForQuery);
|
||||
setEditLocator(nextLocator);
|
||||
if (nextLocator.readOnly && resultData.length > 0) {
|
||||
message.warning(`集合 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`);
|
||||
warnDataViewerReadOnly('collection', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr);
|
||||
}
|
||||
}
|
||||
setColumnNames(fieldNames);
|
||||
@@ -880,8 +928,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
countKeyRef.current = countKey;
|
||||
const countSeq = ++countSeqRef.current;
|
||||
const countStart = Date.now();
|
||||
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
|
||||
// DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。
|
||||
// Large-table COUNT(*) can be slow and may delay later operations in some runtimes.
|
||||
// DuckDB large-file scenarios disable background COUNT because it can slow pagination significantly.
|
||||
const countConfig = buildRpcConnectionConfig(config, { timeout: 5 });
|
||||
|
||||
DBQuery(countConfig, dbName, countSql)
|
||||
@@ -920,7 +968,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
.catch(() => {
|
||||
if (countSeqRef.current !== countSeq) return;
|
||||
if (countKeyRef.current !== countKey) return;
|
||||
// 统计失败不影响主流程,不弹窗;可在日志里查看。
|
||||
// Count failures do not block the main flow; details stay in the SQL log.
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1009,11 +1057,11 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error(formatDataViewerQueryError(dbTypeLower, resData.message));
|
||||
message.error(formatDataViewerQueryError(dbTypeLower, resData.message, tr));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (fetchSeqRef.current !== seq) return;
|
||||
message.error(formatDataViewerQueryError(dbTypeLower, e?.message || e));
|
||||
message.error(formatDataViewerQueryError(dbTypeLower, e?.message || e, tr));
|
||||
addSqlLog({
|
||||
id: `log-${Date.now()}-error`,
|
||||
timestamp: Date.now(),
|
||||
@@ -1025,7 +1073,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
});
|
||||
}
|
||||
if (fetchSeqRef.current === seq) setLoading(false);
|
||||
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, editLocator, forceReadOnly, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
|
||||
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, editLocator, forceReadOnly, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages, tr]);
|
||||
// 依赖定位列:在无手动排序时可回退到安全定位列稳定排序。
|
||||
// 定位信息只会在表上下文变化后重新加载,避免循环查询。
|
||||
|
||||
|
||||
38
frontend/src/components/DefinitionViewer.i18n.test.ts
Normal file
38
frontend/src/components/DefinitionViewer.i18n.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('DefinitionViewer i18n', () => {
|
||||
it('keeps DefinitionViewer shell and validation copy localized', () => {
|
||||
const source = readFileSync(new URL('./DefinitionViewer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/setError\('未找到数据库连接'|setError\('视图名称为空'|setError\('事件名称为空'|setError\('函数\/存储过程名称为空'/);
|
||||
expect(source).not.toMatch(/setError\(result\.message \|\| '查询定义失败'|setError\('查询定义失败: '/);
|
||||
expect(source).not.toMatch(/<Spin tip=\{`加载|message="加载失败"|>数据库:|>类型:/);
|
||||
expect(source).not.toMatch(/objectLabel = tab\.viewKind === 'materialized' \? '物化视图' : '视图'|objectLabel = '事件'|objectLabel = '函数\/存储过程'/);
|
||||
|
||||
expect(source).toContain('definition_viewer.error.connection_not_found');
|
||||
expect(source).toContain('definition_viewer.error.view_name_empty');
|
||||
expect(source).toContain('definition_viewer.error.event_name_empty');
|
||||
expect(source).toContain('definition_viewer.error.routine_name_empty');
|
||||
expect(source).toContain('definition_viewer.error.query_failed');
|
||||
expect(source).toContain('definition_viewer.error.query_failed_detail');
|
||||
expect(source).toContain('definition_viewer.loading.view_definition');
|
||||
expect(source).toContain('definition_viewer.field.database');
|
||||
expect(source).toContain('definition_viewer.field.type');
|
||||
});
|
||||
|
||||
it('keeps DefinitionViewer editor fallback comments localized', () => {
|
||||
const source = readFileSync(new URL('./DefinitionViewer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/暂不支持该数据库类型的视图定义查看|SQLite 不支持函数\/存储过程定义管理|暂不支持该数据库类型的函数\/存储过程定义查看|暂不支持该数据库类型的事件定义查看/);
|
||||
expect(source).not.toMatch(/未找到视图定义|未找到函数\/存储过程定义|未找到事件定义|暂不支持该对象定义查看/);
|
||||
expect(source).not.toMatch(/当前数据源未返回可执行定义文本|当前数据源未返回完整 CREATE EVENT 语句|名称: |类型: /);
|
||||
expect(source).not.toMatch(/当前 Sphinx 实例|已执行多套兼容查询|返回失败信息: |unknown error/);
|
||||
|
||||
expect(source).toContain('definition_viewer.editor.unsupported_view_definition');
|
||||
expect(source).toContain('definition_viewer.editor.unsupported_sqlite_routine_definition');
|
||||
expect(source).toContain('definition_viewer.editor.unsupported_routine_definition');
|
||||
expect(source).toContain('definition_viewer.editor.event_definition_not_found');
|
||||
expect(source).toContain('definition_viewer.editor.sphinx.failed_message_unknown');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { useStore } from '../store';
|
||||
import { DBQuery } from '../../wailsjs/go/app/App';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
|
||||
import { useI18n } from '../i18n/provider';
|
||||
|
||||
interface DefinitionViewerProps {
|
||||
tab: TabData;
|
||||
@@ -36,6 +37,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
const { t } = useI18n();
|
||||
|
||||
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||||
|
||||
@@ -170,7 +172,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
return [`SELECT view_definition FROM information_schema.views WHERE table_schema = '${escapeSQLLiteral(schemaRef)}' AND table_name = '${safeName}' LIMIT 1`];
|
||||
}
|
||||
default:
|
||||
return [`-- 暂不支持该数据库类型的视图定义查看`];
|
||||
return [`-- ${t('definition_viewer.editor.unsupported_view_definition')}`];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -219,9 +221,9 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
];
|
||||
}
|
||||
case 'sqlite':
|
||||
return [`-- SQLite 不支持函数/存储过程定义管理`];
|
||||
return [`-- ${t('definition_viewer.editor.unsupported_sqlite_routine_definition')}`];
|
||||
default:
|
||||
return [`-- 暂不支持该数据库类型的函数/存储过程定义查看`];
|
||||
return [`-- ${t('definition_viewer.editor.unsupported_routine_definition')}`];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,7 +244,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
default:
|
||||
return [`-- 暂不支持该数据库类型的事件定义查看`];
|
||||
return [`-- ${t('definition_viewer.editor.unsupported_event_definition')}`];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -305,7 +307,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
};
|
||||
|
||||
const extractViewDefinition = (dialect: string, data: any[]): string => {
|
||||
if (!data || data.length === 0) return '-- 未找到视图定义';
|
||||
if (!data || data.length === 0) return `-- ${t('definition_viewer.editor.view_definition_not_found')}`;
|
||||
const row = data[0];
|
||||
|
||||
switch (dialect) {
|
||||
@@ -335,7 +337,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
};
|
||||
|
||||
const extractRoutineDefinition = (dialect: string, data: any[]): string => {
|
||||
if (!data || data.length === 0) return '-- 未找到函数/存储过程定义';
|
||||
if (!data || data.length === 0) return `-- ${t('definition_viewer.editor.routine_definition_not_found')}`;
|
||||
|
||||
switch (dialect) {
|
||||
case 'mysql':
|
||||
@@ -356,7 +358,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const routineName = String(row.Name || row.name || '').trim();
|
||||
if (routineName) {
|
||||
const routineType = String(row.Type || row.type || row.ROUTINE_TYPE || row.routine_type || 'FUNCTION').trim().toUpperCase();
|
||||
return `-- 当前数据源未返回可执行定义文本,已返回元数据\n-- 名称: ${routineName}\n-- 类型: ${routineType}\n${JSON.stringify(row, null, 2)}`;
|
||||
return `-- ${t('definition_viewer.editor.metadata_fallback.header')}\n-- ${t('definition_viewer.editor.metadata_fallback.name_label')}: ${routineName}\n-- ${t('definition_viewer.editor.metadata_fallback.type_label')}: ${routineType}\n${JSON.stringify(row, null, 2)}`;
|
||||
}
|
||||
return JSON.stringify(row, null, 2);
|
||||
}
|
||||
@@ -388,7 +390,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
};
|
||||
|
||||
const extractEventDefinition = (dialect: string, data: any[]): string => {
|
||||
if (!data || data.length === 0) return '-- 未找到事件定义';
|
||||
if (!data || data.length === 0) return `-- ${t('definition_viewer.editor.event_definition_not_found')}`;
|
||||
|
||||
switch (dialect) {
|
||||
case 'mysql': {
|
||||
@@ -400,7 +402,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
const definition = row.event_definition || row.EVENT_DEFINITION;
|
||||
const eventName = row.event_name || row.EVENT_NAME || row.Name || row.name;
|
||||
if (definition && eventName) {
|
||||
return `-- 当前数据源未返回完整 CREATE EVENT 语句,已返回事件定义片段\n-- 名称: ${eventName}\n${String(definition)}`;
|
||||
return `-- ${t('definition_viewer.editor.event_fragment_fallback.header')}\n-- ${t('definition_viewer.editor.metadata_fallback.name_label')}: ${eventName}\n${String(definition)}`;
|
||||
}
|
||||
return JSON.stringify(row, null, 2);
|
||||
}
|
||||
@@ -418,7 +420,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
|
||||
const conn = connections.find(c => c.id === tab.connectionId);
|
||||
if (!conn) {
|
||||
setError('未找到数据库连接');
|
||||
setError(t('definition_viewer.error.connection_not_found'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -434,38 +436,40 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
if (tab.type === 'view-def') {
|
||||
const viewName = tab.viewName || '';
|
||||
if (!viewName) {
|
||||
setError('视图名称为空');
|
||||
setError(t('definition_viewer.error.view_name_empty'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
queries = buildShowViewQueries(dialect, viewName, dbName, tab.viewKind);
|
||||
extractFn = extractViewDefinition;
|
||||
objectLabel = tab.viewKind === 'materialized' ? '物化视图' : '视图';
|
||||
objectLabel = tab.viewKind === 'materialized'
|
||||
? t('definition_viewer.object.materialized_view')
|
||||
: t('definition_viewer.object.view');
|
||||
} else if (tab.type === 'event-def') {
|
||||
const eventName = tab.eventName || '';
|
||||
if (!eventName) {
|
||||
setError('事件名称为空');
|
||||
setError(t('definition_viewer.error.event_name_empty'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
queries = buildShowEventQueries(dialect, eventName, dbName);
|
||||
extractFn = extractEventDefinition;
|
||||
objectLabel = '事件';
|
||||
objectLabel = t('definition_viewer.object.event');
|
||||
} else {
|
||||
const routineName = tab.routineName || '';
|
||||
const routineType = tab.routineType || 'FUNCTION';
|
||||
if (!routineName) {
|
||||
setError('函数/存储过程名称为空');
|
||||
setError(t('definition_viewer.error.routine_name_empty'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
queries = buildShowRoutineQueries(dialect, routineName, routineType, dbName);
|
||||
extractFn = extractRoutineDefinition;
|
||||
objectLabel = '函数/存储过程';
|
||||
objectLabel = t('definition_viewer.object.routine');
|
||||
}
|
||||
|
||||
if (!queries.length || String(queries[0] || '').startsWith('--')) {
|
||||
setDefinition(String(queries[0] || '-- 暂不支持该对象定义查看'));
|
||||
setDefinition(String(queries[0] || `-- ${t('definition_viewer.editor.unsupported_object_definition')}`));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -491,39 +495,45 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
if (result.success) {
|
||||
if (sphinxLike) {
|
||||
const version = await getVersionHint(config, dbName);
|
||||
const versionText = version ? `(版本: ${version})` : '';
|
||||
setDefinition(`-- 当前 Sphinx 实例${versionText}未返回${objectLabel}定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`);
|
||||
const versionText = version ? t('definition_viewer.editor.sphinx.version_suffix', { version }) : '';
|
||||
setDefinition(`-- ${t('definition_viewer.editor.sphinx.empty_result', { version: versionText, object: objectLabel })}\n-- ${t('definition_viewer.editor.sphinx.compat_queries_hint')}`);
|
||||
return;
|
||||
}
|
||||
setDefinition(`-- 未找到${objectLabel}定义`);
|
||||
setDefinition(`-- ${t('definition_viewer.editor.object_definition_not_found', { object: objectLabel })}`);
|
||||
} else if (sphinxLike) {
|
||||
const version = await getVersionHint(config, dbName);
|
||||
const versionText = version ? `(版本: ${version})` : '';
|
||||
setDefinition(`-- 当前 Sphinx 实例${versionText}不支持${objectLabel}定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`);
|
||||
const versionText = version ? t('definition_viewer.editor.sphinx.version_suffix', { version }) : '';
|
||||
const failedMessage = result.message
|
||||
? `${t('definition_viewer.editor.sphinx.failed_message_label')}: ${result.message}`
|
||||
: t('definition_viewer.editor.sphinx.failed_message_unknown');
|
||||
setDefinition(`-- ${t('definition_viewer.editor.sphinx.unsupported_query', { version: versionText, object: objectLabel })}\n-- ${failedMessage}`);
|
||||
} else {
|
||||
setError(result.message || '查询定义失败');
|
||||
setError(result.message || t('definition_viewer.error.query_failed'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError('查询定义失败: ' + (e?.message || String(e)));
|
||||
setError(t('definition_viewer.error.query_failed_detail', { detail: e?.message || String(e) }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDefinition();
|
||||
}, [tab.connectionId, tab.dbName, tab.viewName, tab.viewKind, tab.eventName, tab.routineName, tab.routineType, tab.type, connections]);
|
||||
}, [tab.connectionId, tab.dbName, tab.viewName, tab.viewKind, tab.eventName, tab.routineName, tab.routineType, tab.type, connections, t]);
|
||||
|
||||
const objectLabel = tab.type === 'view-def'
|
||||
? (tab.viewKind === 'materialized' ? '物化视图' : '视图')
|
||||
: (tab.type === 'event-def' ? '事件' : '函数/存储过程');
|
||||
? (tab.viewKind === 'materialized' ? t('definition_viewer.object.materialized_view') : t('definition_viewer.object.view'))
|
||||
: (tab.type === 'event-def' ? t('definition_viewer.object.event') : t('definition_viewer.object.routine'));
|
||||
const objectName = tab.type === 'view-def'
|
||||
? tab.viewName
|
||||
: (tab.type === 'event-def' ? tab.eventName : tab.routineName);
|
||||
const loadingTip = tab.type === 'view-def'
|
||||
? t('definition_viewer.loading.view_definition')
|
||||
: (tab.type === 'event-def' ? t('definition_viewer.loading.event_definition') : t('definition_viewer.loading.routine_definition'));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin tip={`加载${objectLabel}定义...`} />
|
||||
<Spin tip={loadingTip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -531,7 +541,7 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Alert type="error" message="加载失败" description={error} showIcon />
|
||||
<Alert type="error" message={t('definition_viewer.error.load_failed')} description={error} showIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -540,8 +550,8 @@ const DefinitionViewer: React.FC<DefinitionViewerProps> = ({ tab }) => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '8px 16px', borderBottom: darkMode ? '1px solid #303030' : '1px solid #f0f0f0' }}>
|
||||
<strong>{objectLabel}: </strong>{objectName}
|
||||
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>数据库: {tab.dbName}</span>}
|
||||
{tab.routineType && <span style={{ marginLeft: 16, color: '#888' }}>类型: {tab.routineType}</span>}
|
||||
{tab.dbName && <span style={{ marginLeft: 16, color: '#888' }}>{t('definition_viewer.field.database')}: {tab.dbName}</span>}
|
||||
{tab.routineType && <span style={{ marginLeft: 16, color: '#888' }}>{t('definition_viewer.field.type')}: {tab.routineType}</span>}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<Editor
|
||||
|
||||
205
frontend/src/components/FindInDatabaseModal.test.tsx
Normal file
205
frontend/src/components/FindInDatabaseModal.test.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import FindInDatabaseModal from "./FindInDatabaseModal";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dbQuery: vi.fn(),
|
||||
dbGetTables: vi.fn(),
|
||||
dbGetAllColumns: vi.fn(),
|
||||
message: {
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
storeState: {
|
||||
connections: [
|
||||
{
|
||||
id: "conn-1",
|
||||
config: {
|
||||
type: "mysql",
|
||||
host: "localhost",
|
||||
port: 3306,
|
||||
user: "root",
|
||||
password: "",
|
||||
database: "app",
|
||||
},
|
||||
},
|
||||
],
|
||||
theme: "light",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState),
|
||||
}));
|
||||
|
||||
vi.mock("../i18n/runtime", () => ({
|
||||
applyDayjsLocale: vi.fn(),
|
||||
syncLanguageRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../wailsjs/go/app/App", () => ({
|
||||
DBQuery: mocks.dbQuery,
|
||||
DBGetTables: mocks.dbGetTables,
|
||||
DBGetAllColumns: mocks.dbGetAllColumns,
|
||||
}));
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
open?: boolean;
|
||||
title?: React.ReactNode;
|
||||
}) => (open ? React.createElement("section", null, title, children) : null),
|
||||
Input: ({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (event: { target: { value: string } }) => void;
|
||||
}) =>
|
||||
React.createElement("input", {
|
||||
placeholder,
|
||||
value,
|
||||
onChange: (event: any) => onChange?.({ target: { value: event.target.value } }),
|
||||
}),
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => React.createElement("button", { disabled, onClick }, icon, children),
|
||||
Select: ({ options }: { options?: Array<{ label: React.ReactNode; value: string }> }) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
null,
|
||||
options?.map((option) => React.createElement("span", { key: option.value }, option.label)),
|
||||
),
|
||||
Table: ({ columns, dataSource }: { columns?: any[]; dataSource?: any[] }) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
null,
|
||||
columns?.map((column) => React.createElement("span", { key: column.key || column.dataIndex }, column.title)),
|
||||
dataSource?.map((row, index) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ key: index },
|
||||
Object.values(row).map((value, valueIndex) =>
|
||||
React.createElement("span", { key: valueIndex }, String(value)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Progress: ({ percent }: { percent: number }) => React.createElement("div", null, `${percent}%`),
|
||||
Space: ({ children }: { children?: React.ReactNode }) => React.createElement("div", null, children),
|
||||
Tag: ({ children }: { children?: React.ReactNode }) => React.createElement("span", null, children),
|
||||
Tooltip: ({ children, title }: { children?: React.ReactNode; title?: React.ReactNode }) =>
|
||||
React.createElement("span", { title }, children),
|
||||
Empty: ({ description }: { description?: React.ReactNode }) => React.createElement("div", null, description),
|
||||
message: mocks.message,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@ant-design/icons", async () => {
|
||||
const React = await import("react");
|
||||
const Icon = () => React.createElement("span", null);
|
||||
return {
|
||||
SearchOutlined: Icon,
|
||||
StopOutlined: Icon,
|
||||
EyeOutlined: Icon,
|
||||
DatabaseOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
const textContent = (node: any): string => {
|
||||
if (node === null || node === undefined) return "";
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map((item) => textContent(item)).join("");
|
||||
return textContent(node.children || []);
|
||||
};
|
||||
|
||||
const renderFindModal = () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
act(() => {
|
||||
renderer = create(
|
||||
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
|
||||
<FindInDatabaseModal open connectionId="conn-1" dbName="app_db" onClose={vi.fn()} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
return renderer;
|
||||
};
|
||||
|
||||
describe("FindInDatabaseModal i18n", () => {
|
||||
beforeEach(() => {
|
||||
mocks.dbQuery.mockReset();
|
||||
mocks.dbGetTables.mockReset();
|
||||
mocks.dbGetAllColumns.mockReset();
|
||||
mocks.message.warning.mockClear();
|
||||
mocks.message.error.mockClear();
|
||||
mocks.message.info.mockClear();
|
||||
});
|
||||
|
||||
it("renders search chrome in the active language while preserving raw database name", () => {
|
||||
const renderer = renderFindModal();
|
||||
const renderedText = textContent(renderer.toJSON());
|
||||
const input = renderer.root.findByType("input");
|
||||
|
||||
expect(renderedText).toContain("Search in database - app_db");
|
||||
expect(input.props.placeholder).toBe("Enter the string to search for...");
|
||||
expect(renderedText).toContain("Contains");
|
||||
expect(renderedText).toContain("Exact match");
|
||||
expect(renderedText).toContain("Search");
|
||||
});
|
||||
|
||||
it("localizes controlled error wrappers while preserving backend detail", async () => {
|
||||
mocks.dbGetTables.mockResolvedValue({ success: false, message: "driver raw detail" });
|
||||
const renderer = renderFindModal();
|
||||
|
||||
const input = renderer.root.findByType("input");
|
||||
await act(async () => {
|
||||
input.props.onChange({ target: { value: "alice" } });
|
||||
});
|
||||
|
||||
const searchButton = renderer.root.findAllByType("button").find((button) => textContent(button).includes("Search"));
|
||||
expect(searchButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
searchButton?.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.message.error).toHaveBeenCalledWith("Failed to get table list: driver raw detail");
|
||||
});
|
||||
|
||||
it("does not keep migrated Chinese UI literals in FindInDatabaseModal source", () => {
|
||||
const source = readFileSync(new URL("./FindInDatabaseModal.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toContain("请输入搜索关键字");
|
||||
expect(source).not.toContain("未找到连接配置");
|
||||
expect(source).not.toContain("获取表列表失败");
|
||||
expect(source).not.toContain("当前数据库没有表");
|
||||
expect(source).not.toContain("未找到匹配的数据");
|
||||
expect(source).not.toContain("搜索出错");
|
||||
expect(source).not.toContain("在数据库中搜索");
|
||||
expect(source).not.toContain("输入要搜索的字符串");
|
||||
expect(source).not.toContain("精确匹配");
|
||||
expect(source).not.toContain("匹配行详情");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { useStore } from '../store';
|
||||
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
import { useI18n } from '../i18n/provider';
|
||||
|
||||
interface FindInDatabaseModalProps {
|
||||
open: boolean;
|
||||
@@ -23,24 +24,24 @@ interface SearchResultItem {
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
/** 判断数据库列类型是否为文本类型(只搜索文本字段) */
|
||||
/** Returns whether a database column type is searchable as text. */
|
||||
const isTextColumnType = (colType: string): boolean => {
|
||||
const t = (colType || '').toLowerCase().trim();
|
||||
// 显式排除非文本类型
|
||||
// Explicitly skip non-text types before falling back to searchable.
|
||||
if (/^(int|bigint|smallint|tinyint|mediumint|float|double|decimal|numeric|real|money|smallmoney|bit|boolean|bool)/.test(t)) return false;
|
||||
if (/^(date|time|datetime|timestamp|year|interval)/.test(t)) return false;
|
||||
if (/^(blob|binary|varbinary|image|bytea|raw|long raw)/.test(t)) return false;
|
||||
if (/^(geometry|geography|point|line|polygon|spatial)/.test(t)) return false;
|
||||
if (/^(json|jsonb|xml|uuid|uniqueidentifier)/.test(t)) return false;
|
||||
if (/^(serial|bigserial|smallserial|autoincrement|identity)/.test(t)) return false;
|
||||
// 文本类型正匹配
|
||||
// Positive text type matches.
|
||||
if (/^(varchar|char|nvarchar|nchar|text|ntext|tinytext|mediumtext|longtext|string|clob|nclob|character)/.test(t)) return true;
|
||||
if (t === 'sysname' || t === 'sql_variant') return true;
|
||||
// 未知类型默认尝试搜索
|
||||
// Unknown types are attempted by default.
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 根据 dbType 构建限制返回行数的 SELECT SQL */
|
||||
/** Builds a SELECT statement with a dialect-specific row limit. */
|
||||
const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number): string => {
|
||||
const normalizedType = (dbType || '').toLowerCase();
|
||||
switch (normalizedType) {
|
||||
@@ -58,6 +59,7 @@ const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number):
|
||||
const MAX_MATCH_ROWS_PER_TABLE = 100;
|
||||
|
||||
const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose, connectionId, dbName }) => {
|
||||
const { t } = useI18n();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [matchMode, setMatchMode] = useState<'contains' | 'exact'>('contains');
|
||||
const [searching, setSearching] = useState(false);
|
||||
@@ -93,12 +95,12 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
const handleSearch = useCallback(async () => {
|
||||
const searchKeyword = keyword.trim();
|
||||
if (!searchKeyword) {
|
||||
message.warning('请输入搜索关键字');
|
||||
message.warning(t('find_in_database.message.keyword_required'));
|
||||
return;
|
||||
}
|
||||
const config = buildConfig();
|
||||
if (!config) {
|
||||
message.error('未找到连接配置');
|
||||
message.error(t('find_in_database.message.connection_config_not_found'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,10 +110,9 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
cancelledRef.current = false;
|
||||
|
||||
try {
|
||||
// 1. 获取所有表
|
||||
const tablesRes = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
|
||||
if (!tablesRes.success) {
|
||||
message.error('获取表列表失败: ' + tablesRes.message);
|
||||
message.error(t('find_in_database.message.get_tables_failed', { detail: tablesRes.message }));
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
@@ -119,18 +120,16 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
const tableNames = tableRows.map((row: any) => Object.values(row)[0] as string).filter(Boolean);
|
||||
|
||||
if (tableNames.length === 0) {
|
||||
message.info('当前数据库没有表');
|
||||
message.info(t('find_in_database.message.no_tables'));
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress({ current: 0, total: tableNames.length, tableName: '' });
|
||||
|
||||
// 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段)
|
||||
const allColsRes = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
|
||||
const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : [];
|
||||
|
||||
// 按表名分组
|
||||
const columnsByTable: Record<string, Array<{ name: string; type: string }>> = {};
|
||||
allColumns.forEach((col: any) => {
|
||||
const tbl = col.tableName || '';
|
||||
@@ -141,20 +140,17 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
const searchResults: SearchResultItem[] = [];
|
||||
const escapedKeyword = escapeLiteral(searchKeyword);
|
||||
|
||||
// 3. 逐表搜索
|
||||
for (let i = 0; i < tableNames.length; i++) {
|
||||
if (cancelledRef.current) break;
|
||||
|
||||
const tableName = tableNames[i];
|
||||
setProgress({ current: i + 1, total: tableNames.length, tableName });
|
||||
|
||||
// 获取该表的文本列
|
||||
const tableCols = columnsByTable[tableName] || [];
|
||||
const textCols = tableCols.filter(c => isTextColumnType(c.type));
|
||||
|
||||
if (textCols.length === 0) continue;
|
||||
|
||||
// 构建 WHERE 子句
|
||||
const castType = (dbType === 'sqlserver' || dbType === 'mssql') ? 'NVARCHAR(MAX)' : 'CHAR';
|
||||
const whereConditions = textCols.map(c => {
|
||||
const quotedCol = quoteIdentPart(dbType, c.name);
|
||||
@@ -171,7 +167,6 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
try {
|
||||
const res = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql);
|
||||
if (res.success && Array.isArray(res.data) && res.data.length > 0) {
|
||||
// 检查哪些列实际匹配了
|
||||
const matchedCols = new Set<string>();
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
res.data.forEach((row: any) => {
|
||||
@@ -199,22 +194,22 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 单表查询失败不中断整体搜索
|
||||
// Per-table query failures should not stop the whole search.
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelledRef.current) {
|
||||
setResults([...searchResults]);
|
||||
if (searchResults.length === 0) {
|
||||
message.info('未找到匹配的数据');
|
||||
message.info(t('find_in_database.message.no_matches'));
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('搜索出错: ' + (e?.message || String(e)));
|
||||
message.error(t('find_in_database.message.search_failed', { detail: e?.message || String(e) }));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [keyword, matchMode, dbName, dbType, buildConfig]);
|
||||
}, [keyword, matchMode, dbName, dbType, buildConfig, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
@@ -228,10 +223,9 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// 汇总表的列定义
|
||||
const summaryColumns = useMemo(() => [
|
||||
{
|
||||
title: '表名',
|
||||
title: t('find_in_database.column.table_name'),
|
||||
dataIndex: 'tableName',
|
||||
key: 'tableName',
|
||||
width: 220,
|
||||
@@ -243,7 +237,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '匹配列',
|
||||
title: t('find_in_database.column.matched_columns'),
|
||||
dataIndex: 'matchedColumns',
|
||||
key: 'matchedColumns',
|
||||
render: (cols: string[]) => (
|
||||
@@ -255,7 +249,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '命中行数',
|
||||
title: t('find_in_database.column.match_count'),
|
||||
dataIndex: 'matchCount',
|
||||
key: 'matchCount',
|
||||
width: 100,
|
||||
@@ -267,12 +261,12 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('find_in_database.column.action'),
|
||||
key: 'action',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: any, record: SearchResultItem) => (
|
||||
<Tooltip title={expandedTable === record.tableName ? '收起详情' : '查看详情'}>
|
||||
<Tooltip title={expandedTable === record.tableName ? t('find_in_database.tooltip.collapse_details') : t('find_in_database.tooltip.view_details')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -283,9 +277,8 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
], [wt, expandedTable]);
|
||||
], [wt, expandedTable, t]);
|
||||
|
||||
// 展开的详情行 - 动态列
|
||||
const expandedResult = useMemo(() => {
|
||||
if (!expandedTable) return null;
|
||||
return results.find(r => r.tableName === expandedTable);
|
||||
@@ -322,7 +315,7 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
title={
|
||||
<span style={{ color: wt.titleText, fontWeight: 600 }}>
|
||||
<SearchOutlined style={{ marginRight: 8, color: wt.iconColor }} />
|
||||
在数据库中搜索 — {dbName}
|
||||
{t('find_in_database.title', { dbName })}
|
||||
</span>
|
||||
}
|
||||
open={open}
|
||||
@@ -344,10 +337,9 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="输入要搜索的字符串..."
|
||||
placeholder={t('find_in_database.placeholder.keyword')}
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onPressEnter={!searching ? handleSearch : undefined}
|
||||
@@ -361,22 +353,21 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
disabled={searching}
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ label: '包含', value: 'contains' },
|
||||
{ label: '精确匹配', value: 'exact' },
|
||||
{ label: t('find_in_database.match.contains'), value: 'contains' },
|
||||
{ label: t('find_in_database.match.exact'), value: 'exact' },
|
||||
]}
|
||||
/>
|
||||
{searching ? (
|
||||
<Button icon={<StopOutlined />} danger onClick={handleCancel}>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch} disabled={!keyword.trim()}>
|
||||
搜索
|
||||
{t('common.search')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{searching && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Progress
|
||||
@@ -386,17 +377,20 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
strokeColor={wt.iconColor}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: wt.mutedText }}>
|
||||
正在搜索 {progress.tableName}... ({progress.current}/{progress.total})
|
||||
{t('find_in_database.progress.searching_table', {
|
||||
table: progress.tableName,
|
||||
current: progress.current,
|
||||
total: progress.total,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果汇总表 */}
|
||||
{results.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: 13, color: wt.mutedText, fontWeight: 500 }}>
|
||||
找到 {results.length} 个表包含匹配数据
|
||||
{searching && '(搜索进行中...)'}
|
||||
{t('find_in_database.summary.found_tables', { count: results.length })}
|
||||
{searching && t('find_in_database.summary.searching')}
|
||||
</div>
|
||||
<Table
|
||||
dataSource={results}
|
||||
@@ -417,7 +411,6 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情展开 */}
|
||||
{expandedResult && (
|
||||
<div style={{
|
||||
border: wt.sectionBorder,
|
||||
@@ -437,9 +430,9 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
}}>
|
||||
<span>
|
||||
<DatabaseOutlined style={{ marginRight: 6 }} />
|
||||
{expandedResult.tableName} — 匹配行详情
|
||||
{t('find_in_database.detail.title', { table: expandedResult.tableName })}
|
||||
</span>
|
||||
<Tag color="blue">{expandedResult.rows.length} 行</Tag>
|
||||
<Tag color="blue">{t('find_in_database.detail.row_count', { count: expandedResult.rows.length })}</Tag>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={expandedResult.rows.map((row, i) => ({ ...row, __rowIdx: i }))}
|
||||
@@ -453,9 +446,8 @@ const FindInDatabaseModal: React.FC<FindInDatabaseModalProps> = ({ open, onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无结果且搜索完成 */}
|
||||
{!searching && results.length === 0 && progress.total > 0 && (
|
||||
<Empty description="未找到匹配的数据" style={{ margin: '24px 0' }} />
|
||||
<Empty description={t('find_in_database.message.no_matches')} style={{ margin: '24px 0' }} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
169
frontend/src/components/ImportPreviewModal.test.tsx
Normal file
169
frontend/src/components/ImportPreviewModal.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import ImportPreviewModal from "./ImportPreviewModal";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
previewImportFile: vi.fn(),
|
||||
importDataWithProgress: vi.fn(),
|
||||
eventsOn: vi.fn(() => vi.fn()),
|
||||
eventsOff: vi.fn(),
|
||||
storeState: {
|
||||
connections: [
|
||||
{
|
||||
id: "conn-1",
|
||||
config: {
|
||||
type: "mysql",
|
||||
host: "localhost",
|
||||
port: 3306,
|
||||
user: "root",
|
||||
password: "",
|
||||
database: "app",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState),
|
||||
}));
|
||||
|
||||
vi.mock("../i18n/runtime", () => ({
|
||||
applyDayjsLocale: vi.fn(),
|
||||
syncLanguageRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../wailsjs/go/app/App", () => ({
|
||||
PreviewImportFile: mocks.previewImportFile,
|
||||
ImportDataWithProgress: mocks.importDataWithProgress,
|
||||
}));
|
||||
|
||||
vi.mock("../../wailsjs/runtime/runtime", () => ({
|
||||
EventsOn: mocks.eventsOn,
|
||||
EventsOff: mocks.eventsOff,
|
||||
}));
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react");
|
||||
const Modal = ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
open?: boolean;
|
||||
title?: React.ReactNode;
|
||||
}) => (open ? React.createElement("section", null, title, children, footer) : null);
|
||||
const Table = ({ columns, dataSource }: { columns?: any[]; dataSource?: any[] }) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
null,
|
||||
columns?.map((column) => React.createElement("span", { key: column.key || column.dataIndex }, column.title)),
|
||||
dataSource?.map((row, index) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ key: index },
|
||||
Object.values(row).map((value, valueIndex) =>
|
||||
React.createElement("span", { key: valueIndex }, String(value)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
Modal,
|
||||
Table,
|
||||
Alert: ({ message, description }: { message?: React.ReactNode; description?: React.ReactNode }) =>
|
||||
React.createElement("div", null, message, description),
|
||||
Progress: ({ percent }: { percent: number }) => React.createElement("div", null, `${percent}%`),
|
||||
Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) =>
|
||||
React.createElement("button", { onClick }, children),
|
||||
Space: ({ children }: { children?: React.ReactNode }) => React.createElement("div", null, children),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@ant-design/icons", async () => {
|
||||
const React = await import("react");
|
||||
const Icon = () => React.createElement("span", null);
|
||||
return {
|
||||
CheckCircleOutlined: Icon,
|
||||
CloseCircleOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
const textContent = (node: any): string => {
|
||||
if (node === null || node === undefined) return "";
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map((item) => textContent(item)).join("");
|
||||
return textContent(node.children || []);
|
||||
};
|
||||
|
||||
const renderImportPreview = async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
|
||||
<ImportPreviewModal
|
||||
visible
|
||||
filePath="D:/imports/users.csv"
|
||||
connectionId="conn-1"
|
||||
dbName="app"
|
||||
tableName="users"
|
||||
onClose={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
return renderer;
|
||||
};
|
||||
|
||||
describe("ImportPreviewModal i18n", () => {
|
||||
beforeEach(() => {
|
||||
mocks.previewImportFile.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
columns: ["id", "user_name"],
|
||||
totalRows: 12,
|
||||
previewRows: [{ id: 1, user_name: "alice" }],
|
||||
},
|
||||
});
|
||||
mocks.importDataWithProgress.mockReset();
|
||||
mocks.eventsOn.mockClear();
|
||||
mocks.eventsOff.mockClear();
|
||||
});
|
||||
|
||||
it("renders preview chrome in the active language while preserving raw column names", async () => {
|
||||
const renderer = await renderImportPreview();
|
||||
const renderedText = textContent(renderer.toJSON());
|
||||
|
||||
expect(renderedText).toContain("Import data preview");
|
||||
expect(renderedText).toContain("12 rows and 2 fields");
|
||||
expect(renderedText).toContain("The first 5 rows are shown below. Start the import after confirming the data.");
|
||||
expect(renderedText).toContain("Field list:");
|
||||
expect(renderedText).toContain("Data preview (first 5 rows):");
|
||||
expect(renderedText).toContain("Cancel");
|
||||
expect(renderedText).toContain("Start import");
|
||||
expect(renderedText).toContain("id");
|
||||
expect(renderedText).toContain("user_name");
|
||||
});
|
||||
|
||||
it("does not keep migrated Chinese UI literals in ImportPreviewModal source", () => {
|
||||
const source = readFileSync(new URL("./ImportPreviewModal.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toContain("导入数据预览");
|
||||
expect(source).not.toContain("开始导入");
|
||||
expect(source).not.toContain("加载预览数据...");
|
||||
expect(source).not.toContain("字段列表:");
|
||||
expect(source).not.toContain("数据预览(前 5 行):");
|
||||
expect(source).not.toContain("正在导入数据...");
|
||||
expect(source).not.toContain("错误日志:");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App';
|
||||
import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime';
|
||||
import { useStore } from '../store';
|
||||
import { useI18n } from '../i18n/provider';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
interface ImportPreviewModalProps {
|
||||
visible: boolean;
|
||||
@@ -37,6 +38,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const connections = useStore(state => state.connections);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [previewData, setPreviewData] = useState<PreviewData | null>(null);
|
||||
@@ -74,10 +76,10 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
previewRows: res.data.previewRows || []
|
||||
});
|
||||
} else {
|
||||
setError(res.message || '预览失败');
|
||||
setError(res.message || t('import_preview.error.preview_failed'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError('预览失败: ' + e.message);
|
||||
setError(t('import_preview.error.preview_failed_detail', { detail: String(e?.message || e) }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -93,7 +95,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
try {
|
||||
const conn = connections.find(c => c.id === connectionId);
|
||||
if (!conn) {
|
||||
setError('连接配置未找到');
|
||||
setError(t('import_preview.error.connection_config_not_found'));
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
@@ -115,10 +117,10 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
setError(res.message || '导入失败');
|
||||
setError(res.message || t('import_preview.error.import_failed'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError('导入失败: ' + e.message);
|
||||
setError(t('import_preview.error.import_failed_detail', { detail: String(e?.message || e) }));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -136,24 +138,24 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="导入数据预览"
|
||||
title={t('import_preview.title')}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={900}
|
||||
footer={
|
||||
importResult ? (
|
||||
<Space>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<Button onClick={onClose}>{t('common.close')}</Button>
|
||||
</Space>
|
||||
) : importing ? null : (
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleImport}
|
||||
disabled={!previewData || loading}
|
||||
>
|
||||
开始导入
|
||||
{t('import_preview.action.start')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
@@ -161,22 +163,22 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
>
|
||||
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} showIcon />}
|
||||
|
||||
{loading && <div style={{ textAlign: 'center', padding: 40 }}>加载预览数据...</div>}
|
||||
{loading && <div style={{ textAlign: 'center', padding: 40 }}>{t('import_preview.status.loading_preview')}</div>}
|
||||
|
||||
{!loading && previewData && !importing && !importResult && (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
message={`共 ${previewData.totalRows} 行数据,${previewData.columns.length} 个字段`}
|
||||
description='以下是前 5 行预览数据,确认无误后点击“开始导入”'
|
||||
message={t('import_preview.preview.summary', { rows: previewData.totalRows, columns: previewData.columns.length })}
|
||||
description={t('import_preview.preview.description')}
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>字段列表:</div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t('import_preview.preview.field_list')}</div>
|
||||
<div style={{ marginBottom: 16, padding: 8, background: '#f5f5f5', borderRadius: 4 }}>
|
||||
{previewData.columns.join(', ')}
|
||||
</div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>数据预览(前 5 行):</div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t('import_preview.preview.table_title')}</div>
|
||||
<Table
|
||||
dataSource={previewData.previewRows}
|
||||
columns={columns}
|
||||
@@ -191,17 +193,17 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
{importing && progress && (
|
||||
<div style={{ padding: '40px 20px' }}>
|
||||
<div style={{ marginBottom: 16, fontSize: 16, fontWeight: 600, textAlign: 'center' }}>
|
||||
正在导入数据...
|
||||
{t('import_preview.status.importing')}
|
||||
</div>
|
||||
<Progress percent={progressPercent} status="active" />
|
||||
<div style={{ marginTop: 16, textAlign: 'center', color: '#666' }}>
|
||||
已处理 {progress.current} / {progress.total} 行
|
||||
{t('import_preview.progress.processed_rows', { current: progress.current, total: progress.total })}
|
||||
<span style={{ marginLeft: 16, color: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> 成功 {progress.success}
|
||||
<CheckCircleOutlined /> {t('import_preview.progress.success_count', { count: progress.success })}
|
||||
</span>
|
||||
{progress.errors > 0 && (
|
||||
<span style={{ marginLeft: 16, color: '#ff4d4f' }}>
|
||||
<CloseCircleOutlined /> 失败 {progress.errors}
|
||||
<CloseCircleOutlined /> {t('import_preview.progress.error_count', { count: progress.errors })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -212,11 +214,11 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
<div style={{ padding: 20 }}>
|
||||
<Alert
|
||||
type={importResult.failed === 0 ? 'success' : 'warning'}
|
||||
message="导入完成"
|
||||
message={t('import_preview.result.completed')}
|
||||
description={
|
||||
<div>
|
||||
<div>成功导入 {importResult.success} 行</div>
|
||||
{importResult.failed > 0 && <div>失败 {importResult.failed} 行</div>}
|
||||
<div>{t('import_preview.result.success_rows', { count: importResult.success })}</div>
|
||||
{importResult.failed > 0 && <div>{t('import_preview.result.failed_rows', { count: importResult.failed })}</div>}
|
||||
</div>
|
||||
}
|
||||
showIcon
|
||||
@@ -224,7 +226,7 @@ const ImportPreviewModal: React.FC<ImportPreviewModalProps> = ({
|
||||
/>
|
||||
{importResult.errorLogs && importResult.errorLogs.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}>错误日志:</div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600, color: '#ff4d4f' }}>{t('import_preview.result.error_logs')}</div>
|
||||
<div style={{
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
|
||||
@@ -1,48 +1,174 @@
|
||||
import React from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import type { LanguagePreference } from "../i18n/types";
|
||||
import JVMAuditViewer from "./JVMAuditViewer";
|
||||
|
||||
const mockState = {
|
||||
connections: [
|
||||
{
|
||||
id: "conn-jvm-1",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "localhost",
|
||||
port: 10990,
|
||||
jvm: {
|
||||
preferredMode: "endpoint",
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as any[],
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
const backendApp = {
|
||||
JVMListAuditRecords: vi.fn(),
|
||||
};
|
||||
const source = readFileSync(new URL("./JVMAuditViewer.tsx", import.meta.url), "utf8");
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: any) => any) =>
|
||||
selector({
|
||||
connections: [
|
||||
{
|
||||
id: "conn-jvm-1",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "localhost",
|
||||
port: 10990,
|
||||
jvm: {
|
||||
preferredMode: "endpoint",
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
theme: "light",
|
||||
connections: mockState.connections,
|
||||
theme: mockState.theme,
|
||||
}),
|
||||
}));
|
||||
|
||||
const tab = {
|
||||
id: "tab-jvm-audit",
|
||||
type: "jvm-audit",
|
||||
title: "[orders-jvm] JVM 审计",
|
||||
connectionId: "conn-jvm-1",
|
||||
providerMode: "endpoint",
|
||||
} as any;
|
||||
|
||||
const renderWithI18n = (node: React.ReactNode, preference: LanguagePreference = "en-US") => (
|
||||
<I18nProvider
|
||||
preference={preference}
|
||||
systemLanguages={[preference]}
|
||||
onPreferenceChange={vi.fn()}
|
||||
>
|
||||
{node}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.connections = [
|
||||
{
|
||||
id: "conn-jvm-1",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "localhost",
|
||||
port: 10990,
|
||||
jvm: {
|
||||
preferredMode: "endpoint",
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockState.theme = "light";
|
||||
backendApp.JVMListAuditRecords.mockReset();
|
||||
vi.stubGlobal("window", {
|
||||
go: {
|
||||
app: {
|
||||
App: backendApp,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("JVMAuditViewer", () => {
|
||||
it("renders a unified JVM workspace audit shell", () => {
|
||||
it("renders a localized en-US JVM workspace audit shell", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMAuditViewer
|
||||
tab={{
|
||||
id: "tab-jvm-audit",
|
||||
type: "jvm-audit",
|
||||
title: "[orders-jvm] JVM 审计",
|
||||
connectionId: "conn-jvm-1",
|
||||
providerMode: "endpoint",
|
||||
} as any}
|
||||
/>,
|
||||
renderWithI18n(<JVMAuditViewer tab={tab} />),
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-jvm-workspace-shell="true"');
|
||||
expect(markup).toContain('data-jvm-workspace-hero="true"');
|
||||
expect(markup).toContain("JVM 变更审计");
|
||||
expect(markup).toContain("审计记录");
|
||||
expect(markup).toContain("最近 50 条");
|
||||
[
|
||||
t("jvm_audit.eyebrow", undefined, "en-US"),
|
||||
t("jvm_audit.title", undefined, "en-US"),
|
||||
t("jvm_audit.card.records", undefined, "en-US"),
|
||||
t("jvm_audit.description.current_range", { limit: 50 }, "en-US"),
|
||||
t("jvm_audit.action.refresh", undefined, "en-US"),
|
||||
t("jvm_audit.option.last_records", { limit: 50 }, "en-US"),
|
||||
t("jvm_audit.column.time", undefined, "en-US"),
|
||||
t("jvm_audit.column.mode", undefined, "en-US"),
|
||||
t("jvm_audit.column.action", undefined, "en-US"),
|
||||
t("jvm_audit.column.resource", undefined, "en-US"),
|
||||
t("jvm_audit.column.reason", undefined, "en-US"),
|
||||
t("jvm_audit.column.source", undefined, "en-US"),
|
||||
t("jvm_audit.column.result", undefined, "en-US"),
|
||||
t("jvm_audit.empty.no_records", undefined, "en-US"),
|
||||
].forEach((snippet) => {
|
||||
expect(markup).toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
"JVM 变更审计",
|
||||
"审计记录",
|
||||
"当前范围",
|
||||
"最近 50 条",
|
||||
"刷新",
|
||||
"时间",
|
||||
"模式",
|
||||
"动作",
|
||||
"资源",
|
||||
"原因",
|
||||
"来源",
|
||||
"结果",
|
||||
"暂无审计记录",
|
||||
].forEach((snippet) => {
|
||||
expect(markup).not.toContain(snippet);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the missing connection empty state in en-US", () => {
|
||||
mockState.connections = [];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
renderWithI18n(<JVMAuditViewer tab={tab} />),
|
||||
);
|
||||
|
||||
expect(markup).toContain(
|
||||
t("jvm_audit.error.connection_missing", undefined, "en-US"),
|
||||
);
|
||||
expect(markup).not.toContain("连接不存在或已被删除");
|
||||
});
|
||||
|
||||
it("wires non-SSR audit wrappers and source tags through existing i18n keys", () => {
|
||||
[
|
||||
"AI 辅助",
|
||||
"手工",
|
||||
"JVMListAuditRecords 后端方法不可用",
|
||||
"读取 JVM 审计记录失败",
|
||||
"当前无法加载审计记录",
|
||||
].forEach((snippet) => {
|
||||
expect(source).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
"jvm_audit.source.ai_plan",
|
||||
"jvm_audit.source.manual",
|
||||
"jvm_audit.error.backend_unavailable",
|
||||
"jvm_audit.error.load_failed",
|
||||
"jvm_audit.empty.load_failed",
|
||||
].forEach((key) => {
|
||||
expect(source).toContain(key);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the active language to action and result presentation helpers", () => {
|
||||
expect(source).toContain("language } = useI18n()");
|
||||
expect(source).toContain("formatTimestamp(value, language)");
|
||||
expect(source).toContain("formatJVMActionDisplayText(value, language)");
|
||||
expect(source).toContain("formatJVMAuditResultLabel(value, language)");
|
||||
expect(source).not.toContain('toLocaleString("zh-CN"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
|
||||
import { useI18n } from "../i18n/provider";
|
||||
import { useStore } from "../store";
|
||||
import type { JVMAuditRecord, TabData } from "../types";
|
||||
import {
|
||||
@@ -63,7 +64,7 @@ const filterAuditRecordsByMode = (
|
||||
);
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number): string => {
|
||||
const formatTimestamp = (timestamp: number, language?: string): string => {
|
||||
if (!timestamp) {
|
||||
return "-";
|
||||
}
|
||||
@@ -72,10 +73,11 @@ const formatTimestamp = (timestamp: number): string => {
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(timestamp);
|
||||
}
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
return date.toLocaleString(language || "zh-CN", { hour12: false });
|
||||
};
|
||||
|
||||
const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
const { t, language } = useI18n();
|
||||
const connection = useStore((state) =>
|
||||
state.connections.find((item) => item.id === tab.connectionId),
|
||||
);
|
||||
@@ -86,17 +88,25 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
const [records, setRecords] = useState<JVMAuditRecord[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const formatLoadFailedError = (detail?: unknown): string => {
|
||||
const normalizedDetail = String(detail || "").trim();
|
||||
return t("jvm_audit.error.load_failed", {
|
||||
separator: normalizedDetail ? ": " : "",
|
||||
detail: normalizedDetail,
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnsType<JVMAuditRecord>>(
|
||||
() => [
|
||||
{
|
||||
title: "时间",
|
||||
title: t("jvm_audit.column.time"),
|
||||
dataIndex: "timestamp",
|
||||
key: "timestamp",
|
||||
width: 180,
|
||||
render: (value: number) => formatTimestamp(value),
|
||||
render: (value: number) => formatTimestamp(value, language),
|
||||
},
|
||||
{
|
||||
title: "模式",
|
||||
title: t("jvm_audit.column.mode"),
|
||||
dataIndex: "providerMode",
|
||||
key: "providerMode",
|
||||
width: 120,
|
||||
@@ -105,28 +115,29 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "动作",
|
||||
title: t("jvm_audit.column.action"),
|
||||
dataIndex: "action",
|
||||
key: "action",
|
||||
width: 160,
|
||||
render: (value: string) => formatJVMActionDisplayText(value) || "-",
|
||||
render: (value: string) =>
|
||||
formatJVMActionDisplayText(value, language) || "-",
|
||||
},
|
||||
{
|
||||
title: "资源",
|
||||
title: t("jvm_audit.column.resource"),
|
||||
dataIndex: "resourceId",
|
||||
key: "resourceId",
|
||||
ellipsis: true,
|
||||
render: (value: string) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "原因",
|
||||
title: t("jvm_audit.column.reason"),
|
||||
dataIndex: "reason",
|
||||
key: "reason",
|
||||
ellipsis: true,
|
||||
render: (value: string) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "来源",
|
||||
title: t("jvm_audit.column.source"),
|
||||
dataIndex: "source",
|
||||
key: "source",
|
||||
width: 120,
|
||||
@@ -135,31 +146,31 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "ai-plan") {
|
||||
return <Tag color="purple">AI 辅助</Tag>;
|
||||
return <Tag color="purple">{t("jvm_audit.source.ai_plan")}</Tag>;
|
||||
}
|
||||
return <Tag>手工</Tag>;
|
||||
return <Tag>{t("jvm_audit.source.manual")}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "结果",
|
||||
title: t("jvm_audit.column.result"),
|
||||
dataIndex: "result",
|
||||
key: "result",
|
||||
width: 140,
|
||||
render: (value: string) => (
|
||||
<Tag color={resolveJVMAuditResultColor(value)}>
|
||||
{formatJVMAuditResultLabel(value)}
|
||||
{formatJVMAuditResultLabel(value, language)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
],
|
||||
[tab.providerMode],
|
||||
[language, tab.providerMode, t],
|
||||
);
|
||||
|
||||
const loadRecords = async () => {
|
||||
if (!connection) {
|
||||
setLoading(false);
|
||||
setRecords([]);
|
||||
setError("连接不存在或已被删除");
|
||||
setError(t("jvm_audit.error.connection_missing"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,7 +178,7 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
if (typeof backendApp?.JVMListAuditRecords !== "function") {
|
||||
setLoading(false);
|
||||
setRecords([]);
|
||||
setError("JVMListAuditRecords 后端方法不可用");
|
||||
setError(t("jvm_audit.error.backend_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,7 +188,7 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
const result = await backendApp.JVMListAuditRecords(connection.id, limit);
|
||||
if (result?.success === false) {
|
||||
setRecords([]);
|
||||
setError(String(result?.message || "读取 JVM 审计记录失败"));
|
||||
setError(formatLoadFailedError(result?.message));
|
||||
return;
|
||||
}
|
||||
setRecords(
|
||||
@@ -188,7 +199,11 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
);
|
||||
} catch (err: any) {
|
||||
setRecords([]);
|
||||
setError(err?.message || "读取 JVM 审计记录失败");
|
||||
setError(
|
||||
formatLoadFailedError(
|
||||
err?.message || (typeof err === "string" ? err : ""),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -196,11 +211,14 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecords();
|
||||
}, [connection, limit, tab.connectionId]);
|
||||
}, [connection, limit, tab.connectionId, tab.providerMode, t]);
|
||||
|
||||
if (!connection) {
|
||||
return (
|
||||
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
|
||||
<Empty
|
||||
description={t("jvm_audit.error.connection_missing")}
|
||||
style={{ marginTop: 64 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -212,13 +230,16 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
<JVMWorkspaceShell darkMode={darkMode}>
|
||||
<JVMWorkspaceHero
|
||||
darkMode={darkMode}
|
||||
eyebrow="JVM Audit"
|
||||
title="JVM 变更审计"
|
||||
eyebrow={t("jvm_audit.eyebrow")}
|
||||
title={t("jvm_audit.title")}
|
||||
description={
|
||||
<>
|
||||
<Text strong>{connection.name}</Text>
|
||||
<Text type="secondary"> · {connection.id}</Text>
|
||||
<Text type="secondary"> · 当前范围:最近 {limit} 条</Text>
|
||||
<Text type="secondary">
|
||||
{" · "}
|
||||
{t("jvm_audit.description.current_range", { limit })}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
badges={<JVMModeBadge mode={activeMode} />}
|
||||
@@ -229,7 +250,7 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => void loadRecords()}
|
||||
>
|
||||
刷新
|
||||
{t("jvm_audit.action.refresh")}
|
||||
</Button>
|
||||
<Select
|
||||
size="small"
|
||||
@@ -237,7 +258,7 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
onChange={setLimit}
|
||||
options={LIMIT_OPTIONS.map((item) => ({
|
||||
value: item,
|
||||
label: `最近 ${item} 条`,
|
||||
label: t("jvm_audit.option.last_records", { limit: item }),
|
||||
}))}
|
||||
style={{ width: 132 }}
|
||||
/>
|
||||
@@ -245,7 +266,11 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="审计记录" variant="borderless" style={cardStyle}>
|
||||
<Card
|
||||
title={t("jvm_audit.card.records")}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{error ? <Alert type="error" showIcon message={error} /> : null}
|
||||
<Table<JVMAuditRecord>
|
||||
@@ -257,7 +282,9 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
dataSource={records}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: error ? "当前无法加载审计记录" : "暂无审计记录",
|
||||
emptyText: error
|
||||
? t("jvm_audit.empty.load_failed")
|
||||
: t("jvm_audit.empty.no_records"),
|
||||
}}
|
||||
scroll={{ x: 960 }}
|
||||
size="small"
|
||||
|
||||
@@ -9,8 +9,18 @@ import JVMDiagnosticConsole, {
|
||||
createJVMDiagnosticRunningRecord,
|
||||
isJVMDiagnosticTerminalPhase,
|
||||
} from "./JVMDiagnosticConsole";
|
||||
import { setCurrentLanguage } from "../i18n";
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import type { SupportedLanguage } from "../i18n/types";
|
||||
|
||||
const baseState = {
|
||||
appearance: {
|
||||
uiVersion: "legacy",
|
||||
dataTableFontSize: 14,
|
||||
dataTableFontSizeFollowGlobal: true,
|
||||
customMonoFontFamily: "",
|
||||
},
|
||||
fontSize: 14,
|
||||
connections: [
|
||||
{
|
||||
id: "conn-1",
|
||||
@@ -36,9 +46,13 @@ const baseState = {
|
||||
let mockState: any = baseState;
|
||||
let registeredCompletionProvider: any = null;
|
||||
let registeredDiagnosticChunkHandler: any = null;
|
||||
let registeredApplyDiagnosticPlanHandler: any = null;
|
||||
const mockBackendApp = {
|
||||
JVMListDiagnosticAuditRecords: vi.fn(),
|
||||
JVMProbeDiagnosticCapabilities: vi.fn(),
|
||||
JVMStartDiagnosticSession: vi.fn(),
|
||||
JVMExecuteDiagnosticCommand: vi.fn(),
|
||||
JVMCancelDiagnosticCommand: vi.fn(),
|
||||
};
|
||||
const mockMonaco = {
|
||||
Range: class {
|
||||
@@ -62,6 +76,7 @@ const mockMonaco = {
|
||||
KeyMod: { CtrlCmd: 2048 },
|
||||
KeyCode: { Enter: 3 },
|
||||
editor: {
|
||||
defineTheme: vi.fn(),
|
||||
setTheme: vi.fn(),
|
||||
},
|
||||
languages: {
|
||||
@@ -168,10 +183,62 @@ vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: any) => any) => selector(mockState),
|
||||
}));
|
||||
|
||||
const renderConsoleWithLanguage = (
|
||||
language: SupportedLanguage,
|
||||
state: any = baseState,
|
||||
) => {
|
||||
mockState = state;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider
|
||||
preference={language}
|
||||
systemLanguages={[language]}
|
||||
onPreferenceChange={vi.fn()}
|
||||
>
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
const createConsoleWithLanguage = async (
|
||||
language: SupportedLanguage,
|
||||
state: any = baseState,
|
||||
) => {
|
||||
mockState = state;
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<I18nProvider
|
||||
preference={language}
|
||||
systemLanguages={[language]}
|
||||
onPreferenceChange={vi.fn()}
|
||||
>
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
return renderer;
|
||||
};
|
||||
|
||||
describe("JVMDiagnosticConsole", () => {
|
||||
beforeEach(() => {
|
||||
setCurrentLanguage("zh-CN");
|
||||
registeredCompletionProvider = null;
|
||||
registeredDiagnosticChunkHandler = null;
|
||||
registeredApplyDiagnosticPlanHandler = null;
|
||||
mockState = {
|
||||
...baseState,
|
||||
setJVMDiagnosticDraft: vi.fn(),
|
||||
@@ -182,17 +249,25 @@ describe("JVMDiagnosticConsole", () => {
|
||||
success: true,
|
||||
data: [],
|
||||
});
|
||||
mockBackendApp.JVMProbeDiagnosticCapabilities.mockReset();
|
||||
mockBackendApp.JVMStartDiagnosticSession.mockReset();
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReset();
|
||||
mockBackendApp.JVMCancelDiagnosticCommand.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(),
|
||||
addEventListener: vi.fn((eventName: string, handler: any) => {
|
||||
if (eventName === "gonavi:jvm-apply-diagnostic-plan") {
|
||||
registeredApplyDiagnosticPlanHandler = handler;
|
||||
}
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
};
|
||||
mockMonaco.editor.setTheme.mockClear();
|
||||
mockMonaco.editor.defineTheme.mockClear();
|
||||
mockMonaco.languages.register.mockClear();
|
||||
mockMonaco.languages.registerCompletionItemProvider.mockClear();
|
||||
mockEditor.addCommand.mockClear();
|
||||
@@ -235,6 +310,37 @@ describe("JVMDiagnosticConsole", () => {
|
||||
expect(isJVMDiagnosticTerminalPhase("running")).toBe(false);
|
||||
});
|
||||
|
||||
it("localizes local pending output while preserving raw command text", () => {
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
const chunk = createJVMDiagnosticLocalPendingChunk({
|
||||
sessionId: "session-1",
|
||||
commandId: "cmd-1",
|
||||
command: "thread -n 5",
|
||||
});
|
||||
|
||||
expect(chunk.content).toBe(
|
||||
"Diagnostic command submitted; waiting for backend output: thread -n 5",
|
||||
);
|
||||
expect(chunk.content).toContain("thread -n 5");
|
||||
expect(chunk.content).not.toContain("已提交诊断命令");
|
||||
expect(chunk.content).not.toContain("{{command}}");
|
||||
});
|
||||
|
||||
it("preserves command placeholder-like text as raw local pending output", () => {
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
const chunk = createJVMDiagnosticLocalPendingChunk({
|
||||
sessionId: "session-1",
|
||||
commandId: "cmd-1",
|
||||
command: "echo {{command}}",
|
||||
});
|
||||
|
||||
expect(chunk.content).toBe(
|
||||
"Diagnostic command submitted; waiting for backend output: echo {{command}}",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a stable workbench shell and hides command inputs before session creation", () => {
|
||||
mockState = {
|
||||
...baseState,
|
||||
@@ -262,6 +368,112 @@ describe("JVMDiagnosticConsole", () => {
|
||||
expect(markup).not.toContain('data-monaco-editor-mock="true"');
|
||||
});
|
||||
|
||||
it("localizes the no-session workflow and workbench shell without translating raw connection details", () => {
|
||||
const zhMarkup = renderConsoleWithLanguage("zh-CN", {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {},
|
||||
});
|
||||
const enMarkup = renderConsoleWithLanguage("en-US", {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {},
|
||||
});
|
||||
|
||||
expect(zhMarkup).toContain("JVM 诊断工作台");
|
||||
expect(zhMarkup).toContain("开始一次诊断");
|
||||
expect(zhMarkup).toContain("命令输入将在会话建立后显示");
|
||||
expect(zhMarkup).toContain("只读取诊断通道、流式输出与命令权限,不创建会话。");
|
||||
expect(zhMarkup).toContain("新建诊断会话");
|
||||
expect(zhMarkup).toContain("未建会话");
|
||||
expect(zhMarkup).toContain("检查能力");
|
||||
|
||||
expect(enMarkup).toContain("JVM diagnostic workbench");
|
||||
expect(enMarkup).toContain("Start a diagnostic session");
|
||||
expect(enMarkup).toContain("Command input appears after a session is created");
|
||||
expect(enMarkup).toContain("Read diagnostic transport, streaming output, and command permissions without creating a session.");
|
||||
expect(enMarkup).toContain("Start diagnostic session");
|
||||
expect(enMarkup).toContain("No session");
|
||||
expect(enMarkup).toContain("Check capabilities");
|
||||
expect(enMarkup).not.toContain("开始一次诊断");
|
||||
expect(enMarkup).not.toContain("命令输入将在会话建立后显示");
|
||||
|
||||
expect(enMarkup).toContain("orders-jvm");
|
||||
expect(enMarkup).toContain("orders.internal");
|
||||
expect(enMarkup).toContain("Agent Bridge");
|
||||
});
|
||||
|
||||
it("localizes session capability and capability results while keeping transport labels raw", async () => {
|
||||
mockState = {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockBackendApp.JVMProbeDiagnosticCapabilities.mockResolvedValue({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
transport: "agent-bridge",
|
||||
canOpenSession: true,
|
||||
canStream: false,
|
||||
allowObserveCommands: false,
|
||||
allowTraceCommands: true,
|
||||
allowMutatingCommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let renderer: any;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<I18nProvider
|
||||
preference="en-US"
|
||||
systemLanguages={["en-US"]}
|
||||
onPreferenceChange={vi.fn()}
|
||||
>
|
||||
<JVMDiagnosticConsole
|
||||
tab={{
|
||||
id: "tab-1",
|
||||
title: "诊断增强",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-1",
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const beforeProbe = JSON.stringify(renderer.toJSON());
|
||||
expect(beforeProbe).toContain("Session and capabilities");
|
||||
expect(beforeProbe).toContain("Session established");
|
||||
expect(beforeProbe).toContain("Idle");
|
||||
expect(beforeProbe).toContain("Clear output");
|
||||
expect(beforeProbe).toContain("Refresh history");
|
||||
expect(beforeProbe).toContain("No capability check yet");
|
||||
expect(beforeProbe).not.toContain("会话与能力");
|
||||
expect(beforeProbe).toContain("session-1");
|
||||
expect(beforeProbe).toContain("Agent Bridge");
|
||||
|
||||
const probeButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Check capabilities"));
|
||||
await act(async () => {
|
||||
probeButton.props.onClick();
|
||||
});
|
||||
|
||||
const afterProbe = JSON.stringify(renderer.toJSON());
|
||||
expect(afterProbe).toContain("Capability check results");
|
||||
expect(afterProbe).toContain("Can start sessions");
|
||||
expect(afterProbe).toContain("Streaming unsupported");
|
||||
expect(afterProbe).toContain("Observe disabled");
|
||||
expect(afterProbe).toContain("Trace commands");
|
||||
expect(afterProbe).toContain("High-risk commands");
|
||||
expect(afterProbe).toContain("Agent Bridge");
|
||||
expect(afterProbe).not.toContain("能力检查结果");
|
||||
});
|
||||
|
||||
it("shows command input, reason field, and presets after a session exists", () => {
|
||||
mockState = {
|
||||
...baseState,
|
||||
@@ -302,6 +514,324 @@ describe("JVMDiagnosticConsole", () => {
|
||||
expect(markup).toContain('data-language="jvm-diagnostic"');
|
||||
});
|
||||
|
||||
it("localizes command input and command template cards while preserving raw command details", () => {
|
||||
const sessionState = {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
reason: "排查 CPU 线程",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const zhMarkup = renderConsoleWithLanguage("zh-CN", sessionState);
|
||||
const enMarkup = renderConsoleWithLanguage("en-US", sessionState);
|
||||
|
||||
expect(zhMarkup).toContain("命令输入");
|
||||
expect(zhMarkup).toContain("支持自动补全,按 Ctrl/Cmd + Enter 执行");
|
||||
expect(zhMarkup).toContain("诊断命令");
|
||||
expect(zhMarkup).toContain("输入 Arthas/诊断命令,例如 thread -n 5、dashboard、jvm;也可以从下方模板一键回填。");
|
||||
expect(zhMarkup).toContain("诊断原因(可选)");
|
||||
expect(zhMarkup).toContain("例如:排查 CPU 飙高、确认线程阻塞、定位慢方法");
|
||||
expect(zhMarkup).toContain("用于审计记录和 AI 上下文理解,不会作为 Arthas 命令发送到目标 JVM。");
|
||||
expect(zhMarkup).toContain("命令模板");
|
||||
|
||||
expect(enMarkup).toContain("Command input");
|
||||
expect(enMarkup).toContain("Supports autocomplete. Press Ctrl/Cmd + Enter to run.");
|
||||
expect(enMarkup).toContain("Diagnostic command");
|
||||
expect(enMarkup).toContain("Enter an Arthas/diagnostic command, for example thread -n 5, dashboard, or jvm; templates below can fill this in with one click.");
|
||||
expect(enMarkup).toContain("Reason (optional)");
|
||||
expect(enMarkup).toContain("For example: investigate high CPU, confirm blocked threads, or locate a slow method");
|
||||
expect(enMarkup).toContain("Used for audit records and AI context. It is not sent to the target JVM as an Arthas command.");
|
||||
expect(enMarkup).toContain("Command templates");
|
||||
expect(enMarkup).not.toContain("诊断原因(可选)");
|
||||
expect(enMarkup).not.toContain("命令模板");
|
||||
|
||||
expect(enMarkup).toContain("thread -n 5");
|
||||
expect(enMarkup).toContain("dashboard");
|
||||
expect(enMarkup).toContain("jvm");
|
||||
expect(enMarkup).toContain("Arthas");
|
||||
expect(enMarkup).toContain("jvm-diagnostic");
|
||||
expect(enMarkup).toContain("orders-jvm");
|
||||
});
|
||||
|
||||
it("localizes remaining output, history, and missing-connection chrome", () => {
|
||||
const sessionState = {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
};
|
||||
const enMarkup = renderConsoleWithLanguage("en-US", sessionState);
|
||||
const missingConnectionMarkup = renderConsoleWithLanguage("en-US", {
|
||||
...baseState,
|
||||
connections: [],
|
||||
});
|
||||
|
||||
expect(enMarkup).toContain("Live output");
|
||||
expect(enMarkup).toContain("Appended from backend event stream");
|
||||
expect(enMarkup).toContain("Audit history");
|
||||
expect(enMarkup).toContain("Recent commands and execution status");
|
||||
expect(enMarkup).toContain("session-1");
|
||||
expect(enMarkup).not.toContain("按后端事件流追加显示");
|
||||
expect(enMarkup).not.toContain("审计历史");
|
||||
expect(missingConnectionMarkup).toContain(
|
||||
"Connection does not exist or has been deleted",
|
||||
);
|
||||
expect(missingConnectionMarkup).not.toContain("连接不存在或已被删除");
|
||||
});
|
||||
|
||||
it("localizes JVM diagnostic operation fallbacks and default session metadata", async () => {
|
||||
mockBackendApp.JVMListDiagnosticAuditRecords.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: "",
|
||||
});
|
||||
let renderer = await createConsoleWithLanguage("en-US", {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {},
|
||||
});
|
||||
expect(JSON.stringify(renderer.toJSON())).toContain(
|
||||
"Failed to load diagnostic history",
|
||||
);
|
||||
|
||||
mockBackendApp.JVMProbeDiagnosticCapabilities.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: "",
|
||||
});
|
||||
const probeButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Check capabilities"));
|
||||
await act(async () => {
|
||||
probeButton.props.onClick();
|
||||
});
|
||||
expect(JSON.stringify(renderer.toJSON())).toContain(
|
||||
"Failed to check diagnostic capabilities",
|
||||
);
|
||||
|
||||
mockBackendApp.JVMStartDiagnosticSession.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { sessionId: "session-created", transport: "agent-bridge" },
|
||||
});
|
||||
const startButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Start diagnostic session"));
|
||||
await act(async () => {
|
||||
startButton.props.onClick();
|
||||
});
|
||||
expect(mockBackendApp.JVMStartDiagnosticSession.mock.calls[0][1]).toMatchObject({
|
||||
title: "JVM diagnostic console",
|
||||
reason: "Session started from the console",
|
||||
});
|
||||
|
||||
renderer = await createConsoleWithLanguage("en-US", {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: " ",
|
||||
},
|
||||
},
|
||||
});
|
||||
const executeButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Execute command"));
|
||||
await act(async () => {
|
||||
executeButton.props.onClick();
|
||||
});
|
||||
expect(JSON.stringify(renderer.toJSON())).toContain(
|
||||
"Diagnostic command is required",
|
||||
);
|
||||
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockResolvedValueOnce({
|
||||
success: true,
|
||||
});
|
||||
renderer = await createConsoleWithLanguage("en-US", {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
});
|
||||
const fallbackExecuteButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Execute command"));
|
||||
await act(async () => {
|
||||
fallbackExecuteButton.props.onClick();
|
||||
});
|
||||
const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
|
||||
(call: any[]) => call[1],
|
||||
);
|
||||
expect(JSON.stringify(appendedChunks)).toContain(
|
||||
"Diagnostic command submitted; waiting for backend output: thread -n 5",
|
||||
);
|
||||
expect(JSON.stringify(appendedChunks)).toContain(
|
||||
"The diagnostic command call returned, but no terminal backend event was received. The frontend ended the waiting state as a fallback.",
|
||||
);
|
||||
expect(JSON.stringify(appendedChunks)).toContain("thread -n 5");
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("已提交诊断命令");
|
||||
expect(JSON.stringify(appendedChunks)).not.toContain("诊断命令调用已返回");
|
||||
});
|
||||
|
||||
it("localizes unavailable execute/cancel fallbacks and AI plan messages without translating raw transports", async () => {
|
||||
const renderer = await createConsoleWithLanguage("en-US", {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
registeredApplyDiagnosticPlanHandler({
|
||||
detail: {
|
||||
targetTabId: "tab-1",
|
||||
plan: {
|
||||
transport: "arthas-tunnel",
|
||||
command: "dashboard",
|
||||
reason: "raw AI reason",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const mismatchJson = JSON.stringify(renderer.toJSON());
|
||||
expect(mismatchJson).toContain(
|
||||
"The AI plan diagnostic transport is arthas-tunnel, which does not match the current console agent-bridge. Regenerate the plan before applying it.",
|
||||
);
|
||||
expect(mismatchJson).toContain("arthas-tunnel");
|
||||
expect(mismatchJson).toContain("agent-bridge");
|
||||
expect(mismatchJson).not.toContain("AI 计划的诊断 transport");
|
||||
|
||||
await act(async () => {
|
||||
registeredApplyDiagnosticPlanHandler({
|
||||
detail: {
|
||||
targetTabId: "tab-1",
|
||||
plan: {
|
||||
transport: "agent-bridge",
|
||||
command: "dashboard",
|
||||
reason: "raw AI reason",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(message.success).toHaveBeenCalledWith(
|
||||
"AI diagnostic plan filled into the console",
|
||||
);
|
||||
expect(mockState.setJVMDiagnosticDraft).toHaveBeenCalledWith("tab-1", {
|
||||
command: "dashboard",
|
||||
reason: "raw AI reason",
|
||||
source: "ai-plan",
|
||||
});
|
||||
|
||||
(globalThis as any).window.go.app.App = {
|
||||
...mockBackendApp,
|
||||
JVMExecuteDiagnosticCommand: undefined,
|
||||
};
|
||||
const unavailableRenderer = await createConsoleWithLanguage("en-US", {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
});
|
||||
const executeButton = unavailableRenderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Execute command"));
|
||||
await act(async () => {
|
||||
executeButton.props.onClick();
|
||||
});
|
||||
expect(JSON.stringify(unavailableRenderer.toJSON())).toContain(
|
||||
"JVMExecuteDiagnosticCommand backend method is unavailable",
|
||||
);
|
||||
|
||||
let resolveCommand: (value: any) => void = () => {};
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCommand = resolve;
|
||||
}),
|
||||
);
|
||||
(globalThis as any).window.go.app.App = {
|
||||
...mockBackendApp,
|
||||
JVMCancelDiagnosticCommand: undefined,
|
||||
};
|
||||
const cancelRenderer = await createConsoleWithLanguage("en-US", {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
});
|
||||
const cancelExecuteButton = cancelRenderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Execute command"));
|
||||
await act(async () => {
|
||||
cancelExecuteButton.props.onClick();
|
||||
});
|
||||
const cancelButton = cancelRenderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Cancel command"));
|
||||
await act(async () => {
|
||||
cancelButton.props.onClick();
|
||||
resolveCommand({ success: true });
|
||||
});
|
||||
expect(JSON.stringify(cancelRenderer.toJSON())).toContain(
|
||||
"JVMCancelDiagnosticCommand backend method is unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("localizes successful cancel request message in en-US", async () => {
|
||||
let resolveCommand: (value: any) => void = () => {};
|
||||
mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCommand = resolve;
|
||||
}),
|
||||
);
|
||||
mockBackendApp.JVMCancelDiagnosticCommand.mockResolvedValueOnce({
|
||||
success: true,
|
||||
});
|
||||
const renderer = await createConsoleWithLanguage("en-US", {
|
||||
...baseState,
|
||||
jvmDiagnosticDrafts: {
|
||||
"tab-1": {
|
||||
sessionId: "session-1",
|
||||
command: "thread -n 5",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const executeButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Execute command"));
|
||||
await act(async () => {
|
||||
executeButton.props.onClick();
|
||||
});
|
||||
const cancelButton = renderer.root
|
||||
.findAllByType("button")
|
||||
.find((button: any) => button.children.includes("Cancel command"));
|
||||
await act(async () => {
|
||||
cancelButton.props.onClick();
|
||||
});
|
||||
|
||||
expect(message.info).toHaveBeenCalledWith("Cancel request sent");
|
||||
expect(message.info).not.toHaveBeenCalledWith("已发送取消请求");
|
||||
|
||||
await act(async () => {
|
||||
resolveCommand({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts sensitive diagnostic output in the rendered console", () => {
|
||||
mockState = {
|
||||
...baseState,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import { EventsOn } from "../../wailsjs/runtime";
|
||||
import { t as translate, type I18nParams } from "../i18n";
|
||||
import { useOptionalI18n } from "../i18n/provider";
|
||||
import { useStore } from "../store";
|
||||
import type {
|
||||
JVMDiagnosticAuditRecord,
|
||||
@@ -56,21 +58,27 @@ const DEFAULT_COMMAND =
|
||||
JVM_DIAGNOSTIC_COMMAND_PRESETS.find((item) => item.category === "observe")
|
||||
?.command || "thread -n 5";
|
||||
|
||||
const translateJVMDiagnostic = (
|
||||
key: string,
|
||||
params?: I18nParams,
|
||||
language?: string,
|
||||
): string => translate(key, params, language);
|
||||
|
||||
const DIAGNOSTIC_WORKFLOW_STEPS = [
|
||||
{
|
||||
index: "01",
|
||||
title: "检查能力",
|
||||
description: "只读取诊断通道、流式输出与命令权限,不创建会话。",
|
||||
titleKey: "jvm_diagnostic.workflow.probe.title",
|
||||
descriptionKey: "jvm_diagnostic.workflow.probe.description",
|
||||
},
|
||||
{
|
||||
index: "02",
|
||||
title: "新建会话",
|
||||
description: "创建诊断上下文,后续命令都会绑定到这个会话。",
|
||||
titleKey: "jvm_diagnostic.workflow.session.title",
|
||||
descriptionKey: "jvm_diagnostic.workflow.session.description",
|
||||
},
|
||||
{
|
||||
index: "03",
|
||||
title: "执行命令",
|
||||
description: "会话建立后显示命令编辑器、原因输入与模板。",
|
||||
titleKey: "jvm_diagnostic.workflow.command.title",
|
||||
descriptionKey: "jvm_diagnostic.workflow.command.description",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -156,18 +164,24 @@ export const createJVMDiagnosticLocalPendingChunk = ({
|
||||
sessionId,
|
||||
commandId,
|
||||
command,
|
||||
content,
|
||||
timestamp = Date.now(),
|
||||
}: {
|
||||
sessionId: string;
|
||||
commandId: string;
|
||||
command: string;
|
||||
content?: string;
|
||||
timestamp?: number;
|
||||
}): JVMDiagnosticEventChunk => ({
|
||||
sessionId,
|
||||
commandId,
|
||||
event: "diagnostic",
|
||||
phase: "running",
|
||||
content: `已提交诊断命令,等待后端输出:${command}`,
|
||||
content:
|
||||
content ||
|
||||
translateJVMDiagnostic("jvm_diagnostic.output.local_pending", {
|
||||
command,
|
||||
}),
|
||||
timestamp,
|
||||
metadata: {
|
||||
source: "local-pending",
|
||||
@@ -209,6 +223,12 @@ const buildJVMDiagnosticRedactionKey = (
|
||||
): string => `${chunk.sessionId || "unknown-session"}::${chunk.commandId || "unknown-command"}`;
|
||||
|
||||
const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
const i18n = useOptionalI18n();
|
||||
const t = useCallback(
|
||||
(key: string, params?: I18nParams) =>
|
||||
i18n?.t ? i18n.t(key, params) : translateJVMDiagnostic(key, params, "zh-CN"),
|
||||
[i18n],
|
||||
);
|
||||
const connection = useStore((state) =>
|
||||
state.connections.find((item) => item.id === tab.connectionId),
|
||||
);
|
||||
@@ -322,15 +342,21 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
try {
|
||||
const result = await backendApp.JVMListDiagnosticAuditRecords(connection.id, 20);
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result?.message || "加载诊断历史失败"));
|
||||
throw new Error(
|
||||
String(result?.message || t("jvm_diagnostic.error.history_load_failed")),
|
||||
);
|
||||
}
|
||||
setRecords(Array.isArray(result?.data) ? result.data : []);
|
||||
} catch (err: any) {
|
||||
setError(redactJVMDiagnosticOutput(err?.message || "加载诊断历史失败"));
|
||||
setError(
|
||||
redactJVMDiagnosticOutput(
|
||||
err?.message || t("jvm_diagnostic.error.history_load_failed"),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [connection]);
|
||||
}, [connection, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
@@ -342,7 +368,10 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
const planTransport = String(detail.plan.transport || diagnosticTransport);
|
||||
if (planTransport !== diagnosticTransport) {
|
||||
setError(
|
||||
`AI 计划的诊断 transport 为 ${planTransport},与当前控制台 ${diagnosticTransport} 不一致,请重新生成计划后再应用。`,
|
||||
t("jvm_diagnostic.ai_plan.error.transport_mismatch", {
|
||||
planTransport,
|
||||
currentTransport: diagnosticTransport,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -353,13 +382,13 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
reason: String(detail.plan.reason || ""),
|
||||
source: "ai-plan",
|
||||
});
|
||||
message.success("AI 诊断计划已回填到控制台");
|
||||
message.success(t("jvm_diagnostic.ai_plan.message.filled"));
|
||||
};
|
||||
|
||||
window.addEventListener("gonavi:jvm-apply-diagnostic-plan", handler);
|
||||
return () =>
|
||||
window.removeEventListener("gonavi:jvm-apply-diagnostic-plan", handler);
|
||||
}, [diagnosticTransport, setDraft, tab.id]);
|
||||
}, [diagnosticTransport, setDraft, t, tab.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAuditRecords();
|
||||
@@ -378,7 +407,9 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
const safeChunk = redactDiagnosticChunk(payload.chunk);
|
||||
appendOutput(tab.id, [safeChunk]);
|
||||
if (safeChunk.phase === "failed") {
|
||||
setError(safeChunk.content || "诊断命令执行失败");
|
||||
setError(
|
||||
safeChunk.content || t("jvm_diagnostic.error.execute_failed"),
|
||||
);
|
||||
}
|
||||
if (safeChunk.commandId && isJVMDiagnosticTerminalPhase(safeChunk.phase)) {
|
||||
terminalCommandIdsRef.current.add(safeChunk.commandId);
|
||||
@@ -392,7 +423,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
stopListening();
|
||||
}
|
||||
};
|
||||
}, [appendOutput, finishActiveCommand, loadAuditRecords, redactDiagnosticChunk, tab.id]);
|
||||
}, [appendOutput, finishActiveCommand, loadAuditRecords, redactDiagnosticChunk, t, tab.id]);
|
||||
|
||||
const handleProbe = async () => {
|
||||
if (!rpcConnectionConfig) {
|
||||
@@ -400,7 +431,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
}
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.JVMProbeDiagnosticCapabilities !== "function") {
|
||||
setError("JVMProbeDiagnosticCapabilities 后端方法不可用");
|
||||
setError(t("jvm_diagnostic.error.probe_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -411,12 +442,18 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
rpcConnectionConfig,
|
||||
);
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result?.message || "检查诊断能力失败"));
|
||||
throw new Error(
|
||||
String(result?.message || t("jvm_diagnostic.error.probe_failed")),
|
||||
);
|
||||
}
|
||||
setCapabilities(Array.isArray(result?.data) ? result.data : []);
|
||||
} catch (err: any) {
|
||||
setCapabilities([]);
|
||||
setError(redactJVMDiagnosticOutput(err?.message || "检查诊断能力失败"));
|
||||
setError(
|
||||
redactJVMDiagnosticOutput(
|
||||
err?.message || t("jvm_diagnostic.error.probe_failed"),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -428,7 +465,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
}
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.JVMStartDiagnosticSession !== "function") {
|
||||
setError("JVMStartDiagnosticSession 后端方法不可用");
|
||||
setError(t("jvm_diagnostic.error.start_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -438,12 +475,14 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
const result = await backendApp.JVMStartDiagnosticSession(
|
||||
rpcConnectionConfig,
|
||||
{
|
||||
title: "JVM 诊断控制台",
|
||||
reason: draft.reason || "控制台启动会话",
|
||||
title: t("jvm_diagnostic.session.default_title"),
|
||||
reason: draft.reason || t("jvm_diagnostic.session.default_reason"),
|
||||
},
|
||||
);
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result?.message || "创建诊断会话失败"));
|
||||
throw new Error(
|
||||
String(result?.message || t("jvm_diagnostic.error.start_failed")),
|
||||
);
|
||||
}
|
||||
const nextSession = (result?.data || null) as JVMDiagnosticSessionHandle | null;
|
||||
setSession(nextSession);
|
||||
@@ -453,7 +492,11 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
void loadAuditRecords();
|
||||
} catch (err: any) {
|
||||
setSession(null);
|
||||
setError(redactJVMDiagnosticOutput(err?.message || "创建诊断会话失败"));
|
||||
setError(
|
||||
redactJVMDiagnosticOutput(
|
||||
err?.message || t("jvm_diagnostic.error.start_failed"),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -465,16 +508,16 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
}
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.JVMExecuteDiagnosticCommand !== "function") {
|
||||
setError("JVMExecuteDiagnosticCommand 后端方法不可用");
|
||||
setError(t("jvm_diagnostic.error.execute_unavailable"));
|
||||
return;
|
||||
}
|
||||
if (!effectiveSession?.sessionId) {
|
||||
setError("请先创建诊断会话,再执行命令");
|
||||
setError(t("jvm_diagnostic.error.execute_session_required"));
|
||||
return;
|
||||
}
|
||||
const command = draft.command.trim();
|
||||
if (!command) {
|
||||
setError("诊断命令不能为空");
|
||||
setError(t("jvm_diagnostic.error.execute_command_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -492,6 +535,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
sessionId,
|
||||
commandId,
|
||||
command,
|
||||
content: t("jvm_diagnostic.output.local_pending", { command }),
|
||||
}),
|
||||
]);
|
||||
setRecords((current) => [
|
||||
@@ -519,7 +563,9 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
},
|
||||
);
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result?.message || "执行诊断命令失败"));
|
||||
throw new Error(
|
||||
String(result?.message || t("jvm_diagnostic.error.execute_failed")),
|
||||
);
|
||||
}
|
||||
if (result?.message) {
|
||||
message.warning(
|
||||
@@ -535,7 +581,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
commandId,
|
||||
event: "diagnostic",
|
||||
phase: "completed",
|
||||
content: "诊断命令调用已返回,但未收到后端终态事件,前端已兜底结束等待状态。",
|
||||
content: t("jvm_diagnostic.output.frontend_completed_fallback"),
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
source: "frontend-fallback",
|
||||
@@ -573,7 +619,9 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
const rawMessageText = String(err?.message || "执行诊断命令失败");
|
||||
const rawMessageText = String(
|
||||
err?.message || t("jvm_diagnostic.error.execute_failed"),
|
||||
);
|
||||
let messageText = "";
|
||||
if (!terminalCommandIdsRef.current.has(commandId)) {
|
||||
const safeChunk = redactDiagnosticChunk({
|
||||
@@ -610,7 +658,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
}
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.JVMCancelDiagnosticCommand !== "function") {
|
||||
setError("JVMCancelDiagnosticCommand 后端方法不可用");
|
||||
setError(t("jvm_diagnostic.error.cancel_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -624,11 +672,17 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
activeCommandId,
|
||||
);
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result?.message || "取消诊断命令失败"));
|
||||
throw new Error(
|
||||
String(result?.message || t("jvm_diagnostic.error.cancel_failed")),
|
||||
);
|
||||
}
|
||||
message.info("已发送取消请求");
|
||||
message.info(t("jvm_diagnostic.message.cancel_sent"));
|
||||
} catch (err: any) {
|
||||
setError(redactJVMDiagnosticOutput(err?.message || "取消诊断命令失败"));
|
||||
setError(
|
||||
redactJVMDiagnosticOutput(
|
||||
err?.message || t("jvm_diagnostic.error.cancel_failed"),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -647,7 +701,12 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
};
|
||||
|
||||
if (!connection) {
|
||||
return <Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />;
|
||||
return (
|
||||
<Empty
|
||||
description={t("jvm_diagnostic.connection_missing.message")}
|
||||
style={{ marginTop: 64 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pageBackground = darkMode
|
||||
@@ -721,7 +780,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
const renderCapabilityContent = () =>
|
||||
capabilities.length ? (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
<Text strong>能力检查结果</Text>
|
||||
<Text strong>{t("jvm_diagnostic.capability_result.title")}</Text>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{capabilities.map((item) => (
|
||||
<div
|
||||
@@ -740,16 +799,30 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
{formatJVMDiagnosticTransportLabel(item.transport)}
|
||||
</Tag>
|
||||
<Tag color={item.canOpenSession ? "green" : "red"}>
|
||||
{item.canOpenSession ? "可建会话" : "不可建会话"}
|
||||
{item.canOpenSession
|
||||
? t("jvm_diagnostic.capability_result.session_allowed")
|
||||
: t("jvm_diagnostic.capability_result.session_denied")}
|
||||
</Tag>
|
||||
<Tag color={item.canStream ? "green" : "red"}>
|
||||
{item.canStream ? "流式输出" : "不支持流式"}
|
||||
{item.canStream
|
||||
? t("jvm_diagnostic.capability_result.streaming_supported")
|
||||
: t("jvm_diagnostic.capability_result.streaming_unsupported")}
|
||||
</Tag>
|
||||
<Tag color={item.allowObserveCommands ? "green" : "red"}>
|
||||
{item.allowObserveCommands ? "观察命令" : "禁止观察"}
|
||||
{item.allowObserveCommands
|
||||
? t("jvm_diagnostic.capability_result.observe_allowed")
|
||||
: t("jvm_diagnostic.capability_result.observe_denied")}
|
||||
</Tag>
|
||||
{item.allowTraceCommands ? <Tag color="gold">跟踪命令</Tag> : null}
|
||||
{item.allowMutatingCommands ? <Tag color="red">高风险命令</Tag> : null}
|
||||
{item.allowTraceCommands ? (
|
||||
<Tag color="gold">
|
||||
{t("jvm_diagnostic.capability_result.trace_allowed")}
|
||||
</Tag>
|
||||
) : null}
|
||||
{item.allowMutatingCommands ? (
|
||||
<Tag color="red">
|
||||
{t("jvm_diagnostic.capability_result.mutating_allowed")}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
@@ -759,8 +832,8 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="尚未检查能力"
|
||||
description="能力检查只读取通道权限和命令策略,不会创建会话或执行命令。"
|
||||
message={t("jvm_diagnostic.capability.empty.title")}
|
||||
description={t("jvm_diagnostic.capability.empty.description")}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -795,9 +868,9 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text type="secondary">JVM 诊断</Text>
|
||||
<Text type="secondary">{t("jvm_diagnostic.workbench.eyebrow")}</Text>
|
||||
<Typography.Title level={3} style={{ margin: "2px 0 6px" }}>
|
||||
JVM 诊断工作台
|
||||
{t("jvm_diagnostic.workbench.title")}
|
||||
</Typography.Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
<Text strong>{connection.name}</Text>
|
||||
@@ -810,16 +883,22 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
|
||||
<Space wrap size={8} style={{ justifyContent: "flex-end" }}>
|
||||
<Tag color={hasSession ? "green" : "default"}>
|
||||
{hasSession ? "会话已建立" : "未建会话"}
|
||||
{hasSession
|
||||
? t("jvm_diagnostic.workbench.status.session_established")
|
||||
: t("jvm_diagnostic.workbench.status.no_session")}
|
||||
</Tag>
|
||||
{commandRunning ? <Tag color="processing">命令执行中</Tag> : null}
|
||||
{commandRunning ? (
|
||||
<Tag color="processing">
|
||||
{t("jvm_diagnostic.workbench.status.command_running")}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Button
|
||||
icon={<ToolOutlined />}
|
||||
style={actionButtonStyle}
|
||||
onClick={() => void handleProbe()}
|
||||
loading={loading}
|
||||
>
|
||||
检查能力
|
||||
{t("jvm_diagnostic.workbench.action.probe")}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<RocketOutlined />}
|
||||
@@ -828,7 +907,9 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
onClick={() => void handleStartSession()}
|
||||
loading={loading}
|
||||
>
|
||||
{hasSession ? "重建会话" : "新建会话"}
|
||||
{hasSession
|
||||
? t("jvm_diagnostic.workbench.action.restart_session")
|
||||
: t("jvm_diagnostic.workbench.action.start_session")}
|
||||
</Button>
|
||||
{hasSession ? (
|
||||
<Button
|
||||
@@ -838,7 +919,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
onClick={() => void handleExecuteCommand()}
|
||||
loading={commandRunning}
|
||||
>
|
||||
执行命令
|
||||
{t("jvm_diagnostic.workbench.action.execute_command")}
|
||||
</Button>
|
||||
) : null}
|
||||
{hasSession ? (
|
||||
@@ -850,7 +931,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
onClick={() => void handleCancelCommand()}
|
||||
loading={loading && commandRunning}
|
||||
>
|
||||
取消命令
|
||||
{t("jvm_diagnostic.workbench.action.cancel_command")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
@@ -872,8 +953,8 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
<Card
|
||||
title={renderCardTitle(
|
||||
<RocketOutlined />,
|
||||
"开始一次诊断",
|
||||
"先建立会话,再显示命令编辑器和模板",
|
||||
t("jvm_diagnostic.no_session.title"),
|
||||
t("jvm_diagnostic.no_session.description"),
|
||||
)}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
@@ -883,8 +964,8 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="命令输入将在会话建立后显示"
|
||||
description="这样可以避免未绑定会话时误以为命令已经可执行,也能保证审计记录、输出流和取消命令都绑定到同一个会话。"
|
||||
message={t("jvm_diagnostic.no_session.alert.title")}
|
||||
description={t("jvm_diagnostic.no_session.alert.description")}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
@@ -916,10 +997,10 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
{step.index}
|
||||
</Text>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Text strong>{step.title}</Text>
|
||||
<Text strong>{t(step.titleKey)}</Text>
|
||||
</div>
|
||||
<Paragraph type="secondary" style={{ margin: "6px 0 0" }}>
|
||||
{step.description}
|
||||
{t(step.descriptionKey)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
@@ -932,7 +1013,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
loading={loading}
|
||||
onClick={() => void handleStartSession()}
|
||||
>
|
||||
新建诊断会话
|
||||
{t("jvm_diagnostic.no_session.action.start")}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ToolOutlined />}
|
||||
@@ -940,7 +1021,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
loading={loading}
|
||||
onClick={() => void handleProbe()}
|
||||
>
|
||||
先检查能力
|
||||
{t("jvm_diagnostic.no_session.action.probe")}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -950,8 +1031,8 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
<Card
|
||||
title={renderCardTitle(
|
||||
<PlayCircleOutlined />,
|
||||
"命令输入",
|
||||
"支持自动补全,按 Ctrl/Cmd + Enter 执行",
|
||||
t("jvm_diagnostic.command_input.title"),
|
||||
t("jvm_diagnostic.command_input.description"),
|
||||
)}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
@@ -959,9 +1040,11 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
>
|
||||
<div style={{ display: "grid", gap: 14 }}>
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<Text strong>诊断命令</Text>
|
||||
<Text strong>
|
||||
{t("jvm_diagnostic.command_input.command_label")}
|
||||
</Text>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
输入 Arthas/诊断命令,例如 thread -n 5、dashboard、jvm;也可以从下方模板一键回填。
|
||||
{t("jvm_diagnostic.command_input.command_description")}
|
||||
</Paragraph>
|
||||
<div
|
||||
data-jvm-diagnostic-command-editor-shell="true"
|
||||
@@ -1004,23 +1087,30 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 6 }}>
|
||||
<Text strong>诊断原因(可选)</Text>
|
||||
<Text strong>
|
||||
{t("jvm_diagnostic.command_input.reason_label")}
|
||||
</Text>
|
||||
<Input
|
||||
value={draft.reason || ""}
|
||||
placeholder="例如:排查 CPU 飙高、确认线程阻塞、定位慢方法"
|
||||
placeholder={t(
|
||||
"jvm_diagnostic.command_input.reason_placeholder",
|
||||
)}
|
||||
onChange={(event) =>
|
||||
setDraft(tab.id, { reason: event.target.value })
|
||||
}
|
||||
/>
|
||||
<Text type="secondary">
|
||||
用于审计记录和 AI 上下文理解,不会作为 Arthas 命令发送到目标 JVM。
|
||||
{t("jvm_diagnostic.command_input.reason_help")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={renderCardTitle(<ToolOutlined />, "命令模板")}
|
||||
title={renderCardTitle(
|
||||
<ToolOutlined />,
|
||||
t("jvm_diagnostic.command_templates.title"),
|
||||
)}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
styles={compactCardStyles}
|
||||
@@ -1042,8 +1132,8 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
<Card
|
||||
title={renderCardTitle(
|
||||
<PlayCircleOutlined />,
|
||||
"实时输出",
|
||||
"按后端事件流追加显示",
|
||||
t("jvm_diagnostic.output.title"),
|
||||
t("jvm_diagnostic.output.description"),
|
||||
)}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
@@ -1058,8 +1148,8 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
<Card
|
||||
title={renderCardTitle(
|
||||
<ToolOutlined />,
|
||||
"会话与能力",
|
||||
"当前通道、权限与快捷维护",
|
||||
t("jvm_diagnostic.session_capability.title"),
|
||||
t("jvm_diagnostic.session_capability.description"),
|
||||
)}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
@@ -1077,11 +1167,15 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
>
|
||||
<Space size={6} wrap>
|
||||
<Tag color={hasSession ? "green" : "default"}>
|
||||
{hasSession ? "会话已建立" : "未建会话"}
|
||||
{hasSession
|
||||
? t("jvm_diagnostic.session_capability.status.session_established")
|
||||
: t("jvm_diagnostic.session_capability.status.no_session")}
|
||||
</Tag>
|
||||
<Tag>{formatJVMDiagnosticTransportLabel(diagnosticTransport)}</Tag>
|
||||
<Tag color={commandRunning ? "processing" : "green"}>
|
||||
{commandRunning ? "命令执行中" : "空闲"}
|
||||
{commandRunning
|
||||
? t("jvm_diagnostic.session_capability.status.command_running")
|
||||
: t("jvm_diagnostic.session_capability.status.idle")}
|
||||
</Tag>
|
||||
</Space>
|
||||
{effectiveSession?.sessionId ? (
|
||||
@@ -1093,11 +1187,13 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
{effectiveSession.sessionId}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">创建会话后会在这里显示会话 ID。</Text>
|
||||
<Text type="secondary">
|
||||
{t("jvm_diagnostic.session_capability.session_id_hint")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
检查能力不会执行命令;执行命令前必须先建会话。审计历史展示最近命令记录,未建会话时也可能包含过去会话的记录。
|
||||
{t("jvm_diagnostic.session_capability.note")}
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button
|
||||
@@ -1105,7 +1201,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
icon={<ClearOutlined />}
|
||||
onClick={() => clearOutput(tab.id)}
|
||||
>
|
||||
清空输出
|
||||
{t("jvm_diagnostic.session_capability.action.clear_output")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -1113,7 +1209,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
onClick={() => void loadAuditRecords()}
|
||||
loading={historyLoading}
|
||||
>
|
||||
刷新历史
|
||||
{t("jvm_diagnostic.session_capability.action.refresh_history")}
|
||||
</Button>
|
||||
</Space>
|
||||
{renderCapabilityContent()}
|
||||
@@ -1123,8 +1219,8 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
|
||||
<Card
|
||||
title={renderCardTitle(
|
||||
<HistoryOutlined />,
|
||||
"审计历史",
|
||||
"最近命令和执行状态",
|
||||
t("jvm_diagnostic.history.title"),
|
||||
t("jvm_diagnostic.history.description"),
|
||||
)}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import JVMMonitoringDashboard from "./JVMMonitoringDashboard";
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
@@ -25,9 +27,13 @@ vi.mock("../store", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("JVMMonitoringDashboard", () => {
|
||||
it("shows start action and empty-state guidance before monitoring starts", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
const renderDashboard = () =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider
|
||||
preference="en-US"
|
||||
systemLanguages={["en-US"]}
|
||||
onPreferenceChange={() => undefined}
|
||||
>
|
||||
<JVMMonitoringDashboard
|
||||
tab={{
|
||||
id: "tab-monitor-1",
|
||||
@@ -36,29 +42,32 @@ describe("JVMMonitoringDashboard", () => {
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("开始监控");
|
||||
expect(markup).toContain("当前尚未开始持续监控");
|
||||
expect(markup).toContain("堆内存");
|
||||
expect(markup).toContain("暂无堆内存采样数据");
|
||||
describe("JVMMonitoringDashboard", () => {
|
||||
it("shows start action and empty-state guidance before monitoring starts", () => {
|
||||
const markup = renderDashboard();
|
||||
|
||||
expect(markup).toContain("Continuous JVM monitoring");
|
||||
expect(markup).toContain("Stopped");
|
||||
expect(markup).toContain("Refresh");
|
||||
expect(markup).toContain("Start monitoring");
|
||||
expect(markup).toContain("Continuous monitoring has not started yet");
|
||||
expect(markup).toContain(
|
||||
"After you click "Start monitoring", GoNavi keeps sampling results for this connection in the current session; switching tabs does not stop sampling.",
|
||||
);
|
||||
expect(markup).toContain("Heap memory");
|
||||
expect(markup).toContain("No heap memory samples yet.");
|
||||
expect(markup).not.toContain("堆内存");
|
||||
expect(markup).not.toContain("暂无堆内存采样数据");
|
||||
expect(markup).not.toContain("暂无 Heap 采样数据");
|
||||
expect(markup).not.toContain("当前 provider 未提供 Heap 指标");
|
||||
});
|
||||
|
||||
it("renders a dedicated vertical scroll shell for tall monitoring content", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringDashboard
|
||||
tab={{
|
||||
id: "tab-monitor-scroll",
|
||||
title: "持续监控",
|
||||
type: "jvm-monitoring",
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const markup = renderDashboard();
|
||||
|
||||
expect(markup).toContain('data-jvm-monitoring-dashboard-scroll-shell="true"');
|
||||
expect(markup).toContain("height:100%");
|
||||
@@ -66,20 +75,38 @@ describe("JVMMonitoringDashboard", () => {
|
||||
});
|
||||
|
||||
it("stacks monitoring charts before detail panels so charts keep full content width", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringDashboard
|
||||
tab={{
|
||||
id: "tab-monitor-layout",
|
||||
title: "持续监控",
|
||||
type: "jvm-monitoring",
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const markup = renderDashboard();
|
||||
|
||||
expect(markup).toContain('data-jvm-monitoring-content-stack="true"');
|
||||
expect(markup).toContain("gap:24px");
|
||||
expect(markup).not.toContain("minmax(min(100%, 320px), 1fr)");
|
||||
});
|
||||
|
||||
it("keeps dashboard-owned Chinese literals out of the component source", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./JVMMonitoringDashboard.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
[
|
||||
"JVMGetMonitoringHistory 后端方法不可用",
|
||||
"读取监控历史失败",
|
||||
"连接不存在或已被删除",
|
||||
"JVMStartMonitoring 后端方法不可用",
|
||||
"开始监控失败",
|
||||
"JVMStopMonitoring 后端方法不可用",
|
||||
"停止监控失败",
|
||||
"JVM 持续监控",
|
||||
"采样中",
|
||||
"未运行",
|
||||
"刷新",
|
||||
"开始监控",
|
||||
"停止监控",
|
||||
"监控能力存在降级",
|
||||
"当前尚未开始持续监控",
|
||||
"点击“开始监控”后",
|
||||
].forEach((literal) => {
|
||||
expect(source).not.toContain(literal);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, Card, Empty, Space, Spin, Tag, Typography } from "antd";
|
||||
import { DashboardOutlined, PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
|
||||
import { useI18n } from "../i18n/provider";
|
||||
import { useStore } from "../store";
|
||||
import type { JVMMonitoringSessionState, TabData } from "../types";
|
||||
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||
@@ -63,6 +64,7 @@ const resolveBackendApp = () =>
|
||||
typeof window === "undefined" ? undefined : (window as any).go?.app?.App;
|
||||
|
||||
const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab }) => {
|
||||
const { t, language } = useI18n();
|
||||
const theme = useStore((state) => state.theme);
|
||||
const connection = useStore((state) =>
|
||||
state.connections.find((item) => item.id === tab.connectionId),
|
||||
@@ -120,7 +122,7 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
setLoading(true);
|
||||
|
||||
if (typeof backendApp?.JVMGetMonitoringHistory !== "function") {
|
||||
setError("JVMGetMonitoringHistory 后端方法不可用");
|
||||
setError(t("jvm_monitoring_dashboard.error.history_unavailable"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -136,7 +138,9 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
}
|
||||
|
||||
if (result?.success === false) {
|
||||
const message = String(result?.message || "读取监控历史失败");
|
||||
const message = String(
|
||||
result?.message || t("jvm_monitoring_dashboard.error.history_load_failed"),
|
||||
);
|
||||
if (isMonitoringSessionMissing(message)) {
|
||||
setSession(createEmptySession(tab.connectionId, providerMode));
|
||||
setError("");
|
||||
@@ -160,7 +164,10 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
}
|
||||
} catch (fetchError: any) {
|
||||
if (!cancelled) {
|
||||
setError(fetchError?.message || "读取监控历史失败");
|
||||
setError(
|
||||
fetchError?.message ||
|
||||
t("jvm_monitoring_dashboard.error.history_load_failed"),
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -174,20 +181,25 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed]);
|
||||
}, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed, t]);
|
||||
|
||||
if (!connection) {
|
||||
return <Empty description="连接不存在或已被删除" style={{ marginTop: 80 }} />;
|
||||
return (
|
||||
<Empty
|
||||
description={t("jvm_monitoring_dashboard.connection_missing.message")}
|
||||
style={{ marginTop: 80 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const backendApp = resolveBackendApp();
|
||||
const availabilityText = buildMonitoringAvailabilityText(session);
|
||||
const availabilityText = buildMonitoringAvailabilityText(session, language);
|
||||
const modeMeta = resolveJVMModeMeta(providerMode);
|
||||
const emptyState = !session.running && (session.points || []).length === 0;
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!rpcConnectionConfig || typeof backendApp?.JVMStartMonitoring !== "function") {
|
||||
setError("JVMStartMonitoring 后端方法不可用");
|
||||
setError(t("jvm_monitoring_dashboard.error.start_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,14 +208,16 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
try {
|
||||
const result = await backendApp.JVMStartMonitoring(rpcConnectionConfig);
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result?.message || "开始监控失败"));
|
||||
throw new Error(
|
||||
String(result?.message || t("jvm_monitoring_dashboard.error.start_failed")),
|
||||
);
|
||||
}
|
||||
setSession(
|
||||
normalizeMonitoringSession(result?.data, tab.connectionId, providerMode),
|
||||
);
|
||||
setPollSeed((current) => current + 1);
|
||||
} catch (startError: any) {
|
||||
setError(startError?.message || "开始监控失败");
|
||||
setError(startError?.message || t("jvm_monitoring_dashboard.error.start_failed"));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -211,7 +225,7 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!rpcConnectionConfig || typeof backendApp?.JVMStopMonitoring !== "function") {
|
||||
setError("JVMStopMonitoring 后端方法不可用");
|
||||
setError(t("jvm_monitoring_dashboard.error.stop_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,12 +237,14 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
providerMode,
|
||||
);
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result?.message || "停止监控失败"));
|
||||
throw new Error(
|
||||
String(result?.message || t("jvm_monitoring_dashboard.error.stop_failed")),
|
||||
);
|
||||
}
|
||||
setSession((current) => ({ ...current, running: false }));
|
||||
setPollSeed((current) => current + 1);
|
||||
} catch (stopError: any) {
|
||||
setError(stopError?.message || "停止监控失败");
|
||||
setError(stopError?.message || t("jvm_monitoring_dashboard.error.stop_failed"));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
@@ -260,7 +276,7 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
<DashboardOutlined style={{ color: "#1677ff", marginRight: 8 }} />
|
||||
JVM 持续监控
|
||||
{t("jvm_monitoring_dashboard.title")}
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
<Text strong>{connection.name}</Text>
|
||||
@@ -275,15 +291,17 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
{modeMeta.label}
|
||||
</Tag>
|
||||
{session.running ? (
|
||||
<Tag color="green">采样中</Tag>
|
||||
<Tag color="green">
|
||||
{t("jvm_monitoring_dashboard.status.sampling")}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>未运行</Tag>
|
||||
<Tag>{t("jvm_monitoring_dashboard.status.stopped")}</Tag>
|
||||
)}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => setPollSeed((current) => current + 1)}
|
||||
>
|
||||
刷新
|
||||
{t("jvm_monitoring_dashboard.action.refresh")}
|
||||
</Button>
|
||||
{session.running ? (
|
||||
<Button
|
||||
@@ -293,7 +311,7 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
loading={actionLoading}
|
||||
onClick={() => void handleStop()}
|
||||
>
|
||||
停止监控
|
||||
{t("jvm_monitoring_dashboard.action.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -302,7 +320,7 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
loading={actionLoading}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
开始监控
|
||||
{t("jvm_monitoring_dashboard.action.start")}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
@@ -312,7 +330,7 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="监控能力存在降级"
|
||||
message={t("jvm_monitoring_dashboard.degraded.message")}
|
||||
description={availabilityText}
|
||||
/>
|
||||
) : null}
|
||||
@@ -337,11 +355,11 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
>
|
||||
<Card variant="borderless" style={{ borderRadius: 12 }}>
|
||||
<Empty
|
||||
description="当前尚未开始持续监控"
|
||||
description={t("jvm_monitoring_dashboard.empty.title")}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Paragraph type="secondary" style={{ maxWidth: 520, margin: "0 auto 16px" }}>
|
||||
点击“开始监控”后,GoNavi 会在当前会话内持续保留该连接的采样结果;切换页签不会停止采样。
|
||||
{t("jvm_monitoring_dashboard.empty.description")}
|
||||
</Paragraph>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -349,7 +367,7 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
loading={actionLoading}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
开始监控
|
||||
{t("jvm_monitoring_dashboard.action.start")}
|
||||
</Button>
|
||||
</Empty>
|
||||
</Card>
|
||||
@@ -357,6 +375,7 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
points={session.points || []}
|
||||
session={session}
|
||||
darkMode={darkMode}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -372,16 +391,19 @@ const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab })
|
||||
latestPoint={latestPoint}
|
||||
session={session}
|
||||
darkMode={darkMode}
|
||||
language={language}
|
||||
/>
|
||||
<JVMMonitoringCharts
|
||||
points={session.points || []}
|
||||
session={session}
|
||||
darkMode={darkMode}
|
||||
language={language}
|
||||
/>
|
||||
<JVMMonitoringDetailPanel
|
||||
session={session}
|
||||
latestPoint={latestPoint}
|
||||
darkMode={darkMode}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,65 +1,193 @@
|
||||
import React from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import type { LanguagePreference } from "../i18n/types";
|
||||
import JVMOverview from "./JVMOverview";
|
||||
|
||||
vi.mock("../../wailsjs/go/app/App", () => ({
|
||||
JVMProbeCapabilities: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockState = {
|
||||
connections: [] as any[],
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: any) => any) =>
|
||||
selector({
|
||||
connections: [
|
||||
{
|
||||
id: "conn-jvm-1",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "localhost",
|
||||
port: 10990,
|
||||
jvm: {
|
||||
preferredMode: "jmx",
|
||||
allowedModes: ["jmx", "endpoint", "agent"],
|
||||
readOnly: true,
|
||||
environment: "dev",
|
||||
endpoint: {
|
||||
enabled: true,
|
||||
baseUrl: "http://localhost:8080/actuator",
|
||||
},
|
||||
agent: {
|
||||
enabled: true,
|
||||
baseUrl: "http://localhost:8563",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
theme: "light",
|
||||
connections: mockState.connections,
|
||||
theme: mockState.theme,
|
||||
}),
|
||||
}));
|
||||
|
||||
const source = readFileSync(new URL("./JVMOverview.tsx", import.meta.url), "utf8");
|
||||
|
||||
const renderWithI18n = (node: React.ReactNode, preference: LanguagePreference = "en-US") => (
|
||||
<I18nProvider
|
||||
preference={preference}
|
||||
systemLanguages={[preference]}
|
||||
onPreferenceChange={vi.fn()}
|
||||
>
|
||||
{node}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
const overviewTab = {
|
||||
id: "tab-jvm-overview",
|
||||
type: "jvm-overview",
|
||||
title: "[orders-jvm] JVM 概览",
|
||||
connectionId: "conn-jvm-1",
|
||||
providerMode: "jmx",
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.connections = [
|
||||
{
|
||||
id: "conn-jvm-1",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "localhost",
|
||||
port: 10990,
|
||||
jvm: {
|
||||
preferredMode: "jmx",
|
||||
allowedModes: ["jmx", "endpoint", "agent"],
|
||||
readOnly: true,
|
||||
environment: "dev",
|
||||
endpoint: {
|
||||
enabled: true,
|
||||
baseUrl: "",
|
||||
},
|
||||
agent: {
|
||||
enabled: false,
|
||||
baseUrl: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
mockState.theme = "light";
|
||||
});
|
||||
|
||||
describe("JVMOverview", () => {
|
||||
it("renders a unified JVM workspace overview shell", () => {
|
||||
it("renders a localized en-US JVM workspace overview shell", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMOverview
|
||||
tab={{
|
||||
id: "tab-jvm-overview",
|
||||
type: "jvm-overview",
|
||||
title: "[orders-jvm] JVM 概览",
|
||||
connectionId: "conn-jvm-1",
|
||||
providerMode: "jmx",
|
||||
} as any}
|
||||
/>,
|
||||
renderWithI18n(<JVMOverview tab={overviewTab} />),
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-jvm-workspace-shell="true"');
|
||||
expect(markup).toContain('data-jvm-workspace-hero="true"');
|
||||
expect(markup).toContain("JVM 运行时概览");
|
||||
expect(markup).toContain("连接摘要");
|
||||
expect(markup).toContain("模式能力");
|
||||
expect(markup).toContain("JMX 地址");
|
||||
[
|
||||
t("jvm_overview.eyebrow", undefined, "en-US"),
|
||||
t("jvm_overview.title", undefined, "en-US"),
|
||||
t("jvm_overview.badge.read_only", undefined, "en-US"),
|
||||
t("jvm_overview.card.connection_summary", undefined, "en-US"),
|
||||
t("jvm_overview.card.mode_capability", undefined, "en-US"),
|
||||
t("jvm_overview.field.current_mode", undefined, "en-US"),
|
||||
t("jvm_overview.field.allowed_modes", undefined, "en-US"),
|
||||
t("jvm_overview.field.jmx_address", undefined, "en-US"),
|
||||
t("jvm_overview.field.endpoint", undefined, "en-US"),
|
||||
t("jvm_overview.field.agent", undefined, "en-US"),
|
||||
t("jvm_overview.field.resource_browse", undefined, "en-US"),
|
||||
t("jvm_overview.value.enabled", undefined, "en-US"),
|
||||
t("jvm_overview.value.not_configured", undefined, "en-US"),
|
||||
t("jvm_overview.value.resource_browse_lazy_load", undefined, "en-US"),
|
||||
].forEach((snippet) => {
|
||||
expect(markup).toContain(snippet);
|
||||
});
|
||||
expect(markup).toContain("JMX, Endpoint, Agent");
|
||||
expect(markup).toContain("orders-jvm");
|
||||
expect(markup).toContain("localhost:10990");
|
||||
expect(markup).toContain("Endpoint");
|
||||
expect(markup).toContain("Agent");
|
||||
|
||||
[
|
||||
"JVM 运行时概览",
|
||||
"只读连接",
|
||||
"连接摘要",
|
||||
"模式能力",
|
||||
"当前模式",
|
||||
"允许模式",
|
||||
"JMX 地址",
|
||||
"资源浏览",
|
||||
"已启用",
|
||||
"未配置",
|
||||
"通过侧边栏展开模式节点后懒加载",
|
||||
"JMX、Endpoint、Agent",
|
||||
].forEach((snippet) => {
|
||||
expect(markup).not.toContain(snippet);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the missing connection empty state in en-US", () => {
|
||||
mockState.connections = [];
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
renderWithI18n(<JVMOverview tab={overviewTab} />),
|
||||
);
|
||||
|
||||
expect(markup).toContain(
|
||||
t("jvm_overview.connection_missing.message", undefined, "en-US"),
|
||||
);
|
||||
expect(markup).not.toContain("连接不存在或已被删除");
|
||||
});
|
||||
|
||||
it("wires async capability and fallback wrappers through existing i18n keys", () => {
|
||||
[
|
||||
"已启用",
|
||||
"连接不存在或已被删除",
|
||||
"读取 JVM 模式能力失败",
|
||||
"JVM 运行时概览",
|
||||
"只读连接",
|
||||
"可写连接",
|
||||
"连接摘要",
|
||||
"模式能力",
|
||||
"当前模式",
|
||||
"允许模式",
|
||||
"JMX 地址",
|
||||
"资源浏览",
|
||||
"未配置",
|
||||
"通过侧边栏展开模式节点后懒加载",
|
||||
"暂无模式能力数据",
|
||||
"可浏览",
|
||||
"不可浏览",
|
||||
"可写",
|
||||
"只读",
|
||||
"支持预览",
|
||||
"不支持预览",
|
||||
].forEach((snippet) => {
|
||||
expect(source).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
"useI18n()",
|
||||
"jvm_overview.value.enabled",
|
||||
"jvm_overview.connection_missing.message",
|
||||
"jvm_overview.error.capability_load_failed",
|
||||
"jvm_overview.title",
|
||||
"jvm_overview.badge.read_only",
|
||||
"jvm_overview.badge.writable",
|
||||
"jvm_overview.card.connection_summary",
|
||||
"jvm_overview.card.mode_capability",
|
||||
"jvm_overview.field.current_mode",
|
||||
"jvm_overview.field.allowed_modes",
|
||||
"jvm_overview.field.jmx_address",
|
||||
"jvm_overview.field.resource_browse",
|
||||
"jvm_overview.value.not_configured",
|
||||
"jvm_overview.value.resource_browse_lazy_load",
|
||||
"jvm_overview.empty.capabilities",
|
||||
"jvm_overview.capability.can_browse",
|
||||
"jvm_overview.capability.cannot_browse",
|
||||
"jvm_overview.capability.writable",
|
||||
"jvm_overview.capability.read_only",
|
||||
"jvm_overview.capability.preview_supported",
|
||||
"jvm_overview.capability.preview_unsupported",
|
||||
].forEach((key) => {
|
||||
expect(source).toContain(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
JVMWorkspaceHero,
|
||||
JVMWorkspaceShell,
|
||||
} from "./jvm/JVMWorkspaceLayout";
|
||||
import { useI18n } from "../i18n/provider";
|
||||
|
||||
const { Text } = Typography;
|
||||
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
|
||||
@@ -30,6 +31,7 @@ type JVMOverviewProps = {
|
||||
};
|
||||
|
||||
const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
const { language, t } = useI18n();
|
||||
const connection = useStore((state) =>
|
||||
state.connections.find((item) => item.id === tab.connectionId),
|
||||
);
|
||||
@@ -51,8 +53,8 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
if (!endpoint.enabled && !endpoint.baseUrl) {
|
||||
return "";
|
||||
}
|
||||
return endpoint.baseUrl || "已启用";
|
||||
}, [connection]);
|
||||
return endpoint.baseUrl || t("jvm_overview.value.enabled");
|
||||
}, [connection, t]);
|
||||
|
||||
const agentSummary = useMemo(() => {
|
||||
if (!connection?.config.jvm?.agent) {
|
||||
@@ -62,18 +64,20 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
if (!agent.enabled && !agent.baseUrl) {
|
||||
return "";
|
||||
}
|
||||
return agent.baseUrl || "已启用";
|
||||
}, [connection]);
|
||||
return agent.baseUrl || t("jvm_overview.value.enabled");
|
||||
}, [connection, t]);
|
||||
|
||||
const allowedModeSummary = useMemo(() => {
|
||||
const items = allowedModes.length > 0 ? allowedModes : ["jmx"];
|
||||
return items.map((item) => resolveJVMModeMeta(item).label).join("、");
|
||||
}, [allowedModes]);
|
||||
const delimiter =
|
||||
language.startsWith("zh") || language === "ja-JP" ? "、" : ", ";
|
||||
return items.map((item) => resolveJVMModeMeta(item).label).join(delimiter);
|
||||
}, [allowedModes, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection) {
|
||||
setCapabilities([]);
|
||||
setCapabilityError("连接不存在或已被删除");
|
||||
setCapabilityError(t("jvm_overview.connection_missing.message"));
|
||||
setCapabilityLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -92,7 +96,9 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
if (result?.success === false) {
|
||||
setCapabilities([]);
|
||||
setCapabilityError(
|
||||
String(result?.message || "读取 JVM 模式能力失败"),
|
||||
String(
|
||||
result?.message || t("jvm_overview.error.capability_load_failed"),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -102,7 +108,9 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
setCapabilities([]);
|
||||
setCapabilityError(error?.message || "读取 JVM 模式能力失败");
|
||||
setCapabilityError(
|
||||
error?.message || t("jvm_overview.error.capability_load_failed"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -115,11 +123,14 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connection]);
|
||||
}, [connection, t]);
|
||||
|
||||
if (!connection) {
|
||||
return (
|
||||
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
|
||||
<Empty
|
||||
description={t("jvm_overview.connection_missing.message")}
|
||||
style={{ marginTop: 64 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,8 +143,8 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
<JVMWorkspaceShell darkMode={darkMode}>
|
||||
<JVMWorkspaceHero
|
||||
darkMode={darkMode}
|
||||
eyebrow="JVM Runtime"
|
||||
title="JVM 运行时概览"
|
||||
eyebrow={t("jvm_overview.eyebrow")}
|
||||
title={t("jvm_overview.title")}
|
||||
description={
|
||||
<>
|
||||
<Text strong>{connection.name}</Text>
|
||||
@@ -147,42 +158,54 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
<>
|
||||
<JVMModeBadge mode={providerMode} />
|
||||
<Tag color={readOnly ? "blue" : "red"}>
|
||||
{readOnly ? "只读连接" : "可写连接"}
|
||||
{readOnly
|
||||
? t("jvm_overview.badge.read_only")
|
||||
: t("jvm_overview.badge.writable")}
|
||||
</Tag>
|
||||
<Tag>{connection.config.jvm?.environment || "dev"}</Tag>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="连接摘要" variant="borderless" style={cardStyle}>
|
||||
<Card
|
||||
title={t("jvm_overview.card.connection_summary")}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
>
|
||||
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
|
||||
<Descriptions.Item label="当前模式">
|
||||
<Descriptions.Item label={t("jvm_overview.field.current_mode")}>
|
||||
{resolveJVMModeMeta(providerMode).label}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="允许模式">
|
||||
<Descriptions.Item label={t("jvm_overview.field.allowed_modes")}>
|
||||
{allowedModeSummary}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="JMX 地址">{`${jmxHost}:${jmxPort}`}</Descriptions.Item>
|
||||
<Descriptions.Item label="Endpoint">
|
||||
{endpointSummary || "未配置"}
|
||||
<Descriptions.Item label={t("jvm_overview.field.jmx_address")}>
|
||||
{`${jmxHost}:${jmxPort}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Agent">
|
||||
{agentSummary || "未配置"}
|
||||
<Descriptions.Item label={t("jvm_overview.field.endpoint")}>
|
||||
{endpointSummary || t("jvm_overview.value.not_configured")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源浏览">
|
||||
{"通过侧边栏展开模式节点后懒加载"}
|
||||
<Descriptions.Item label={t("jvm_overview.field.agent")}>
|
||||
{agentSummary || t("jvm_overview.value.not_configured")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t("jvm_overview.field.resource_browse")}>
|
||||
{t("jvm_overview.value.resource_browse_lazy_load")}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="模式能力" variant="borderless" style={cardStyle}>
|
||||
<Card
|
||||
title={t("jvm_overview.card.mode_capability")}
|
||||
variant="borderless"
|
||||
style={cardStyle}
|
||||
>
|
||||
{capabilityLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
) : capabilityError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="读取 JVM 模式能力失败"
|
||||
message={t("jvm_overview.error.capability_load_failed")}
|
||||
description={
|
||||
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{capabilityError}
|
||||
@@ -190,7 +213,7 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
}
|
||||
/>
|
||||
) : capabilities.length === 0 ? (
|
||||
<Empty description="暂无模式能力数据" />
|
||||
<Empty description={t("jvm_overview.empty.capabilities")} />
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
{capabilities.map((capability) => (
|
||||
@@ -205,13 +228,19 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
<Space size={8} wrap>
|
||||
<JVMModeBadge mode={capability.mode} />
|
||||
<Tag color={capability.canBrowse ? "green" : "default"}>
|
||||
{capability.canBrowse ? "可浏览" : "不可浏览"}
|
||||
{capability.canBrowse
|
||||
? t("jvm_overview.capability.can_browse")
|
||||
: t("jvm_overview.capability.cannot_browse")}
|
||||
</Tag>
|
||||
<Tag color={capability.canWrite ? "red" : "blue"}>
|
||||
{capability.canWrite ? "可写" : "只读"}
|
||||
{capability.canWrite
|
||||
? t("jvm_overview.capability.writable")
|
||||
: t("jvm_overview.capability.read_only")}
|
||||
</Tag>
|
||||
<Tag color={capability.canPreview ? "gold" : "default"}>
|
||||
{capability.canPreview ? "支持预览" : "不支持预览"}
|
||||
{capability.canPreview
|
||||
? t("jvm_overview.capability.preview_supported")
|
||||
: t("jvm_overview.capability.preview_unsupported")}
|
||||
</Tag>
|
||||
</Space>
|
||||
{capability.reason ? (
|
||||
|
||||
@@ -4,6 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import JVMResourceBrowser from "./JVMResourceBrowser";
|
||||
import type { JVMValueSnapshot } from "../types";
|
||||
import {
|
||||
getCurrentLanguage,
|
||||
setCurrentLanguage,
|
||||
t as translate,
|
||||
} from "../i18n";
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
connections: [
|
||||
@@ -37,7 +43,7 @@ const backendApp = vi.hoisted(() => ({
|
||||
JVMApplyChange: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@monaco-editor/react", () => ({
|
||||
vi.mock("./MonacoEditor", () => ({
|
||||
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
|
||||
}));
|
||||
|
||||
@@ -147,8 +153,39 @@ const waitForEffects = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const emitJVMApplyAIPlan = async (detail: any) => {
|
||||
const eventListeners = (window.addEventListener as any).mock.calls.filter(
|
||||
([eventName]: [string]) => eventName === "gonavi:jvm-apply-ai-plan",
|
||||
);
|
||||
const handler = eventListeners[eventListeners.length - 1]?.[1] as
|
||||
| EventListener
|
||||
| undefined;
|
||||
expect(handler).toBeTruthy();
|
||||
await act(async () => {
|
||||
handler!(new CustomEvent("gonavi:jvm-apply-ai-plan", { detail }));
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithI18n = (
|
||||
tab: typeof writableTab,
|
||||
language: "zh-CN" | "en-US",
|
||||
) => (
|
||||
<I18nProvider
|
||||
preference={language}
|
||||
systemLanguages={[]}
|
||||
onPreferenceChange={vi.fn()}
|
||||
>
|
||||
<JVMResourceBrowser tab={tab} />
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
describe("JVMResourceBrowser interactions", () => {
|
||||
let previousLanguage = getCurrentLanguage();
|
||||
|
||||
beforeEach(() => {
|
||||
previousLanguage = getCurrentLanguage();
|
||||
setCurrentLanguage("zh-CN");
|
||||
|
||||
storeState.connections = [
|
||||
{
|
||||
id: "conn-jvm-writable",
|
||||
@@ -215,12 +252,675 @@ describe("JVMResourceBrowser interactions", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentLanguage(previousLanguage);
|
||||
backendApp.JVMGetValue.mockReset();
|
||||
backendApp.JVMPreviewChange.mockReset();
|
||||
backendApp.JVMApplyChange.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("localizes resource snapshot and draft form chrome without translating raw values", async () => {
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
backendApp.JVMGetValue.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
version: "v1",
|
||||
value: "cold",
|
||||
metadata: {
|
||||
source: "runtime",
|
||||
},
|
||||
supportedActions: [
|
||||
{
|
||||
action: "set",
|
||||
label: "设置属性",
|
||||
description: "运行时原始说明",
|
||||
payloadExample: { value: "warm" },
|
||||
payloadFields: [
|
||||
{ name: "value", required: true },
|
||||
{ name: "ttlSeconds" },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as JVMValueSnapshot,
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const text = textContent(renderer!.root);
|
||||
expect(text).toContain("JVM Resource Workbench");
|
||||
expect(text).toContain("Writable connection");
|
||||
expect(findButton(renderer!, "Refresh")).toBeTruthy();
|
||||
expect(findButton(renderer!, "Audit log")).toBeTruthy();
|
||||
expect(findButton(renderer!, "Generate AI plan")).toBeTruthy();
|
||||
expect(text).toContain("Resource snapshot");
|
||||
expect(text).toContain("Resource ID");
|
||||
expect(text).toContain("Resource type");
|
||||
expect(text).toContain("Format");
|
||||
expect(text).toContain("Version");
|
||||
expect(text).toContain("Available actions");
|
||||
expect(text).toContain("Resource value");
|
||||
expect(text).toContain("Metadata");
|
||||
expect(text).toContain("Change draft");
|
||||
expect(text).toContain("Resource path");
|
||||
expect(text).toContain("Target resource");
|
||||
expect(text).toContain("Resource version");
|
||||
expect(text).toContain("Draft source");
|
||||
expect(text).toContain("Manual edit");
|
||||
expect(text).toContain("Supported resource actions");
|
||||
expect(text).toContain("Payload fields: value (required), ttlSeconds");
|
||||
expect(text).toContain("Action");
|
||||
expect(text).toContain("Current action: 设置属性");
|
||||
expect(text).toContain("Change reason");
|
||||
expect(text).toContain("Payload (JSON)");
|
||||
expect(text).toContain("Preview uses the current draft.");
|
||||
expect(text).toContain(
|
||||
"A recommended template has been filled for the current action.",
|
||||
);
|
||||
expect(text).toContain("jmx:/attribute/app/Mode");
|
||||
expect(text).toContain("attribute");
|
||||
expect(text).toContain("string");
|
||||
expect(text).toContain("v1");
|
||||
expect(text).toContain("cold");
|
||||
expect(text).toContain("设置属性");
|
||||
expect(text).toContain("运行时原始说明");
|
||||
expect(
|
||||
renderer!.root.findAllByType("input").some(
|
||||
(item) => item.props.placeholder === "For example, set or invoke",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
renderer!.root.findAllByType("input").some(
|
||||
(item) =>
|
||||
item.props.placeholder ===
|
||||
"Enter the reason for this JVM resource change",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(findButton(renderer!, "Preview change")).toBeTruthy();
|
||||
expect(findButton(renderer!, "Ask AI for a plan")).toBeTruthy();
|
||||
|
||||
backendApp.JVMGetValue.mockResolvedValueOnce({ success: true, data: null });
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(textContent(renderer!.root)).toContain("No resource data");
|
||||
|
||||
storeState.connections = [
|
||||
{
|
||||
...storeState.connections[0],
|
||||
config: {
|
||||
...storeState.connections[0].config,
|
||||
jvm: {
|
||||
...storeState.connections[0].config.jvm,
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(textContent(renderer!.root)).toContain("Read-only connection");
|
||||
});
|
||||
|
||||
it("localizes JVM resource load error chrome without leaking raw backend detail", async () => {
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
storeState.connections = [];
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
"The connection does not exist or has been deleted.",
|
||||
);
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
go: {
|
||||
app: {
|
||||
App: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
"JVM value reading is not available in this build.",
|
||||
);
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
go: {
|
||||
app: {
|
||||
App: backendApp,
|
||||
},
|
||||
},
|
||||
});
|
||||
backendApp.JVMGetValue.mockResolvedValueOnce({ success: false });
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
expect(textContent(renderer!.root)).toContain("Failed to read JVM resource.");
|
||||
|
||||
backendApp.JVMGetValue.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: "raw backend boom",
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const text = textContent(renderer!.root);
|
||||
expect(text).toContain("Failed to read JVM resource.");
|
||||
expect(text).not.toContain("raw backend boom");
|
||||
});
|
||||
|
||||
it("refreshes fallback JVM resource load errors after provider locale changes", async () => {
|
||||
setCurrentLanguage("en-US");
|
||||
backendApp.JVMGetValue
|
||||
.mockResolvedValueOnce({ success: false })
|
||||
.mockResolvedValueOnce({ success: false });
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(renderWithI18n(writableTab, "en-US"));
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
translate("jvm_resource.error.read_failed", undefined, "en-US"),
|
||||
);
|
||||
|
||||
setCurrentLanguage("zh-CN");
|
||||
await act(async () => {
|
||||
renderer!.update(renderWithI18n(writableTab, "zh-CN"));
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const text = textContent(renderer!.root);
|
||||
expect(backendApp.JVMGetValue).toHaveBeenCalledTimes(2);
|
||||
expect(text).toContain(
|
||||
translate("jvm_resource.error.read_failed", undefined, "zh-CN"),
|
||||
);
|
||||
expect(text).not.toContain(
|
||||
translate("jvm_resource.error.read_failed", undefined, "en-US"),
|
||||
);
|
||||
});
|
||||
|
||||
it("localizes AI-plan import and fill chrome while preserving raw resource ids", async () => {
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
const rawResourceId = "jmx:/attribute/app/Mode-RAW-42";
|
||||
const tab = {
|
||||
...writableTab,
|
||||
resourcePath: rawResourceId,
|
||||
};
|
||||
const planContext = {
|
||||
targetTabId: tab.id,
|
||||
connectionId: tab.connectionId,
|
||||
providerMode: tab.providerMode,
|
||||
resourcePath: tab.resourcePath,
|
||||
};
|
||||
const validPlan = {
|
||||
targetType: "attribute",
|
||||
selector: {
|
||||
resourcePath: rawResourceId,
|
||||
},
|
||||
action: "set",
|
||||
payload: {
|
||||
format: "json",
|
||||
value: { value: "warm" },
|
||||
},
|
||||
reason: "Keep raw id visible",
|
||||
};
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={tab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await emitJVMApplyAIPlan({ plan: validPlan });
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
"The AI plan is missing its source context. Regenerate it from the target JVM resource page before applying it.",
|
||||
);
|
||||
|
||||
await emitJVMApplyAIPlan({
|
||||
plan: validPlan,
|
||||
...planContext,
|
||||
connectionId: "conn-other",
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
"The current JVM tab does not match the source context of the AI plan, so automatic application was rejected.",
|
||||
);
|
||||
|
||||
const fallbackPlan = {
|
||||
...validPlan,
|
||||
selector: {
|
||||
resourcePath: {
|
||||
toString: () => {
|
||||
throw null;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await emitJVMApplyAIPlan({
|
||||
plan: fallbackPlan,
|
||||
...planContext,
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
"The AI plan cannot be converted into a JVM preview draft right now.",
|
||||
);
|
||||
|
||||
const rawErrorPlan = {
|
||||
...validPlan,
|
||||
selector: {
|
||||
resourcePath: {
|
||||
toString: () => {
|
||||
throw new Error("raw plan detail");
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await emitJVMApplyAIPlan({
|
||||
plan: rawErrorPlan,
|
||||
...planContext,
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
"The AI plan cannot be converted into a JVM preview draft right now.",
|
||||
);
|
||||
expect(textContent(renderer!.root)).not.toContain("raw plan detail");
|
||||
|
||||
await emitJVMApplyAIPlan({
|
||||
plan: validPlan,
|
||||
...planContext,
|
||||
});
|
||||
const text = textContent(renderer!.root);
|
||||
expect(text).toContain(
|
||||
`The draft was filled from the AI plan for ${rawResourceId}. Preview the change before confirming the write.`,
|
||||
);
|
||||
expect(text).toContain(rawResourceId);
|
||||
});
|
||||
|
||||
it("updates AI-plan listener translations after locale changes", async () => {
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
const rawResourceId = "jmx:/attribute/app/Mode-RAW-42";
|
||||
const tab = {
|
||||
...writableTab,
|
||||
resourcePath: rawResourceId,
|
||||
};
|
||||
const planContext = {
|
||||
targetTabId: tab.id,
|
||||
connectionId: tab.connectionId,
|
||||
providerMode: tab.providerMode,
|
||||
resourcePath: tab.resourcePath,
|
||||
};
|
||||
const validPlan = {
|
||||
targetType: "attribute",
|
||||
selector: {
|
||||
resourcePath: rawResourceId,
|
||||
},
|
||||
action: "set",
|
||||
payload: {
|
||||
format: "json",
|
||||
value: { value: "warm" },
|
||||
},
|
||||
reason: "Keep raw id visible",
|
||||
};
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(renderWithI18n(tab, "en-US"));
|
||||
});
|
||||
await waitForEffects();
|
||||
const firstAIPlanHandler = (window.addEventListener as any).mock.calls.find(
|
||||
([eventName]: [string]) => eventName === "gonavi:jvm-apply-ai-plan",
|
||||
)?.[1];
|
||||
expect(firstAIPlanHandler).toBeTruthy();
|
||||
|
||||
setCurrentLanguage("zh-CN");
|
||||
await act(async () => {
|
||||
renderer!.update(renderWithI18n(tab, "zh-CN"));
|
||||
});
|
||||
await waitForEffects();
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith(
|
||||
"gonavi:jvm-apply-ai-plan",
|
||||
firstAIPlanHandler,
|
||||
);
|
||||
|
||||
await emitJVMApplyAIPlan({ plan: validPlan });
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
translate("jvm_resource.error.ai_plan_missing_context"),
|
||||
);
|
||||
|
||||
await emitJVMApplyAIPlan({
|
||||
plan: validPlan,
|
||||
...planContext,
|
||||
});
|
||||
const text = textContent(renderer!.root);
|
||||
expect(text).toContain(
|
||||
translate("jvm_resource.message.ai_plan_draft_filled", {
|
||||
resourceId: rawResourceId,
|
||||
}),
|
||||
);
|
||||
expect(text).toContain(rawResourceId);
|
||||
});
|
||||
|
||||
it("localizes draft preview and apply fallbacks while preserving raw backend messages", async () => {
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "Preview change").props.onClick();
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
translate("jvm_resource.error.reason_required", undefined, "en-US"),
|
||||
);
|
||||
expect(textContent(renderer!.root)).not.toContain("请填写变更原因");
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
go: {
|
||||
app: {
|
||||
App: {
|
||||
JVMGetValue: backendApp.JVMGetValue,
|
||||
JVMApplyChange: backendApp.JVMApplyChange,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findAllByType("input")
|
||||
.find(
|
||||
(item) =>
|
||||
item.props.placeholder ===
|
||||
translate("jvm_resource.placeholder.reason", undefined, "en-US"),
|
||||
)!
|
||||
.props.onChange({ target: { value: "change mode" } });
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer!, "Preview change").props.onClick();
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
translate("jvm_resource.error.preview_unavailable", undefined, "en-US"),
|
||||
);
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
go: {
|
||||
app: {
|
||||
App: backendApp,
|
||||
},
|
||||
},
|
||||
});
|
||||
backendApp.JVMPreviewChange.mockResolvedValueOnce({ success: false });
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findAllByType("input")
|
||||
.find(
|
||||
(item) =>
|
||||
item.props.placeholder ===
|
||||
translate("jvm_resource.placeholder.reason", undefined, "en-US"),
|
||||
)!
|
||||
.props.onChange({ target: { value: "change mode" } });
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer!, "Preview change").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
translate("jvm_resource.error.preview_failed", undefined, "en-US"),
|
||||
);
|
||||
|
||||
backendApp.JVMPreviewChange.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: "raw preview backend detail",
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findAllByType("input")
|
||||
.find(
|
||||
(item) =>
|
||||
item.props.placeholder ===
|
||||
translate("jvm_resource.placeholder.reason", undefined, "en-US"),
|
||||
)!
|
||||
.props.onChange({ target: { value: "change mode" } });
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer!, "Preview change").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
const rawPreviewText = textContent(renderer!.root);
|
||||
expect(rawPreviewText).toContain("raw preview backend detail");
|
||||
expect(rawPreviewText).not.toContain(
|
||||
translate("jvm_resource.error.preview_failed", undefined, "en-US"),
|
||||
);
|
||||
|
||||
backendApp.JVMPreviewChange.mockRejectedValueOnce(
|
||||
new Error("HTTP 500 /system raw preview failure checksum=abc123"),
|
||||
);
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findAllByType("input")
|
||||
.find(
|
||||
(item) =>
|
||||
item.props.placeholder ===
|
||||
translate("jvm_resource.placeholder.reason", undefined, "en-US"),
|
||||
)!
|
||||
.props.onChange({ target: { value: "change mode" } });
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer!, "Preview change").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
const thrownPreviewText = textContent(renderer!.root);
|
||||
expect(thrownPreviewText).toContain(
|
||||
"HTTP 500 /system raw preview failure checksum=abc123",
|
||||
);
|
||||
expect(thrownPreviewText).not.toContain(
|
||||
translate("jvm_resource.error.preview_failed", undefined, "en-US"),
|
||||
);
|
||||
|
||||
backendApp.JVMPreviewChange.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
confirmationToken: "token-from-preview",
|
||||
summary: "preview ready",
|
||||
riskLevel: "low",
|
||||
});
|
||||
backendApp.JVMApplyChange.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: { status: "applied" },
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findAllByType("input")
|
||||
.find(
|
||||
(item) =>
|
||||
item.props.placeholder ===
|
||||
translate("jvm_resource.placeholder.reason", undefined, "en-US"),
|
||||
)!
|
||||
.props.onChange({ target: { value: "change mode" } });
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer!, "Preview change").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
findButton(renderer!, "确认执行").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
expect(textContent(renderer!.root)).toContain(
|
||||
translate("jvm_resource.message.apply_success", undefined, "en-US"),
|
||||
);
|
||||
|
||||
backendApp.JVMPreviewChange.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
confirmationToken: "token-from-preview",
|
||||
summary: "preview ready",
|
||||
riskLevel: "low",
|
||||
});
|
||||
backendApp.JVMApplyChange.mockResolvedValueOnce({
|
||||
success: false,
|
||||
message: "HTTP 409 raw apply backend detail resourceId=jmx:/internal",
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findAllByType("input")
|
||||
.find(
|
||||
(item) =>
|
||||
item.props.placeholder ===
|
||||
translate("jvm_resource.placeholder.reason", undefined, "en-US"),
|
||||
)!
|
||||
.props.onChange({ target: { value: "change mode" } });
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer!, "Preview change").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
findButton(renderer!, "确认执行").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
const rawApplyFailureText = textContent(renderer!.root);
|
||||
expect(rawApplyFailureText).toContain(
|
||||
"HTTP 409 raw apply backend detail resourceId=jmx:/internal",
|
||||
);
|
||||
expect(rawApplyFailureText).not.toContain(
|
||||
translate("jvm_resource.error.apply_failed", undefined, "en-US"),
|
||||
);
|
||||
|
||||
backendApp.JVMPreviewChange.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
confirmationToken: "token-from-preview",
|
||||
summary: "preview ready",
|
||||
riskLevel: "low",
|
||||
});
|
||||
backendApp.JVMApplyChange.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
status: "applied",
|
||||
message: "raw apply result success detail",
|
||||
},
|
||||
message: "raw top-level apply success detail",
|
||||
});
|
||||
await act(async () => {
|
||||
renderer!.unmount();
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
renderer!.root
|
||||
.findAllByType("input")
|
||||
.find(
|
||||
(item) =>
|
||||
item.props.placeholder ===
|
||||
translate("jvm_resource.placeholder.reason", undefined, "en-US"),
|
||||
)!
|
||||
.props.onChange({ target: { value: "change mode" } });
|
||||
});
|
||||
await act(async () => {
|
||||
findButton(renderer!, "Preview change").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
await act(async () => {
|
||||
findButton(renderer!, "确认执行").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
const rawApplySuccessText = textContent(renderer!.root);
|
||||
expect(rawApplySuccessText).toContain("raw apply result success detail");
|
||||
expect(rawApplySuccessText).not.toContain(
|
||||
translate("jvm_resource.message.apply_success", undefined, "en-US"),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the latest successful preview request even when the draft is edited afterward", async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
|
||||
@@ -30,6 +30,8 @@ import type {
|
||||
TabData,
|
||||
} from "../types";
|
||||
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||
import { t as translate } from "../i18n";
|
||||
import { useOptionalI18n } from "../i18n/provider";
|
||||
import {
|
||||
buildJVMChangeDraftFromAIPlan,
|
||||
buildJVMAIPlanPrompt,
|
||||
@@ -67,6 +69,26 @@ type JVMResourceBrowserProps = {
|
||||
tab: TabData;
|
||||
};
|
||||
|
||||
type LocalizedError = Error & {
|
||||
userMessage?: string;
|
||||
};
|
||||
|
||||
const createLocalizedError = (message: string): LocalizedError => {
|
||||
const error = new Error(message) as LocalizedError;
|
||||
error.userMessage = message;
|
||||
return error;
|
||||
};
|
||||
|
||||
const resolveLocalizedErrorMessage = (
|
||||
error: unknown,
|
||||
fallback: string,
|
||||
): string => {
|
||||
const userMessage = (error as LocalizedError | undefined)?.userMessage;
|
||||
return typeof userMessage === "string" && userMessage.trim()
|
||||
? userMessage
|
||||
: fallback;
|
||||
};
|
||||
|
||||
const buildJVMRuntimeConfig = (
|
||||
connection: SavedConnection,
|
||||
providerMode: string,
|
||||
@@ -194,6 +216,10 @@ const normalizeApplyResult = (value: any): JVMApplyResult | null => {
|
||||
};
|
||||
|
||||
const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
const i18n = useOptionalI18n();
|
||||
const i18nLanguage = i18n?.language;
|
||||
const tr = (key: string, params?: Parameters<typeof translate>[1]) =>
|
||||
translate(key, params, i18nLanguage);
|
||||
const connection = useStore((state) =>
|
||||
state.connections.find((item) => item.id === tab.connectionId),
|
||||
);
|
||||
@@ -234,6 +260,8 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
);
|
||||
const [previewContextKey, setPreviewContextKey] = useState("");
|
||||
const [applyLoading, setApplyLoading] = useState(false);
|
||||
const snapshotLoadSequenceRef = useRef(0);
|
||||
const i18nLanguageRef = useRef(i18nLanguage);
|
||||
const previewSequenceRef = useRef(0);
|
||||
const currentPreviewContextKey = buildJVMPreviewContextKey(
|
||||
tab.connectionId,
|
||||
@@ -242,6 +270,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
runtimeFingerprint,
|
||||
);
|
||||
const previewContextKeyRef = useRef(currentPreviewContextKey);
|
||||
i18nLanguageRef.current = i18nLanguage;
|
||||
previewContextKeyRef.current = currentPreviewContextKey;
|
||||
|
||||
const clearPreviewState = () => {
|
||||
@@ -284,23 +313,25 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
[action, supportedActions],
|
||||
);
|
||||
const selectedActionDisplay = useMemo(
|
||||
() => resolveJVMActionDisplay(selectedActionDefinition || action),
|
||||
[action, selectedActionDefinition],
|
||||
() =>
|
||||
resolveJVMActionDisplay(selectedActionDefinition || action, i18nLanguage),
|
||||
[action, i18nLanguage, selectedActionDefinition],
|
||||
);
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
const loadContextKey = currentPreviewContextKey;
|
||||
const loadLanguage = i18nLanguage;
|
||||
if (!connection) {
|
||||
setLoading(false);
|
||||
setSnapshot(null);
|
||||
setError("连接不存在或已被删除");
|
||||
setError(tr("jvm_resource.error.connection_missing"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resourcePath) {
|
||||
setLoading(false);
|
||||
setSnapshot(null);
|
||||
setError("资源路径为空");
|
||||
setError(tr("jvm_resource.error.resource_path_empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,10 +339,11 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
if (typeof backendApp?.JVMGetValue !== "function") {
|
||||
setLoading(false);
|
||||
setSnapshot(null);
|
||||
setError("JVMGetValue 后端方法不可用");
|
||||
setError(tr("jvm_resource.error.get_value_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
const loadSequence = ++snapshotLoadSequenceRef.current;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -319,26 +351,43 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
buildJVMRuntimeConfig(connection, providerMode),
|
||||
resourcePath,
|
||||
);
|
||||
if (loadContextKey !== previewContextKeyRef.current) {
|
||||
if (
|
||||
loadSequence !== snapshotLoadSequenceRef.current ||
|
||||
loadContextKey !== previewContextKeyRef.current ||
|
||||
loadLanguage !== i18nLanguageRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!result?.success) {
|
||||
setSnapshot(null);
|
||||
setError(String(result?.message || "读取 JVM 资源失败"));
|
||||
setError(tr("jvm_resource.error.read_failed"));
|
||||
return;
|
||||
}
|
||||
setSnapshot((result.data || null) as JVMValueSnapshot | null);
|
||||
} catch (err: any) {
|
||||
if (
|
||||
loadSequence !== snapshotLoadSequenceRef.current ||
|
||||
loadContextKey !== previewContextKeyRef.current ||
|
||||
loadLanguage !== i18nLanguageRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSnapshot(null);
|
||||
setError(err?.message || "读取 JVM 资源失败");
|
||||
setError(tr("jvm_resource.error.read_failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (
|
||||
loadSequence === snapshotLoadSequenceRef.current &&
|
||||
loadContextKey === previewContextKeyRef.current &&
|
||||
loadLanguage === i18nLanguageRef.current
|
||||
) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSnapshot();
|
||||
}, [connection, providerMode, resourcePath, runtimeFingerprint, tab.connectionId]);
|
||||
}, [connection, i18nLanguage, providerMode, resourcePath, runtimeFingerprint, tab.connectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshot(null);
|
||||
@@ -400,18 +449,14 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
: undefined;
|
||||
|
||||
if (!planContext) {
|
||||
setDraftError(
|
||||
"AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。",
|
||||
);
|
||||
setDraftError(tr("jvm_resource.error.ai_plan_missing_context"));
|
||||
setApplyMessage("");
|
||||
clearPreviewState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matchesJVMAIPlanTargetTab(tab, planContext)) {
|
||||
setDraftError(
|
||||
"当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。",
|
||||
);
|
||||
setDraftError(tr("jvm_resource.error.ai_plan_context_mismatch"));
|
||||
setApplyMessage("");
|
||||
clearPreviewState();
|
||||
return;
|
||||
@@ -420,8 +465,8 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
let draftFromPlan: JVMAIChangeDraft;
|
||||
try {
|
||||
draftFromPlan = buildJVMChangeDraftFromAIPlan(plan);
|
||||
} catch (err: any) {
|
||||
setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿");
|
||||
} catch {
|
||||
setDraftError(tr("jvm_resource.error.ai_plan_to_draft_failed"));
|
||||
setApplyMessage("");
|
||||
clearPreviewState();
|
||||
return;
|
||||
@@ -434,7 +479,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
setDraftSource(draftFromPlan.source || "ai-plan");
|
||||
setDraftError("");
|
||||
setApplyMessage(
|
||||
`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`,
|
||||
tr("jvm_resource.message.ai_plan_draft_filled", {
|
||||
resourceId: draftFromPlan.resourceId,
|
||||
}),
|
||||
);
|
||||
clearPreviewState();
|
||||
};
|
||||
@@ -448,7 +495,14 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
"gonavi:jvm-apply-ai-plan",
|
||||
handler as EventListener,
|
||||
);
|
||||
}, [resourcePath, tab.id]);
|
||||
}, [
|
||||
i18nLanguage,
|
||||
resourcePath,
|
||||
tab.connectionId,
|
||||
tab.id,
|
||||
tab.providerMode,
|
||||
tab.type,
|
||||
]);
|
||||
|
||||
const handleSelectAction = (
|
||||
nextAction: string,
|
||||
@@ -473,7 +527,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
const trimmedAction = String(action || "").trim() || "put";
|
||||
const trimmedReason = String(reason || "").trim();
|
||||
if (!trimmedReason) {
|
||||
throw new Error("请填写变更原因");
|
||||
throw createLocalizedError(tr("jvm_resource.error.reason_required"));
|
||||
}
|
||||
|
||||
const rawPayload = String(payloadText || "").trim();
|
||||
@@ -481,14 +535,16 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
if (rawPayload) {
|
||||
const parsed = JSON.parse(rawPayload);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Payload 必须是 JSON 对象");
|
||||
throw createLocalizedError(
|
||||
tr("jvm_resource.error.payload_object_required"),
|
||||
);
|
||||
}
|
||||
payload = parsed as Record<string, any>;
|
||||
}
|
||||
|
||||
const resourceId = String(draftResourceId || resourcePath).trim();
|
||||
if (!resourceId) {
|
||||
throw new Error("资源 ID 为空,无法生成变更草稿");
|
||||
throw createLocalizedError(tr("jvm_resource.error.resource_id_empty"));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -518,7 +574,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
|
||||
const handleAskAIForPlan = () => {
|
||||
if (!connection) {
|
||||
setDraftError("连接不存在或已被删除");
|
||||
setDraftError(tr("jvm_resource.error.connection_missing"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -549,21 +605,26 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!connection) {
|
||||
setDraftError("连接不存在或已被删除");
|
||||
setDraftError(tr("jvm_resource.error.connection_missing"));
|
||||
return;
|
||||
}
|
||||
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.JVMPreviewChange !== "function") {
|
||||
setDraftError("JVMPreviewChange 后端方法不可用");
|
||||
setDraftError(tr("jvm_resource.error.preview_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
let draftPlan: JVMChangeRequest;
|
||||
try {
|
||||
draftPlan = buildDraftPlan();
|
||||
} catch (err: any) {
|
||||
setDraftError(err?.message || "变更草稿不合法");
|
||||
} catch (err) {
|
||||
setDraftError(
|
||||
resolveLocalizedErrorMessage(
|
||||
err,
|
||||
tr("jvm_resource.error.draft_invalid"),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -588,14 +649,16 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
|
||||
if (result?.success === false) {
|
||||
clearPreviewState();
|
||||
setDraftError(String(result?.message || "预览 JVM 变更失败"));
|
||||
setDraftError(
|
||||
String(result?.message || tr("jvm_resource.error.preview_failed")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = normalizePreviewResult(result);
|
||||
if (!preview) {
|
||||
clearPreviewState();
|
||||
setDraftError("预览结果格式不正确");
|
||||
setDraftError(tr("jvm_resource.error.preview_result_invalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -606,7 +669,11 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
setPreviewOpen(true);
|
||||
} catch (err: any) {
|
||||
clearPreviewState();
|
||||
setDraftError(err?.message || "预览 JVM 变更失败");
|
||||
setDraftError(
|
||||
err?.message ||
|
||||
(typeof err === "string" ? err : "") ||
|
||||
tr("jvm_resource.error.preview_failed"),
|
||||
);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
@@ -616,31 +683,31 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
await Promise.resolve();
|
||||
|
||||
if (!connection) {
|
||||
setDraftError("连接不存在或已被删除");
|
||||
setDraftError(tr("jvm_resource.error.connection_missing"));
|
||||
return;
|
||||
}
|
||||
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
if (typeof backendApp?.JVMApplyChange !== "function") {
|
||||
setDraftError("JVMApplyChange 后端方法不可用");
|
||||
setDraftError(tr("jvm_resource.error.apply_unavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!previewResult || !previewRequest || !previewRuntimeConfig) {
|
||||
setDraftError("请先预览变更,再确认执行");
|
||||
setDraftError(tr("jvm_resource.error.preview_required"));
|
||||
return;
|
||||
}
|
||||
if (previewContextKey !== previewContextKeyRef.current) {
|
||||
clearPreviewState();
|
||||
setDraftError("资源上下文已变化,请重新预览后再执行");
|
||||
setDraftError(tr("jvm_resource.error.context_changed"));
|
||||
return;
|
||||
}
|
||||
|
||||
let applyRequest: JVMChangeRequest;
|
||||
try {
|
||||
applyRequest = buildJVMPreviewApplyRequest(previewRequest, previewResult);
|
||||
} catch (err: any) {
|
||||
setDraftError(err?.message || "确认令牌缺失,请重新预览后再执行");
|
||||
} catch {
|
||||
setDraftError(tr("jvm_resource.error.confirmation_missing"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -653,7 +720,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
applyRequest,
|
||||
);
|
||||
if (result?.success === false) {
|
||||
setDraftError(String(result?.message || "执行 JVM 变更失败"));
|
||||
setDraftError(
|
||||
String(result?.message || tr("jvm_resource.error.apply_failed")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -664,11 +733,17 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
|
||||
clearPreviewState();
|
||||
setApplyMessage(
|
||||
applyResult?.message || result?.message || "JVM 变更已执行",
|
||||
applyResult?.message ||
|
||||
result?.message ||
|
||||
tr("jvm_resource.message.apply_success"),
|
||||
);
|
||||
await loadSnapshot();
|
||||
} catch (err: any) {
|
||||
setDraftError(err?.message || "执行 JVM 变更失败");
|
||||
setDraftError(
|
||||
err?.message ||
|
||||
(typeof err === "string" ? err : "") ||
|
||||
tr("jvm_resource.error.apply_failed"),
|
||||
);
|
||||
} finally {
|
||||
setApplyLoading(false);
|
||||
}
|
||||
@@ -676,7 +751,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
|
||||
if (!connection) {
|
||||
return (
|
||||
<Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />
|
||||
<Empty description={tr("jvm_resource.error.connection_missing")} style={{ marginTop: 64 }} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -716,7 +791,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
<JVMWorkspaceHero
|
||||
darkMode={darkMode}
|
||||
eyebrow="JVM Resource"
|
||||
title="JVM 资源工作台"
|
||||
title={tr("jvm_resource.title")}
|
||||
description={
|
||||
<>
|
||||
<Text strong>{connection.name}</Text>
|
||||
@@ -727,7 +802,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
<>
|
||||
<JVMModeBadge mode={providerMode} />
|
||||
<Tag color={readOnly ? "blue" : "red"}>
|
||||
{readOnly ? "只读连接" : "可写连接"}
|
||||
{readOnly
|
||||
? tr("jvm_resource.badge.read_only")
|
||||
: tr("jvm_resource.badge.writable")}
|
||||
</Tag>
|
||||
</>
|
||||
}
|
||||
@@ -738,21 +815,21 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => void loadSnapshot()}
|
||||
>
|
||||
刷新
|
||||
{tr("common.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileSearchOutlined />}
|
||||
onClick={handleOpenAudit}
|
||||
>
|
||||
审计记录
|
||||
{tr("jvm_resource.action.audit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={handleAskAIForPlan}
|
||||
>
|
||||
AI 生成计划
|
||||
{tr("jvm_resource.action.generate_ai_plan")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -769,7 +846,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title="资源快照"
|
||||
title={tr("jvm_resource.card.snapshot")}
|
||||
variant="borderless"
|
||||
style={{
|
||||
...cardStyle,
|
||||
@@ -788,20 +865,20 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
size="small"
|
||||
styles={DESCRIPTION_STYLES}
|
||||
>
|
||||
<Descriptions.Item label="资源 ID">
|
||||
<Descriptions.Item label={tr("jvm_resource.field.resource_id")}>
|
||||
{snapshot.resourceId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源类型">
|
||||
<Descriptions.Item label={tr("jvm_resource.field.resource_type")}>
|
||||
{snapshot.kind || tab.resourceKind || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="格式">
|
||||
<Descriptions.Item label={tr("jvm_resource.field.format")}>
|
||||
{snapshot.format || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">
|
||||
<Descriptions.Item label={tr("jvm_resource.field.version")}>
|
||||
{snapshot.version || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="可用动作">
|
||||
{formatJVMActionSummary(supportedActions)}
|
||||
<Descriptions.Item label={tr("jvm_resource.field.available_actions")}>
|
||||
{formatJVMActionSummary(supportedActions, i18nLanguage)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{snapshot.description ? (
|
||||
@@ -812,7 +889,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
strong
|
||||
style={{ display: "block", marginBottom: 8 }}
|
||||
>
|
||||
资源值
|
||||
{tr("jvm_resource.section.resource_value")}
|
||||
</Text>
|
||||
<div
|
||||
className="jvm-resource-browser-code-block"
|
||||
@@ -847,7 +924,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
strong
|
||||
style={{ display: "block", marginBottom: 8 }}
|
||||
>
|
||||
元数据
|
||||
{tr("jvm_resource.section.metadata")}
|
||||
</Text>
|
||||
<div
|
||||
className="jvm-resource-browser-code-block"
|
||||
@@ -882,14 +959,14 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
) : null}
|
||||
</>
|
||||
) : error ? null : (
|
||||
<Empty description="暂无资源数据" />
|
||||
<Empty description={tr("jvm_resource.empty.no_resource_data")} />
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!readOnly ? (
|
||||
<Card title="变更草稿" variant="borderless" style={cardStyle}>
|
||||
<Card title={tr("jvm_resource.card.change_draft")} variant="borderless" style={cardStyle}>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{draftError ? (
|
||||
<Alert type="error" showIcon message={draftError} />
|
||||
@@ -902,17 +979,19 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
size="small"
|
||||
styles={DESCRIPTION_STYLES}
|
||||
>
|
||||
<Descriptions.Item label="资源路径">
|
||||
<Descriptions.Item label={tr("jvm_resource.field.resource_path")}>
|
||||
{resourcePath || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="目标资源">
|
||||
<Descriptions.Item label={tr("jvm_resource.field.target_resource")}>
|
||||
{draftResourceId || resourcePath || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源版本">
|
||||
<Descriptions.Item label={tr("jvm_resource.field.resource_version")}>
|
||||
{snapshot?.version || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="草稿来源">
|
||||
{draftSource === "ai-plan" ? "AI 辅助草稿" : "手工编辑"}
|
||||
<Descriptions.Item label={tr("jvm_resource.field.draft_source")}>
|
||||
{draftSource === "ai-plan"
|
||||
? tr("jvm_resource.draft_source.ai_plan")
|
||||
: tr("jvm_resource.draft_source.manual")}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{supportedActions.length > 0 ? (
|
||||
@@ -921,7 +1000,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
size={8}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Text strong>资源支持动作</Text>
|
||||
<Text strong>
|
||||
{tr("jvm_resource.section.supported_actions")}
|
||||
</Text>
|
||||
<Space size={8} wrap>
|
||||
{supportedActions.map((item) => (
|
||||
<Button
|
||||
@@ -931,7 +1012,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
danger={item.dangerous}
|
||||
onClick={() => handleSelectAction(item.action, item)}
|
||||
>
|
||||
{resolveJVMActionDisplay(item).label}
|
||||
{resolveJVMActionDisplay(item, i18nLanguage).label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
@@ -942,19 +1023,23 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
) : null}
|
||||
{selectedActionDefinition?.payloadFields?.length ? (
|
||||
<Text type="secondary">
|
||||
Payload 字段:
|
||||
{tr("jvm_resource.field.payload_fields")}
|
||||
{selectedActionDefinition.payloadFields
|
||||
.map(
|
||||
(field) =>
|
||||
`${field.name}${field.required ? "(必填)" : ""}`,
|
||||
`${field.name}${
|
||||
field.required
|
||||
? tr("jvm_resource.marker.required_suffix")
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
.join("、")}
|
||||
.join(tr("jvm_resource.list_separator"))}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null}
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>动作</Text>
|
||||
<Text strong>{tr("jvm_resource.field.action")}</Text>
|
||||
<Input
|
||||
value={action}
|
||||
onChange={(event) =>
|
||||
@@ -965,34 +1050,37 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
}
|
||||
placeholder={
|
||||
providerMode === "jmx"
|
||||
? "例如 set 或 invoke"
|
||||
: "例如 put / clear / evict"
|
||||
? tr("jvm_resource.placeholder.action_jmx")
|
||||
: tr("jvm_resource.placeholder.action_default")
|
||||
}
|
||||
maxLength={64}
|
||||
/>
|
||||
{action ? (
|
||||
<Text type="secondary">
|
||||
当前动作:
|
||||
{formatJVMActionDisplayText(selectedActionDisplay)}
|
||||
{tr("jvm_resource.message.current_action")}
|
||||
{formatJVMActionDisplayText(
|
||||
selectedActionDisplay,
|
||||
i18nLanguage,
|
||||
)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>变更原因</Text>
|
||||
<Text strong>{tr("jvm_resource.field.reason")}</Text>
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="填写本次 JVM 资源变更原因"
|
||||
placeholder={tr("jvm_resource.placeholder.reason")}
|
||||
maxLength={200}
|
||||
/>
|
||||
</Space>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>Payload(JSON)</Text>
|
||||
<Text strong>{tr("jvm_resource.field.payload")}</Text>
|
||||
<Text type="secondary">
|
||||
预览会使用当前草稿;确认执行会使用最近一次成功预览的
|
||||
request,修改草稿后请重新预览。
|
||||
{selectedActionDefinition?.payloadExample && !snapshot?.sensitive
|
||||
? " 已按当前动作填充推荐模板。"
|
||||
{tr("jvm_resource.message.payload_hint")}
|
||||
{selectedActionDefinition?.payloadExample &&
|
||||
!snapshot?.sensitive
|
||||
? ` ${tr("jvm_resource.message.payload_template_applied")}`
|
||||
: ""}
|
||||
</Text>
|
||||
<TextArea
|
||||
@@ -1008,10 +1096,10 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
loading={previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
>
|
||||
预览变更
|
||||
{tr("jvm_resource.action.preview_change")}
|
||||
</Button>
|
||||
<Button icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
|
||||
让 AI 生成计划
|
||||
{tr("jvm_resource.action.ask_ai_plan")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
157
frontend/src/components/LogPanel.test.tsx
Normal file
157
frontend/src/components/LogPanel.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import LogPanel from "./LogPanel";
|
||||
|
||||
const storeState = {
|
||||
sqlLogs: [] as Array<{
|
||||
id: string;
|
||||
timestamp: number;
|
||||
sql: string;
|
||||
status: "success" | "error";
|
||||
duration: number;
|
||||
message?: string;
|
||||
affectedRows?: number;
|
||||
}>,
|
||||
clearSqlLogs: vi.fn(),
|
||||
theme: "light",
|
||||
appearance: { enabled: true, opacity: 1, blur: 0, uiVersion: "legacy" },
|
||||
};
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock("../i18n/runtime", () => ({
|
||||
applyDayjsLocale: vi.fn(),
|
||||
syncLanguageRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react");
|
||||
const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
null,
|
||||
dataSource.map((record) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ key: record.id },
|
||||
columns.map((column) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{ key: column.dataIndex || column.title },
|
||||
column.render
|
||||
? column.render(record[column.dataIndex], record)
|
||||
: record[column.dataIndex],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const Empty = ({ description }: { description?: React.ReactNode }) =>
|
||||
React.createElement("div", null, description);
|
||||
(Empty as any).PRESENTED_IMAGE_SIMPLE = "simple";
|
||||
|
||||
return {
|
||||
Table,
|
||||
Tag: ({ children }: { children?: React.ReactNode }) => React.createElement("span", null, children),
|
||||
Button: ({
|
||||
children,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => React.createElement("button", { onClick }, icon, children),
|
||||
Tooltip: ({ children, title }: { children?: React.ReactNode; title?: React.ReactNode }) =>
|
||||
React.createElement("span", { title }, children),
|
||||
Empty,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@ant-design/icons", async () => {
|
||||
const React = await import("react");
|
||||
const Icon = () => React.createElement("span", null);
|
||||
return {
|
||||
BugOutlined: Icon,
|
||||
ClearOutlined: Icon,
|
||||
CloseOutlined: Icon,
|
||||
ClockCircleOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
const renderLogPanel = () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
act(() => {
|
||||
renderer = create(
|
||||
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
|
||||
<LogPanel height={260} onClose={vi.fn()} onResizeStart={vi.fn()} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
return renderer;
|
||||
};
|
||||
|
||||
const textContent = (node: any): string => {
|
||||
if (node === null || node === undefined) return "";
|
||||
if (typeof node === "string" || typeof node === "number") return String(node);
|
||||
if (Array.isArray(node)) return node.map((item) => textContent(item)).join("");
|
||||
return textContent(node.children || []);
|
||||
};
|
||||
|
||||
describe("LogPanel i18n", () => {
|
||||
beforeEach(() => {
|
||||
storeState.sqlLogs = [];
|
||||
storeState.clearSqlLogs.mockClear();
|
||||
});
|
||||
|
||||
it("renders log panel chrome in the active language", () => {
|
||||
const renderer = renderLogPanel();
|
||||
const renderedText = textContent(renderer.toJSON());
|
||||
|
||||
expect(renderedText).toContain("SQL execution log");
|
||||
expect(renderedText).toContain("Track execution status, duration, and errors for quick review.");
|
||||
expect(renderedText).toContain("No SQL execution logs");
|
||||
expect(renderer.root.findAll((node) => node.props?.title === "Clear logs").length).toBeGreaterThan(0);
|
||||
expect(renderer.root.findAll((node) => node.props?.title === "Close panel").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("localizes table labels while preserving raw SQL and message content", () => {
|
||||
storeState.sqlLogs = [
|
||||
{
|
||||
id: "log-1",
|
||||
timestamp: Date.UTC(2026, 5, 16, 1, 2, 3),
|
||||
sql: "SELECT * FROM users WHERE id = 7",
|
||||
status: "success",
|
||||
duration: 42,
|
||||
message: "driver raw detail",
|
||||
affectedRows: 7,
|
||||
},
|
||||
];
|
||||
|
||||
const renderer = renderLogPanel();
|
||||
const renderedText = textContent(renderer.toJSON());
|
||||
|
||||
expect(renderedText).toContain("SELECT * FROM users WHERE id = 7");
|
||||
expect(renderedText).toContain("driver raw detail");
|
||||
expect(renderedText).toContain("Affected: 7");
|
||||
expect(renderedText).toContain("42ms");
|
||||
expect(renderedText).toContain("OK");
|
||||
});
|
||||
|
||||
it("does not keep migrated Chinese UI literals in LogPanel source", () => {
|
||||
const source = readFileSync(new URL("./LogPanel.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toContain("SQL 执行日志");
|
||||
expect(source).not.toContain("记录执行状态、耗时与错误信息,便于快速回溯。");
|
||||
expect(source).not.toContain("清空日志");
|
||||
expect(source).not.toContain("关闭面板");
|
||||
expect(source).not.toContain("暂无 SQL 执行日志");
|
||||
expect(source).not.toContain("Affected: ");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Table, Tag, Button, Tooltip, Empty } from 'antd';
|
||||
import { ClearOutlined, CloseOutlined, BugOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { ClearOutlined, CloseOutlined, BugOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useI18n } from '../i18n/provider';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
interface LogPanelProps {
|
||||
height: number;
|
||||
@@ -10,6 +11,7 @@ interface LogPanelProps {
|
||||
}
|
||||
|
||||
const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
|
||||
const { t } = useI18n();
|
||||
const sqlLogs = useStore(state => state.sqlLogs);
|
||||
const clearSqlLogs = useStore(state => state.clearSqlLogs);
|
||||
const theme = useStore(state => state.theme);
|
||||
@@ -50,13 +52,13 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Time',
|
||||
title: t('log_panel.column.time'),
|
||||
dataIndex: 'timestamp',
|
||||
width: 80,
|
||||
render: (ts: number) => <span style={{ color: panelMutedTextColor, fontSize: '12px' }}>{new Date(ts).toLocaleTimeString()}</span>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
title: t('log_panel.column.status'),
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
render: (status: string) => (
|
||||
@@ -66,19 +68,19 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
title: t('log_panel.column.duration'),
|
||||
dataIndex: 'duration',
|
||||
width: 70,
|
||||
render: (d: number) => <span style={{ color: d > 1000 ? 'orange' : 'inherit', fontSize: '12px' }}>{d}ms</span>
|
||||
},
|
||||
{
|
||||
title: 'SQL / Message',
|
||||
title: t('log_panel.column.sql_message'),
|
||||
dataIndex: 'sql',
|
||||
render: (text: string, record: any) => (
|
||||
<div style={{ fontFamily: 'var(--gn-font-mono)', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.45' }}>
|
||||
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
|
||||
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
|
||||
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>Affected: {record.affectedRows}</div>}
|
||||
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>{t('log_panel.affected_rows', { count: record.affectedRows })}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -129,15 +131,15 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
<BugOutlined />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, color: darkMode ? '#f5f7ff' : '#162033' }}>SQL 执行日志</div>
|
||||
<div style={{ fontSize: 12, color: panelMutedTextColor }}>记录执行状态、耗时与错误信息,便于快速回溯。</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, color: darkMode ? '#f5f7ff' : '#162033' }}>{t('log_panel.title')}</div>
|
||||
<div style={{ fontSize: 12, color: panelMutedTextColor }}>{t('log_panel.description')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tooltip title="清空日志">
|
||||
<Tooltip title={t('log_panel.action.clear')}>
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} style={{ color: panelMutedTextColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭面板">
|
||||
<Tooltip title={t('log_panel.action.close')}>
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -149,7 +151,7 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
<div style={{ height: '100%', minHeight: 160, display: 'grid', placeItems: 'center' }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<span style={{ color: panelMutedTextColor }}>暂无 SQL 执行日志</span>}
|
||||
description={<span style={{ color: panelMutedTextColor }}>{t('log_panel.empty')}</span>}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
15
frontend/src/components/QueryEditor.i18n.test.ts
Normal file
15
frontend/src/components/QueryEditor.i18n.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const queryEditorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('QueryEditor i18n source guards', () => {
|
||||
it('does not keep legacy builtin SQL function completion details in component source', () => {
|
||||
expect(queryEditorSource).not.toContain('const SQL_FUNCTIONS');
|
||||
expect(queryEditorSource).not.toContain("detail: '聚合 - 计数'");
|
||||
expect(queryEditorSource).not.toContain("detail: '字符串 - 拼接'");
|
||||
expect(queryEditorSource).not.toContain("detail: '日期 - 当前日期时间'");
|
||||
expect(queryEditorSource).not.toContain("detail: 'JSON - 提取值'");
|
||||
expect(queryEditorSource).not.toContain("detail: '窗口 - 行号'");
|
||||
});
|
||||
});
|
||||
@@ -79,156 +79,6 @@ const buildQueryEditorAiContextPrompt = (connection: any, database: string): str
|
||||
});
|
||||
};
|
||||
|
||||
// SQL 常用内置函数(通用,适用于 MySQL/PostgreSQL/Oracle/SQL Server 等主流数据源)
|
||||
const SQL_FUNCTIONS: { name: string; detail: string }[] = [
|
||||
// 聚合函数
|
||||
{ name: 'COUNT', detail: '聚合 - 计数' },
|
||||
{ name: 'SUM', detail: '聚合 - 求和' },
|
||||
{ name: 'AVG', detail: '聚合 - 平均值' },
|
||||
{ name: 'MAX', detail: '聚合 - 最大值' },
|
||||
{ name: 'MIN', detail: '聚合 - 最小值' },
|
||||
{ name: 'GROUP_CONCAT', detail: '聚合 - 拼接分组值' },
|
||||
// 字符串函数
|
||||
{ name: 'CONCAT', detail: '字符串 - 拼接' },
|
||||
{ name: 'CONCAT_WS', detail: '字符串 - 带分隔符拼接' },
|
||||
{ name: 'SUBSTRING', detail: '字符串 - 截取子串' },
|
||||
{ name: 'SUBSTR', detail: '字符串 - 截取子串' },
|
||||
{ name: 'LEFT', detail: '字符串 - 从左截取' },
|
||||
{ name: 'RIGHT', detail: '字符串 - 从右截取' },
|
||||
{ name: 'LENGTH', detail: '字符串 - 字节长度' },
|
||||
{ name: 'CHAR_LENGTH', detail: '字符串 - 字符长度' },
|
||||
{ name: 'UPPER', detail: '字符串 - 转大写' },
|
||||
{ name: 'LOWER', detail: '字符串 - 转小写' },
|
||||
{ name: 'TRIM', detail: '字符串 - 去空格' },
|
||||
{ name: 'LTRIM', detail: '字符串 - 去左空格' },
|
||||
{ name: 'RTRIM', detail: '字符串 - 去右空格' },
|
||||
{ name: 'REPLACE', detail: '字符串 - 替换' },
|
||||
{ name: 'REVERSE', detail: '字符串 - 反转' },
|
||||
{ name: 'REPEAT', detail: '字符串 - 重复' },
|
||||
{ name: 'LPAD', detail: '字符串 - 左填充' },
|
||||
{ name: 'RPAD', detail: '字符串 - 右填充' },
|
||||
{ name: 'INSTR', detail: '字符串 - 查找位置' },
|
||||
{ name: 'LOCATE', detail: '字符串 - 查找位置' },
|
||||
{ name: 'FIND_IN_SET', detail: '字符串 - 在集合中查找' },
|
||||
{ name: 'FORMAT', detail: '字符串 - 数字格式化' },
|
||||
{ name: 'SPACE', detail: '字符串 - 生成空格' },
|
||||
{ name: 'INSERT', detail: '字符串 - 插入替换' },
|
||||
{ name: 'FIELD', detail: '字符串 - 返回位置索引' },
|
||||
{ name: 'ELT', detail: '字符串 - 按索引返回' },
|
||||
{ name: 'HEX', detail: '字符串 - 十六进制编码' },
|
||||
{ name: 'UNHEX', detail: '字符串 - 十六进制解码' },
|
||||
// 数学函数
|
||||
{ name: 'ABS', detail: '数学 - 绝对值' },
|
||||
{ name: 'CEIL', detail: '数学 - 向上取整' },
|
||||
{ name: 'CEILING', detail: '数学 - 向上取整' },
|
||||
{ name: 'FLOOR', detail: '数学 - 向下取整' },
|
||||
{ name: 'ROUND', detail: '数学 - 四舍五入' },
|
||||
{ name: 'TRUNCATE', detail: '数学 - 截断小数' },
|
||||
{ name: 'MOD', detail: '数学 - 取模' },
|
||||
{ name: 'RAND', detail: '数学 - 随机数' },
|
||||
{ name: 'SIGN', detail: '数学 - 符号' },
|
||||
{ name: 'POWER', detail: '数学 - 幂运算' },
|
||||
{ name: 'POW', detail: '数学 - 幂运算' },
|
||||
{ name: 'SQRT', detail: '数学 - 平方根' },
|
||||
{ name: 'LOG', detail: '数学 - 对数' },
|
||||
{ name: 'LOG2', detail: '数学 - 以2为底对数' },
|
||||
{ name: 'LOG10', detail: '数学 - 以10为底对数' },
|
||||
{ name: 'LN', detail: '数学 - 自然对数' },
|
||||
{ name: 'EXP', detail: '数学 - e的次方' },
|
||||
{ name: 'PI', detail: '数学 - 圆周率' },
|
||||
{ name: 'GREATEST', detail: '数学 - 返回最大值' },
|
||||
{ name: 'LEAST', detail: '数学 - 返回最小值' },
|
||||
// 日期时间函数
|
||||
{ name: 'NOW', detail: '日期 - 当前日期时间' },
|
||||
{ name: 'CURDATE', detail: '日期 - 当前日期' },
|
||||
{ name: 'CURRENT_DATE', detail: '日期 - 当前日期' },
|
||||
{ name: 'CURTIME', detail: '日期 - 当前时间' },
|
||||
{ name: 'CURRENT_TIME', detail: '日期 - 当前时间' },
|
||||
{ name: 'CURRENT_TIMESTAMP', detail: '日期 - 当前时间戳' },
|
||||
{ name: 'SYSDATE', detail: '日期 - 系统当前时间' },
|
||||
{ name: 'DATE', detail: '日期 - 提取日期部分' },
|
||||
{ name: 'TIME', detail: '日期 - 提取时间部分' },
|
||||
{ name: 'YEAR', detail: '日期 - 提取年份' },
|
||||
{ name: 'MONTH', detail: '日期 - 提取月份' },
|
||||
{ name: 'DAY', detail: '日期 - 提取天' },
|
||||
{ name: 'DAYOFWEEK', detail: '日期 - 星期几(1=周日)' },
|
||||
{ name: 'DAYOFYEAR', detail: '日期 - 年中第几天' },
|
||||
{ name: 'HOUR', detail: '日期 - 提取小时' },
|
||||
{ name: 'MINUTE', detail: '日期 - 提取分钟' },
|
||||
{ name: 'SECOND', detail: '日期 - 提取秒' },
|
||||
{ name: 'DATE_FORMAT', detail: '日期 - 格式化' },
|
||||
{ name: 'DATE_ADD', detail: '日期 - 加日期' },
|
||||
{ name: 'DATE_SUB', detail: '日期 - 减日期' },
|
||||
{ name: 'DATEDIFF', detail: '日期 - 日期差(天)' },
|
||||
{ name: 'TIMEDIFF', detail: '日期 - 时间差' },
|
||||
{ name: 'TIMESTAMPDIFF', detail: '日期 - 时间戳差' },
|
||||
{ name: 'TIMESTAMPADD', detail: '日期 - 时间戳加' },
|
||||
{ name: 'STR_TO_DATE', detail: '日期 - 字符串转日期' },
|
||||
{ name: 'UNIX_TIMESTAMP', detail: '日期 - Unix时间戳' },
|
||||
{ name: 'FROM_UNIXTIME', detail: '日期 - 从Unix时间戳转换' },
|
||||
{ name: 'LAST_DAY', detail: '日期 - 月末日期' },
|
||||
{ name: 'WEEK', detail: '日期 - 第几周' },
|
||||
{ name: 'QUARTER', detail: '日期 - 第几季度' },
|
||||
{ name: 'ADDDATE', detail: '日期 - 加日期' },
|
||||
{ name: 'SUBDATE', detail: '日期 - 减日期' },
|
||||
// 条件/流程控制函数
|
||||
{ name: 'IF', detail: '条件 - 如果' },
|
||||
{ name: 'IFNULL', detail: '条件 - NULL替换' },
|
||||
{ name: 'NULLIF', detail: '条件 - 相等返回NULL' },
|
||||
{ name: 'COALESCE', detail: '条件 - 返回第一个非NULL' },
|
||||
{ name: 'CASE', detail: '条件 - 分支表达式' },
|
||||
// 类型转换
|
||||
{ name: 'CAST', detail: '转换 - 类型转换' },
|
||||
{ name: 'CONVERT', detail: '转换 - 类型/字符集转换' },
|
||||
// JSON 函数
|
||||
{ name: 'JSON_EXTRACT', detail: 'JSON - 提取值' },
|
||||
{ name: 'JSON_UNQUOTE', detail: 'JSON - 去引号' },
|
||||
{ name: 'JSON_SET', detail: 'JSON - 设置值' },
|
||||
{ name: 'JSON_INSERT', detail: 'JSON - 插入值' },
|
||||
{ name: 'JSON_REPLACE', detail: 'JSON - 替换值' },
|
||||
{ name: 'JSON_REMOVE', detail: 'JSON - 删除值' },
|
||||
{ name: 'JSON_CONTAINS', detail: 'JSON - 包含判断' },
|
||||
{ name: 'JSON_OBJECT', detail: 'JSON - 构建对象' },
|
||||
{ name: 'JSON_ARRAY', detail: 'JSON - 构建数组' },
|
||||
{ name: 'JSON_LENGTH', detail: 'JSON - 元素个数' },
|
||||
{ name: 'JSON_TYPE', detail: 'JSON - 值类型' },
|
||||
{ name: 'JSON_VALID', detail: 'JSON - 验证' },
|
||||
{ name: 'JSON_KEYS', detail: 'JSON - 获取键列表' },
|
||||
// 加密/哈希函数
|
||||
{ name: 'MD5', detail: '加密 - MD5哈希' },
|
||||
{ name: 'SHA1', detail: '加密 - SHA1哈希' },
|
||||
{ name: 'SHA2', detail: '加密 - SHA2哈希' },
|
||||
{ name: 'UUID', detail: '工具 - 生成UUID' },
|
||||
// 信息函数
|
||||
{ name: 'DATABASE', detail: '信息 - 当前数据库' },
|
||||
{ name: 'USER', detail: '信息 - 当前用户' },
|
||||
{ name: 'VERSION', detail: '信息 - MySQL版本' },
|
||||
{ name: 'CONNECTION_ID', detail: '信息 - 连接ID' },
|
||||
{ name: 'LAST_INSERT_ID', detail: '信息 - 最后插入ID' },
|
||||
{ name: 'ROW_COUNT', detail: '信息 - 影响行数' },
|
||||
{ name: 'FOUND_ROWS', detail: '信息 - 匹配总行数' },
|
||||
{ name: 'CHARSET', detail: '信息 - 字符集' },
|
||||
{ name: 'COLLATION', detail: '信息 - 排序规则' },
|
||||
// 窗口函数
|
||||
{ name: 'ROW_NUMBER', detail: '窗口 - 行号' },
|
||||
{ name: 'RANK', detail: '窗口 - 排名(有间隔)' },
|
||||
{ name: 'DENSE_RANK', detail: '窗口 - 排名(无间隔)' },
|
||||
{ name: 'NTILE', detail: '窗口 - 分桶' },
|
||||
{ name: 'LAG', detail: '窗口 - 前一行' },
|
||||
{ name: 'LEAD', detail: '窗口 - 后一行' },
|
||||
{ name: 'FIRST_VALUE', detail: '窗口 - 第一个值' },
|
||||
{ name: 'LAST_VALUE', detail: '窗口 - 最后一个值' },
|
||||
{ name: 'NTH_VALUE', detail: '窗口 - 第N个值' },
|
||||
// 其他
|
||||
{ name: 'DISTINCT', detail: '修饰 - 去重' },
|
||||
{ name: 'EXISTS', detail: '修饰 - 存在判断' },
|
||||
{ name: 'BETWEEN', detail: '修饰 - 范围判断' },
|
||||
{ name: 'LIKE', detail: '修饰 - 模式匹配' },
|
||||
{ name: 'REGEXP', detail: '修饰 - 正则匹配' },
|
||||
{ name: 'BENCHMARK', detail: '工具 - 性能测试' },
|
||||
{ name: 'SLEEP', detail: '工具 - 延时' },
|
||||
];
|
||||
|
||||
// HMR 重载时释放旧注册避免补全项重复
|
||||
const _g = globalThis as any;
|
||||
if (!_g.__gonaviSqlCompletionState) {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
const source = readFileSync(new URL('./TableDesigner.tsx', import.meta.url), 'utf8');
|
||||
|
||||
const readLocale = (locale: string) => JSON.parse(
|
||||
readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'),
|
||||
) as Record<string, string>;
|
||||
|
||||
describe('TableDesigner i18n', () => {
|
||||
it('localizes designer title, toolbar, tabs, modals, and schema messages', () => {
|
||||
[
|
||||
@@ -38,4 +42,147 @@ describe('TableDesigner i18n', () => {
|
||||
expect(source).toContain('-- Enter a CREATE TRIGGER statement');
|
||||
expect(source).toContain('-- Trigger definition unavailable');
|
||||
});
|
||||
|
||||
it('localizes remaining V2 and StarRocks technical labels without translating raw values', () => {
|
||||
[
|
||||
'SCHEMA DESIGNER',
|
||||
'Duplicate Key',
|
||||
'Primary Key',
|
||||
'Unique Key',
|
||||
'Aggregate Key',
|
||||
'Buckets Auto',
|
||||
'placeholder="Buckets"',
|
||||
'utf8mb4 (Recommended)',
|
||||
].forEach((snippet) => {
|
||||
expect(source).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
"t('table_designer.title.schema_designer'",
|
||||
"t('table_designer.starrocks.key_model.duplicate'",
|
||||
"t('table_designer.column.primary_key'",
|
||||
"t('table_designer.starrocks.key_model.unique'",
|
||||
"t('table_designer.starrocks.key_model.aggregate'",
|
||||
"t('table_designer.starrocks.bucket_mode.auto'",
|
||||
"t('table_designer.starrocks.placeholder.bucket_count'",
|
||||
"t('table_designer.option.recommended_suffix'",
|
||||
].forEach((snippet) => {
|
||||
expect(source).toContain(snippet);
|
||||
});
|
||||
|
||||
['utf8mb4', 'DUPLICATE', 'PRIMARY', 'UNIQUE', 'AGGREGATE', 'AUTO'].forEach((rawValue) => {
|
||||
expect(source).toContain(rawValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('localizes the default collation label suffix while keeping the raw collation value', () => {
|
||||
expect(source).not.toContain('utf8mb4_unicode_ci (Default)');
|
||||
expect(source).toContain('utf8mb4_unicode_ci');
|
||||
expect(source).toContain("t('table_designer.option.default'");
|
||||
});
|
||||
|
||||
it('does not use English Bucket fallback for newly localized non-English bucket labels', () => {
|
||||
['zh-CN', 'zh-TW', 'ja-JP', 'de-DE', 'ru-RU'].forEach((locale) => {
|
||||
const messages = readLocale(locale);
|
||||
|
||||
[
|
||||
messages['table_designer.starrocks.bucket_mode.auto'],
|
||||
messages['table_designer.starrocks.bucket_mode.number'],
|
||||
messages['table_designer.starrocks.placeholder.bucket_count'],
|
||||
].forEach((message) => {
|
||||
expect(message).toBeTruthy();
|
||||
expect(message).not.toContain('Bucket');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('localizes StarRocks key column placeholders in Chinese locales while keeping raw examples', () => {
|
||||
['zh-CN', 'zh-TW'].forEach((locale) => {
|
||||
const message = readLocale(locale)['table_designer.starrocks.placeholder.key_columns'];
|
||||
|
||||
expect(message).toBeTruthy();
|
||||
expect(message).not.toContain('Key');
|
||||
expect(message).toContain('id');
|
||||
expect(message).toContain('date');
|
||||
});
|
||||
});
|
||||
|
||||
it('removes English words from Chinese StarRocks distribution labels', () => {
|
||||
['zh-CN', 'zh-TW'].forEach((locale) => {
|
||||
const messages = readLocale(locale);
|
||||
|
||||
[
|
||||
messages['table_designer.starrocks.distribution.hash'],
|
||||
messages['table_designer.starrocks.distribution.random'],
|
||||
].forEach((message) => {
|
||||
expect(message).toBeTruthy();
|
||||
expect(message).not.toContain('Hash');
|
||||
expect(message).not.toContain('Random');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('removes English StarRocks distribution words from Japanese and Russian labels', () => {
|
||||
[
|
||||
{
|
||||
locale: 'ja-JP',
|
||||
key: 'table_designer.starrocks.distribution.hash',
|
||||
forbidden: 'Hash',
|
||||
},
|
||||
{
|
||||
locale: 'ja-JP',
|
||||
key: 'table_designer.starrocks.distribution.random',
|
||||
forbidden: 'Random',
|
||||
},
|
||||
{
|
||||
locale: 'ru-RU',
|
||||
key: 'table_designer.starrocks.distribution.hash',
|
||||
forbidden: 'Hash',
|
||||
},
|
||||
{
|
||||
locale: 'ru-RU',
|
||||
key: 'table_designer.starrocks.distribution.random',
|
||||
forbidden: 'Random',
|
||||
},
|
||||
].forEach(({ locale, key, forbidden }) => {
|
||||
const message = readLocale(locale)[key];
|
||||
|
||||
expect(message).toBeTruthy();
|
||||
expect(message).not.toContain(forbidden);
|
||||
});
|
||||
});
|
||||
|
||||
it('localizes StarRocks key model and primary key labels for key locales without English fallback words', () => {
|
||||
const expectationEntries = [
|
||||
{
|
||||
key: 'table_designer.starrocks.key_model.duplicate',
|
||||
forbidden: ['Duplicate', 'Key'],
|
||||
},
|
||||
{
|
||||
key: 'table_designer.starrocks.key_model.unique',
|
||||
forbidden: ['Unique', 'Key'],
|
||||
},
|
||||
{
|
||||
key: 'table_designer.starrocks.key_model.aggregate',
|
||||
forbidden: ['Aggregate', 'Key'],
|
||||
},
|
||||
{
|
||||
key: 'table_designer.column.primary_key',
|
||||
forbidden: ['Primary', 'Key'],
|
||||
},
|
||||
];
|
||||
|
||||
['zh-CN', 'zh-TW', 'ja-JP', 'de-DE', 'ru-RU'].forEach((locale) => {
|
||||
const messages = readLocale(locale);
|
||||
|
||||
expectationEntries.forEach(({ key, forbidden }) => {
|
||||
const message = messages[key];
|
||||
|
||||
expect(message).toBeTruthy();
|
||||
forbidden.forEach((word) => {
|
||||
expect(message).not.toContain(word);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,15 +257,22 @@ const SQLSERVER_INDEX_TYPE_OPTIONS = [
|
||||
];
|
||||
|
||||
const CHARSETS = [
|
||||
{ label: 'utf8mb4 (Recommended)', value: 'utf8mb4' },
|
||||
{ label: 'utf8', value: 'utf8' },
|
||||
{ label: 'latin1', value: 'latin1' },
|
||||
{ label: 'ascii', value: 'ascii' },
|
||||
{ value: 'utf8mb4' },
|
||||
{ value: 'utf8' },
|
||||
{ value: 'latin1' },
|
||||
{ value: 'ascii' },
|
||||
];
|
||||
|
||||
const getCharsetOptions = (i18nLanguage: string) => CHARSETS.map(({ value }) => ({
|
||||
label: value === 'utf8mb4'
|
||||
? `${value} ${t('table_designer.option.recommended_suffix', undefined, i18nLanguage)}`
|
||||
: value,
|
||||
value,
|
||||
}));
|
||||
|
||||
const COLLATIONS = {
|
||||
'utf8mb4': [
|
||||
{ label: 'utf8mb4_unicode_ci (Default)', value: 'utf8mb4_unicode_ci' },
|
||||
{ label: 'utf8mb4_unicode_ci', value: 'utf8mb4_unicode_ci' },
|
||||
{ label: 'utf8mb4_general_ci', value: 'utf8mb4_general_ci' },
|
||||
{ label: 'utf8mb4_bin', value: 'utf8mb4_bin' },
|
||||
{ label: 'utf8mb4_0900_ai_ci', value: 'utf8mb4_0900_ai_ci' },
|
||||
@@ -277,6 +284,15 @@ const COLLATIONS = {
|
||||
]
|
||||
};
|
||||
|
||||
const getCollationOptions = (i18nLanguage: string) => Object.fromEntries(
|
||||
Object.entries(COLLATIONS).map(([charset, options]) => [
|
||||
charset,
|
||||
options.map((option, index) => option.value === 'utf8mb4_unicode_ci' && index === 0
|
||||
? { ...option, label: `${option.value} (${t('table_designer.option.default', undefined, i18nLanguage)})` }
|
||||
: option),
|
||||
]),
|
||||
) as typeof COLLATIONS;
|
||||
|
||||
const useTableDesignerI18nLanguage = () => {
|
||||
const i18n = useOptionalI18n();
|
||||
return i18n?.language ?? getCurrentLanguage();
|
||||
@@ -449,6 +465,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const designerTableTitle = tab.tableName || newTableName || t('table_designer.title.untitled_table', undefined, i18nLanguage);
|
||||
const designerDbTitle = tab.dbName || t('table_designer.title.default_database', undefined, i18nLanguage);
|
||||
const designerColumnSummary = t('table_designer.summary.columns', { count: columns.length }, i18nLanguage);
|
||||
const charsetOptions = useMemo(() => getCharsetOptions(i18nLanguage), [i18nLanguage]);
|
||||
const collationOptions = useMemo(() => getCollationOptions(i18nLanguage), [i18nLanguage]);
|
||||
const panelRadius = 10;
|
||||
const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.18)' : 'rgba(0, 0, 0, 0.12)';
|
||||
const panelToolbarBorder = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)';
|
||||
@@ -2432,10 +2450,10 @@ END;`;
|
||||
value={starRocksKeyModel}
|
||||
onChange={setStarRocksKeyModel}
|
||||
options={[
|
||||
{ label: 'Duplicate Key', value: 'DUPLICATE' },
|
||||
{ label: 'Primary Key', value: 'PRIMARY' },
|
||||
{ label: 'Unique Key', value: 'UNIQUE' },
|
||||
{ label: 'Aggregate Key', value: 'AGGREGATE' },
|
||||
{ label: t('table_designer.starrocks.key_model.duplicate', undefined, i18nLanguage), value: 'DUPLICATE' },
|
||||
{ label: t('table_designer.column.primary_key', undefined, i18nLanguage), value: 'PRIMARY' },
|
||||
{ label: t('table_designer.starrocks.key_model.unique', undefined, i18nLanguage), value: 'UNIQUE' },
|
||||
{ label: t('table_designer.starrocks.key_model.aggregate', undefined, i18nLanguage), value: 'AGGREGATE' },
|
||||
]}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
@@ -2482,7 +2500,7 @@ END;`;
|
||||
value={starRocksBucketMode}
|
||||
onChange={setStarRocksBucketMode}
|
||||
options={[
|
||||
{ label: 'Buckets Auto', value: 'AUTO' },
|
||||
{ label: t('table_designer.starrocks.bucket_mode.auto', undefined, i18nLanguage), value: 'AUTO' },
|
||||
{ label: t('table_designer.starrocks.bucket_mode.number', undefined, i18nLanguage), value: 'NUMBER' },
|
||||
]}
|
||||
style={{ width: 160 }}
|
||||
@@ -2492,7 +2510,7 @@ END;`;
|
||||
disabled={starRocksBucketMode !== 'NUMBER'}
|
||||
value={starRocksBucketCount}
|
||||
onChange={(e) => setStarRocksBucketCount(e.target.value.replace(/[^\d]/g, ''))}
|
||||
placeholder="Buckets"
|
||||
placeholder={t('table_designer.starrocks.placeholder.bucket_count', undefined, i18nLanguage)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</Space>
|
||||
@@ -2694,7 +2712,7 @@ END;`;
|
||||
{isV2Ui && (
|
||||
<div className="gn-v2-designer-header">
|
||||
<div className="gn-v2-designer-title">
|
||||
<span>SCHEMA DESIGNER</span>
|
||||
<span>{t('table_designer.title.schema_designer', undefined, i18nLanguage)}</span>
|
||||
<strong>{designerTableTitle}</strong>
|
||||
</div>
|
||||
<div className="gn-v2-designer-meta">
|
||||
@@ -2736,14 +2754,14 @@ END;`;
|
||||
// Set default collation
|
||||
const cols = (COLLATIONS as any)[v];
|
||||
if (cols && cols.length > 0) setCollation(cols[0].value);
|
||||
}}
|
||||
options={CHARSETS}
|
||||
style={{ width: 120 }}
|
||||
}}
|
||||
options={charsetOptions}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Select
|
||||
value={collation}
|
||||
onChange={setCollation}
|
||||
options={(COLLATIONS as any)[charset] || []}
|
||||
options={(collationOptions as any)[charset] || []}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</>
|
||||
@@ -3071,13 +3089,13 @@ END;`;
|
||||
const cols = (COLLATIONS as any)[v];
|
||||
if (cols && cols.length > 0) setCopyCollation(cols[0].value);
|
||||
}}
|
||||
options={CHARSETS}
|
||||
options={charsetOptions}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Select
|
||||
value={copyCollation}
|
||||
onChange={setCopyCollation}
|
||||
options={(COLLATIONS as any)[copyCharset] || []}
|
||||
options={(collationOptions as any)[copyCharset] || []}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
136
frontend/src/components/jvm/JVMChangePreviewModal.test.tsx
Normal file
136
frontend/src/components/jvm/JVMChangePreviewModal.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import JVMChangePreviewModal from "./JVMChangePreviewModal";
|
||||
import { getCurrentLanguage, setCurrentLanguage, t as translate } from "../../i18n";
|
||||
import { I18nProvider } from "../../i18n/provider";
|
||||
|
||||
vi.mock("antd", () => {
|
||||
const Text = ({ children }: any) => <span>{children}</span>;
|
||||
const Modal = ({
|
||||
cancelText,
|
||||
children,
|
||||
okText,
|
||||
title,
|
||||
}: any) => (
|
||||
<section>
|
||||
<h1>{title}</h1>
|
||||
<button type="button">{okText}</button>
|
||||
<button type="button">{cancelText}</button>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
const Descriptions: any = ({ children }: any) => <dl>{children}</dl>;
|
||||
Descriptions.Item = ({ children, label }: any) => (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
return {
|
||||
Alert: ({ description, message }: any) => (
|
||||
<div role="alert">
|
||||
{message}
|
||||
{description}
|
||||
</div>
|
||||
),
|
||||
Descriptions,
|
||||
Modal,
|
||||
Space: ({ children }: any) => <div>{children}</div>,
|
||||
Tag: ({ children }: any) => <span>{children}</span>,
|
||||
Typography: { Text },
|
||||
};
|
||||
});
|
||||
|
||||
const textContent = (node: any): string =>
|
||||
(node.children || [])
|
||||
.map((item: any) => (typeof item === "string" ? item : textContent(item)))
|
||||
.join("");
|
||||
|
||||
describe("JVMChangePreviewModal", () => {
|
||||
const previousLanguage = getCurrentLanguage();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("window", {
|
||||
go: {
|
||||
app: {
|
||||
App: {},
|
||||
},
|
||||
aiservice: {
|
||||
Service: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentLanguage(previousLanguage);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("localizes modal chrome and risk formatter through provider locale", async () => {
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<I18nProvider
|
||||
preference="en-US"
|
||||
systemLanguages={[]}
|
||||
onPreferenceChange={vi.fn()}
|
||||
>
|
||||
<JVMChangePreviewModal
|
||||
open
|
||||
applying={false}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
preview={{
|
||||
allowed: false,
|
||||
requiresConfirmation: true,
|
||||
confirmationToken: "token-from-preview",
|
||||
summary: "",
|
||||
riskLevel: "high",
|
||||
blockingReason: "policy denied by raw backend",
|
||||
before: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "cold",
|
||||
},
|
||||
after: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "warm",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const text = textContent(renderer!.root);
|
||||
expect(text).toContain(translate("jvm_change_preview_modal.title", undefined, "en-US"));
|
||||
expect(text).toContain(translate("jvm_change_preview_modal.action.confirm_execute", undefined, "en-US"));
|
||||
expect(text).toContain(translate("jvm_change_preview_modal.action.close", undefined, "en-US"));
|
||||
expect(text).toContain(translate("jvm_change_preview_modal.status.generated", undefined, "en-US"));
|
||||
expect(text).toContain(
|
||||
translate(
|
||||
"jvm_change_preview_modal.risk.label",
|
||||
{
|
||||
level: translate("jvm_change_preview_modal.risk.high", undefined, "en-US"),
|
||||
},
|
||||
"en-US",
|
||||
),
|
||||
);
|
||||
expect(text).toContain(translate("jvm_change_preview_modal.permission.forbidden", undefined, "en-US"));
|
||||
expect(text).toContain("policy denied by raw backend");
|
||||
expect(text).toContain("jmx:/attribute/app/Mode");
|
||||
expect(text).not.toContain("JVM 变更预览");
|
||||
expect(text).not.toContain("确认执行");
|
||||
expect(text).not.toContain("风险 高");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import React, { useMemo } from "react";
|
||||
import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd";
|
||||
|
||||
import type { JVMChangePreview } from "../../types";
|
||||
import { t as translate } from "../../i18n";
|
||||
import { useOptionalI18n } from "../../i18n/provider";
|
||||
import {
|
||||
formatJVMRiskLevelText,
|
||||
formatJVMValueForDisplay,
|
||||
@@ -42,48 +44,75 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const i18n = useOptionalI18n();
|
||||
const i18nLanguage = i18n?.language;
|
||||
const tr = (key: string, params?: Parameters<typeof translate>[1]) =>
|
||||
translate(key, params, i18nLanguage);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!preview) {
|
||||
return "暂无预览结果";
|
||||
return tr("jvm_change_preview_modal.status.no_preview");
|
||||
}
|
||||
return preview.summary || "预览已生成";
|
||||
}, [preview]);
|
||||
return preview.summary || tr("jvm_change_preview_modal.status.generated");
|
||||
}, [i18nLanguage, preview]);
|
||||
const riskLevelText = formatJVMRiskLevelText(
|
||||
preview?.riskLevel,
|
||||
i18nLanguage,
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="JVM 变更预览"
|
||||
title={tr("jvm_change_preview_modal.title")}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onConfirm}
|
||||
okText="确认执行"
|
||||
cancelText="关闭"
|
||||
okText={tr("jvm_change_preview_modal.action.confirm_execute")}
|
||||
cancelText={tr("jvm_change_preview_modal.action.close")}
|
||||
okButtonProps={{ disabled: !preview?.allowed, loading: applying }}
|
||||
width={880}
|
||||
destroyOnHidden
|
||||
>
|
||||
{!preview ? (
|
||||
<Alert type="info" showIcon message="暂无预览结果" />
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={tr("jvm_change_preview_modal.status.no_preview")}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
|
||||
<Descriptions.Item label="变更摘要">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_change_preview_modal.section.summary")}
|
||||
>
|
||||
<Space size={8} wrap>
|
||||
<Text>{summary}</Text>
|
||||
<Tag color={riskColorMap[preview.riskLevel] || "default"}>
|
||||
风险 {formatJVMRiskLevelText(preview.riskLevel)}
|
||||
{tr("jvm_change_preview_modal.risk.label", {
|
||||
level: riskLevelText,
|
||||
})}
|
||||
</Tag>
|
||||
{preview.requiresConfirmation ? (
|
||||
<Tag color="gold">需要确认</Tag>
|
||||
<Tag color="gold">
|
||||
{tr(
|
||||
"jvm_change_preview_modal.permission.requires_confirmation",
|
||||
)}
|
||||
</Tag>
|
||||
) : null}
|
||||
{preview.allowed ? (
|
||||
<Tag color="green">允许执行</Tag>
|
||||
<Tag color="green">
|
||||
{tr("jvm_change_preview_modal.permission.allowed")}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="red">禁止执行</Tag>
|
||||
<Tag color="red">
|
||||
{tr("jvm_change_preview_modal.permission.forbidden")}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
{preview.blockingReason ? (
|
||||
<Descriptions.Item label="阻断原因">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_change_preview_modal.blocking.label")}
|
||||
>
|
||||
<Text type="danger" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{preview.blockingReason}
|
||||
</Text>
|
||||
@@ -95,7 +124,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="当前变更不可执行"
|
||||
message={tr("jvm_change_preview_modal.blocking.alert_message")}
|
||||
description={
|
||||
<span style={{ whiteSpace: "pre-wrap" }}>
|
||||
{preview.blockingReason}
|
||||
@@ -108,7 +137,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
|
||||
|
||||
<div>
|
||||
<Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||
变更前
|
||||
{tr("jvm_change_preview_modal.section.before")}
|
||||
</Text>
|
||||
<Descriptions
|
||||
column={1}
|
||||
@@ -116,13 +145,19 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
|
||||
styles={DESCRIPTION_STYLES}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Descriptions.Item label="资源 ID">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_change_preview_modal.field.resource_id")}
|
||||
>
|
||||
{preview.before?.resourceId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_change_preview_modal.field.version")}
|
||||
>
|
||||
{preview.before?.version || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="格式">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_change_preview_modal.field.format")}
|
||||
>
|
||||
{preview.before?.format || "-"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
@@ -133,7 +168,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
|
||||
|
||||
<div>
|
||||
<Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||
变更后
|
||||
{tr("jvm_change_preview_modal.section.after")}
|
||||
</Text>
|
||||
<Descriptions
|
||||
column={1}
|
||||
@@ -141,13 +176,19 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
|
||||
styles={DESCRIPTION_STYLES}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Descriptions.Item label="资源 ID">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_change_preview_modal.field.resource_id")}
|
||||
>
|
||||
{preview.after?.resourceId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_change_preview_modal.field.version")}
|
||||
>
|
||||
{preview.after?.version || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="格式">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_change_preview_modal.field.format")}
|
||||
>
|
||||
{preview.after?.format || "-"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
@@ -28,10 +28,11 @@ vi.mock("recharts", () => {
|
||||
});
|
||||
|
||||
describe("JVMMonitoringCharts", () => {
|
||||
it("renders chart titles, empty text, and legends in Chinese", () => {
|
||||
it("renders chart titles, empty text, and legends with the requested language", () => {
|
||||
const emptyMarkup = renderToStaticMarkup(
|
||||
<JVMMonitoringCharts
|
||||
darkMode={false}
|
||||
language="en-US"
|
||||
session={{
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
@@ -44,13 +45,15 @@ describe("JVMMonitoringCharts", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(emptyMarkup).toContain("堆内存");
|
||||
expect(emptyMarkup).toContain("暂无堆内存采样数据");
|
||||
expect(emptyMarkup).not.toContain("暂无 Heap 采样数据");
|
||||
expect(emptyMarkup).toContain("Heap memory");
|
||||
expect(emptyMarkup).toContain("No heap memory samples yet.");
|
||||
expect(emptyMarkup).not.toContain("堆内存");
|
||||
expect(emptyMarkup).not.toContain("暂无堆内存采样数据");
|
||||
|
||||
const dataMarkup = renderToStaticMarkup(
|
||||
<JVMMonitoringCharts
|
||||
darkMode={false}
|
||||
language="en-US"
|
||||
session={{
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
@@ -81,18 +84,17 @@ describe("JVMMonitoringCharts", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(dataMarkup).toContain("堆内存已使用");
|
||||
expect(dataMarkup).toContain("堆内存已提交");
|
||||
expect(dataMarkup).toContain("垃圾回收次数");
|
||||
expect(dataMarkup).toContain("垃圾回收耗时(ms)");
|
||||
expect(dataMarkup).toContain("线程数");
|
||||
expect(dataMarkup).toContain("守护线程数");
|
||||
expect(dataMarkup).toContain("线程峰值");
|
||||
expect(dataMarkup).toContain("已加载类");
|
||||
expect(dataMarkup).toContain("已卸载类");
|
||||
expect(dataMarkup).not.toContain("Heap Used");
|
||||
expect(dataMarkup).not.toContain("GC Count");
|
||||
expect(dataMarkup).not.toContain("Threads");
|
||||
expect(dataMarkup).toContain("Heap used");
|
||||
expect(dataMarkup).toContain("Heap committed");
|
||||
expect(dataMarkup).toContain("GC count");
|
||||
expect(dataMarkup).toContain("GC time (ms)");
|
||||
expect(dataMarkup).toContain("Thread count");
|
||||
expect(dataMarkup).toContain("Daemon threads");
|
||||
expect(dataMarkup).toContain("Peak threads");
|
||||
expect(dataMarkup).toContain("Loaded classes");
|
||||
expect(dataMarkup).toContain("Unloaded classes");
|
||||
expect(dataMarkup).not.toContain("堆内存已使用");
|
||||
expect(dataMarkup).not.toContain("垃圾回收次数");
|
||||
expect(dataMarkup).not.toContain("ClassLoading");
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { t, type SupportedLanguage } from "../../i18n";
|
||||
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
|
||||
import {
|
||||
buildMonitoringChartPoints,
|
||||
@@ -25,6 +26,7 @@ type JVMMonitoringChartsProps = {
|
||||
points: JVMMonitoringPoint[];
|
||||
session: JVMMonitoringSessionState;
|
||||
darkMode: boolean;
|
||||
language?: SupportedLanguage;
|
||||
};
|
||||
|
||||
const buildCardStyle = (darkMode: boolean): React.CSSProperties => ({
|
||||
@@ -49,8 +51,10 @@ const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
|
||||
points,
|
||||
session,
|
||||
darkMode,
|
||||
language,
|
||||
}) => {
|
||||
const data = buildMonitoringChartPoints(points);
|
||||
const tr = (key: string) => t(key, undefined, language);
|
||||
const data = buildMonitoringChartPoints(points, language);
|
||||
const textColor = darkMode ? "rgba(255,255,255,0.72)" : "rgba(0,0,0,0.65)";
|
||||
const gridColor = darkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
|
||||
const tooltipStyle = {
|
||||
@@ -84,11 +88,11 @@ const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} xl={12}>
|
||||
{renderCard(
|
||||
"堆内存",
|
||||
tr("jvm_monitoring_charts.title.heap"),
|
||||
!hasData
|
||||
? renderEmpty("暂无堆内存采样数据")
|
||||
? renderEmpty(tr("jvm_monitoring_charts.empty.heap.no_samples"))
|
||||
: !monitoringMetricAvailable(session, "heap.used")
|
||||
? renderEmpty("当前监控来源未提供堆内存指标")
|
||||
? renderEmpty(tr("jvm_monitoring_charts.empty.heap.metric_unavailable"))
|
||||
: (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={chartMargin}>
|
||||
@@ -103,8 +107,8 @@ const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
|
||||
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} tickFormatter={formatMonitoringAxisBytes} width={74} />
|
||||
<RechartsTooltip contentStyle={tooltipStyle} />
|
||||
<Legend {...legendProps} />
|
||||
<Area type="monotone" dataKey="heapUsedBytes" name="堆内存已使用" stroke="#fa8c16" fill="url(#jvmHeapGradient)" isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="heapCommittedBytes" name="堆内存已提交" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Area type="monotone" dataKey="heapUsedBytes" name={tr("jvm_monitoring_charts.legend.heap_used")} stroke="#fa8c16" fill="url(#jvmHeapGradient)" isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="heapCommittedBytes" name={tr("jvm_monitoring_charts.legend.heap_committed")} stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
),
|
||||
@@ -112,11 +116,11 @@ const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
{renderCard(
|
||||
"垃圾回收",
|
||||
tr("jvm_monitoring_charts.title.gc"),
|
||||
!hasData
|
||||
? renderEmpty("暂无垃圾回收采样数据")
|
||||
? renderEmpty(tr("jvm_monitoring_charts.empty.gc.no_samples"))
|
||||
: !monitoringMetricAvailable(session, "gc.count")
|
||||
? renderEmpty("当前监控来源未提供垃圾回收指标")
|
||||
? renderEmpty(tr("jvm_monitoring_charts.empty.gc.metric_unavailable"))
|
||||
: (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={chartMargin}>
|
||||
@@ -126,8 +130,8 @@ const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
|
||||
<YAxis yAxisId="right" orientation="right" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
|
||||
<RechartsTooltip contentStyle={tooltipStyle} />
|
||||
<Legend {...legendProps} />
|
||||
<Line yAxisId="left" type="monotone" dataKey="gcCollectionCount" name="垃圾回收次数" stroke="#52c41a" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="gcCollectionTimeMs" name="垃圾回收耗时(ms)" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="left" type="monotone" dataKey="gcCollectionCount" name={tr("jvm_monitoring_charts.legend.gc_count")} stroke="#52c41a" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="gcCollectionTimeMs" name={tr("jvm_monitoring_charts.legend.gc_time_ms")} stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
),
|
||||
@@ -135,11 +139,11 @@ const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
{renderCard(
|
||||
"线程",
|
||||
tr("jvm_monitoring_charts.title.threads"),
|
||||
!hasData
|
||||
? renderEmpty("暂无线程采样数据")
|
||||
? renderEmpty(tr("jvm_monitoring_charts.empty.threads.no_samples"))
|
||||
: !monitoringMetricAvailable(session, "thread.count")
|
||||
? renderEmpty("当前监控来源未提供线程指标")
|
||||
? renderEmpty(tr("jvm_monitoring_charts.empty.threads.metric_unavailable"))
|
||||
: (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={chartMargin}>
|
||||
@@ -148,9 +152,9 @@ const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
|
||||
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
|
||||
<RechartsTooltip contentStyle={tooltipStyle} />
|
||||
<Legend {...legendProps} />
|
||||
<Line type="monotone" dataKey="threadCount" name="线程数" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="daemonThreadCount" name="守护线程数" stroke="#13c2c2" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="peakThreadCount" name="线程峰值" stroke="#faad14" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="threadCount" name={tr("jvm_monitoring_charts.legend.thread_count")} stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="daemonThreadCount" name={tr("jvm_monitoring_charts.legend.daemon_thread_count")} stroke="#13c2c2" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="peakThreadCount" name={tr("jvm_monitoring_charts.legend.peak_thread_count")} stroke="#faad14" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
),
|
||||
@@ -158,21 +162,21 @@ const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
{renderCard(
|
||||
"类加载",
|
||||
tr("jvm_monitoring_charts.title.classes"),
|
||||
!hasData
|
||||
? renderEmpty("暂无类加载采样数据")
|
||||
? renderEmpty(tr("jvm_monitoring_charts.empty.classes.no_samples"))
|
||||
: !monitoringMetricAvailable(session, "class.loading")
|
||||
? renderEmpty("当前监控来源未提供类加载指标")
|
||||
? renderEmpty(tr("jvm_monitoring_charts.empty.classes.metric_unavailable"))
|
||||
: (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={chartMargin}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
|
||||
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
|
||||
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} tickFormatter={formatCompactNumber} width={58} />
|
||||
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} tickFormatter={(value) => formatCompactNumber(Number(value), language)} width={58} />
|
||||
<RechartsTooltip contentStyle={tooltipStyle} />
|
||||
<Legend {...legendProps} />
|
||||
<Line type="monotone" dataKey="loadedClassCount" name="已加载类" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="unloadedClassCount" name="已卸载类" stroke="#8c8c8c" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="loadedClassCount" name={tr("jvm_monitoring_charts.legend.loaded_classes")} stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="unloadedClassCount" name={tr("jvm_monitoring_charts.legend.unloaded_classes")} stroke="#8c8c8c" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
),
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { JVMMonitoringSessionState } from "../../types";
|
||||
import JVMMonitoringDetailPanel from "./JVMMonitoringDetailPanel";
|
||||
|
||||
describe("JVMMonitoringDetailPanel", () => {
|
||||
it("explains why process physical memory can be unavailable for JMX", () => {
|
||||
it("explains why process physical memory can be unavailable for JMX in the requested language", () => {
|
||||
const session: JVMMonitoringSessionState = {
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
@@ -19,6 +19,7 @@ describe("JVMMonitoringDetailPanel", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringDetailPanel
|
||||
session={session}
|
||||
language="en-US"
|
||||
latestPoint={{
|
||||
timestamp: 1713945600000,
|
||||
committedVirtualMemoryBytes: 385 * 1024 * 1024,
|
||||
@@ -27,14 +28,15 @@ describe("JVMMonitoringDetailPanel", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("进程物理内存");
|
||||
expect(markup).toContain("JMX 连接未暴露进程驻留物理内存属性");
|
||||
expect(markup).toContain("HTTP 端点或增强代理");
|
||||
expect(markup).toContain("Process physical memory");
|
||||
expect(markup).toContain("JMX connection does not expose process resident physical memory");
|
||||
expect(markup).toContain("HTTP endpoint or enhanced agent");
|
||||
expect(markup).not.toContain("进程物理内存");
|
||||
expect(markup).not.toContain("CommittedVirtualMemorySize");
|
||||
expect(markup).not.toContain("Endpoint/Agent");
|
||||
});
|
||||
|
||||
it("renders thread state names with Chinese semantic labels", () => {
|
||||
it("renders thread state names with localized semantic labels", () => {
|
||||
const session: JVMMonitoringSessionState = {
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
@@ -47,6 +49,7 @@ describe("JVMMonitoringDetailPanel", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringDetailPanel
|
||||
session={session}
|
||||
language="en-US"
|
||||
latestPoint={{
|
||||
timestamp: 1713945600000,
|
||||
threadStateCounts: {
|
||||
@@ -59,9 +62,12 @@ describe("JVMMonitoringDetailPanel", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("等待中 12");
|
||||
expect(markup).toContain("可运行 11");
|
||||
expect(markup).toContain("限时等待 10");
|
||||
expect(markup).toContain("Waiting 12");
|
||||
expect(markup).toContain("Runnable 11");
|
||||
expect(markup).toContain("Timed waiting 10");
|
||||
expect(markup).not.toContain("等待中");
|
||||
expect(markup).not.toContain("可运行");
|
||||
expect(markup).not.toContain("限时等待");
|
||||
expect(markup).not.toContain("WAITING 12");
|
||||
expect(markup).not.toContain("RUNNABLE 11");
|
||||
expect(markup).not.toContain("TIMED_WAITING 10");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Alert, Card, Descriptions, Empty, List, Space, Tag, Typography } from "antd";
|
||||
|
||||
import { t, type SupportedLanguage } from "../../i18n";
|
||||
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
|
||||
import {
|
||||
buildMonitoringAvailabilityText,
|
||||
@@ -17,6 +18,7 @@ type JVMMonitoringDetailPanelProps = {
|
||||
session: JVMMonitoringSessionState;
|
||||
latestPoint?: JVMMonitoringPoint;
|
||||
darkMode: boolean;
|
||||
language?: SupportedLanguage;
|
||||
};
|
||||
|
||||
const buildCardStyle = (darkMode: boolean): React.CSSProperties => ({
|
||||
@@ -27,42 +29,54 @@ const buildCardStyle = (darkMode: boolean): React.CSSProperties => ({
|
||||
|
||||
const buildProcessMemoryMissingHint = (
|
||||
session: JVMMonitoringSessionState,
|
||||
language?: SupportedLanguage,
|
||||
): string | null => {
|
||||
if (!(session.missingMetrics || []).includes("memory.rss")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.providerMode === "jmx") {
|
||||
return "JMX 连接未暴露进程驻留物理内存属性,当前只能读取进程虚拟内存指标;如需进程物理内存,请切换到 HTTP 端点或增强代理采集。";
|
||||
return t("jvm_monitoring_detail_panel.memory_missing.jmx", undefined, language);
|
||||
}
|
||||
|
||||
return "当前监控来源未返回进程驻留物理内存指标;请确认 HTTP 端点或增强代理已采集并上报进程物理内存。";
|
||||
return t("jvm_monitoring_detail_panel.memory_missing.default", undefined, language);
|
||||
};
|
||||
|
||||
const JVMMonitoringDetailPanel: React.FC<JVMMonitoringDetailPanelProps> = ({
|
||||
session,
|
||||
latestPoint,
|
||||
darkMode,
|
||||
language,
|
||||
}) => {
|
||||
const threadRows = extractThreadStateRows(latestPoint);
|
||||
const tr = (key: string, params?: Record<string, string | number>) =>
|
||||
t(key, params, language);
|
||||
const threadRows = extractThreadStateRows(latestPoint, language);
|
||||
const recentGcEvents = session.recentGcEvents || [];
|
||||
const missingMetrics = session.missingMetrics || [];
|
||||
const processMemoryMissingHint = buildProcessMemoryMissingHint(session);
|
||||
const processMemoryMissingHint = buildProcessMemoryMissingHint(session, language);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card variant="borderless" title="排障指标" style={buildCardStyle(darkMode)}>
|
||||
<Card
|
||||
variant="borderless"
|
||||
title={tr("jvm_monitoring_detail_panel.title.troubleshooting_metrics")}
|
||||
style={buildCardStyle(darkMode)}
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="进程 CPU">
|
||||
<Descriptions.Item label={tr("jvm_monitoring_detail_panel.field.process_cpu")}>
|
||||
{formatPercent(latestPoint?.processCpuLoad)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="系统 CPU">
|
||||
<Descriptions.Item label={tr("jvm_monitoring_detail_panel.field.system_cpu")}>
|
||||
{formatPercent(latestPoint?.systemCpuLoad)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="进程物理内存">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_monitoring_detail_panel.field.process_physical_memory")}
|
||||
>
|
||||
{formatBytes(latestPoint?.processRssBytes)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="进程虚拟内存">
|
||||
<Descriptions.Item
|
||||
label={tr("jvm_monitoring_detail_panel.field.process_virtual_memory")}
|
||||
>
|
||||
{formatBytes(latestPoint?.committedVirtualMemoryBytes)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
@@ -70,35 +84,46 @@ const JVMMonitoringDetailPanel: React.FC<JVMMonitoringDetailPanelProps> = ({
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="进程物理内存缺失原因"
|
||||
message={tr("jvm_monitoring_detail_panel.memory_missing.title")}
|
||||
description={processMemoryMissingHint}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card variant="borderless" title="线程状态分布" style={buildCardStyle(darkMode)}>
|
||||
<Card
|
||||
variant="borderless"
|
||||
title={tr("jvm_monitoring_detail_panel.title.thread_state_distribution")}
|
||||
style={buildCardStyle(darkMode)}
|
||||
>
|
||||
{threadRows.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无线程状态采样" />
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={tr("jvm_monitoring_detail_panel.empty.thread_states")}
|
||||
/>
|
||||
) : (
|
||||
<Space wrap size={[8, 8]}>
|
||||
{threadRows.map((item) => (
|
||||
<Tag key={item.state} color="blue">
|
||||
{item.label} {formatCompactNumber(item.count)}
|
||||
{item.label} {formatCompactNumber(item.count, language)}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card variant="borderless" title="最近垃圾回收明细" style={buildCardStyle(darkMode)}>
|
||||
<Card
|
||||
variant="borderless"
|
||||
title={tr("jvm_monitoring_detail_panel.title.recent_gc_details")}
|
||||
style={buildCardStyle(darkMode)}
|
||||
>
|
||||
{recentGcEvents.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
missingMetrics.includes("gc.events")
|
||||
? "当前监控来源未提供事件级垃圾回收数据"
|
||||
: "最近窗口暂无垃圾回收事件"
|
||||
? tr("jvm_monitoring_detail_panel.empty.gc_events_unavailable")
|
||||
: tr("jvm_monitoring_detail_panel.empty.recent_gc_events")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -107,17 +132,19 @@ const JVMMonitoringDetailPanel: React.FC<JVMMonitoringDetailPanelProps> = ({
|
||||
renderItem={(event) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={formatRecentGCLabel(event)}
|
||||
title={formatRecentGCLabel(event, language)}
|
||||
description={
|
||||
<Space size={12} wrap>
|
||||
{typeof event.beforeUsedBytes === "number" ? (
|
||||
<Text type="secondary">
|
||||
回收前 {formatBytes(event.beforeUsedBytes)}
|
||||
{tr("jvm_monitoring_detail_panel.gc.before")}{" "}
|
||||
{formatBytes(event.beforeUsedBytes)}
|
||||
</Text>
|
||||
) : null}
|
||||
{typeof event.afterUsedBytes === "number" ? (
|
||||
<Text type="secondary">
|
||||
回收后 {formatBytes(event.afterUsedBytes)}
|
||||
{tr("jvm_monitoring_detail_panel.gc.after")}{" "}
|
||||
{formatBytes(event.afterUsedBytes)}
|
||||
</Text>
|
||||
) : null}
|
||||
{event.action ? <Tag>{event.action}</Tag> : null}
|
||||
@@ -130,9 +157,13 @@ const JVMMonitoringDetailPanel: React.FC<JVMMonitoringDetailPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card variant="borderless" title="能力与降级" style={buildCardStyle(darkMode)}>
|
||||
<Card
|
||||
variant="borderless"
|
||||
title={tr("jvm_monitoring_detail_panel.title.capabilities_and_degradation")}
|
||||
style={buildCardStyle(darkMode)}
|
||||
>
|
||||
<Paragraph type="secondary" style={{ whiteSpace: "pre-wrap", marginBottom: 12 }}>
|
||||
{buildMonitoringAvailabilityText(session)}
|
||||
{buildMonitoringAvailabilityText(session, language)}
|
||||
</Paragraph>
|
||||
<Space size={[8, 8]} wrap>
|
||||
{(session.missingMetrics || []).map((metric) => (
|
||||
|
||||
@@ -5,10 +5,11 @@ import { describe, expect, it } from "vitest";
|
||||
import JVMMonitoringStatusCards from "./JVMMonitoringStatusCards";
|
||||
|
||||
describe("JVMMonitoringStatusCards", () => {
|
||||
it("renders monitoring summary labels in Chinese", () => {
|
||||
it("renders monitoring summary labels with the requested language", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringStatusCards
|
||||
darkMode={false}
|
||||
language="en-US"
|
||||
session={{
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
@@ -30,17 +31,17 @@ describe("JVMMonitoringStatusCards", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("堆内存");
|
||||
expect(markup).toContain("已提交");
|
||||
expect(markup).toContain("垃圾回收压力");
|
||||
expect(markup).toContain("累计 50ms");
|
||||
expect(markup).toContain("线程");
|
||||
expect(markup).toContain("峰值 44");
|
||||
expect(markup).toContain("可运行 11");
|
||||
expect(markup).toContain("类加载");
|
||||
expect(markup).not.toContain("Committed");
|
||||
expect(markup).not.toContain("Total");
|
||||
expect(markup).not.toContain("Peak");
|
||||
expect(markup).toContain("Heap memory");
|
||||
expect(markup).toContain("Committed 128 MB");
|
||||
expect(markup).toContain("Garbage collection pressure");
|
||||
expect(markup).toContain("Total 50ms");
|
||||
expect(markup).toContain("Threads");
|
||||
expect(markup).toContain("Peak 44");
|
||||
expect(markup).toContain("Runnable 11");
|
||||
expect(markup).toContain("Class loading");
|
||||
expect(markup).not.toContain("堆内存");
|
||||
expect(markup).not.toContain("已提交");
|
||||
expect(markup).not.toContain("可运行");
|
||||
expect(markup).not.toContain("RUNNABLE");
|
||||
expect(markup).not.toContain("ClassLoading");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Card, Col, Row, Space, Statistic, Tag, Typography } from "antd";
|
||||
|
||||
import { t, type SupportedLanguage } from "../../i18n";
|
||||
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
|
||||
import {
|
||||
formatBytes,
|
||||
@@ -15,6 +16,7 @@ type JVMMonitoringStatusCardsProps = {
|
||||
latestPoint?: JVMMonitoringPoint;
|
||||
session?: JVMMonitoringSessionState;
|
||||
darkMode: boolean;
|
||||
language?: SupportedLanguage;
|
||||
};
|
||||
|
||||
const cardStyle = (darkMode: boolean): React.CSSProperties => ({
|
||||
@@ -27,49 +29,71 @@ const JVMMonitoringStatusCards: React.FC<JVMMonitoringStatusCardsProps> = ({
|
||||
latestPoint,
|
||||
session,
|
||||
darkMode,
|
||||
language,
|
||||
}) => {
|
||||
const tr = (key: string, params?: Record<string, string | number>) =>
|
||||
t(key, params, language);
|
||||
const runnableCount = latestPoint?.threadStateCounts?.RUNNABLE || 0;
|
||||
const heapMeta =
|
||||
latestPoint?.heapCommittedBytes && latestPoint.heapCommittedBytes > 0
|
||||
? `已提交 ${formatBytes(latestPoint.heapCommittedBytes)}`
|
||||
: "等待采样";
|
||||
? tr("jvm_monitoring_status_cards.meta.heap_committed", {
|
||||
value: formatBytes(latestPoint.heapCommittedBytes),
|
||||
})
|
||||
: tr("jvm_monitoring_status_cards.meta.waiting_samples");
|
||||
const gcMeta =
|
||||
typeof latestPoint?.gcDeltaTimeMs === "number" && latestPoint.gcDeltaTimeMs >= 0
|
||||
? `Δ ${formatDurationMs(latestPoint.gcDeltaTimeMs)}`
|
||||
: typeof latestPoint?.gcCollectionTimeMs === "number"
|
||||
? `累计 ${formatDurationMs(latestPoint.gcCollectionTimeMs)}`
|
||||
: "等待采样";
|
||||
? tr("jvm_monitoring_status_cards.meta.gc_total_time", {
|
||||
value: formatDurationMs(latestPoint.gcCollectionTimeMs),
|
||||
})
|
||||
: tr("jvm_monitoring_status_cards.meta.waiting_samples");
|
||||
const threadMeta =
|
||||
latestPoint?.peakThreadCount && latestPoint.peakThreadCount > 0
|
||||
? `峰值 ${formatCompactNumber(latestPoint.peakThreadCount)}`
|
||||
: "等待采样";
|
||||
? tr("jvm_monitoring_status_cards.meta.thread_peak", {
|
||||
value: formatCompactNumber(latestPoint.peakThreadCount, language),
|
||||
})
|
||||
: tr("jvm_monitoring_status_cards.meta.waiting_samples");
|
||||
const classMeta =
|
||||
typeof latestPoint?.classLoadDelta === "number"
|
||||
? `Δ ${formatCompactNumber(latestPoint.classLoadDelta)}`
|
||||
: "等待采样";
|
||||
const runnableLabel = resolveThreadStateLabel("RUNNABLE");
|
||||
? `Δ ${formatCompactNumber(latestPoint.classLoadDelta, language)}`
|
||||
: tr("jvm_monitoring_status_cards.meta.waiting_samples");
|
||||
const runnableLabel = resolveThreadStateLabel("RUNNABLE", language);
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card variant="borderless" style={cardStyle(darkMode)} title="堆内存">
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={cardStyle(darkMode)}
|
||||
title={tr("jvm_monitoring_status_cards.title.heap")}
|
||||
>
|
||||
<Statistic value={formatBytes(latestPoint?.heapUsedBytes)} />
|
||||
<Text type="secondary">{heapMeta}</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card variant="borderless" style={cardStyle(darkMode)} title="垃圾回收压力">
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={cardStyle(darkMode)}
|
||||
title={tr("jvm_monitoring_status_cards.title.gc_pressure")}
|
||||
>
|
||||
<Statistic
|
||||
value={formatCompactNumber(
|
||||
latestPoint?.gcDeltaCount ?? latestPoint?.gcCollectionCount,
|
||||
language,
|
||||
)}
|
||||
/>
|
||||
<Text type="secondary">{gcMeta}</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card variant="borderless" style={cardStyle(darkMode)} title="线程">
|
||||
<Statistic value={formatCompactNumber(latestPoint?.threadCount)} />
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={cardStyle(darkMode)}
|
||||
title={tr("jvm_monitoring_status_cards.title.threads")}
|
||||
>
|
||||
<Statistic value={formatCompactNumber(latestPoint?.threadCount, language)} />
|
||||
<Space size={8} wrap>
|
||||
<Text type="secondary">{threadMeta}</Text>
|
||||
{runnableCount > 0 ? <Tag color="blue">{runnableLabel} {runnableCount}</Tag> : null}
|
||||
@@ -77,11 +101,23 @@ const JVMMonitoringStatusCards: React.FC<JVMMonitoringStatusCardsProps> = ({
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card variant="borderless" style={cardStyle(darkMode)} title="类加载">
|
||||
<Statistic value={formatCompactNumber(latestPoint?.loadedClassCount)} />
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={cardStyle(darkMode)}
|
||||
title={tr("jvm_monitoring_status_cards.title.classes")}
|
||||
>
|
||||
<Statistic
|
||||
value={formatCompactNumber(latestPoint?.loadedClassCount, language)}
|
||||
/>
|
||||
<Space size={8} wrap>
|
||||
<Text type="secondary">{classMeta}</Text>
|
||||
{session?.running ? <Tag color="green">采样中</Tag> : <Tag>未运行</Tag>}
|
||||
{session?.running ? (
|
||||
<Tag color="green">
|
||||
{tr("jvm_monitoring_status_cards.status.sampling")}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>{tr("jvm_monitoring_status_cards.status.stopped")}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { setCurrentLanguage } from "../i18n";
|
||||
import {
|
||||
resolveJVMDiagnosticCompletionItems,
|
||||
resolveJVMDiagnosticCompletionMode,
|
||||
} from "./jvmDiagnosticCompletion";
|
||||
|
||||
describe("jvmDiagnosticCompletion", () => {
|
||||
beforeEach(() => {
|
||||
setCurrentLanguage("zh-CN");
|
||||
});
|
||||
|
||||
it("suggests command keywords when typing the first token", () => {
|
||||
const items = resolveJVMDiagnosticCompletionItems("t");
|
||||
|
||||
@@ -50,4 +55,89 @@ describe("jvmDiagnosticCompletion", () => {
|
||||
expect(items.some((item) => item.label === "dashboard")).toBe(true);
|
||||
expect(items.some((item) => item.label === "thread")).toBe(true);
|
||||
});
|
||||
|
||||
it("localizes command completion text while keeping raw command fields stable", () => {
|
||||
setCurrentLanguage("zh-CN");
|
||||
const zhThread = resolveJVMDiagnosticCompletionItems("thr").find(
|
||||
(item) => item.label === "thread",
|
||||
);
|
||||
|
||||
setCurrentLanguage("en-US");
|
||||
const enThread = resolveJVMDiagnosticCompletionItems("thr").find(
|
||||
(item) => item.label === "thread",
|
||||
);
|
||||
|
||||
expect(zhThread).toMatchObject({
|
||||
label: "thread",
|
||||
insertText: "thread",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看热点线程、线程栈和阻塞线程。",
|
||||
});
|
||||
expect(enThread).toMatchObject({
|
||||
label: "thread",
|
||||
insertText: "thread",
|
||||
detail: "observation command",
|
||||
documentation: "View hot threads, thread stacks, and blocked threads.",
|
||||
});
|
||||
});
|
||||
|
||||
it("localizes argument completion text while keeping snippets and flags stable", () => {
|
||||
setCurrentLanguage("zh-CN");
|
||||
const zhWatchTemplate = resolveJVMDiagnosticCompletionItems("watch ").find(
|
||||
(item) => item.insertText.includes("com.foo.OrderService"),
|
||||
);
|
||||
const zhThreadBusy = resolveJVMDiagnosticCompletionItems("thread ").find(
|
||||
(item) => item.insertText === "-n ${1:5}",
|
||||
);
|
||||
const zhClassloaderUrlStat = resolveJVMDiagnosticCompletionItems(
|
||||
"classloader ",
|
||||
).find((item) => item.insertText === "--url-stat");
|
||||
|
||||
setCurrentLanguage("en-US");
|
||||
const enWatchTemplate = resolveJVMDiagnosticCompletionItems("watch ").find(
|
||||
(item) => item.insertText.includes("com.foo.OrderService"),
|
||||
);
|
||||
const enThreadBusy = resolveJVMDiagnosticCompletionItems("thread ").find(
|
||||
(item) => item.insertText === "-n ${1:5}",
|
||||
);
|
||||
const enClassloaderUrlStat = resolveJVMDiagnosticCompletionItems(
|
||||
"classloader ",
|
||||
).find((item) => item.insertText === "--url-stat");
|
||||
|
||||
expect(zhWatchTemplate).toMatchObject({
|
||||
label: "watch 模板",
|
||||
insertText:
|
||||
"${1:com.foo.OrderService} ${2:submitOrder} '${3:{params,returnObj}}' -x ${4:2}",
|
||||
detail: "观察模板",
|
||||
documentation: "观察入参、返回值或异常。",
|
||||
});
|
||||
expect(enWatchTemplate).toMatchObject({
|
||||
label: "watch template",
|
||||
insertText:
|
||||
"${1:com.foo.OrderService} ${2:submitOrder} '${3:{params,returnObj}}' -x ${4:2}",
|
||||
detail: "watch template",
|
||||
documentation: "Observe parameters, return values, or exceptions.",
|
||||
});
|
||||
|
||||
expect(zhThreadBusy).toMatchObject({
|
||||
label: "繁忙线程 TOP N (-n)",
|
||||
detail: "线程参数",
|
||||
documentation: "查看 CPU 最繁忙的前 N 个线程。",
|
||||
});
|
||||
expect(enThreadBusy).toMatchObject({
|
||||
label: "Busy threads TOP N (-n)",
|
||||
detail: "thread option",
|
||||
documentation: "View the top N CPU-busiest threads.",
|
||||
});
|
||||
expect(zhClassloaderUrlStat).toMatchObject({
|
||||
label: "全部 URL 统计 (--url-stat)",
|
||||
detail: "类加载器模板",
|
||||
documentation: "查看类加载器 URL 统计。",
|
||||
});
|
||||
expect(enClassloaderUrlStat).toMatchObject({
|
||||
label: "All URL statistics (--url-stat)",
|
||||
detail: "class loader template",
|
||||
documentation: "View class loader URL statistics.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "../i18n";
|
||||
import { JVM_DIAGNOSTIC_COMMAND_PRESETS } from "./jvmDiagnosticPresentation";
|
||||
|
||||
export type JVMDiagnosticCompletionMode = "command" | "argument";
|
||||
@@ -19,117 +20,160 @@ export interface JVMDiagnosticCompletionItem {
|
||||
|
||||
type DiagnosticCommandDefinition = {
|
||||
head: string;
|
||||
detail: string;
|
||||
documentation: string;
|
||||
detailKey: string;
|
||||
documentationKey: string;
|
||||
};
|
||||
|
||||
const BASE_COMMAND_DEFINITIONS: DiagnosticCommandDefinition[] = [
|
||||
{
|
||||
head: "dashboard",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看 JVM 运行总览。",
|
||||
detailKey: "jvm_diagnostic.completion.category.observe",
|
||||
documentationKey: "jvm_diagnostic.completion.command.dashboard.documentation",
|
||||
},
|
||||
{
|
||||
head: "jvm",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看 JVM 内存、线程、类加载、GC 和运行参数信息。",
|
||||
detailKey: "jvm_diagnostic.completion.category.observe",
|
||||
documentationKey: "jvm_diagnostic.completion.command.jvm.documentation",
|
||||
},
|
||||
{
|
||||
head: "thread",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看热点线程、线程栈和阻塞线程。",
|
||||
detailKey: "jvm_diagnostic.completion.category.observe",
|
||||
documentationKey: "jvm_diagnostic.completion.command.thread.documentation",
|
||||
},
|
||||
{
|
||||
head: "sc",
|
||||
detail: "观察类命令",
|
||||
documentation: "搜索匹配类信息。",
|
||||
detailKey: "jvm_diagnostic.completion.category.observe",
|
||||
documentationKey: "jvm_diagnostic.completion.command.sc.documentation",
|
||||
},
|
||||
{
|
||||
head: "sm",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看类的方法签名。",
|
||||
detailKey: "jvm_diagnostic.completion.category.observe",
|
||||
documentationKey: "jvm_diagnostic.completion.command.sm.documentation",
|
||||
},
|
||||
{
|
||||
head: "jad",
|
||||
detail: "观察类命令",
|
||||
documentation: "反编译指定类。",
|
||||
detailKey: "jvm_diagnostic.completion.category.observe",
|
||||
documentationKey: "jvm_diagnostic.completion.command.jad.documentation",
|
||||
},
|
||||
{
|
||||
head: "sysprop",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看系统属性。",
|
||||
detailKey: "jvm_diagnostic.completion.category.observe",
|
||||
documentationKey: "jvm_diagnostic.completion.command.sysprop.documentation",
|
||||
},
|
||||
{
|
||||
head: "sysenv",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看环境变量。",
|
||||
detailKey: "jvm_diagnostic.completion.category.observe",
|
||||
documentationKey: "jvm_diagnostic.completion.command.sysenv.documentation",
|
||||
},
|
||||
{
|
||||
head: "classloader",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看类加载器信息。",
|
||||
detailKey: "jvm_diagnostic.completion.category.observe",
|
||||
documentationKey: "jvm_diagnostic.completion.command.classloader.documentation",
|
||||
},
|
||||
{
|
||||
head: "trace",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "跟踪方法调用耗时路径。",
|
||||
detailKey: "jvm_diagnostic.completion.category.trace",
|
||||
documentationKey: "jvm_diagnostic.completion.command.trace.documentation",
|
||||
},
|
||||
{
|
||||
head: "watch",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "观察入参、返回值或异常。",
|
||||
detailKey: "jvm_diagnostic.completion.category.trace",
|
||||
documentationKey: "jvm_diagnostic.completion.command.watch.documentation",
|
||||
},
|
||||
{
|
||||
head: "stack",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "输出方法调用栈。",
|
||||
detailKey: "jvm_diagnostic.completion.category.trace",
|
||||
documentationKey: "jvm_diagnostic.completion.command.stack.documentation",
|
||||
},
|
||||
{
|
||||
head: "monitor",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "周期性统计方法调用。",
|
||||
detailKey: "jvm_diagnostic.completion.category.trace",
|
||||
documentationKey: "jvm_diagnostic.completion.command.monitor.documentation",
|
||||
},
|
||||
{
|
||||
head: "tt",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "方法时光隧道,记录和回放调用。",
|
||||
detailKey: "jvm_diagnostic.completion.category.trace",
|
||||
documentationKey: "jvm_diagnostic.completion.command.tt.documentation",
|
||||
},
|
||||
{
|
||||
head: "ognl",
|
||||
detail: "高风险命令",
|
||||
documentation: "执行 OGNL 表达式,默认需要额外授权。",
|
||||
detailKey: "jvm_diagnostic.completion.category.mutating",
|
||||
documentationKey: "jvm_diagnostic.completion.command.ognl.documentation",
|
||||
},
|
||||
{
|
||||
head: "vmtool",
|
||||
detail: "高风险命令",
|
||||
documentation: "直接操作 JVM 对象或执行 VMTool 动作。",
|
||||
detailKey: "jvm_diagnostic.completion.category.mutating",
|
||||
documentationKey: "jvm_diagnostic.completion.command.vmtool.documentation",
|
||||
},
|
||||
{
|
||||
head: "redefine",
|
||||
detail: "高风险命令",
|
||||
documentation: "重新定义类字节码。",
|
||||
detailKey: "jvm_diagnostic.completion.category.mutating",
|
||||
documentationKey: "jvm_diagnostic.completion.command.redefine.documentation",
|
||||
},
|
||||
{
|
||||
head: "retransform",
|
||||
detail: "高风险命令",
|
||||
documentation: "重新触发类转换。",
|
||||
detailKey: "jvm_diagnostic.completion.category.mutating",
|
||||
documentationKey: "jvm_diagnostic.completion.command.retransform.documentation",
|
||||
},
|
||||
{
|
||||
head: "stop",
|
||||
detail: "控制命令",
|
||||
documentation: "停止当前后台任务。",
|
||||
detailKey: "jvm_diagnostic.completion.category.control",
|
||||
documentationKey: "jvm_diagnostic.completion.command.stop.documentation",
|
||||
},
|
||||
];
|
||||
|
||||
const buildBaseCommandItems = (): JVMDiagnosticCompletionItem[] => {
|
||||
type JVMDiagnosticCompletionTranslateParams = Record<string, string | number>;
|
||||
|
||||
type JVMDiagnosticCompletionTranslator = (
|
||||
key: string,
|
||||
params?: JVMDiagnosticCompletionTranslateParams,
|
||||
) => string;
|
||||
|
||||
type JVMDiagnosticCompletionItemDefinition = Omit<
|
||||
JVMDiagnosticCompletionItem,
|
||||
"label" | "detail" | "documentation"
|
||||
> & {
|
||||
label?: string;
|
||||
labelKey?: string;
|
||||
labelParams?: JVMDiagnosticCompletionTranslateParams;
|
||||
detailKey: string;
|
||||
detailFallback?: string;
|
||||
documentationKey?: string;
|
||||
documentationFallback?: string;
|
||||
};
|
||||
|
||||
const translateCompletionText = (
|
||||
translate: JVMDiagnosticCompletionTranslator,
|
||||
key: string,
|
||||
params?: JVMDiagnosticCompletionTranslateParams,
|
||||
fallback = key,
|
||||
): string => {
|
||||
const translated = translate(key, params);
|
||||
return translated === key ? fallback : translated;
|
||||
};
|
||||
|
||||
const defaultCompletionTranslator: JVMDiagnosticCompletionTranslator = (
|
||||
key,
|
||||
params,
|
||||
) => t(key, params);
|
||||
|
||||
const PRESET_CATEGORY_DETAIL_KEYS: Record<string, string> = {
|
||||
observe: "jvm_diagnostic.completion.preset.category.observe",
|
||||
trace: "jvm_diagnostic.completion.preset.category.trace",
|
||||
mutating: "jvm_diagnostic.completion.preset.category.mutating",
|
||||
};
|
||||
|
||||
const buildBaseCommandItems = (
|
||||
translate: JVMDiagnosticCompletionTranslator,
|
||||
): JVMDiagnosticCompletionItem[] => {
|
||||
const itemsByHead = new Map<string, JVMDiagnosticCompletionItem>();
|
||||
|
||||
BASE_COMMAND_DEFINITIONS.forEach((item) => {
|
||||
itemsByHead.set(item.head, {
|
||||
label: item.head,
|
||||
insertText: item.head,
|
||||
detail: item.detail,
|
||||
documentation: item.documentation,
|
||||
detail: translateCompletionText(translate, item.detailKey),
|
||||
documentation: translateCompletionText(translate, item.documentationKey),
|
||||
scope: "command",
|
||||
});
|
||||
});
|
||||
@@ -142,8 +186,18 @@ const buildBaseCommandItems = (): JVMDiagnosticCompletionItem[] => {
|
||||
itemsByHead.set(head, {
|
||||
label: head,
|
||||
insertText: head,
|
||||
detail: `${item.category} 命令`,
|
||||
documentation: item.description,
|
||||
detail: translateCompletionText(
|
||||
translate,
|
||||
PRESET_CATEGORY_DETAIL_KEYS[item.category] || item.category,
|
||||
undefined,
|
||||
item.category,
|
||||
),
|
||||
documentation: translateCompletionText(
|
||||
translate,
|
||||
`jvm_diagnostic.completion.preset.${item.key}.documentation`,
|
||||
undefined,
|
||||
item.description,
|
||||
),
|
||||
scope: "command",
|
||||
});
|
||||
});
|
||||
@@ -151,280 +205,346 @@ const buildBaseCommandItems = (): JVMDiagnosticCompletionItem[] => {
|
||||
return Array.from(itemsByHead.values());
|
||||
};
|
||||
|
||||
const BASE_COMMAND_ITEMS = buildBaseCommandItems();
|
||||
|
||||
const ARGUMENT_ITEMS_BY_HEAD: Record<string, JVMDiagnosticCompletionItem[]> = {
|
||||
const ARGUMENT_ITEMS_BY_HEAD: Record<
|
||||
string,
|
||||
JVMDiagnosticCompletionItemDefinition[]
|
||||
> = {
|
||||
dashboard: [
|
||||
{
|
||||
label: "dashboard",
|
||||
labelKey: "jvm_diagnostic.completion.argument.dashboard.direct.label",
|
||||
insertText: "",
|
||||
detail: "直接执行",
|
||||
documentation: "查看当前 JVM 运行总览。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.execute_directly",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.dashboard.direct.documentation",
|
||||
scope: "argument",
|
||||
},
|
||||
],
|
||||
jvm: [
|
||||
{
|
||||
label: "jvm",
|
||||
labelKey: "jvm_diagnostic.completion.argument.jvm.direct.label",
|
||||
insertText: "",
|
||||
detail: "直接执行",
|
||||
documentation: "查看 JVM 内存、线程、类加载、GC 和运行参数信息。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.execute_directly",
|
||||
documentationKey: "jvm_diagnostic.completion.argument.jvm.direct.documentation",
|
||||
scope: "argument",
|
||||
},
|
||||
],
|
||||
thread: [
|
||||
{
|
||||
label: "繁忙线程 TOP N (-n)",
|
||||
labelKey: "jvm_diagnostic.completion.argument.thread.busy_top.label",
|
||||
insertText: "-n ${1:5}",
|
||||
detail: "线程参数",
|
||||
documentation: "查看 CPU 最繁忙的前 N 个线程。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.thread_option",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.thread.busy_top.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "阻塞线程 (-b)",
|
||||
labelKey: "jvm_diagnostic.completion.argument.thread.blocking.label",
|
||||
insertText: "-b",
|
||||
detail: "线程参数",
|
||||
documentation: "查找当前阻塞其他线程的线程。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.thread_option",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.thread.blocking.documentation",
|
||||
scope: "argument",
|
||||
},
|
||||
{
|
||||
label: "指定线程 ID",
|
||||
labelKey: "jvm_diagnostic.completion.argument.thread.thread_id.label",
|
||||
insertText: "${1:1}",
|
||||
detail: "线程参数",
|
||||
documentation: "查看指定线程的详细栈信息。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.thread_option",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.thread.thread_id.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
sc: [
|
||||
{
|
||||
label: "类匹配模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.sc.class_pattern.label",
|
||||
insertText: "${1:com.foo.*}",
|
||||
detail: "类搜索模板",
|
||||
documentation: "按类名模式搜索。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.class_search_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.sc.class_pattern.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "详细模式 (-d)",
|
||||
labelKey: "jvm_diagnostic.completion.argument.detail_mode_d.label",
|
||||
insertText: "-d ${1:com.foo.OrderService}",
|
||||
detail: "类搜索模板",
|
||||
documentation: "输出类的详细信息。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.class_search_template",
|
||||
documentationKey: "jvm_diagnostic.completion.argument.sc.detail.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
sm: [
|
||||
{
|
||||
label: "方法签名模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.sm.method_signature.label",
|
||||
insertText: "${1:com.foo.OrderService} ${2:submitOrder}",
|
||||
detail: "方法搜索模板",
|
||||
documentation: "查看类的方法签名。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.method_search_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.sm.method_signature.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "详细模式 (-d)",
|
||||
labelKey: "jvm_diagnostic.completion.argument.detail_mode_d.label",
|
||||
insertText: "-d ${1:com.foo.OrderService} ${2:submitOrder}",
|
||||
detail: "方法搜索模板",
|
||||
documentation: "输出方法详细签名。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.method_search_template",
|
||||
documentationKey: "jvm_diagnostic.completion.argument.sm.detail.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
jad: [
|
||||
{
|
||||
label: "反编译模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.jad.template.label",
|
||||
insertText: "${1:com.foo.OrderService}",
|
||||
detail: "反编译模板",
|
||||
documentation: "反编译指定类。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.decompile_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.jad.template.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
sysprop: [
|
||||
{
|
||||
label: "查看属性",
|
||||
labelKey: "jvm_diagnostic.completion.argument.sysprop.property.label",
|
||||
insertText: "${1:java.version}",
|
||||
detail: "系统属性模板",
|
||||
documentation: "读取指定系统属性。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.system_property_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.sysprop.property.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
sysenv: [
|
||||
{
|
||||
label: "查看环境变量",
|
||||
labelKey: "jvm_diagnostic.completion.argument.sysenv.variable.label",
|
||||
insertText: "${1:JAVA_HOME}",
|
||||
detail: "环境变量模板",
|
||||
documentation: "读取指定环境变量。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.environment_variable_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.sysenv.variable.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
classloader: [
|
||||
{
|
||||
label: "树形视图 (-t)",
|
||||
labelKey: "jvm_diagnostic.completion.argument.classloader.tree.label",
|
||||
insertText: "-t",
|
||||
detail: "类加载器模板",
|
||||
documentation: "输出类加载器树形结构。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.classloader_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.classloader.tree.documentation",
|
||||
scope: "argument",
|
||||
},
|
||||
{
|
||||
label: "全部 URL 统计 (--url-stat)",
|
||||
labelKey: "jvm_diagnostic.completion.argument.classloader.url_stat.label",
|
||||
insertText: "--url-stat",
|
||||
detail: "类加载器模板",
|
||||
documentation: "查看类加载器 URL 统计。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.classloader_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.classloader.url_stat.documentation",
|
||||
scope: "argument",
|
||||
},
|
||||
{
|
||||
label: "指定类加载器 Hash",
|
||||
labelKey: "jvm_diagnostic.completion.argument.classloader.hash.label",
|
||||
insertText: "${1:19469ea2}",
|
||||
detail: "类加载器模板",
|
||||
documentation: "查看指定类加载器详情。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.classloader_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.classloader.hash.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
trace: [
|
||||
{
|
||||
label: "trace 模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.command_template.label",
|
||||
labelParams: { command: "trace" },
|
||||
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
|
||||
detail: "跟踪模板",
|
||||
documentation: "跟踪慢方法调用链路。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.trace_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.trace.template.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "条件过滤 '#cost > 100'",
|
||||
labelKey: "jvm_diagnostic.completion.argument.trace.condition.label",
|
||||
insertText: "'${1:#cost > 100}'",
|
||||
detail: "跟踪参数",
|
||||
documentation: "追加 trace 条件表达式。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.trace_option",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.trace.condition.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
watch: [
|
||||
{
|
||||
label: "watch 模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.command_template.label",
|
||||
labelParams: { command: "watch" },
|
||||
insertText:
|
||||
"${1:com.foo.OrderService} ${2:submitOrder} '${3:{params,returnObj}}' -x ${4:2}",
|
||||
detail: "观察模板",
|
||||
documentation: "观察入参、返回值或异常。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.watch_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.watch.template.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "展开层级 -x 2",
|
||||
labelKey: "jvm_diagnostic.completion.argument.watch.expand_depth.label",
|
||||
insertText: "-x ${1:2}",
|
||||
detail: "观察参数",
|
||||
documentation: "设置对象展开层级。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.watch_option",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.watch.expand_depth.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
stack: [
|
||||
{
|
||||
label: "stack 模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.command_template.label",
|
||||
labelParams: { command: "stack" },
|
||||
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
|
||||
detail: "调用栈模板",
|
||||
documentation: "输出方法调用栈。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.stack_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.stack.template.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
monitor: [
|
||||
{
|
||||
label: "monitor 模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.command_template.label",
|
||||
labelParams: { command: "monitor" },
|
||||
insertText: "${1:com.foo.OrderService} ${2:submitOrder} -c ${3:5}",
|
||||
detail: "监控模板",
|
||||
documentation: "按周期统计方法调用情况。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.monitor_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.monitor.template.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
tt: [
|
||||
{
|
||||
label: "tt 录制模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.tt.record.label",
|
||||
insertText: "-t ${1:com.foo.OrderService} ${2:submitOrder}",
|
||||
detail: "时光隧道模板",
|
||||
documentation: "录制指定方法调用。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.time_tunnel_template",
|
||||
documentationKey: "jvm_diagnostic.completion.argument.tt.record.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "查看记录列表 (-l)",
|
||||
labelKey: "jvm_diagnostic.completion.argument.tt.list.label",
|
||||
insertText: "-l",
|
||||
detail: "时光隧道模板",
|
||||
documentation: "查看当前录制列表。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.time_tunnel_template",
|
||||
documentationKey: "jvm_diagnostic.completion.argument.tt.list.documentation",
|
||||
scope: "argument",
|
||||
},
|
||||
{
|
||||
label: "回放记录 (-i)",
|
||||
labelKey: "jvm_diagnostic.completion.argument.tt.replay.label",
|
||||
insertText: "-i ${1:1000} -p",
|
||||
detail: "时光隧道模板",
|
||||
documentation: "查看指定记录详情。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.time_tunnel_template",
|
||||
documentationKey: "jvm_diagnostic.completion.argument.tt.replay.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
ognl: [
|
||||
{
|
||||
label: "ognl 模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.command_template.label",
|
||||
labelParams: { command: "ognl" },
|
||||
insertText: "'${1:@java.lang.System@getProperty(\"user.dir\")}'",
|
||||
detail: "高风险模板",
|
||||
documentation: "执行 OGNL 表达式,高风险命令默认受策略限制。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.high_risk_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.ognl.template.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
vmtool: [
|
||||
{
|
||||
label: "vmtool getInstances",
|
||||
labelKey: "jvm_diagnostic.completion.argument.vmtool.get_instances.label",
|
||||
insertText:
|
||||
"--action getInstances --className ${1:com.foo.OrderService} --limit ${2:10}",
|
||||
detail: "高风险模板",
|
||||
documentation: "获取指定类实例,高风险命令默认受策略限制。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.high_risk_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.vmtool.get_instances.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
redefine: [
|
||||
{
|
||||
label: "redefine 模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.command_template.label",
|
||||
labelParams: { command: "redefine" },
|
||||
insertText: "${1:/tmp/OrderService.class}",
|
||||
detail: "高风险模板",
|
||||
documentation: "重新定义类字节码文件路径。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.high_risk_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.redefine.template.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
retransform: [
|
||||
{
|
||||
label: "retransform 模板",
|
||||
labelKey: "jvm_diagnostic.completion.argument.command_template.label",
|
||||
labelParams: { command: "retransform" },
|
||||
insertText: "${1:com.foo.OrderService}",
|
||||
detail: "高风险模板",
|
||||
documentation: "重新转换指定类。",
|
||||
detailKey: "jvm_diagnostic.completion.detail.high_risk_template",
|
||||
documentationKey:
|
||||
"jvm_diagnostic.completion.argument.retransform.template.documentation",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
stop: [
|
||||
{
|
||||
label: "stop",
|
||||
labelKey: "jvm_diagnostic.completion.argument.stop.direct.label",
|
||||
insertText: "",
|
||||
detail: "控制命令",
|
||||
documentation: "停止当前后台任务。",
|
||||
detailKey: "jvm_diagnostic.completion.category.control",
|
||||
documentationKey: "jvm_diagnostic.completion.argument.stop.direct.documentation",
|
||||
scope: "argument",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const COMMAND_HEAD_SET = new Set(
|
||||
BASE_COMMAND_ITEMS.map((item) => item.label.toLowerCase()),
|
||||
[
|
||||
...BASE_COMMAND_DEFINITIONS.map((item) => item.head),
|
||||
...JVM_DIAGNOSTIC_COMMAND_PRESETS.map(
|
||||
(item) => item.command.split(/\s+/, 1)[0]?.trim().toLowerCase() || item.label,
|
||||
),
|
||||
].map((head) => head.toLowerCase()),
|
||||
);
|
||||
|
||||
const materializeCompletionItem = (
|
||||
item: JVMDiagnosticCompletionItemDefinition,
|
||||
translate: JVMDiagnosticCompletionTranslator,
|
||||
): JVMDiagnosticCompletionItem => ({
|
||||
label: item.labelKey
|
||||
? translateCompletionText(
|
||||
translate,
|
||||
item.labelKey,
|
||||
item.labelParams,
|
||||
item.label || item.labelKey,
|
||||
)
|
||||
: item.label || "",
|
||||
insertText: item.insertText,
|
||||
detail: translateCompletionText(
|
||||
translate,
|
||||
item.detailKey,
|
||||
undefined,
|
||||
item.detailFallback,
|
||||
),
|
||||
documentation: item.documentationKey
|
||||
? translateCompletionText(
|
||||
translate,
|
||||
item.documentationKey,
|
||||
undefined,
|
||||
item.documentationFallback,
|
||||
)
|
||||
: item.documentationFallback,
|
||||
scope: item.scope,
|
||||
isSnippet: item.isSnippet,
|
||||
});
|
||||
|
||||
const normalizeSearchText = (value: string): string =>
|
||||
String(value || "").trim().toLowerCase();
|
||||
|
||||
@@ -490,10 +610,17 @@ export const resolveJVMDiagnosticCompletionItems = (
|
||||
textBeforeCursor: string,
|
||||
): JVMDiagnosticCompletionItem[] => {
|
||||
const state = resolveJVMDiagnosticCompletionMode(textBeforeCursor);
|
||||
const baseCommandItems = buildBaseCommandItems(defaultCompletionTranslator);
|
||||
const source =
|
||||
state.mode === "argument" && state.head
|
||||
? ARGUMENT_ITEMS_BY_HEAD[state.head] || []
|
||||
: BASE_COMMAND_ITEMS;
|
||||
: baseCommandItems;
|
||||
|
||||
return source.filter((item) => matchesSearch(item, state.search));
|
||||
return source
|
||||
.map((item) =>
|
||||
"detailKey" in item
|
||||
? materializeCompletionItem(item, defaultCompletionTranslator)
|
||||
: item,
|
||||
)
|
||||
.filter((item) => matchesSearch(item, state.search));
|
||||
};
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildMonitoringAvailabilityText,
|
||||
extractThreadStateRows,
|
||||
formatCompactNumber,
|
||||
formatMonitoringAxisBytes,
|
||||
formatRecentGCLabel,
|
||||
normalizeMonitoringProviderMode,
|
||||
resolveMonitoringMetricLabel,
|
||||
resolveThreadStateLabel,
|
||||
} from "./jvmMonitoringPresentation";
|
||||
|
||||
describe("jvmMonitoringPresentation", () => {
|
||||
it("summarizes degraded metrics with missing items and warnings", () => {
|
||||
it("summarizes degraded metrics with localized labels and raw provider warnings", () => {
|
||||
expect(
|
||||
buildMonitoringAvailabilityText({
|
||||
missingMetrics: ["cpu.process", "memory.rss"],
|
||||
providerWarnings: ["endpoint cpu metric unavailable"],
|
||||
}),
|
||||
).toContain("缺失指标");
|
||||
}, "en-US"),
|
||||
).toBe(
|
||||
"Missing metrics: Process CPU, Process physical memory | Monitoring source warning: endpoint cpu metric unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("localizes metric and thread state labels while preserving unknown raw values", () => {
|
||||
expect(resolveMonitoringMetricLabel("cpu.process", "en-US")).toBe("Process CPU");
|
||||
expect(resolveMonitoringMetricLabel("custom.metric", "en-US")).toBe("custom.metric");
|
||||
expect(resolveThreadStateLabel("RUNNABLE", "en-US")).toBe("Runnable");
|
||||
expect(resolveThreadStateLabel("CUSTOM_STATE", "en-US")).toBe("CUSTOM_STATE");
|
||||
expect(
|
||||
extractThreadStateRows(
|
||||
{ timestamp: 1713945600000, threadStateCounts: { RUNNABLE: 2 } },
|
||||
"en-US",
|
||||
)[0]?.label,
|
||||
).toBe("Runnable");
|
||||
});
|
||||
|
||||
it("formats locale-sensitive compact numbers with the requested language", () => {
|
||||
expect(formatCompactNumber(1234, "en-US")).toBe("1,234");
|
||||
expect(formatCompactNumber(1234, "de-DE")).toBe("1.234");
|
||||
});
|
||||
|
||||
it("formats recent gc event label with duration", () => {
|
||||
@@ -23,7 +48,7 @@ describe("jvmMonitoringPresentation", () => {
|
||||
timestamp: 1713945600000,
|
||||
name: "G1 Young Generation",
|
||||
durationMs: 21,
|
||||
}),
|
||||
}, "en-US"),
|
||||
).toContain("21ms");
|
||||
});
|
||||
|
||||
@@ -38,4 +63,22 @@ describe("jvmMonitoringPresentation", () => {
|
||||
expect(normalizeMonitoringProviderMode("unsupported", "endpoint")).toBe("endpoint");
|
||||
expect(normalizeMonitoringProviderMode(undefined, "jmx")).toBe("jmx");
|
||||
});
|
||||
|
||||
it("keeps presentation-owned Chinese literals out of the utility source", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./jvmMonitoringPresentation.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
[
|
||||
"缺失指标",
|
||||
"监控来源告警",
|
||||
"当前监控会话未发现明显降级",
|
||||
"堆内存",
|
||||
"可运行",
|
||||
"zh-CN",
|
||||
].forEach((literal) => {
|
||||
expect(source).not.toContain(literal);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,20 +3,25 @@ import type {
|
||||
JVMMonitoringRecentGCEvent,
|
||||
JVMMonitoringSessionState,
|
||||
} from "../types";
|
||||
import {
|
||||
getCurrentLanguage,
|
||||
t,
|
||||
type SupportedLanguage,
|
||||
} from "../i18n";
|
||||
|
||||
const METRIC_LABELS: Record<string, string> = {
|
||||
"heap.used": "堆内存",
|
||||
"heap.non_heap": "非堆内存",
|
||||
"gc.count": "垃圾回收次数",
|
||||
"gc.time": "垃圾回收耗时",
|
||||
"gc.events": "最近垃圾回收事件",
|
||||
"thread.count": "线程数",
|
||||
"thread.states": "线程状态",
|
||||
"class.loading": "类加载",
|
||||
"cpu.process": "进程 CPU",
|
||||
"cpu.system": "系统 CPU",
|
||||
"memory.rss": "进程物理内存",
|
||||
"memory.virtual": "进程虚拟内存",
|
||||
"heap.used": "jvm_monitoring_presentation.metric.heap_used",
|
||||
"heap.non_heap": "jvm_monitoring_presentation.metric.heap_non_heap",
|
||||
"gc.count": "jvm_monitoring_presentation.metric.gc_count",
|
||||
"gc.time": "jvm_monitoring_presentation.metric.gc_time",
|
||||
"gc.events": "jvm_monitoring_presentation.metric.gc_events",
|
||||
"thread.count": "jvm_monitoring_presentation.metric.thread_count",
|
||||
"thread.states": "jvm_monitoring_presentation.metric.thread_states",
|
||||
"class.loading": "jvm_monitoring_presentation.metric.class_loading",
|
||||
"cpu.process": "jvm_monitoring_presentation.metric.cpu_process",
|
||||
"cpu.system": "jvm_monitoring_presentation.metric.cpu_system",
|
||||
"memory.rss": "jvm_monitoring_presentation.metric.memory_rss",
|
||||
"memory.virtual": "jvm_monitoring_presentation.metric.memory_virtual",
|
||||
};
|
||||
|
||||
export type JVMMonitoringProviderMode = JVMMonitoringSessionState["providerMode"];
|
||||
@@ -28,15 +33,18 @@ const MONITORING_PROVIDER_MODES: JVMMonitoringProviderMode[] = [
|
||||
];
|
||||
|
||||
const THREAD_STATE_LABELS: Record<string, string> = {
|
||||
NEW: "新建",
|
||||
RUNNABLE: "可运行",
|
||||
BLOCKED: "阻塞",
|
||||
WAITING: "等待中",
|
||||
TIMED_WAITING: "限时等待",
|
||||
TERMINATED: "已终止",
|
||||
NEW: "jvm_monitoring_presentation.thread_state.new",
|
||||
RUNNABLE: "jvm_monitoring_presentation.thread_state.runnable",
|
||||
BLOCKED: "jvm_monitoring_presentation.thread_state.blocked",
|
||||
WAITING: "jvm_monitoring_presentation.thread_state.waiting",
|
||||
TIMED_WAITING: "jvm_monitoring_presentation.thread_state.timed_waiting",
|
||||
TERMINATED: "jvm_monitoring_presentation.thread_state.terminated",
|
||||
};
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
const resolveLanguage = (language?: SupportedLanguage): SupportedLanguage =>
|
||||
language ?? getCurrentLanguage();
|
||||
|
||||
const createTimeFormatter = (language?: SupportedLanguage) => new Intl.DateTimeFormat(resolveLanguage(language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
@@ -47,19 +55,32 @@ export type MonitoringChartPoint = JVMMonitoringPoint & {
|
||||
timeLabel: string;
|
||||
};
|
||||
|
||||
export const resolveMonitoringMetricLabel = (metric: string): string =>
|
||||
METRIC_LABELS[String(metric || "").trim()] || String(metric || "").trim();
|
||||
|
||||
export const resolveThreadStateLabel = (state?: string | null): string => {
|
||||
const normalized = String(state || "").trim().toUpperCase();
|
||||
return THREAD_STATE_LABELS[normalized] || String(state || "").trim();
|
||||
export const resolveMonitoringMetricLabel = (
|
||||
metric: string,
|
||||
language?: SupportedLanguage,
|
||||
): string => {
|
||||
const normalized = String(metric || "").trim();
|
||||
const key = METRIC_LABELS[normalized];
|
||||
return key ? t(key, undefined, resolveLanguage(language)) : normalized;
|
||||
};
|
||||
|
||||
export const formatMonitoringTime = (timestamp?: number): string => {
|
||||
export const resolveThreadStateLabel = (
|
||||
state?: string | null,
|
||||
language?: SupportedLanguage,
|
||||
): string => {
|
||||
const normalized = String(state || "").trim().toUpperCase();
|
||||
const key = THREAD_STATE_LABELS[normalized];
|
||||
return key ? t(key, undefined, resolveLanguage(language)) : String(state || "").trim();
|
||||
};
|
||||
|
||||
export const formatMonitoringTime = (
|
||||
timestamp?: number,
|
||||
language?: SupportedLanguage,
|
||||
): string => {
|
||||
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
|
||||
return "--";
|
||||
}
|
||||
return timeFormatter.format(new Date(timestamp));
|
||||
return createTimeFormatter(language).format(new Date(timestamp));
|
||||
};
|
||||
|
||||
export const formatBytes = (value?: number): string => {
|
||||
@@ -86,11 +107,14 @@ export const formatPercent = (value?: number): string => {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
};
|
||||
|
||||
export const formatCompactNumber = (value?: number): string => {
|
||||
export const formatCompactNumber = (
|
||||
value?: number,
|
||||
language?: SupportedLanguage,
|
||||
): string => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return "--";
|
||||
}
|
||||
return value.toLocaleString("zh-CN");
|
||||
return value.toLocaleString(resolveLanguage(language));
|
||||
};
|
||||
|
||||
export const formatDurationMs = (value?: number): string => {
|
||||
@@ -114,23 +138,37 @@ export const normalizeMonitoringProviderMode = (
|
||||
export const buildMonitoringAvailabilityText = ({
|
||||
missingMetrics,
|
||||
providerWarnings,
|
||||
}: Pick<JVMMonitoringSessionState, "missingMetrics" | "providerWarnings">): string => {
|
||||
}: Pick<JVMMonitoringSessionState, "missingMetrics" | "providerWarnings">,
|
||||
language?: SupportedLanguage,
|
||||
): string => {
|
||||
const resolvedLanguage = resolveLanguage(language);
|
||||
const fragments: string[] = [];
|
||||
|
||||
if (Array.isArray(missingMetrics) && missingMetrics.length > 0) {
|
||||
const metrics = missingMetrics
|
||||
.map((metric) => resolveMonitoringMetricLabel(metric, resolvedLanguage))
|
||||
.join(resolvedLanguage.startsWith("zh") ? "、" : ", ");
|
||||
fragments.push(
|
||||
`缺失指标:${missingMetrics
|
||||
.map((metric) => resolveMonitoringMetricLabel(metric))
|
||||
.join("、")}`,
|
||||
t("jvm_monitoring_presentation.availability.missing_metrics", {
|
||||
metrics,
|
||||
}, resolvedLanguage),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(providerWarnings) && providerWarnings.length > 0) {
|
||||
fragments.push(`监控来源告警:${providerWarnings.join(";")}`);
|
||||
fragments.push(
|
||||
t("jvm_monitoring_presentation.availability.provider_warnings", {
|
||||
warnings: providerWarnings.join(resolvedLanguage.startsWith("zh") ? ";" : "; "),
|
||||
}, resolvedLanguage),
|
||||
);
|
||||
}
|
||||
|
||||
if (fragments.length === 0) {
|
||||
return "当前监控会话未发现明显降级。";
|
||||
return t(
|
||||
"jvm_monitoring_presentation.availability.no_obvious_degradation",
|
||||
undefined,
|
||||
resolvedLanguage,
|
||||
);
|
||||
}
|
||||
|
||||
return fragments.join(" | ");
|
||||
@@ -138,9 +176,10 @@ export const buildMonitoringAvailabilityText = ({
|
||||
|
||||
export const formatRecentGCLabel = (
|
||||
event: JVMMonitoringRecentGCEvent,
|
||||
language?: SupportedLanguage,
|
||||
): string => {
|
||||
const parts = [
|
||||
formatMonitoringTime(event.timestamp),
|
||||
formatMonitoringTime(event.timestamp, language),
|
||||
String(event.name || "").trim(),
|
||||
typeof event.durationMs === "number" ? `${event.durationMs}ms` : "",
|
||||
String(event.cause || "").trim(),
|
||||
@@ -151,19 +190,21 @@ export const formatRecentGCLabel = (
|
||||
|
||||
export const buildMonitoringChartPoints = (
|
||||
points: JVMMonitoringPoint[] = [],
|
||||
language?: SupportedLanguage,
|
||||
): MonitoringChartPoint[] =>
|
||||
points.map((point) => ({
|
||||
...point,
|
||||
timeLabel: formatMonitoringTime(point.timestamp),
|
||||
timeLabel: formatMonitoringTime(point.timestamp, language),
|
||||
}));
|
||||
|
||||
export const extractThreadStateRows = (
|
||||
point?: JVMMonitoringPoint,
|
||||
language?: SupportedLanguage,
|
||||
): Array<{ state: string; label: string; count: number }> =>
|
||||
Object.entries(point?.threadStateCounts || {})
|
||||
.map(([state, count]) => ({
|
||||
state,
|
||||
label: resolveThreadStateLabel(state),
|
||||
label: resolveThreadStateLabel(state, language),
|
||||
count: Number(count) || 0,
|
||||
}))
|
||||
.sort((left, right) => right.count - left.count);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -16,9 +17,15 @@ import {
|
||||
|
||||
describe("jvmResourcePresentation", () => {
|
||||
it("provides a localized fallback label for built-in JVM actions", () => {
|
||||
expect(resolveJVMActionDisplay({ action: "set" })).toMatchObject({
|
||||
expect(resolveJVMActionDisplay({ action: "set" }, "zh-CN")).toMatchObject({
|
||||
action: "set",
|
||||
label: "设置属性",
|
||||
description: "更新当前资源暴露的可写属性值。",
|
||||
});
|
||||
expect(resolveJVMActionDisplay({ action: "set" }, "en-US")).toMatchObject({
|
||||
action: "set",
|
||||
label: "Set property",
|
||||
description: "Update a writable property exposed by the current resource.",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,18 +48,45 @@ describe("jvmResourcePresentation", () => {
|
||||
formatJVMActionSummary([
|
||||
{ action: "set" },
|
||||
{ action: "invoke", label: "执行重置" },
|
||||
]),
|
||||
], "zh-CN"),
|
||||
).toBe("设置属性(set), 执行重置(invoke)");
|
||||
expect(formatJVMActionSummary([{ action: "set" }], "en-US")).toBe(
|
||||
"Set property(set)",
|
||||
);
|
||||
});
|
||||
|
||||
it("localizes risk levels and audit result states", () => {
|
||||
expect(formatJVMRiskLevelText("medium")).toBe("中");
|
||||
expect(formatJVMRiskLevelText("")).toBe("未知");
|
||||
expect(formatJVMAuditResultLabel("applied")).toBe("已执行");
|
||||
expect(formatJVMAuditResultLabel("error")).toBe("失败");
|
||||
expect(formatJVMRiskLevelText("medium", "zh-CN")).toBe("中");
|
||||
expect(formatJVMRiskLevelText("", "zh-CN")).toBe("未知");
|
||||
expect(formatJVMRiskLevelText("medium", "en-US")).toBe("Medium");
|
||||
expect(formatJVMRiskLevelText("", "en-US")).toBe("Unknown");
|
||||
expect(formatJVMAuditResultLabel("applied", "zh-CN")).toBe("已执行");
|
||||
expect(formatJVMAuditResultLabel("applied", "en-US")).toBe("Applied");
|
||||
expect(formatJVMAuditResultLabel("done", "en-US")).toBe("Success");
|
||||
expect(formatJVMAuditResultLabel("warning", "en-US")).toBe("Warning");
|
||||
expect(formatJVMAuditResultLabel("blocked", "en-US")).toBe("Blocked");
|
||||
expect(formatJVMAuditResultLabel("error", "en-US")).toBe("Failed");
|
||||
expect(formatJVMAuditResultLabel("", "en-US")).toBe("Unknown");
|
||||
expect(formatJVMAuditResultLabel("custom-state", "en-US")).toBe(
|
||||
"custom-state",
|
||||
);
|
||||
expect(resolveJVMAuditResultColor("warning")).toBe("gold");
|
||||
});
|
||||
|
||||
it("keeps built-in action fallback copy in catalog instead of source literals", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./jvmResourcePresentation.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain("jvm_resource.presentation.action.");
|
||||
expect(source).not.toMatch(
|
||||
/设置属性|更新当前资源暴露|调用操作|写入资源|清空资源|驱逐缓存|删除条目|删除资源|刷新资源|重新加载|重置状态/,
|
||||
);
|
||||
expect(source).toContain("jvm_resource.presentation.audit_result.");
|
||||
expect(source).not.toMatch(/已执行|成功|警告|已阻断|失败/);
|
||||
});
|
||||
|
||||
it("uses json mode for structured snapshots", () => {
|
||||
expect(resolveJVMValueEditorLanguage("json", { name: "orders" })).toBe(
|
||||
"json",
|
||||
@@ -155,7 +189,7 @@ describe("jvmResourcePresentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects confirmed apply requests when preview token is missing", () => {
|
||||
it("localizes missing confirmation token errors for apply requests", () => {
|
||||
expect(() =>
|
||||
buildJVMPreviewApplyRequest(
|
||||
{
|
||||
@@ -183,8 +217,40 @@ describe("jvmResourcePresentation", () => {
|
||||
value: "warm",
|
||||
},
|
||||
},
|
||||
"zh-CN",
|
||||
),
|
||||
).toThrow("确认令牌缺失");
|
||||
|
||||
expect(() =>
|
||||
buildJVMPreviewApplyRequest(
|
||||
{
|
||||
providerMode: "jmx",
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
action: "set",
|
||||
reason: "repair runtime mode",
|
||||
payload: { value: "warm" },
|
||||
},
|
||||
{
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
summary: "Set 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",
|
||||
},
|
||||
},
|
||||
"en-US",
|
||||
),
|
||||
).toThrow("Confirmation token is missing. Preview again before executing.");
|
||||
});
|
||||
|
||||
it("caps editor height for very long payloads while keeping short content compact", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
JVMChangeRequest,
|
||||
JVMValueSnapshot,
|
||||
} from "../types";
|
||||
import { t as translate } from "../i18n";
|
||||
|
||||
type JVMActionDisplay = {
|
||||
action: string;
|
||||
@@ -11,51 +12,18 @@ type JVMActionDisplay = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const ACTION_FALLBACK_META: Record<
|
||||
string,
|
||||
{ label: string; description?: string }
|
||||
> = {
|
||||
set: {
|
||||
label: "设置属性",
|
||||
description: "更新当前资源暴露的可写属性值。",
|
||||
},
|
||||
invoke: {
|
||||
label: "调用操作",
|
||||
description: "调用当前资源暴露的管理操作。",
|
||||
},
|
||||
put: {
|
||||
label: "写入资源",
|
||||
description: "将 payload 内容写入当前 JVM 资源。",
|
||||
},
|
||||
clear: {
|
||||
label: "清空资源",
|
||||
description: "清空当前 JVM 资源里的数据或状态。",
|
||||
},
|
||||
evict: {
|
||||
label: "驱逐缓存",
|
||||
description: "将目标缓存项从当前 JVM 运行时中驱逐。",
|
||||
},
|
||||
remove: {
|
||||
label: "删除条目",
|
||||
description: "删除当前资源中的指定条目。",
|
||||
},
|
||||
delete: {
|
||||
label: "删除资源",
|
||||
description: "删除或注销当前资源。",
|
||||
},
|
||||
refresh: {
|
||||
label: "刷新资源",
|
||||
description: "刷新当前资源的运行时状态。",
|
||||
},
|
||||
reload: {
|
||||
label: "重新加载",
|
||||
description: "重新加载当前资源或其配置。",
|
||||
},
|
||||
reset: {
|
||||
label: "重置状态",
|
||||
description: "将当前资源恢复到初始或默认状态。",
|
||||
},
|
||||
};
|
||||
const BUILTIN_JVM_ACTIONS = new Set([
|
||||
"set",
|
||||
"invoke",
|
||||
"put",
|
||||
"clear",
|
||||
"evict",
|
||||
"remove",
|
||||
"delete",
|
||||
"refresh",
|
||||
"reload",
|
||||
"reset",
|
||||
]);
|
||||
|
||||
const normalizeText = (value: unknown): string => String(value || "").trim();
|
||||
|
||||
@@ -82,19 +50,33 @@ const looksLikeStructuredJSONText = (value: string): boolean => {
|
||||
|
||||
export const resolveJVMActionDisplay = (
|
||||
value?: Partial<JVMActionDefinition> | string | null,
|
||||
language?: string,
|
||||
): JVMActionDisplay => {
|
||||
const action = normalizeText(
|
||||
typeof value === "string" ? value : value?.action,
|
||||
);
|
||||
const fallback = ACTION_FALLBACK_META[action.toLowerCase()] || null;
|
||||
const normalizedAction = action.toLowerCase();
|
||||
const fallbackKeyPrefix = BUILTIN_JVM_ACTIONS.has(normalizedAction)
|
||||
? `jvm_resource.presentation.action.${normalizedAction}`
|
||||
: "";
|
||||
const localizedFallbackLabel = fallbackKeyPrefix
|
||||
? translate(`${fallbackKeyPrefix}.label`, undefined, language)
|
||||
: "";
|
||||
const localizedFallbackDescription = fallbackKeyPrefix
|
||||
? translate(`${fallbackKeyPrefix}.description`, undefined, language)
|
||||
: "";
|
||||
const label =
|
||||
normalizeText(typeof value === "string" ? "" : value?.label) ||
|
||||
fallback?.label ||
|
||||
(localizedFallbackLabel !== `${fallbackKeyPrefix}.label`
|
||||
? localizedFallbackLabel
|
||||
: "") ||
|
||||
action ||
|
||||
"未命名动作";
|
||||
translate("jvm_resource.presentation.unnamed_action", undefined, language);
|
||||
const description =
|
||||
normalizeText(typeof value === "string" ? "" : value?.description) ||
|
||||
fallback?.description ||
|
||||
(localizedFallbackDescription !== `${fallbackKeyPrefix}.description`
|
||||
? localizedFallbackDescription
|
||||
: "") ||
|
||||
"";
|
||||
|
||||
return {
|
||||
@@ -106,8 +88,9 @@ export const resolveJVMActionDisplay = (
|
||||
|
||||
export const formatJVMActionDisplayText = (
|
||||
value?: Partial<JVMActionDefinition> | string | null,
|
||||
language?: string,
|
||||
): string => {
|
||||
const resolved = resolveJVMActionDisplay(value);
|
||||
const resolved = resolveJVMActionDisplay(value, language);
|
||||
if (!resolved.action || resolved.label === resolved.action) {
|
||||
return resolved.label;
|
||||
}
|
||||
@@ -116,28 +99,39 @@ export const formatJVMActionDisplayText = (
|
||||
|
||||
export const formatJVMActionSummary = (
|
||||
actions?: JVMActionDefinition[] | null,
|
||||
language?: string,
|
||||
): string => {
|
||||
if (!Array.isArray(actions) || actions.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
return actions
|
||||
.map((item) => formatJVMActionDisplayText(item))
|
||||
.map((item) => formatJVMActionDisplayText(item, language))
|
||||
.filter((item) => item !== "")
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
export const formatJVMRiskLevelText = (value?: string | null): string => {
|
||||
export const formatJVMRiskLevelText = (
|
||||
value?: string | null,
|
||||
language?: string,
|
||||
): string => {
|
||||
const normalized = normalizeText(value).toLowerCase();
|
||||
if (normalized === "low") {
|
||||
return "低";
|
||||
return translate("jvm_resource.presentation.risk.low", undefined, language);
|
||||
}
|
||||
if (normalized === "medium") {
|
||||
return "中";
|
||||
return translate(
|
||||
"jvm_resource.presentation.risk.medium",
|
||||
undefined,
|
||||
language,
|
||||
);
|
||||
}
|
||||
if (normalized === "high") {
|
||||
return "高";
|
||||
return translate("jvm_resource.presentation.risk.high", undefined, language);
|
||||
}
|
||||
return normalizeText(value) || "未知";
|
||||
return (
|
||||
normalizeText(value) ||
|
||||
translate("jvm_resource.presentation.risk.unknown", undefined, language)
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveJVMAuditResultColor = (value?: string | null): string => {
|
||||
@@ -165,33 +159,60 @@ export const resolveJVMAuditResultColor = (value?: string | null): string => {
|
||||
return "default";
|
||||
};
|
||||
|
||||
export const formatJVMAuditResultLabel = (value?: string | null): string => {
|
||||
export const formatJVMAuditResultLabel = (
|
||||
value?: string | null,
|
||||
language?: string,
|
||||
): string => {
|
||||
const normalized = normalizeText(value).toLowerCase();
|
||||
if (!normalized) {
|
||||
return "未知";
|
||||
return translate(
|
||||
"jvm_resource.presentation.audit_result.unknown",
|
||||
undefined,
|
||||
language,
|
||||
);
|
||||
}
|
||||
if (normalized === "applied") {
|
||||
return "已执行";
|
||||
return translate(
|
||||
"jvm_resource.presentation.audit_result.applied",
|
||||
undefined,
|
||||
language,
|
||||
);
|
||||
}
|
||||
if (
|
||||
normalized.includes("success") ||
|
||||
normalized.includes("ok") ||
|
||||
normalized.includes("done")
|
||||
) {
|
||||
return "成功";
|
||||
return translate(
|
||||
"jvm_resource.presentation.audit_result.success",
|
||||
undefined,
|
||||
language,
|
||||
);
|
||||
}
|
||||
if (normalized.includes("warn")) {
|
||||
return "警告";
|
||||
return translate(
|
||||
"jvm_resource.presentation.audit_result.warning",
|
||||
undefined,
|
||||
language,
|
||||
);
|
||||
}
|
||||
if (
|
||||
normalized.includes("block") ||
|
||||
normalized.includes("deny") ||
|
||||
normalized.includes("forbid")
|
||||
) {
|
||||
return "已阻断";
|
||||
return translate(
|
||||
"jvm_resource.presentation.audit_result.blocked",
|
||||
undefined,
|
||||
language,
|
||||
);
|
||||
}
|
||||
if (normalized.includes("fail") || normalized.includes("error")) {
|
||||
return "失败";
|
||||
return translate(
|
||||
"jvm_resource.presentation.audit_result.failed",
|
||||
undefined,
|
||||
language,
|
||||
);
|
||||
}
|
||||
return normalizeText(value);
|
||||
};
|
||||
@@ -248,10 +269,17 @@ export const buildJVMActionPayloadTemplate = (
|
||||
export const buildJVMPreviewApplyRequest = (
|
||||
previewRequest: JVMChangeRequest,
|
||||
preview: JVMChangePreview,
|
||||
language?: string,
|
||||
): JVMChangeRequest => {
|
||||
const confirmationToken = String(preview.confirmationToken || "").trim();
|
||||
if (preview.requiresConfirmation && !confirmationToken) {
|
||||
throw new Error("确认令牌缺失,请重新预览后再执行");
|
||||
throw new Error(
|
||||
translate(
|
||||
"jvm_resource.error.confirmation_missing",
|
||||
undefined,
|
||||
language || "zh-CN",
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
...previewRequest,
|
||||
|
||||
@@ -126,9 +126,20 @@ describe('resolveRowLocatorValues', () => {
|
||||
indexes: [uniqueIndex('uk_email', 'EMAIL')],
|
||||
});
|
||||
|
||||
expect(resolveRowLocatorValues(locator, { EMAIL: null, NAME: 'A' })).toEqual({
|
||||
expect(resolveRowLocatorValues(locator, { EMAIL: null, NAME: 'A' }, {
|
||||
emptyLocatorValue: (column) => `Locator column ${column} is empty, so changes cannot be submitted safely.`,
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
error: '定位列 EMAIL 的值为空,无法安全提交修改。',
|
||||
error: 'Locator column EMAIL is empty, so changes cannot be submitted safely.',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses injected messages when no safe locator is available', () => {
|
||||
expect(resolveRowLocatorValues(undefined, { EMAIL: 'a@example.com' }, {
|
||||
noSafeLocator: () => 'No safe row locator is available for this result set.',
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
error: 'No safe row locator is available for this result set.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,11 @@ export type ResolveRowLocatorValuesResult =
|
||||
| { ok: true; values: Record<string, any> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type RowLocatorMessages = {
|
||||
noSafeLocator?: () => string;
|
||||
emptyLocatorValue?: (column: string) => string;
|
||||
};
|
||||
|
||||
const normalizeColumnName = (value: string): string => String(value || '').trim();
|
||||
|
||||
const hasColumn = (columns: string[], target: string): boolean => {
|
||||
@@ -103,9 +108,10 @@ export const resolveEditRowLocator = ({
|
||||
export const resolveRowLocatorValues = (
|
||||
locator: EditRowLocator | undefined,
|
||||
row: Record<string, any>,
|
||||
messages?: RowLocatorMessages,
|
||||
): ResolveRowLocatorValuesResult => {
|
||||
if (!locator || locator.readOnly || locator.strategy === 'none') {
|
||||
return { ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' };
|
||||
return { ok: false, error: messages?.noSafeLocator?.() || 'No safe row locator is available for this result set.' };
|
||||
}
|
||||
|
||||
const values: Record<string, any> = {};
|
||||
@@ -114,7 +120,7 @@ export const resolveRowLocatorValues = (
|
||||
const valueColumn = locator.valueColumns[index] || column;
|
||||
const value = row?.[valueColumn];
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return { ok: false, error: `定位列 ${column} 的值为空,无法安全提交修改。` };
|
||||
return { ok: false, error: messages?.emptyLocatorValue?.(column) || `Locator column ${column} is empty, so changes cannot be submitted safely.` };
|
||||
}
|
||||
values[column] = value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user