mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-08 23:49:34 +08:00
✨ feat(jvm): 完成资源治理与诊断增强
- 新增 JMX/Endpoint/Agent 三种 JVM 连接模式与配置归一化链路 - 支持资源浏览、变更预览、写入应用、审计记录与只读约束 - 接入 AI 结构化写入计划与诊断计划回填能力 - 新增 Agent Bridge、Arthas Tunnel、JMX Helper 诊断传输实现 - 增加诊断控制台、命令模板、输出历史与自动补全交互 - 补齐前后端契约、运行夹具与 JVM 相关回归测试
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
getStoredSecretPlaceholder,
|
||||
normalizeConnectionSecretErrorMessage,
|
||||
resolveConnectionTestFailureFeedback,
|
||||
summarizeConnectionTestFailureMessage,
|
||||
} from './connectionModalPresentation';
|
||||
|
||||
describe('connectionModalPresentation', () => {
|
||||
@@ -33,14 +34,14 @@ describe('connectionModalPresentation', () => {
|
||||
expect(normalizeConnectionSecretErrorMessage('连接测试超时')).toBe('连接测试超时');
|
||||
});
|
||||
|
||||
it('shows a toast-worthy failure message for saved-secret lookup errors during connection tests', () => {
|
||||
it('keeps saved-secret lookup errors inside the modal instead of raising a global toast', () => {
|
||||
expect(resolveConnectionTestFailureFeedback({
|
||||
kind: 'runtime',
|
||||
reason: 'saved connection not found: conn-1',
|
||||
fallback: '连接失败',
|
||||
})).toEqual({
|
||||
message: '测试失败: 未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
|
||||
shouldToast: true,
|
||||
shouldToast: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,4 +55,10 @@ describe('connectionModalPresentation', () => {
|
||||
shouldToast: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses only the first line for connection failure toast summaries', () => {
|
||||
expect(summarizeConnectionTestFailureMessage(`测试失败: 当前端口不是 JMX 远程管理端口\n建议:请改填 JMX 端口\n技术细节:raw error`)).toBe(
|
||||
'测试失败: 当前端口不是 JMX 远程管理端口',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,18 @@ export const normalizeConnectionSecretErrorMessage = (
|
||||
return text;
|
||||
};
|
||||
|
||||
export const summarizeConnectionTestFailureMessage = (
|
||||
value: unknown,
|
||||
fallback = '',
|
||||
): string => {
|
||||
const text = normalizeConnectionSecretErrorMessage(value, fallback);
|
||||
const [firstLine] = text
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== '');
|
||||
return firstLine || text;
|
||||
};
|
||||
|
||||
export const resolveConnectionTestFailureFeedback = ({
|
||||
kind,
|
||||
reason,
|
||||
@@ -68,7 +80,7 @@ export const resolveConnectionTestFailureFeedback = ({
|
||||
|
||||
return {
|
||||
message: `测试失败: ${normalizeConnectionSecretErrorMessage(reason, fallback)}`,
|
||||
shouldToast: true,
|
||||
shouldToast: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
|
||||
resourceId: 'orders/user:1',
|
||||
action: 'put',
|
||||
reason: '修复缓存脏值',
|
||||
source: 'ai-plan',
|
||||
payload: {
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
@@ -69,6 +70,7 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
|
||||
resourceId: '/cache/orders',
|
||||
action: 'clear',
|
||||
reason: '受控清理',
|
||||
source: 'ai-plan',
|
||||
payload: {},
|
||||
});
|
||||
});
|
||||
@@ -79,7 +81,24 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
|
||||
);
|
||||
|
||||
expect(plan).not.toBeNull();
|
||||
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow('当前 JVM 预览仅支持 JSON 对象作为变更 payload');
|
||||
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow('当前 JVM 预览要求 payload 仍然是 JSON 对象');
|
||||
});
|
||||
|
||||
it('keeps generic action for managed bean payload updates', () => {
|
||||
const plan = extractJVMChangePlan(
|
||||
'```json\n{"targetType":"attribute","selector":{"resourcePath":"jmx://java.lang/type=Memory/attribute/Verbose"},"action":"set","payload":{"format":"json","value":{"value":true}},"reason":"开启诊断日志"}\n```',
|
||||
);
|
||||
|
||||
expect(plan).not.toBeNull();
|
||||
expect(buildJVMChangeDraftFromAIPlan(plan!)).toEqual({
|
||||
resourceId: 'jmx://java.lang/type=Memory/attribute/Verbose',
|
||||
action: 'set',
|
||||
reason: '开启诊断日志',
|
||||
source: 'ai-plan',
|
||||
payload: {
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
|
||||
import type { JVMActionDefinition, JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
|
||||
|
||||
export type JVMAIChangePlan = {
|
||||
targetType: 'cacheEntry' | 'managedBean';
|
||||
targetType: 'cacheEntry' | 'managedBean' | 'attribute' | 'operation';
|
||||
selector: {
|
||||
namespace?: string;
|
||||
key?: string;
|
||||
resourcePath?: string;
|
||||
};
|
||||
action: 'updateValue' | 'evict' | 'clear';
|
||||
action: string;
|
||||
payload?: {
|
||||
format: 'json' | 'text';
|
||||
value: unknown;
|
||||
@@ -15,7 +15,7 @@ export type JVMAIChangePlan = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type JVMAIChangeDraft = Pick<JVMChangeRequest, 'resourceId' | 'action' | 'reason' | 'payload'>;
|
||||
export type JVMAIChangeDraft = Pick<JVMChangeRequest, 'resourceId' | 'action' | 'reason' | 'source' | 'payload'>;
|
||||
|
||||
type JVMAIPlanPromptContext = {
|
||||
connectionName: string;
|
||||
@@ -28,8 +28,7 @@ type JVMAIPlanPromptContext = {
|
||||
};
|
||||
|
||||
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
|
||||
const allowedTargetTypes = new Set<JVMAIChangePlan['targetType']>(['cacheEntry', 'managedBean']);
|
||||
const allowedActions = new Set<JVMAIChangePlan['action']>(['updateValue', 'evict', 'clear']);
|
||||
const allowedTargetTypes = new Set<JVMAIChangePlan['targetType']>(['cacheEntry', 'managedBean', 'attribute', 'operation']);
|
||||
const allowedPayloadFormats = new Set<NonNullable<JVMAIChangePlan['payload']>['format']>(['json', 'text']);
|
||||
|
||||
const asTrimmedString = (value: unknown): string => String(value ?? '').trim();
|
||||
@@ -90,7 +89,7 @@ const normalizePlan = (value: unknown): JVMAIChangePlan | null => {
|
||||
const selector = normalizeSelector(value.selector);
|
||||
const payload = normalizePayload(value.payload);
|
||||
|
||||
if (!allowedTargetTypes.has(targetType) || !allowedActions.has(action) || !reason || !selector) {
|
||||
if (!allowedTargetTypes.has(targetType) || !action || !reason || !selector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -189,27 +188,74 @@ export const buildJVMChangeDraftFromAIPlan = (plan: JVMAIChangePlan): JVMAIChang
|
||||
throw new Error('AI 计划缺少变更原因');
|
||||
}
|
||||
|
||||
const action = asTrimmedString(plan.action);
|
||||
if (!action) {
|
||||
throw new Error('AI 计划缺少可执行 action');
|
||||
}
|
||||
|
||||
if (plan.action === 'updateValue') {
|
||||
const value = plan.payload?.value;
|
||||
if (plan.payload?.format !== 'json' || !isRecord(value)) {
|
||||
throw new Error('当前 JVM 预览仅支持 JSON 对象作为变更 payload');
|
||||
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
|
||||
}
|
||||
return {
|
||||
resourceId,
|
||||
action: 'put',
|
||||
reason,
|
||||
source: 'ai-plan',
|
||||
payload: value as Record<string, any>,
|
||||
};
|
||||
}
|
||||
|
||||
const payloadValue = plan.payload?.value;
|
||||
if (plan.payload && plan.payload.format === 'json') {
|
||||
if (!isRecord(payloadValue)) {
|
||||
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
|
||||
}
|
||||
return {
|
||||
resourceId,
|
||||
action,
|
||||
reason,
|
||||
source: 'ai-plan',
|
||||
payload: payloadValue as Record<string, any>,
|
||||
};
|
||||
}
|
||||
|
||||
if (plan.payload && plan.payload.format === 'text') {
|
||||
return {
|
||||
resourceId,
|
||||
action,
|
||||
reason,
|
||||
source: 'ai-plan',
|
||||
payload: {
|
||||
value: payloadValue == null ? '' : String(payloadValue),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
resourceId,
|
||||
action: plan.action,
|
||||
action,
|
||||
reason,
|
||||
source: 'ai-plan',
|
||||
payload: {},
|
||||
};
|
||||
};
|
||||
|
||||
const formatSupportedActions = (actions?: JVMActionDefinition[]): string => {
|
||||
if (!actions || actions.length === 0) {
|
||||
return '当前资源未声明支持动作。若要生成计划,请仅在你能从快照内容中明确推断时给出 action,并保持 payload 为 JSON 对象。';
|
||||
}
|
||||
return actions
|
||||
.map((item) => {
|
||||
const payloadFields = Array.isArray(item.payloadFields) && item.payloadFields.length > 0
|
||||
? `;payload 字段:${item.payloadFields.map((field) => `${field.name}${field.required ? '(required)' : ''}`).join('、')}`
|
||||
: '';
|
||||
return `- ${item.action}${item.label ? ` (${item.label})` : ''}${item.description ? `:${item.description}` : ''}${payloadFields}`;
|
||||
})
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
export const buildJVMAIPlanPrompt = ({
|
||||
connectionName,
|
||||
host,
|
||||
@@ -222,6 +268,7 @@ export const buildJVMAIPlanPrompt = ({
|
||||
const normalizedPath = asTrimmedString(resourcePath) || '(未提供资源路径)';
|
||||
const snapshotFormat = asTrimmedString(snapshot?.format) || 'json';
|
||||
const environmentLabel = asTrimmedString(environment) || 'unknown';
|
||||
const supportedActionsText = formatSupportedActions(snapshot?.supportedActions);
|
||||
|
||||
return [
|
||||
'请分析下面这个 JVM 资源,并生成一个可用于 GoNavi “预览变更” 的结构化修改计划。',
|
||||
@@ -238,12 +285,15 @@ export const buildJVMAIPlanPrompt = ({
|
||||
formatSnapshotValue(snapshot),
|
||||
'```',
|
||||
'',
|
||||
'当前资源支持动作:',
|
||||
supportedActionsText,
|
||||
'',
|
||||
'输出要求:',
|
||||
'1. 可以先给一小段分析,但必须包含且只包含一个 ```json 代码块。',
|
||||
'2. 代码块里的 JSON 字段必须严格是:targetType、selector、action、payload、reason。',
|
||||
`3. selector.resourcePath 优先使用当前资源路径 ${normalizedPath},不要凭空编造其他路径。`,
|
||||
'4. action 只能使用 updateValue、evict、clear 之一。',
|
||||
'5. 当前 MVP 只支持 JSON 对象变更:如果 action=updateValue,则 payload 必须是 {"format":"json","value":{...}},且 value 必须是 JSON 对象;evict/clear 时 payload 可以省略。',
|
||||
'4. action 优先从“当前资源支持动作”里选择;如果当前资源未声明支持动作,才允许基于快照内容推断。',
|
||||
'5. payload 只能使用 JSON 对象包装,不要输出脚本、命令或原始二进制。若需要纯文本值,也请包装成 {"format":"text","value":"..."}。',
|
||||
'6. 不要声称已经执行修改,也不要输出脚本或命令。',
|
||||
'',
|
||||
'JSON 示例:',
|
||||
@@ -254,7 +304,7 @@ export const buildJVMAIPlanPrompt = ({
|
||||
selector: {
|
||||
resourcePath: normalizedPath,
|
||||
},
|
||||
action: 'updateValue',
|
||||
action: 'put',
|
||||
payload: {
|
||||
format: 'json',
|
||||
value: {
|
||||
|
||||
@@ -1,119 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildDefaultJVMConnectionValues,
|
||||
buildJVMConnectionConfig,
|
||||
hasUnsupportedJVMDiagnosticTransport,
|
||||
hasUnsupportedJVMEditableModes,
|
||||
normalizeEditableJVMModes,
|
||||
resolveEditableJVMModeSelection,
|
||||
} from './jvmConnectionConfig';
|
||||
} from "./jvmConnectionConfig";
|
||||
|
||||
describe('jvmConnectionConfig', () => {
|
||||
it('defaults to readonly jmx mode', () => {
|
||||
describe("jvmConnectionConfig", () => {
|
||||
it("defaults to readonly jmx mode", () => {
|
||||
const values = buildDefaultJVMConnectionValues();
|
||||
expect(values.type).toBe('jvm');
|
||||
expect(values.type).toBe("jvm");
|
||||
expect(values.jvmReadOnly).toBe(true);
|
||||
expect(values.jvmAllowedModes).toEqual(['jmx']);
|
||||
expect(values.jvmPreferredMode).toBe('jmx');
|
||||
expect(values.jvmAllowedModes).toEqual(["jmx"]);
|
||||
expect(values.jvmPreferredMode).toBe("jmx");
|
||||
expect(values.jvmDiagnosticEnabled).toBe(false);
|
||||
expect(values.jvmDiagnosticTransport).toBe("agent-bridge");
|
||||
expect(values.jvmDiagnosticAllowObserveCommands).toBe(true);
|
||||
expect(values.jvmDiagnosticAllowTraceCommands).toBe(false);
|
||||
expect(values.jvmDiagnosticAllowMutatingCommands).toBe(false);
|
||||
expect(values.jvmDiagnosticTimeoutSeconds).toBe(15);
|
||||
});
|
||||
|
||||
it('builds nested jvm config payload', () => {
|
||||
it("builds nested jvm config payload", () => {
|
||||
const config = buildJVMConnectionConfig({
|
||||
name: 'Orders JVM',
|
||||
type: 'jvm',
|
||||
host: 'orders.internal',
|
||||
name: "Orders JVM",
|
||||
type: "jvm",
|
||||
host: "orders.internal",
|
||||
port: 9010,
|
||||
jvmReadOnly: true,
|
||||
jvmAllowedModes: ['jmx', 'endpoint'],
|
||||
jvmPreferredMode: 'endpoint',
|
||||
jvmEnvironment: 'prod',
|
||||
jvmAllowedModes: ["jmx", "endpoint", "agent"],
|
||||
jvmPreferredMode: "agent",
|
||||
jvmEnvironment: "prod",
|
||||
jvmEndpointEnabled: true,
|
||||
jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm',
|
||||
jvmEndpointApiKey: 'token-1',
|
||||
jvmEndpointBaseUrl: "https://orders.internal/manage/jvm",
|
||||
jvmEndpointApiKey: "token-1",
|
||||
jvmAgentEnabled: true,
|
||||
jvmAgentBaseUrl: "http://127.0.0.1:19090/gonavi/agent/jvm",
|
||||
jvmAgentApiKey: "agent-token",
|
||||
timeout: 45,
|
||||
jvmDiagnosticEnabled: true,
|
||||
jvmDiagnosticTransport: "arthas-tunnel",
|
||||
jvmDiagnosticBaseUrl: "https://orders.internal/diag",
|
||||
jvmDiagnosticTargetId: "orders-01",
|
||||
jvmDiagnosticApiKey: "diag-token",
|
||||
jvmDiagnosticAllowObserveCommands: true,
|
||||
jvmDiagnosticAllowTraceCommands: true,
|
||||
jvmDiagnosticAllowMutatingCommands: false,
|
||||
jvmDiagnosticTimeoutSeconds: 18,
|
||||
});
|
||||
expect(config.jvm?.preferredMode).toBe("agent");
|
||||
expect(config.jvm?.endpoint?.baseUrl).toBe(
|
||||
"https://orders.internal/manage/jvm",
|
||||
);
|
||||
expect(config.jvm?.agent?.baseUrl).toBe(
|
||||
"http://127.0.0.1:19090/gonavi/agent/jvm",
|
||||
);
|
||||
expect(config.jvm?.diagnostic).toEqual({
|
||||
enabled: true,
|
||||
transport: "arthas-tunnel",
|
||||
baseUrl: "https://orders.internal/diag",
|
||||
targetId: "orders-01",
|
||||
apiKey: "diag-token",
|
||||
allowObserveCommands: true,
|
||||
allowTraceCommands: true,
|
||||
allowMutatingCommands: false,
|
||||
timeoutSeconds: 18,
|
||||
});
|
||||
expect(config.jvm?.preferredMode).toBe('endpoint');
|
||||
expect(config.jvm?.endpoint?.baseUrl).toBe('https://orders.internal/manage/jvm');
|
||||
});
|
||||
|
||||
it('normalizes allowed modes and falls back preferred mode to first allowed mode', () => {
|
||||
it("normalizes allowed modes and falls back preferred mode to first allowed mode", () => {
|
||||
const config = buildJVMConnectionConfig({
|
||||
host: 'cache.internal',
|
||||
host: "cache.internal",
|
||||
port: 9010,
|
||||
jvmAllowedModes: [' Endpoint ', 'invalid', 'JMX', 'endpoint'],
|
||||
jvmPreferredMode: 'AGENT',
|
||||
jvmAllowedModes: [" Endpoint ", "invalid", "JMX", "endpoint"],
|
||||
jvmPreferredMode: "AGENT",
|
||||
});
|
||||
|
||||
expect(config.jvm?.allowedModes).toEqual(['endpoint', 'jmx']);
|
||||
expect(config.jvm?.preferredMode).toBe('endpoint');
|
||||
expect(config.jvm?.allowedModes).toEqual(["endpoint", "jmx"]);
|
||||
expect(config.jvm?.preferredMode).toBe("endpoint");
|
||||
expect(config.jvm?.jmx?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes environment and port defaults when input is invalid', () => {
|
||||
it("normalizes environment and port defaults when input is invalid", () => {
|
||||
const config = buildJVMConnectionConfig({
|
||||
host: 'orders.internal',
|
||||
host: "orders.internal",
|
||||
port: 0,
|
||||
jvmJmxPort: '',
|
||||
jvmEnvironment: ' PROD ',
|
||||
jvmJmxPort: "",
|
||||
jvmEnvironment: " PROD ",
|
||||
jvmReadOnly: false,
|
||||
jvmAllowedModes: ['JMX'],
|
||||
jvmPreferredMode: 'jmx',
|
||||
jvmAllowedModes: ["JMX"],
|
||||
jvmPreferredMode: "jmx",
|
||||
});
|
||||
|
||||
expect(config.port).toBe(9010);
|
||||
expect(config.jvm?.jmx?.port).toBe(9010);
|
||||
expect(config.jvm?.environment).toBe('prod');
|
||||
expect(config.jvm?.environment).toBe("prod");
|
||||
expect(config.jvm?.readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the visible timeout as the source of truth for endpoint probing', () => {
|
||||
it("keeps endpoint timeout aligned to the visible connection timeout", () => {
|
||||
const config = buildJVMConnectionConfig({
|
||||
host: 'orders.internal',
|
||||
host: "orders.internal",
|
||||
port: 9010,
|
||||
timeout: 45,
|
||||
jvmEndpointTimeoutSeconds: 30,
|
||||
jvmAllowedModes: ['endpoint'],
|
||||
jvmPreferredMode: 'endpoint',
|
||||
jvmAllowedModes: ["endpoint"],
|
||||
jvmPreferredMode: "endpoint",
|
||||
jvmEndpointEnabled: true,
|
||||
jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm',
|
||||
jvmEndpointBaseUrl: "https://orders.internal/manage/jvm",
|
||||
jvmDiagnosticEnabled: true,
|
||||
jvmDiagnosticTransport: "arthas-tunnel",
|
||||
jvmDiagnosticBaseUrl: "https://orders.internal/diag",
|
||||
jvmDiagnosticTargetId: "orders-01",
|
||||
jvmDiagnosticApiKey: "diag-token",
|
||||
jvmDiagnosticAllowObserveCommands: true,
|
||||
jvmDiagnosticAllowTraceCommands: true,
|
||||
jvmDiagnosticAllowMutatingCommands: false,
|
||||
jvmDiagnosticTimeoutSeconds: 18,
|
||||
});
|
||||
|
||||
expect(config.timeout).toBe(45);
|
||||
expect(config.jvm?.endpoint?.timeoutSeconds).toBe(45);
|
||||
expect(config.jvm?.diagnostic?.timeoutSeconds).toBe(18);
|
||||
});
|
||||
|
||||
it('normalizes editable JVM modes to the supported form subset', () => {
|
||||
expect(normalizeEditableJVMModes([' endpoint ', 'agent', 'JMX', 'endpoint'])).toEqual(['endpoint', 'jmx']);
|
||||
it("detects unsupported diagnostic transport without silently accepting it", () => {
|
||||
expect(hasUnsupportedJVMDiagnosticTransport("legacy-bridge")).toBe(true);
|
||||
expect(hasUnsupportedJVMDiagnosticTransport("agent-bridge")).toBe(false);
|
||||
expect(hasUnsupportedJVMDiagnosticTransport("")).toBe(false);
|
||||
});
|
||||
|
||||
it('detects unsupported editable JVM modes without downgrading them silently', () => {
|
||||
expect(hasUnsupportedJVMEditableModes({
|
||||
allowedModes: ['agent', 'jmx'],
|
||||
preferredMode: 'agent',
|
||||
})).toBe(true);
|
||||
expect(hasUnsupportedJVMEditableModes({
|
||||
allowedModes: ['endpoint', 'jmx'],
|
||||
preferredMode: 'agent',
|
||||
})).toBe(true);
|
||||
expect(hasUnsupportedJVMEditableModes({
|
||||
allowedModes: ['endpoint', 'jmx'],
|
||||
preferredMode: 'endpoint',
|
||||
})).toBe(false);
|
||||
it("normalizes editable JVM modes to the supported form subset", () => {
|
||||
expect(
|
||||
normalizeEditableJVMModes([" endpoint ", "agent", "JMX", "endpoint"]),
|
||||
).toEqual(["endpoint", "agent", "jmx"]);
|
||||
});
|
||||
|
||||
it('preserves preferred mode when rebuilding editable mode selection from stored config', () => {
|
||||
expect(resolveEditableJVMModeSelection({
|
||||
allowedModes: [],
|
||||
preferredMode: 'agent',
|
||||
})).toEqual({
|
||||
allowedModes: ['agent'],
|
||||
preferredMode: 'agent',
|
||||
it("detects unsupported editable JVM modes without downgrading them silently", () => {
|
||||
expect(
|
||||
hasUnsupportedJVMEditableModes({
|
||||
allowedModes: ["agent", "jmx"],
|
||||
preferredMode: "agent",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasUnsupportedJVMEditableModes({
|
||||
allowedModes: ["endpoint", "jmx"],
|
||||
preferredMode: "otel",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasUnsupportedJVMEditableModes({
|
||||
allowedModes: ["endpoint", "jmx"],
|
||||
preferredMode: "endpoint",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves preferred mode when rebuilding editable mode selection from stored config", () => {
|
||||
expect(
|
||||
resolveEditableJVMModeSelection({
|
||||
allowedModes: [],
|
||||
preferredMode: "agent",
|
||||
}),
|
||||
).toEqual({
|
||||
allowedModes: ["agent"],
|
||||
preferredMode: "agent",
|
||||
});
|
||||
expect(resolveEditableJVMModeSelection({
|
||||
allowedModes: ['endpoint', 'jmx'],
|
||||
preferredMode: 'agent',
|
||||
})).toEqual({
|
||||
allowedModes: ['endpoint', 'jmx'],
|
||||
preferredMode: 'agent',
|
||||
expect(
|
||||
resolveEditableJVMModeSelection({
|
||||
allowedModes: ["endpoint", "jmx"],
|
||||
preferredMode: "agent",
|
||||
}),
|
||||
).toEqual({
|
||||
allowedModes: ["endpoint", "jmx"],
|
||||
preferredMode: "agent",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import type { ConnectionConfig } from '../types';
|
||||
import type { ConnectionConfig } from "../types";
|
||||
|
||||
const DEFAULT_JMX_PORT = 9010;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const DEFAULT_ENVIRONMENT = 'dev';
|
||||
const JVM_MODES = ['jmx', 'endpoint', 'agent'] as const;
|
||||
export const JVM_EDITABLE_MODES = ['jmx', 'endpoint'] as const;
|
||||
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
|
||||
const DEFAULT_ENVIRONMENT = "dev";
|
||||
const JVM_MODES = ["jmx", "endpoint", "agent"] as const;
|
||||
export const JVM_EDITABLE_MODES = ["jmx", "endpoint", "agent"] as const;
|
||||
const JVM_DIAGNOSTIC_TRANSPORTS = ["agent-bridge", "arthas-tunnel"] as const;
|
||||
|
||||
type JVMMode = typeof JVM_MODES[number];
|
||||
type JVMEditableMode = typeof JVM_EDITABLE_MODES[number];
|
||||
type JVMEnvironment = 'dev' | 'uat' | 'prod';
|
||||
type JVMMode = (typeof JVM_MODES)[number];
|
||||
type JVMEditableMode = (typeof JVM_EDITABLE_MODES)[number];
|
||||
type JVMDiagnosticTransport = (typeof JVM_DIAGNOSTIC_TRANSPORTS)[number];
|
||||
type JVMEnvironment = "dev" | "uat" | "prod";
|
||||
type JVMConnectionFormValues = Record<string, unknown>;
|
||||
|
||||
const isJVMMode = (value: string): value is JVMMode => JVM_MODES.includes(value as JVMMode);
|
||||
const isJVMEditableMode = (value: string): value is JVMEditableMode => JVM_EDITABLE_MODES.includes(value as JVMEditableMode);
|
||||
const isJVMMode = (value: string): value is JVMMode =>
|
||||
JVM_MODES.includes(value as JVMMode);
|
||||
const isJVMEditableMode = (value: string): value is JVMEditableMode =>
|
||||
JVM_EDITABLE_MODES.includes(value as JVMEditableMode);
|
||||
const isJVMDiagnosticTransport = (
|
||||
value: string,
|
||||
): value is JVMDiagnosticTransport =>
|
||||
JVM_DIAGNOSTIC_TRANSPORTS.includes(value as JVMDiagnosticTransport);
|
||||
|
||||
const toStringValue = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value).trim();
|
||||
}
|
||||
return '';
|
||||
return "";
|
||||
};
|
||||
|
||||
const toInteger = (value: unknown, fallback: number): number => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
@@ -38,7 +47,7 @@ const toInteger = (value: unknown, fallback: number): number => {
|
||||
|
||||
const normalizeModes = (value: unknown): JVMMode[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return ['jmx'];
|
||||
return ["jmx"];
|
||||
}
|
||||
|
||||
const result: JVMMode[] = [];
|
||||
@@ -51,12 +60,14 @@ const normalizeModes = (value: unknown): JVMMode[] => {
|
||||
seen.add(mode);
|
||||
result.push(mode);
|
||||
}
|
||||
return result.length > 0 ? result : ['jmx'];
|
||||
return result.length > 0 ? result : ["jmx"];
|
||||
};
|
||||
|
||||
export const normalizeEditableJVMModes = (value: unknown): JVMEditableMode[] => {
|
||||
export const normalizeEditableJVMModes = (
|
||||
value: unknown,
|
||||
): JVMEditableMode[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return ['jmx'];
|
||||
return ["jmx"];
|
||||
}
|
||||
|
||||
const result: JVMEditableMode[] = [];
|
||||
@@ -69,7 +80,7 @@ export const normalizeEditableJVMModes = (value: unknown): JVMEditableMode[] =>
|
||||
seen.add(mode);
|
||||
result.push(mode);
|
||||
}
|
||||
return result.length > 0 ? result : ['jmx'];
|
||||
return result.length > 0 ? result : ["jmx"];
|
||||
};
|
||||
|
||||
export const hasUnsupportedJVMEditableModes = ({
|
||||
@@ -80,12 +91,23 @@ export const hasUnsupportedJVMEditableModes = ({
|
||||
preferredMode: unknown;
|
||||
}): boolean => {
|
||||
const allowed = Array.isArray(allowedModes)
|
||||
? allowedModes.map((item) => toStringValue(item).toLowerCase()).filter((item) => item !== '')
|
||||
? allowedModes
|
||||
.map((item) => toStringValue(item).toLowerCase())
|
||||
.filter((item) => item !== "")
|
||||
: [];
|
||||
const preferred = toStringValue(preferredMode).toLowerCase();
|
||||
|
||||
return allowed.some((mode) => !isJVMEditableMode(mode))
|
||||
|| (preferred !== '' && !isJVMEditableMode(preferred));
|
||||
return (
|
||||
allowed.some((mode) => !isJVMEditableMode(mode)) ||
|
||||
(preferred !== "" && !isJVMEditableMode(preferred))
|
||||
);
|
||||
};
|
||||
|
||||
export const hasUnsupportedJVMDiagnosticTransport = (
|
||||
value: unknown,
|
||||
): boolean => {
|
||||
const transport = toStringValue(value).toLowerCase();
|
||||
return transport !== "" && !isJVMDiagnosticTransport(transport);
|
||||
};
|
||||
|
||||
export const resolveEditableJVMModeSelection = ({
|
||||
@@ -96,12 +118,17 @@ export const resolveEditableJVMModeSelection = ({
|
||||
preferredMode: unknown;
|
||||
}): { allowedModes: string[]; preferredMode: string } => {
|
||||
const normalizedAllowedModes = Array.isArray(allowedModes)
|
||||
? allowedModes.map((item) => toStringValue(item).toLowerCase()).filter((item) => item !== '')
|
||||
? allowedModes
|
||||
.map((item) => toStringValue(item).toLowerCase())
|
||||
.filter((item) => item !== "")
|
||||
: [];
|
||||
const normalizedPreferredMode = toStringValue(preferredMode).toLowerCase();
|
||||
const resolvedAllowedModes = normalizedAllowedModes.length > 0
|
||||
? Array.from(new Set(normalizedAllowedModes))
|
||||
: (normalizedPreferredMode ? [normalizedPreferredMode] : ['jmx']);
|
||||
const resolvedAllowedModes =
|
||||
normalizedAllowedModes.length > 0
|
||||
? Array.from(new Set(normalizedAllowedModes))
|
||||
: normalizedPreferredMode
|
||||
? [normalizedPreferredMode]
|
||||
: ["jmx"];
|
||||
|
||||
return {
|
||||
allowedModes: resolvedAllowedModes,
|
||||
@@ -109,7 +136,10 @@ export const resolveEditableJVMModeSelection = ({
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMode => {
|
||||
const normalizePreferredMode = (
|
||||
value: unknown,
|
||||
allowedModes: JVMMode[],
|
||||
): JVMMode => {
|
||||
const preferred = toStringValue(value).toLowerCase();
|
||||
if (isJVMMode(preferred) && allowedModes.includes(preferred)) {
|
||||
return preferred;
|
||||
@@ -119,46 +149,80 @@ const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMod
|
||||
|
||||
const normalizeEnvironment = (value: unknown): JVMEnvironment => {
|
||||
const env = toStringValue(value).toLowerCase();
|
||||
if (env === 'uat' || env === 'prod') {
|
||||
if (env === "uat" || env === "prod") {
|
||||
return env;
|
||||
}
|
||||
return DEFAULT_ENVIRONMENT;
|
||||
};
|
||||
|
||||
const normalizeReadOnly = (value: unknown): boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const normalizeDiagnosticTransport = (
|
||||
value: unknown,
|
||||
): JVMDiagnosticTransport => {
|
||||
const transport = toStringValue(value).toLowerCase();
|
||||
if (isJVMDiagnosticTransport(transport)) {
|
||||
return transport;
|
||||
}
|
||||
return "agent-bridge";
|
||||
};
|
||||
|
||||
export const buildDefaultJVMConnectionValues = () => ({
|
||||
type: 'jvm',
|
||||
host: 'localhost',
|
||||
type: "jvm",
|
||||
host: "localhost",
|
||||
port: DEFAULT_JMX_PORT,
|
||||
jvmReadOnly: true,
|
||||
jvmAllowedModes: ['jmx'],
|
||||
jvmPreferredMode: 'jmx',
|
||||
jvmAllowedModes: ["jmx"],
|
||||
jvmPreferredMode: "jmx",
|
||||
jvmEnvironment: DEFAULT_ENVIRONMENT,
|
||||
jvmEndpointEnabled: false,
|
||||
jvmEndpointBaseUrl: '',
|
||||
jvmEndpointApiKey: '',
|
||||
jvmEndpointBaseUrl: "",
|
||||
jvmEndpointApiKey: "",
|
||||
jvmAgentEnabled: false,
|
||||
jvmAgentBaseUrl: "",
|
||||
jvmAgentApiKey: "",
|
||||
jvmDiagnosticEnabled: false,
|
||||
jvmDiagnosticTransport: "agent-bridge",
|
||||
jvmDiagnosticBaseUrl: "",
|
||||
jvmDiagnosticTargetId: "",
|
||||
jvmDiagnosticApiKey: "",
|
||||
jvmDiagnosticAllowObserveCommands: true,
|
||||
jvmDiagnosticAllowTraceCommands: false,
|
||||
jvmDiagnosticAllowMutatingCommands: false,
|
||||
jvmDiagnosticTimeoutSeconds: DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
|
||||
});
|
||||
|
||||
export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): ConnectionConfig => {
|
||||
export const buildJVMConnectionConfig = (
|
||||
values: JVMConnectionFormValues,
|
||||
): ConnectionConfig => {
|
||||
const allowedModes = normalizeModes(values.jvmAllowedModes);
|
||||
const preferredMode = normalizePreferredMode(values.jvmPreferredMode, allowedModes);
|
||||
const preferredMode = normalizePreferredMode(
|
||||
values.jvmPreferredMode,
|
||||
allowedModes,
|
||||
);
|
||||
const port = toInteger(values.port, DEFAULT_JMX_PORT);
|
||||
const timeout = values.timeout === undefined || values.timeout === null || values.timeout === ''
|
||||
? toInteger(values.jvmEndpointTimeoutSeconds, DEFAULT_TIMEOUT_SECONDS)
|
||||
: toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
|
||||
const timeout =
|
||||
values.timeout === undefined ||
|
||||
values.timeout === null ||
|
||||
values.timeout === ""
|
||||
? toInteger(values.jvmEndpointTimeoutSeconds, DEFAULT_TIMEOUT_SECONDS)
|
||||
: toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
|
||||
const diagnosticTimeout = toInteger(
|
||||
values.jvmDiagnosticTimeoutSeconds,
|
||||
DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'jvm',
|
||||
type: "jvm",
|
||||
host: toStringValue(values.host),
|
||||
port,
|
||||
user: '',
|
||||
password: '',
|
||||
user: "",
|
||||
password: "",
|
||||
timeout,
|
||||
jvm: {
|
||||
environment: normalizeEnvironment(values.jvmEnvironment),
|
||||
@@ -166,7 +230,7 @@ export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): Conne
|
||||
allowedModes,
|
||||
preferredMode,
|
||||
jmx: {
|
||||
enabled: allowedModes.includes('jmx'),
|
||||
enabled: allowedModes.includes("jmx"),
|
||||
host: toStringValue(values.jvmJmxHost) || toStringValue(values.host),
|
||||
port: toInteger(values.jvmJmxPort, port),
|
||||
username: toStringValue(values.jvmJmxUsername),
|
||||
@@ -178,6 +242,24 @@ export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): Conne
|
||||
apiKey: toStringValue(values.jvmEndpointApiKey),
|
||||
timeoutSeconds: timeout,
|
||||
},
|
||||
agent: {
|
||||
enabled: values.jvmAgentEnabled === true,
|
||||
baseUrl: toStringValue(values.jvmAgentBaseUrl),
|
||||
apiKey: toStringValue(values.jvmAgentApiKey),
|
||||
timeoutSeconds: timeout,
|
||||
},
|
||||
diagnostic: {
|
||||
enabled: values.jvmDiagnosticEnabled === true,
|
||||
transport: normalizeDiagnosticTransport(values.jvmDiagnosticTransport),
|
||||
baseUrl: toStringValue(values.jvmDiagnosticBaseUrl),
|
||||
targetId: toStringValue(values.jvmDiagnosticTargetId),
|
||||
apiKey: toStringValue(values.jvmDiagnosticApiKey),
|
||||
allowObserveCommands: values.jvmDiagnosticAllowObserveCommands !== false,
|
||||
allowTraceCommands: values.jvmDiagnosticAllowTraceCommands === true,
|
||||
allowMutatingCommands:
|
||||
values.jvmDiagnosticAllowMutatingCommands === true,
|
||||
timeoutSeconds: diagnosticTimeout,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
47
frontend/src/utils/jvmDiagnosticCompletion.test.ts
Normal file
47
frontend/src/utils/jvmDiagnosticCompletion.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveJVMDiagnosticCompletionItems,
|
||||
resolveJVMDiagnosticCompletionMode,
|
||||
} from "./jvmDiagnosticCompletion";
|
||||
|
||||
describe("jvmDiagnosticCompletion", () => {
|
||||
it("suggests command keywords when typing the first token", () => {
|
||||
const items = resolveJVMDiagnosticCompletionItems("t");
|
||||
|
||||
expect(items.some((item) => item.label === "thread")).toBe(true);
|
||||
expect(items.some((item) => item.label === "trace")).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to argument mode after the command head", () => {
|
||||
expect(resolveJVMDiagnosticCompletionMode("thread -")).toEqual({
|
||||
head: "thread",
|
||||
mode: "argument",
|
||||
search: "-",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns command-specific snippets for trace style commands", () => {
|
||||
const items = resolveJVMDiagnosticCompletionItems("watch ");
|
||||
|
||||
expect(items.some((item) => item.label === "watch 模板")).toBe(true);
|
||||
expect(items.some((item) => item.label === "展开层级 -x 2")).toBe(true);
|
||||
expect(items.every((item) => item.scope === "argument")).toBe(true);
|
||||
});
|
||||
|
||||
it("supports multiline commands by using the current line before cursor", () => {
|
||||
const items = resolveJVMDiagnosticCompletionItems(
|
||||
"thread -n 5\nclas",
|
||||
);
|
||||
|
||||
expect(items.some((item) => item.label === "classloader")).toBe(true);
|
||||
expect(items.some((item) => item.label === "watch")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to command suggestions for unknown heads", () => {
|
||||
const items = resolveJVMDiagnosticCompletionItems("unknown ");
|
||||
|
||||
expect(items.some((item) => item.label === "dashboard")).toBe(true);
|
||||
expect(items.some((item) => item.label === "thread")).toBe(true);
|
||||
});
|
||||
});
|
||||
485
frontend/src/utils/jvmDiagnosticCompletion.ts
Normal file
485
frontend/src/utils/jvmDiagnosticCompletion.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import { JVM_DIAGNOSTIC_COMMAND_PRESETS } from "./jvmDiagnosticPresentation";
|
||||
|
||||
export type JVMDiagnosticCompletionMode = "command" | "argument";
|
||||
|
||||
export interface JVMDiagnosticCompletionState {
|
||||
mode: JVMDiagnosticCompletionMode;
|
||||
head: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface JVMDiagnosticCompletionItem {
|
||||
label: string;
|
||||
insertText: string;
|
||||
detail: string;
|
||||
documentation?: string;
|
||||
scope: JVMDiagnosticCompletionMode;
|
||||
isSnippet?: boolean;
|
||||
}
|
||||
|
||||
type DiagnosticCommandDefinition = {
|
||||
head: string;
|
||||
detail: string;
|
||||
documentation: string;
|
||||
};
|
||||
|
||||
const BASE_COMMAND_DEFINITIONS: DiagnosticCommandDefinition[] = [
|
||||
{
|
||||
head: "dashboard",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看 JVM 运行总览。",
|
||||
},
|
||||
{
|
||||
head: "thread",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看热点线程、线程栈和阻塞线程。",
|
||||
},
|
||||
{
|
||||
head: "sc",
|
||||
detail: "观察类命令",
|
||||
documentation: "搜索匹配类信息。",
|
||||
},
|
||||
{
|
||||
head: "sm",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看类的方法签名。",
|
||||
},
|
||||
{
|
||||
head: "jad",
|
||||
detail: "观察类命令",
|
||||
documentation: "反编译指定类。",
|
||||
},
|
||||
{
|
||||
head: "sysprop",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看系统属性。",
|
||||
},
|
||||
{
|
||||
head: "sysenv",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看环境变量。",
|
||||
},
|
||||
{
|
||||
head: "classloader",
|
||||
detail: "观察类命令",
|
||||
documentation: "查看类加载器信息。",
|
||||
},
|
||||
{
|
||||
head: "trace",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "跟踪方法调用耗时路径。",
|
||||
},
|
||||
{
|
||||
head: "watch",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "观察入参、返回值或异常。",
|
||||
},
|
||||
{
|
||||
head: "stack",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "输出方法调用栈。",
|
||||
},
|
||||
{
|
||||
head: "monitor",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "周期性统计方法调用。",
|
||||
},
|
||||
{
|
||||
head: "tt",
|
||||
detail: "跟踪类命令",
|
||||
documentation: "方法时光隧道,记录和回放调用。",
|
||||
},
|
||||
{
|
||||
head: "ognl",
|
||||
detail: "高风险命令",
|
||||
documentation: "执行 OGNL 表达式,默认需要额外授权。",
|
||||
},
|
||||
{
|
||||
head: "vmtool",
|
||||
detail: "高风险命令",
|
||||
documentation: "直接操作 JVM 对象或执行 VMTool 动作。",
|
||||
},
|
||||
{
|
||||
head: "redefine",
|
||||
detail: "高风险命令",
|
||||
documentation: "重新定义类字节码。",
|
||||
},
|
||||
{
|
||||
head: "retransform",
|
||||
detail: "高风险命令",
|
||||
documentation: "重新触发类转换。",
|
||||
},
|
||||
{
|
||||
head: "stop",
|
||||
detail: "控制命令",
|
||||
documentation: "停止当前后台任务。",
|
||||
},
|
||||
];
|
||||
|
||||
const buildBaseCommandItems = (): 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,
|
||||
scope: "command",
|
||||
});
|
||||
});
|
||||
|
||||
JVM_DIAGNOSTIC_COMMAND_PRESETS.forEach((item) => {
|
||||
const head = item.command.split(/\s+/, 1)[0]?.trim().toLowerCase() || item.label;
|
||||
if (itemsByHead.has(head)) {
|
||||
return;
|
||||
}
|
||||
itemsByHead.set(head, {
|
||||
label: head,
|
||||
insertText: head,
|
||||
detail: `${item.category} 命令`,
|
||||
documentation: item.description,
|
||||
scope: "command",
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(itemsByHead.values());
|
||||
};
|
||||
|
||||
const BASE_COMMAND_ITEMS = buildBaseCommandItems();
|
||||
|
||||
const ARGUMENT_ITEMS_BY_HEAD: Record<string, JVMDiagnosticCompletionItem[]> = {
|
||||
dashboard: [
|
||||
{
|
||||
label: "dashboard",
|
||||
insertText: "",
|
||||
detail: "直接执行",
|
||||
documentation: "查看当前 JVM 运行总览。",
|
||||
scope: "argument",
|
||||
},
|
||||
],
|
||||
thread: [
|
||||
{
|
||||
label: "繁忙线程 TOP N (-n)",
|
||||
insertText: "-n ${1:5}",
|
||||
detail: "线程参数",
|
||||
documentation: "查看 CPU 最繁忙的前 N 个线程。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "阻塞线程 (-b)",
|
||||
insertText: "-b",
|
||||
detail: "线程参数",
|
||||
documentation: "查找当前阻塞其他线程的线程。",
|
||||
scope: "argument",
|
||||
},
|
||||
{
|
||||
label: "指定线程 ID",
|
||||
insertText: "${1:1}",
|
||||
detail: "线程参数",
|
||||
documentation: "查看指定线程的详细栈信息。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
sc: [
|
||||
{
|
||||
label: "类匹配模板",
|
||||
insertText: "${1:com.foo.*}",
|
||||
detail: "类搜索模板",
|
||||
documentation: "按类名模式搜索。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "详细模式 (-d)",
|
||||
insertText: "-d ${1:com.foo.OrderService}",
|
||||
detail: "类搜索模板",
|
||||
documentation: "输出类的详细信息。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
sm: [
|
||||
{
|
||||
label: "方法签名模板",
|
||||
insertText: "${1:com.foo.OrderService} ${2:submitOrder}",
|
||||
detail: "方法搜索模板",
|
||||
documentation: "查看类的方法签名。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "详细模式 (-d)",
|
||||
insertText: "-d ${1:com.foo.OrderService} ${2:submitOrder}",
|
||||
detail: "方法搜索模板",
|
||||
documentation: "输出方法详细签名。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
jad: [
|
||||
{
|
||||
label: "反编译模板",
|
||||
insertText: "${1:com.foo.OrderService}",
|
||||
detail: "反编译模板",
|
||||
documentation: "反编译指定类。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
sysprop: [
|
||||
{
|
||||
label: "查看属性",
|
||||
insertText: "${1:java.version}",
|
||||
detail: "系统属性模板",
|
||||
documentation: "读取指定系统属性。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
sysenv: [
|
||||
{
|
||||
label: "查看环境变量",
|
||||
insertText: "${1:JAVA_HOME}",
|
||||
detail: "环境变量模板",
|
||||
documentation: "读取指定环境变量。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
classloader: [
|
||||
{
|
||||
label: "树形视图 (-t)",
|
||||
insertText: "-t",
|
||||
detail: "类加载器模板",
|
||||
documentation: "输出类加载器树形结构。",
|
||||
scope: "argument",
|
||||
},
|
||||
{
|
||||
label: "全部 URL 统计 (--url-stat)",
|
||||
insertText: "--url-stat",
|
||||
detail: "类加载器模板",
|
||||
documentation: "查看类加载器 URL 统计。",
|
||||
scope: "argument",
|
||||
},
|
||||
{
|
||||
label: "指定类加载器 Hash",
|
||||
insertText: "${1:19469ea2}",
|
||||
detail: "类加载器模板",
|
||||
documentation: "查看指定类加载器详情。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
trace: [
|
||||
{
|
||||
label: "trace 模板",
|
||||
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
|
||||
detail: "跟踪模板",
|
||||
documentation: "跟踪慢方法调用链路。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "条件过滤 '#cost > 100'",
|
||||
insertText: "'${1:#cost > 100}'",
|
||||
detail: "跟踪参数",
|
||||
documentation: "追加 trace 条件表达式。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
watch: [
|
||||
{
|
||||
label: "watch 模板",
|
||||
insertText:
|
||||
"${1:com.foo.OrderService} ${2:submitOrder} '${3:{params,returnObj}}' -x ${4:2}",
|
||||
detail: "观察模板",
|
||||
documentation: "观察入参、返回值或异常。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "展开层级 -x 2",
|
||||
insertText: "-x ${1:2}",
|
||||
detail: "观察参数",
|
||||
documentation: "设置对象展开层级。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
stack: [
|
||||
{
|
||||
label: "stack 模板",
|
||||
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
|
||||
detail: "调用栈模板",
|
||||
documentation: "输出方法调用栈。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
monitor: [
|
||||
{
|
||||
label: "monitor 模板",
|
||||
insertText: "${1:com.foo.OrderService} ${2:submitOrder} -c ${3:5}",
|
||||
detail: "监控模板",
|
||||
documentation: "按周期统计方法调用情况。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
tt: [
|
||||
{
|
||||
label: "tt 录制模板",
|
||||
insertText: "-t ${1:com.foo.OrderService} ${2:submitOrder}",
|
||||
detail: "时光隧道模板",
|
||||
documentation: "录制指定方法调用。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
{
|
||||
label: "查看记录列表 (-l)",
|
||||
insertText: "-l",
|
||||
detail: "时光隧道模板",
|
||||
documentation: "查看当前录制列表。",
|
||||
scope: "argument",
|
||||
},
|
||||
{
|
||||
label: "回放记录 (-i)",
|
||||
insertText: "-i ${1:1000} -p",
|
||||
detail: "时光隧道模板",
|
||||
documentation: "查看指定记录详情。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
ognl: [
|
||||
{
|
||||
label: "ognl 模板",
|
||||
insertText: "'${1:@java.lang.System@getProperty(\"user.dir\")}'",
|
||||
detail: "高风险模板",
|
||||
documentation: "执行 OGNL 表达式,高风险命令默认受策略限制。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
vmtool: [
|
||||
{
|
||||
label: "vmtool getInstances",
|
||||
insertText:
|
||||
"--action getInstances --className ${1:com.foo.OrderService} --limit ${2:10}",
|
||||
detail: "高风险模板",
|
||||
documentation: "获取指定类实例,高风险命令默认受策略限制。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
redefine: [
|
||||
{
|
||||
label: "redefine 模板",
|
||||
insertText: "${1:/tmp/OrderService.class}",
|
||||
detail: "高风险模板",
|
||||
documentation: "重新定义类字节码文件路径。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
retransform: [
|
||||
{
|
||||
label: "retransform 模板",
|
||||
insertText: "${1:com.foo.OrderService}",
|
||||
detail: "高风险模板",
|
||||
documentation: "重新转换指定类。",
|
||||
scope: "argument",
|
||||
isSnippet: true,
|
||||
},
|
||||
],
|
||||
stop: [
|
||||
{
|
||||
label: "stop",
|
||||
insertText: "",
|
||||
detail: "控制命令",
|
||||
documentation: "停止当前后台任务。",
|
||||
scope: "argument",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const COMMAND_HEAD_SET = new Set(
|
||||
BASE_COMMAND_ITEMS.map((item) => item.label.toLowerCase()),
|
||||
);
|
||||
|
||||
const normalizeSearchText = (value: string): string =>
|
||||
String(value || "").trim().toLowerCase();
|
||||
|
||||
const resolveCurrentLine = (textBeforeCursor: string): string =>
|
||||
String(textBeforeCursor || "").split(/\r?\n/).pop() || "";
|
||||
|
||||
const matchesSearch = (
|
||||
item: JVMDiagnosticCompletionItem,
|
||||
search: string,
|
||||
): boolean => {
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
const normalizedSearch = normalizeSearchText(search);
|
||||
const candidates = [item.label, item.insertText, item.detail];
|
||||
return candidates.some((candidate) =>
|
||||
String(candidate || "").toLowerCase().includes(normalizedSearch),
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveJVMDiagnosticCompletionMode = (
|
||||
textBeforeCursor: string,
|
||||
): JVMDiagnosticCompletionState => {
|
||||
const currentLine = resolveCurrentLine(textBeforeCursor);
|
||||
const normalizedLine = currentLine.replace(/^\s+/, "");
|
||||
|
||||
if (!normalizedLine) {
|
||||
return {
|
||||
mode: "command",
|
||||
head: "",
|
||||
search: "",
|
||||
};
|
||||
}
|
||||
|
||||
const head = normalizedLine.split(/\s+/, 1)[0]?.toLowerCase() || "";
|
||||
const hasWhitespaceAfterHead = /\s/.test(normalizedLine);
|
||||
|
||||
if (!hasWhitespaceAfterHead) {
|
||||
return {
|
||||
mode: "command",
|
||||
head,
|
||||
search: head,
|
||||
};
|
||||
}
|
||||
|
||||
const search = (normalizedLine.match(/([^\s]*)$/)?.[1] || "").toLowerCase();
|
||||
if (COMMAND_HEAD_SET.has(head)) {
|
||||
return {
|
||||
mode: "argument",
|
||||
head,
|
||||
search,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "command",
|
||||
head: "",
|
||||
search,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveJVMDiagnosticCompletionItems = (
|
||||
textBeforeCursor: string,
|
||||
): JVMDiagnosticCompletionItem[] => {
|
||||
const state = resolveJVMDiagnosticCompletionMode(textBeforeCursor);
|
||||
const source =
|
||||
state.mode === "argument" && state.head
|
||||
? ARGUMENT_ITEMS_BY_HEAD[state.head] || []
|
||||
: BASE_COMMAND_ITEMS;
|
||||
|
||||
return source.filter((item) => matchesSearch(item, state.search));
|
||||
};
|
||||
119
frontend/src/utils/jvmDiagnosticPlan.test.ts
Normal file
119
frontend/src/utils/jvmDiagnosticPlan.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseJVMDiagnosticPlan,
|
||||
resolveJVMDiagnosticPlanTargetTabId,
|
||||
} from "./jvmDiagnosticPlan";
|
||||
|
||||
describe("jvmDiagnosticPlan", () => {
|
||||
it("parses arthas-style diagnostic plan payload", () => {
|
||||
const plan = parseJVMDiagnosticPlan(`{
|
||||
"intent": "trace_slow_method",
|
||||
"transport": "agent-bridge",
|
||||
"command": "trace com.foo.OrderService submitOrder '#cost > 100'",
|
||||
"riskLevel": "medium",
|
||||
"reason": "定位慢调用"
|
||||
}`);
|
||||
|
||||
expect(plan?.command).toContain("trace com.foo.OrderService");
|
||||
expect(plan?.riskLevel).toBe("medium");
|
||||
});
|
||||
|
||||
it("parses fenced json blocks mixed with analysis text", () => {
|
||||
const plan = parseJVMDiagnosticPlan(
|
||||
[
|
||||
"建议先观察再做下一步:",
|
||||
"```json",
|
||||
'{"intent":"dump_threads","transport":"arthas-tunnel","command":"thread -n 5","riskLevel":"low","reason":"观察阻塞线程","expectedSignals":["Top N busy threads"]}',
|
||||
"```",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(plan).toEqual({
|
||||
intent: "dump_threads",
|
||||
transport: "arthas-tunnel",
|
||||
command: "thread -n 5",
|
||||
riskLevel: "low",
|
||||
reason: "观察阻塞线程",
|
||||
expectedSignals: ["Top N busy threads"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for malformed diagnostic payload", () => {
|
||||
expect(parseJVMDiagnosticPlan('{"command":1}')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveJVMDiagnosticPlanTargetTabId", () => {
|
||||
it("prefers the original diagnostic tab when context still matches", () => {
|
||||
expect(
|
||||
resolveJVMDiagnosticPlanTargetTabId(
|
||||
[
|
||||
{
|
||||
id: "tab-diagnostic",
|
||||
title: "诊断控制台",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-orders",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "conn-orders",
|
||||
config: {
|
||||
type: "jvm",
|
||||
host: "orders.internal",
|
||||
port: 9010,
|
||||
user: "",
|
||||
jvm: {
|
||||
diagnostic: {
|
||||
transport: "agent-bridge",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
tabId: "tab-diagnostic",
|
||||
connectionId: "conn-orders",
|
||||
transport: "agent-bridge",
|
||||
},
|
||||
),
|
||||
).toBe("tab-diagnostic");
|
||||
});
|
||||
|
||||
it("rejects fallback tabs whose connection transport does not match", () => {
|
||||
expect(
|
||||
resolveJVMDiagnosticPlanTargetTabId(
|
||||
[
|
||||
{
|
||||
id: "tab-diagnostic",
|
||||
title: "诊断控制台",
|
||||
type: "jvm-diagnostic",
|
||||
connectionId: "conn-orders",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "conn-orders",
|
||||
config: {
|
||||
type: "jvm",
|
||||
host: "orders.internal",
|
||||
port: 9010,
|
||||
user: "",
|
||||
jvm: {
|
||||
diagnostic: {
|
||||
transport: "arthas-tunnel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
tabId: "tab-missing",
|
||||
connectionId: "conn-orders",
|
||||
transport: "agent-bridge",
|
||||
},
|
||||
),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
135
frontend/src/utils/jvmDiagnosticPlan.ts
Normal file
135
frontend/src/utils/jvmDiagnosticPlan.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
JVMDiagnosticPlan,
|
||||
JVMDiagnosticPlanContext,
|
||||
SavedConnection,
|
||||
TabData,
|
||||
} from "../types";
|
||||
|
||||
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
|
||||
const allowedTransports = new Set<JVMDiagnosticPlan["transport"]>([
|
||||
"agent-bridge",
|
||||
"arthas-tunnel",
|
||||
]);
|
||||
const allowedRiskLevels = new Set<JVMDiagnosticPlan["riskLevel"]>([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
]);
|
||||
|
||||
const asTrimmedString = (value: unknown): string => String(value ?? "").trim();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const normalizeTransport = (value: unknown): JVMDiagnosticPlan["transport"] => {
|
||||
const transport = asTrimmedString(value) as JVMDiagnosticPlan["transport"];
|
||||
return allowedTransports.has(transport) ? transport : "agent-bridge";
|
||||
};
|
||||
|
||||
const normalizeRiskLevel = (value: unknown): JVMDiagnosticPlan["riskLevel"] => {
|
||||
const riskLevel = asTrimmedString(value) as JVMDiagnosticPlan["riskLevel"];
|
||||
return allowedRiskLevels.has(riskLevel) ? riskLevel : "low";
|
||||
};
|
||||
|
||||
const normalizePlan = (value: unknown): JVMDiagnosticPlan | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value.command !== "string") {
|
||||
return null;
|
||||
}
|
||||
const command = asTrimmedString(value.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intent = asTrimmedString(value.intent) || "generic_diagnostic";
|
||||
const reason = asTrimmedString(value.reason) || `AI 诊断计划:${intent}`;
|
||||
|
||||
return {
|
||||
intent,
|
||||
transport: normalizeTransport(value.transport),
|
||||
command,
|
||||
riskLevel: normalizeRiskLevel(value.riskLevel),
|
||||
reason,
|
||||
expectedSignals: Array.isArray(value.expectedSignals)
|
||||
? value.expectedSignals
|
||||
.map((item) => asTrimmedString(item))
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
const tryParsePlan = (content: string): JVMDiagnosticPlan | null => {
|
||||
try {
|
||||
return normalizePlan(JSON.parse(content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveDiagnosticTransport = (
|
||||
connection?: Pick<SavedConnection, "config">,
|
||||
): JVMDiagnosticPlan["transport"] =>
|
||||
normalizeTransport(connection?.config?.jvm?.diagnostic?.transport);
|
||||
|
||||
export const parseJVMDiagnosticPlan = (
|
||||
content: string,
|
||||
): JVMDiagnosticPlan | null => {
|
||||
const source = String(content || "").trim();
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
planFencePattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = planFencePattern.exec(source)) !== null) {
|
||||
const parsed = tryParsePlan(match[1]);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return tryParsePlan(source);
|
||||
};
|
||||
|
||||
export const matchesJVMDiagnosticPlanTargetTab = (
|
||||
tab: Pick<TabData, "id" | "type" | "connectionId">,
|
||||
connections: Pick<SavedConnection, "id" | "config">[],
|
||||
context?: JVMDiagnosticPlanContext,
|
||||
): boolean => {
|
||||
if (!context || tab.type !== "jvm-diagnostic") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const connection = connections.find((item) => item.id === tab.connectionId);
|
||||
return (
|
||||
tab.connectionId === context.connectionId &&
|
||||
resolveDiagnosticTransport(connection) === normalizeTransport(context.transport)
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveJVMDiagnosticPlanTargetTabId = (
|
||||
tabs: TabData[],
|
||||
connections: Pick<SavedConnection, "id" | "config">[],
|
||||
context?: JVMDiagnosticPlanContext,
|
||||
): string => {
|
||||
if (!context) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const exactMatch = tabs.find(
|
||||
(tab) =>
|
||||
tab.id === context.tabId &&
|
||||
matchesJVMDiagnosticPlanTargetTab(tab, connections, context),
|
||||
);
|
||||
if (exactMatch) {
|
||||
return exactMatch.id;
|
||||
}
|
||||
|
||||
const fallbackMatch = tabs.find((tab) =>
|
||||
matchesJVMDiagnosticPlanTargetTab(tab, connections, context),
|
||||
);
|
||||
return fallbackMatch?.id || "";
|
||||
};
|
||||
35
frontend/src/utils/jvmDiagnosticPresentation.test.ts
Normal file
35
frontend/src/utils/jvmDiagnosticPresentation.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
formatJVMDiagnosticChunkText,
|
||||
groupJVMDiagnosticPresets,
|
||||
resolveJVMDiagnosticRiskColor,
|
||||
} from "./jvmDiagnosticPresentation";
|
||||
|
||||
describe("jvmDiagnosticPresentation", () => {
|
||||
it("groups presets by category in a stable order", () => {
|
||||
const groups = groupJVMDiagnosticPresets();
|
||||
expect(groups.map((group) => group.label)).toEqual([
|
||||
"观察类命令",
|
||||
"跟踪类命令",
|
||||
"高风险命令",
|
||||
]);
|
||||
expect(groups[0].items.some((item) => item.label === "thread")).toBe(true);
|
||||
});
|
||||
|
||||
it("formats chunk text with phase prefix when content exists", () => {
|
||||
expect(
|
||||
formatJVMDiagnosticChunkText({
|
||||
sessionId: "sess-1",
|
||||
phase: "running",
|
||||
content: "thread -n 5",
|
||||
}),
|
||||
).toBe("running: thread -n 5");
|
||||
});
|
||||
|
||||
it("maps risk levels to tag colors", () => {
|
||||
expect(resolveJVMDiagnosticRiskColor("low")).toBe("green");
|
||||
expect(resolveJVMDiagnosticRiskColor("medium")).toBe("gold");
|
||||
expect(resolveJVMDiagnosticRiskColor("high")).toBe("red");
|
||||
});
|
||||
});
|
||||
105
frontend/src/utils/jvmDiagnosticPresentation.ts
Normal file
105
frontend/src/utils/jvmDiagnosticPresentation.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { JVMDiagnosticEventChunk } from "../types";
|
||||
|
||||
export type JVMDiagnosticPresetCategory = "observe" | "trace" | "mutating";
|
||||
|
||||
export interface JVMDiagnosticCommandPreset {
|
||||
key: string;
|
||||
label: string;
|
||||
category: JVMDiagnosticPresetCategory;
|
||||
command: string;
|
||||
description: string;
|
||||
riskLevel: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
export const JVM_DIAGNOSTIC_COMMAND_PRESETS: JVMDiagnosticCommandPreset[] = [
|
||||
{
|
||||
key: "thread-top",
|
||||
label: "thread",
|
||||
category: "observe",
|
||||
command: "thread -n 5",
|
||||
description: "查看最繁忙线程,快速定位阻塞或高 CPU 线程。",
|
||||
riskLevel: "low",
|
||||
},
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "dashboard",
|
||||
category: "observe",
|
||||
command: "dashboard",
|
||||
description: "查看 JVM 运行总览。",
|
||||
riskLevel: "low",
|
||||
},
|
||||
{
|
||||
key: "trace-slow-method",
|
||||
label: "trace",
|
||||
category: "trace",
|
||||
command: "trace com.foo.OrderService submitOrder '#cost > 100'",
|
||||
description: "跟踪慢方法调用路径。",
|
||||
riskLevel: "medium",
|
||||
},
|
||||
{
|
||||
key: "watch-return",
|
||||
label: "watch",
|
||||
category: "trace",
|
||||
command: "watch com.foo.OrderService submitOrder '{params,returnObj}' -x 2",
|
||||
description: "观察入参与返回值。",
|
||||
riskLevel: "medium",
|
||||
},
|
||||
{
|
||||
key: "ognl-sample",
|
||||
label: "ognl",
|
||||
category: "mutating",
|
||||
command: "ognl '@java.lang.System@getProperty(\"user.dir\")'",
|
||||
description: "高风险表达式命令,默认只作示意。",
|
||||
riskLevel: "high",
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS: Record<JVMDiagnosticPresetCategory, string> = {
|
||||
observe: "观察类命令",
|
||||
trace: "跟踪类命令",
|
||||
mutating: "高风险命令",
|
||||
};
|
||||
|
||||
const RISK_COLORS: Record<"low" | "medium" | "high", string> = {
|
||||
low: "green",
|
||||
medium: "gold",
|
||||
high: "red",
|
||||
};
|
||||
|
||||
export const formatJVMDiagnosticPresetCategory = (
|
||||
category: JVMDiagnosticPresetCategory,
|
||||
): string => CATEGORY_LABELS[category];
|
||||
|
||||
export const resolveJVMDiagnosticRiskColor = (
|
||||
riskLevel: "low" | "medium" | "high",
|
||||
): string => RISK_COLORS[riskLevel];
|
||||
|
||||
export const groupJVMDiagnosticPresets = (
|
||||
presets: JVMDiagnosticCommandPreset[] = JVM_DIAGNOSTIC_COMMAND_PRESETS,
|
||||
): Array<{
|
||||
category: JVMDiagnosticPresetCategory;
|
||||
label: string;
|
||||
items: JVMDiagnosticCommandPreset[];
|
||||
}> =>
|
||||
(["observe", "trace", "mutating"] as const).map((category) => ({
|
||||
category,
|
||||
label: formatJVMDiagnosticPresetCategory(category),
|
||||
items: presets.filter((item) => item.category === category),
|
||||
}));
|
||||
|
||||
export const formatJVMDiagnosticChunkText = (
|
||||
chunk: JVMDiagnosticEventChunk,
|
||||
): string => {
|
||||
const phase = String(chunk.phase || chunk.event || "").trim();
|
||||
const content = String(chunk.content || "").trim();
|
||||
if (!phase && !content) {
|
||||
return "空事件";
|
||||
}
|
||||
if (!phase) {
|
||||
return content;
|
||||
}
|
||||
if (!content) {
|
||||
return phase;
|
||||
}
|
||||
return `${phase}: ${content}`;
|
||||
};
|
||||
77
frontend/src/utils/jvmResourcePresentation.test.ts
Normal file
77
frontend/src/utils/jvmResourcePresentation.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
estimateJVMResourceEditorHeight,
|
||||
formatJVMAuditResultLabel,
|
||||
formatJVMActionSummary,
|
||||
formatJVMRiskLevelText,
|
||||
resolveJVMAuditResultColor,
|
||||
resolveJVMActionDisplay,
|
||||
resolveJVMValueEditorLanguage,
|
||||
} from "./jvmResourcePresentation";
|
||||
|
||||
describe("jvmResourcePresentation", () => {
|
||||
it("provides a localized fallback label for built-in JVM actions", () => {
|
||||
expect(resolveJVMActionDisplay({ action: "set" })).toMatchObject({
|
||||
action: "set",
|
||||
label: "设置属性",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provider-supplied action labels when they already exist", () => {
|
||||
expect(
|
||||
resolveJVMActionDisplay({
|
||||
action: "invoke",
|
||||
label: "执行重置",
|
||||
description: "调用 reset 操作",
|
||||
}),
|
||||
).toEqual({
|
||||
action: "invoke",
|
||||
label: "执行重置",
|
||||
description: "调用 reset 操作",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats the supported action summary with both localized label and code", () => {
|
||||
expect(
|
||||
formatJVMActionSummary([
|
||||
{ action: "set" },
|
||||
{ action: "invoke", label: "执行重置" },
|
||||
]),
|
||||
).toBe("设置属性(set), 执行重置(invoke)");
|
||||
});
|
||||
|
||||
it("localizes risk levels and audit result states", () => {
|
||||
expect(formatJVMRiskLevelText("medium")).toBe("中");
|
||||
expect(formatJVMRiskLevelText("")).toBe("未知");
|
||||
expect(formatJVMAuditResultLabel("applied")).toBe("已执行");
|
||||
expect(formatJVMAuditResultLabel("error")).toBe("失败");
|
||||
expect(resolveJVMAuditResultColor("warning")).toBe("gold");
|
||||
});
|
||||
|
||||
it("uses json mode for structured snapshots", () => {
|
||||
expect(resolveJVMValueEditorLanguage("json", { name: "orders" })).toBe(
|
||||
"json",
|
||||
);
|
||||
expect(resolveJVMValueEditorLanguage("array", [{ id: 1 }])).toBe("json");
|
||||
});
|
||||
|
||||
it("detects JSON-looking strings so the preview can use the structured editor", () => {
|
||||
expect(
|
||||
resolveJVMValueEditorLanguage("string", '{\"name\":\"orders\"}'),
|
||||
).toBe("json");
|
||||
});
|
||||
|
||||
it("falls back to plaintext for ordinary string values", () => {
|
||||
expect(resolveJVMValueEditorLanguage("string", "cache-enabled")).toBe(
|
||||
"plaintext",
|
||||
);
|
||||
});
|
||||
|
||||
it("caps editor height for very long payloads while keeping short content compact", () => {
|
||||
expect(estimateJVMResourceEditorHeight("line-1")).toBe(180);
|
||||
expect(
|
||||
estimateJVMResourceEditorHeight(new Array(80).fill("line").join("\n")),
|
||||
).toBe(420);
|
||||
});
|
||||
});
|
||||
238
frontend/src/utils/jvmResourcePresentation.ts
Normal file
238
frontend/src/utils/jvmResourcePresentation.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { JVMActionDefinition } from "../types";
|
||||
|
||||
type JVMActionDisplay = {
|
||||
action: string;
|
||||
label: string;
|
||||
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 normalizeText = (value: unknown): string => String(value || "").trim();
|
||||
|
||||
const looksLikeStructuredJSONText = (value: string): boolean => {
|
||||
const trimmed = normalizeText(value);
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!(
|
||||
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveJVMActionDisplay = (
|
||||
value?: Partial<JVMActionDefinition> | string | null,
|
||||
): JVMActionDisplay => {
|
||||
const action = normalizeText(
|
||||
typeof value === "string" ? value : value?.action,
|
||||
);
|
||||
const fallback = ACTION_FALLBACK_META[action.toLowerCase()] || null;
|
||||
const label =
|
||||
normalizeText(typeof value === "string" ? "" : value?.label) ||
|
||||
fallback?.label ||
|
||||
action ||
|
||||
"未命名动作";
|
||||
const description =
|
||||
normalizeText(typeof value === "string" ? "" : value?.description) ||
|
||||
fallback?.description ||
|
||||
"";
|
||||
|
||||
return {
|
||||
action,
|
||||
label,
|
||||
description: description || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatJVMActionDisplayText = (
|
||||
value?: Partial<JVMActionDefinition> | string | null,
|
||||
): string => {
|
||||
const resolved = resolveJVMActionDisplay(value);
|
||||
if (!resolved.action || resolved.label === resolved.action) {
|
||||
return resolved.label;
|
||||
}
|
||||
return `${resolved.label}(${resolved.action})`;
|
||||
};
|
||||
|
||||
export const formatJVMActionSummary = (
|
||||
actions?: JVMActionDefinition[] | null,
|
||||
): string => {
|
||||
if (!Array.isArray(actions) || actions.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
return actions
|
||||
.map((item) => formatJVMActionDisplayText(item))
|
||||
.filter((item) => item !== "")
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
export const formatJVMRiskLevelText = (value?: string | null): string => {
|
||||
const normalized = normalizeText(value).toLowerCase();
|
||||
if (normalized === "low") {
|
||||
return "低";
|
||||
}
|
||||
if (normalized === "medium") {
|
||||
return "中";
|
||||
}
|
||||
if (normalized === "high") {
|
||||
return "高";
|
||||
}
|
||||
return normalizeText(value) || "未知";
|
||||
};
|
||||
|
||||
export const resolveJVMAuditResultColor = (value?: string | null): string => {
|
||||
const normalized = normalizeText(value).toLowerCase();
|
||||
if (
|
||||
normalized === "applied" ||
|
||||
normalized.includes("success") ||
|
||||
normalized.includes("ok") ||
|
||||
normalized.includes("done")
|
||||
) {
|
||||
return "green";
|
||||
}
|
||||
if (normalized.includes("warn")) {
|
||||
return "gold";
|
||||
}
|
||||
if (
|
||||
normalized.includes("block") ||
|
||||
normalized.includes("deny") ||
|
||||
normalized.includes("forbid") ||
|
||||
normalized.includes("fail") ||
|
||||
normalized.includes("error")
|
||||
) {
|
||||
return "red";
|
||||
}
|
||||
return "default";
|
||||
};
|
||||
|
||||
export const formatJVMAuditResultLabel = (value?: string | null): string => {
|
||||
const normalized = normalizeText(value).toLowerCase();
|
||||
if (!normalized) {
|
||||
return "未知";
|
||||
}
|
||||
if (normalized === "applied") {
|
||||
return "已执行";
|
||||
}
|
||||
if (
|
||||
normalized.includes("success") ||
|
||||
normalized.includes("ok") ||
|
||||
normalized.includes("done")
|
||||
) {
|
||||
return "成功";
|
||||
}
|
||||
if (normalized.includes("warn")) {
|
||||
return "警告";
|
||||
}
|
||||
if (
|
||||
normalized.includes("block") ||
|
||||
normalized.includes("deny") ||
|
||||
normalized.includes("forbid")
|
||||
) {
|
||||
return "已阻断";
|
||||
}
|
||||
if (normalized.includes("fail") || normalized.includes("error")) {
|
||||
return "失败";
|
||||
}
|
||||
return normalizeText(value);
|
||||
};
|
||||
|
||||
export const resolveJVMValueEditorLanguage = (
|
||||
format: string,
|
||||
value: unknown,
|
||||
): string => {
|
||||
const normalizedFormat = normalizeText(format).toLowerCase();
|
||||
if (
|
||||
["json", "array", "object", "number", "boolean", "null"].includes(
|
||||
normalizedFormat,
|
||||
)
|
||||
) {
|
||||
return "json";
|
||||
}
|
||||
if (normalizedFormat === "sql") {
|
||||
return "sql";
|
||||
}
|
||||
if (normalizedFormat === "xml") {
|
||||
return "xml";
|
||||
}
|
||||
if (normalizedFormat === "yaml" || normalizedFormat === "yml") {
|
||||
return "yaml";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return looksLikeStructuredJSONText(value) ? "json" : "plaintext";
|
||||
}
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
Array.isArray(value)
|
||||
) {
|
||||
return "json";
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return "json";
|
||||
}
|
||||
return "plaintext";
|
||||
};
|
||||
|
||||
export const estimateJVMResourceEditorHeight = (value: unknown): number => {
|
||||
const text = String(value ?? "");
|
||||
const lineCount = Math.max(1, text.split(/\r?\n/).length);
|
||||
return Math.min(420, Math.max(180, lineCount * 22 + 24));
|
||||
};
|
||||
|
||||
export type { JVMActionDisplay };
|
||||
@@ -1,5 +1,5 @@
|
||||
export type JVMRuntimeMode = 'jmx' | 'endpoint' | 'agent';
|
||||
export type JVMTabKind = 'overview' | 'resource' | 'audit';
|
||||
export type JVMTabKind = 'overview' | 'resource' | 'audit' | 'diagnostic';
|
||||
|
||||
export type JVMModeMeta = {
|
||||
mode: string;
|
||||
@@ -35,6 +35,7 @@ const JVM_TAB_KIND_LABELS: Record<JVMTabKind, string> = {
|
||||
overview: 'JVM 概览',
|
||||
resource: 'JVM 资源',
|
||||
audit: 'JVM 审计',
|
||||
diagnostic: 'JVM 诊断',
|
||||
};
|
||||
|
||||
const normalizeMode = (mode: string): string => String(mode || '').trim().toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user