feat(i18n): 推进多语言剩余切片闭环

- 补齐 DataGrid、DataViewer、DefinitionViewer、JVM 等模块多语言文案与回归测试
- 收口 JVM 前后端展示、诊断、监控和资源呈现相关多语言路径
- 更新六语言共享词典并保留 raw 边界
This commit is contained in:
tianqijiuyun-latiao
2026-06-16 12:40:33 +08:00
parent 558966a129
commit 5fc29a6fd3
69 changed files with 6551 additions and 1276 deletions

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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]);
// 依赖定位列:在无手动排序时可回退到安全定位列稳定排序。
// 定位信息只会在表上下文变化后重新加载,避免循环查询。

View 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');
});
});

View File

@@ -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

View 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("匹配行详情");
});
});

View File

@@ -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>

View 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("错误日志:");
});
});

View File

@@ -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',

View File

@@ -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"');
});
});

View File

@@ -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"

View File

@@ -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,

View File

@@ -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 5dashboardjvm
{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}

View File

@@ -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 &quot;Start monitoring&quot;, 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);
});
});
});

View File

@@ -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>
)}

View File

@@ -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);
});
});
});

View File

@@ -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 ? (

View File

@@ -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 () => {

View File

@@ -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>

View 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: ");
});
});

View File

@@ -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>
) : (

View 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: '窗口 - 行号'");
});
});

View File

@@ -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) {

View File

@@ -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);
});
});
});
});
});

View File

@@ -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>

View 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("风险 高");
});
});

View File

@@ -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>

View File

@@ -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");
});

View File

@@ -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>
),

View File

@@ -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");

View File

@@ -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) => (

View File

@@ -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");
});

View File

@@ -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>

View File

@@ -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.",
});
});
});

View File

@@ -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));
};

View File

@@ -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);
});
});
});

View File

@@ -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);

View File

@@ -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 propertyset",
);
});
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", () => {

View File

@@ -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,

View File

@@ -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.',
});
});
});

View File

@@ -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;
}